Enrich the listSecrets API response with a linkedSources array per secret by joining credentials, source-credential bindings, and sources. Each secret row in the UI now displays clickable source name badges that link to the source detail page.
Unified · Split
| @@ -1,4 +1,5 @@ | |||
| 1 | import { useState, type ReactNode } from "react"; | 1 | import { useState, type ReactNode } from "react"; |
| 2 | import { Link } from "@tanstack/react-router"; | ||
| 2 | import { | 3 | import { |
| 3 | type SecretListItem, | 4 | type SecretListItem, |
| 4 | type Loadable, | 5 | type Loadable, |
| @@ -247,6 +248,22 @@ function SecretRow(props: { · 242 unmodified lines | |||
| 247 | {createdDate} | 248 | {createdDate} |
| 248 | </span> | 249 | </span> |
| 249 | </div> | 250 | </div> |
| 251 | {secret.linkedSources.length > 0 && ( | ||
| 252 | <div className="flex items-center gap-1.5 mt-1 flex-wrap"> | ||
| 253 | <span className="text-[10px] text-muted-foreground/50">Used by</span> | ||
| 254 | {secret.linkedSources.map((ls) => ( | ||
| 255 | <Link | ||
| 256 | key={ls.sourceId} | ||
| 257 | to="/sources/$sourceId" | ||
| 258 | params={{ sourceId: ls.sourceId }} | ||
| 259 | search={{ tab: "model" }} | ||
| 260 | className="inline-flex items-center gap-1 rounded-md bg-accent/60 px-1.5 py-0.5 text-[10px] font-medium text-foreground/70 transition-colors hover:bg-accent hover:text-foreground" | ||
| 261 | > | ||
| 262 | {ls.sourceName} | ||
| 263 | </Link> | ||
| 264 | ))} | ||
| 265 | </div> | ||
| 266 | )} | ||
| 250 | </div> | 267 | </div> |
| 251 | </div> | 268 | </div> |
| 252 | <div className="flex items-center gap-1.5 shrink-0"> | 269 | <div className="flex items-center gap-1.5 shrink-0"> |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import { LocalInstallationSchema } from "#schema"; | 2 | import { LocalInstallationSchema, SecretMaterialPurposeSchema } from "#schema"; |
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| @@ -25,12 +25,20 @@ export type InstanceConfig = typeof InstanceConfigSchema.Type; · 19 unmodified lines | |||
| 25 | 25 | ||
| 26 | // -- Secrets CRUD schemas --------------------------------------------------- | 26 | // -- Secrets CRUD schemas --------------------------------------------------- |
| 27 | 27 | ||
| 28 | export const SecretLinkedSourceSchema = Schema.Struct({ | ||
| 29 | sourceId: Schema.String, | ||
| 30 | sourceName: Schema.String, | ||
| 31 | }); | ||
| 32 | |||
| 33 | export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type; | ||
| 34 | |||
| 28 | export const SecretListItemSchema = Schema.Struct({ | 35 | export const SecretListItemSchema = Schema.Struct({ |
| 29 | id: Schema.String, | 36 | id: Schema.String, |
| 30 | name: Schema.NullOr(Schema.String), | 37 | name: Schema.NullOr(Schema.String), |
| 31 | purpose: Schema.String, | 38 | purpose: Schema.String, |
| 32 | createdAt: Schema.Number, | 39 | createdAt: Schema.Number, |
| 33 | updatedAt: Schema.Number, | 40 | updatedAt: Schema.Number, |
| 41 | linkedSources: Schema.Array(SecretLinkedSourceSchema), | ||
| 34 | }); | 42 | }); |
| 35 | 43 | ||
| 36 | export type SecretListItem = typeof SecretListItemSchema.Type; | 44 | export type SecretListItem = typeof SecretListItemSchema.Type; |
| @@ -38,6 +46,7 @@ export type SecretListItem = typeof SecretListItemSchema.Type; · 1 unmodified line | |||
| 38 | export const CreateSecretPayloadSchema = Schema.Struct({ | 46 | export const CreateSecretPayloadSchema = Schema.Struct({ |
| 39 | name: Schema.String, | 47 | name: Schema.String, |
| 40 | value: Schema.String, | 48 | value: Schema.String, |
| 49 | purpose: Schema.optional(SecretMaterialPurposeSchema), | ||
| 41 | }); | 50 | }); |
| 42 | 51 | ||
| 43 | export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; | 52 | export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; |
| @@ -90,13 +90,20 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 89 unmodified lines | |||
| 90 | const rows = yield* store.secretMaterials.listAll().pipe( | 90 | const rows = yield* store.secretMaterials.listAll().pipe( |
| 91 | Effect.mapError(() => storageError("Failed listing secrets.")), | 91 | Effect.mapError(() => storageError("Failed listing secrets.")), |
| 92 | ); | 92 | ); |
| 93 | return rows; | 93 | const linkedSourcesMap = yield* store.secretMaterials.listLinkedSources().pipe( |
| 94 | Effect.mapError(() => storageError("Failed loading linked sources.")), | ||
| 95 | ); | ||
| 96 | return rows.map((row) => ({ | ||
| 97 | ...row, | ||
| 98 | linkedSources: linkedSourcesMap.get(row.id) ?? [], | ||
| 99 | })); | ||
| 94 | }), | 100 | }), |
| 95 | ) | 101 | ) |
| 96 | .handle("createSecret", ({ payload }) => | 102 | .handle("createSecret", ({ payload }) => |
| 97 | Effect.gen(function* () { | 103 | Effect.gen(function* () { |
| 98 | const name = payload.name.trim(); | 104 | const name = payload.name.trim(); |
| 99 | const value = payload.value; | 105 | const value = payload.value; |
| 106 | const purpose = payload.purpose ?? "auth_material"; | ||
| 100 | 107 | ||
| 101 | if (name.length === 0) { | 108 | if (name.length === 0) { |
| 102 | return yield* Effect.fail( | 109 | return yield* Effect.fail( |
| @@ -115,7 +122,7 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 12 unmodified lines | |||
| 115 | yield* store.secretMaterials.upsert({ | 122 | yield* store.secretMaterials.upsert({ |
| 116 | id, | 123 | id, |
| 117 | name, | 124 | name, |
| 118 | purpose: "auth_material", | 125 | purpose, |
| 119 | value, | 126 | value, |
| 120 | createdAt: now, | 127 | createdAt: now, |
| 121 | updatedAt: now, | 128 | updatedAt: now, |
| @@ -127,7 +134,7 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 5 unmodified lines | |||
| 127 | id, | 134 | id, |
| 128 | name, | 135 | name, |
| 129 | providerId: POSTGRES_SECRET_PROVIDER_ID, | 136 | providerId: POSTGRES_SECRET_PROVIDER_ID, |
| 130 | purpose: "auth_material", | 137 | purpose, |
| 131 | createdAt: now, | 138 | createdAt: now, |
| 132 | updatedAt: now, | 139 | updatedAt: now, |
| 133 | } satisfies CreateSecretResult; | 140 | } satisfies CreateSecretResult; |
| @@ -4,7 +4,7 @@ import { · 3 unmodified lines | |||
| 4 | } from "#schema"; | 4 | } from "#schema"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import { Schema } from "effect"; | 6 | import { Schema } from "effect"; |
| 7 | import { eq, desc } from "drizzle-orm"; | 7 | import { eq, desc, or, and } from "drizzle-orm"; |
| 8 | 8 | ||
| 9 | import type { DrizzleClient } from "../client"; | 9 | import type { DrizzleClient } from "../client"; |
| 10 | import type { DrizzleTables } from "../schema"; | 10 | import type { DrizzleTables } from "../schema"; |
| @@ -90,4 +90,66 @@ export const createSecretMaterialsRepo = ( · 79 unmodified lines | |||
| 90 | 90 | ||
| 91 | return deleted.length > 0; | 91 | return deleted.length > 0; |
| 92 | }), | 92 | }), |
| 93 | |||
| 94 | /** | ||
| 95 | * For each secret, find the sources that reference it via credentials. | ||
| 96 | * Returns a map of secretId -> Array<{ sourceId, sourceName }>. | ||
| 97 | */ | ||
| 98 | listLinkedSources: () => | ||
| 99 | client.use("rows.secret_materials.list_linked_sources", async (db) => { | ||
| 100 | // Find all credentials that reference any postgres-stored secret | ||
| 101 | // and join through bindings to sources. | ||
| 102 | const rows = await db | ||
| 103 | .select({ | ||
| 104 | tokenHandle: tables.credentialsTable.tokenHandle, | ||
| 105 | tokenProviderId: tables.credentialsTable.tokenProviderId, | ||
| 106 | refreshTokenHandle: tables.credentialsTable.refreshTokenHandle, | ||
| 107 | refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId, | ||
| 108 | sourceId: tables.sourceCredentialBindingsTable.sourceId, | ||
| 109 | sourceName: tables.sourcesTable.name, | ||
| 110 | }) | ||
| 111 | .from(tables.credentialsTable) | ||
| 112 | .innerJoin( | ||
| 113 | tables.sourceCredentialBindingsTable, | ||
| 114 | eq(tables.credentialsTable.id, tables.sourceCredentialBindingsTable.credentialId), | ||
| 115 | ) | ||
| 116 | .innerJoin( | ||
| 117 | tables.sourcesTable, | ||
| 118 | and( | ||
| 119 | eq(tables.sourceCredentialBindingsTable.workspaceId, tables.sourcesTable.workspaceId), | ||
| 120 | eq(tables.sourceCredentialBindingsTable.sourceId, tables.sourcesTable.sourceId), | ||
| 121 | ), | ||
| 122 | ) | ||
| 123 | .where( | ||
| 124 | or( | ||
| 125 | eq(tables.credentialsTable.tokenProviderId, "postgres"), | ||
| 126 | eq(tables.credentialsTable.refreshTokenProviderId, "postgres"), | ||
| 127 | ), | ||
| 128 | ); | ||
| 129 | |||
| 130 | const result = new Map<string, Array<{ sourceId: string; sourceName: string }>>(); | ||
| 131 | |||
| 132 | for (const row of rows) { | ||
| 133 | const addLink = (secretId: string) => { | ||
| 134 | let links = result.get(secretId); | ||
| 135 | if (!links) { | ||
| 136 | links = []; | ||
| 137 | result.set(secretId, links); | ||
| 138 | } | ||
| 139 | // Avoid duplicate entries | ||
| 140 | if (!links.some((l) => l.sourceId === row.sourceId)) { | ||
| 141 | links.push({ sourceId: row.sourceId, sourceName: row.sourceName }); | ||
| 142 | } | ||
| 143 | }; | ||
| 144 | |||
| 145 | if (row.tokenProviderId === "postgres") { | ||
| 146 | addLink(row.tokenHandle); | ||
| 147 | } | ||
| 148 | if (row.refreshTokenProviderId === "postgres" && row.refreshTokenHandle) { | ||
| 149 | addLink(row.refreshTokenHandle); | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | return result; | ||
| 154 | }), | ||
| 93 | }); | 155 | }); |