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 | 1 | import { useState, type ReactNode } from "react"; |
| 2 | +import { Link } from "@tanstack/react-router"; | |
| 2 | 3 | import { |
| 3 | 4 | type SecretListItem, |
| 4 | 5 | type Loadable, |
| @@ -247,6 +248,22 @@ function SecretRow(props: { · 242 unmodified lines | ||
| 247 | 248 | {createdDate} |
| 248 | 249 | </span> |
| 249 | 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 | 267 | </div> |
| 251 | 268 | </div> |
| 252 | 269 | <div className="flex items-center gap-1.5 shrink-0"> |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | −import { LocalInstallationSchema } from "#schema"; | |
| 2 | +import { LocalInstallationSchema, SecretMaterialPurposeSchema } from "#schema"; | |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | |
| 5 | 5 | import { |
| @@ -25,12 +25,20 @@ export type InstanceConfig = typeof InstanceConfigSchema.Type; · 19 unmodified lines | ||
| 25 | 25 | |
| 26 | 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 | 35 | export const SecretListItemSchema = Schema.Struct({ |
| 29 | 36 | id: Schema.String, |
| 30 | 37 | name: Schema.NullOr(Schema.String), |
| 31 | 38 | purpose: Schema.String, |
| 32 | 39 | createdAt: Schema.Number, |
| 33 | 40 | updatedAt: Schema.Number, |
| 41 | + linkedSources: Schema.Array(SecretLinkedSourceSchema), | |
| 34 | 42 | }); |
| 35 | 43 | |
| 36 | 44 | export type SecretListItem = typeof SecretListItemSchema.Type; |
| @@ -38,6 +46,7 @@ export type SecretListItem = typeof SecretListItemSchema.Type; · 1 unmodified line | ||
| 38 | 46 | export const CreateSecretPayloadSchema = Schema.Struct({ |
| 39 | 47 | name: Schema.String, |
| 40 | 48 | value: Schema.String, |
| 49 | + purpose: Schema.optional(SecretMaterialPurposeSchema), | |
| 41 | 50 | }); |
| 42 | 51 | |
| 43 | 52 | export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; |
| @@ -90,13 +90,20 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 89 unmodified lines | ||
| 90 | 90 | const rows = yield* store.secretMaterials.listAll().pipe( |
| 91 | 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 | 102 | .handle("createSecret", ({ payload }) => |
| 97 | 103 | Effect.gen(function* () { |
| 98 | 104 | const name = payload.name.trim(); |
| 99 | 105 | const value = payload.value; |
| 106 | + const purpose = payload.purpose ?? "auth_material"; | |
| 100 | 107 | |
| 101 | 108 | if (name.length === 0) { |
| 102 | 109 | return yield* Effect.fail( |
| @@ -115,7 +122,7 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 12 unmodified lines | ||
| 115 | 122 | yield* store.secretMaterials.upsert({ |
| 116 | 123 | id, |
| 117 | 124 | name, |
| 118 | − purpose: "auth_material", | |
| 125 | + purpose, | |
| 119 | 126 | value, |
| 120 | 127 | createdAt: now, |
| 121 | 128 | updatedAt: now, |
| @@ -127,7 +134,7 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 5 unmodified lines | ||
| 127 | 134 | id, |
| 128 | 135 | name, |
| 129 | 136 | providerId: POSTGRES_SECRET_PROVIDER_ID, |
| 130 | − purpose: "auth_material", | |
| 137 | + purpose, | |
| 131 | 138 | createdAt: now, |
| 132 | 139 | updatedAt: now, |
| 133 | 140 | } satisfies CreateSecretResult; |
| @@ -4,7 +4,7 @@ import { · 3 unmodified lines | ||
| 4 | 4 | } from "#schema"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | import { Schema } from "effect"; |
| 7 | −import { eq, desc } from "drizzle-orm"; | |
| 7 | +import { eq, desc, or, and } from "drizzle-orm"; | |
| 8 | 8 | |
| 9 | 9 | import type { DrizzleClient } from "../client"; |
| 10 | 10 | import type { DrizzleTables } from "../schema"; |
| @@ -90,4 +90,66 @@ export const createSecretMaterialsRepo = ( · 79 unmodified lines | ||
| 90 | 90 | |
| 91 | 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 | }); |