diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx index bd5d8f83b04..e80e31c68bb 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -517,7 +517,7 @@ describe("Secrets page layout", () => { }); }); - it("opens reference details from the secrets table count", async () => { + it("keeps references reachable from the compact secrets row and detail drawer", async () => { mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]); mockSecretsApi.usage.mockResolvedValue({ secretId: "secret-openai", @@ -563,17 +563,29 @@ describe("Secrets page layout", () => { await flushReact(); const referencesButton = container.querySelector( - 'button[aria-label="View references for OPENAI_API_KEY"]', + 'button[aria-label="Actions for OPENAI_API_KEY"]', ) as HTMLButtonElement | null; - expect(referencesButton?.textContent).toBe("2"); + expect(referencesButton).not.toBeNull(); + const companyRow = Array.from(container.querySelectorAll("[role='row']")).find( + (row) => row.textContent?.includes("OPENAI_API_KEY"), + ) as HTMLElement | undefined; await act(async () => { - referencesButton?.click(); + companyRow?.click(); + }); + await flushReact(); + + const usageButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Usage"), + ) as HTMLButtonElement | undefined; + await act(async () => { + usageButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + usageButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); + usageButton?.click(); }); await flushReact(); expect(mockSecretsApi.usage).toHaveBeenCalledWith("secret-openai"); - expect(document.body.textContent).toContain("Secret references"); expect(document.body.textContent).toContain("CodexCoder"); expect(document.body.textContent).toContain("env.OPENAI_API_KEY"); @@ -606,7 +618,7 @@ describe("Secrets page layout", () => { expect(container.textContent).toContain("Personal GitHub token"); expect(container.textContent).toContain("Company"); expect(container.textContent).toContain("Each user"); - expect(container.textContent).toContain("3 of 5 set"); + expect(container.textContent).toContain("3/5 set"); expect(container.textContent).not.toContain("User secret definitions"); expect(mockSecretsApi.list).toHaveBeenCalledWith("company-1"); expect(mockSecretsApi.listUserSecretDefinitions).toHaveBeenCalledWith("company-1"); @@ -712,11 +724,11 @@ describe("Secrets page layout", () => { await flushReact(); await flushReact(); - const rowOpenButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Open", - ) as HTMLButtonElement | undefined; + const definitionRow = Array.from(container.querySelectorAll("[role='row']")).find( + (row) => row.textContent?.includes("Personal GitHub token"), + ) as HTMLElement | undefined; await act(async () => { - rowOpenButton?.click(); + definitionRow?.click(); }); await flushReact(); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index b8da13bd529..94ab638740a 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -8,12 +8,15 @@ import { Ban, CheckCircle2, Cloud, + Copy, Database, Edit3, ExternalLink, KeyRound, Link2, + Lock, Loader2, + MoreHorizontal, Plus, RefreshCw, Search, @@ -79,7 +82,16 @@ import { } from "@/components/ui/dialog"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "../lib/utils"; +import { copyTextToClipboard } from "../lib/clipboard"; import { PageTabBar } from "../components/PageTabBar"; import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog"; import { MyUserSecretsTab } from "./secrets/MyUserSecretsTab"; @@ -284,6 +296,107 @@ function modeDescription(managedMode: SecretManagedMode) { : "Paperclip resolves this provider reference but does not rotate the provider value."; } +function statusLabel(status: SecretStatus) { + return status.charAt(0).toUpperCase() + status.slice(1); +} + +function statusDotTone(status: SecretStatus) { + switch (status) { + case "active": + return "bg-emerald-500"; + case "disabled": + return "bg-amber-500"; + case "archived": + return "bg-muted-foreground"; + case "deleted": + return "bg-destructive"; + default: + return "bg-muted-foreground"; + } +} + +function StatusBadge({ status }: { status: SecretStatus }) { + return ( + + + ); +} + +function MetaChip({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function providerIndicatorLabel( + secret: CompanySecret, + providers: SecretProviderDescriptor[], + providerConfigs: CompanySecretProviderConfig[], +) { + const provider = providerLabel(providers, secret.provider); + const vault = providerVaultLabel(providerConfigs, secret.providerConfigId); + const custody = modeLabel(secret.managedMode); + return [ + `${custody} · ${provider}`, + vault ? `Vault: ${vault}` : null, + secret.externalRef ? `Reference: ${secret.externalRef}` : null, + ] + .filter(Boolean) + .join("\n"); +} + +function SecretProviderIndicator({ + secret, + providers, + providerConfigs, +}: { + secret: CompanySecret; + providers: SecretProviderDescriptor[]; + providerConfigs: CompanySecretProviderConfig[]; +}) { + const label = providerIndicatorLabel(secret, providers, providerConfigs); + const Icon = secret.managedMode === "external_reference" ? ExternalLink : Lock; + return ( + + + + + + + {label} + + ); +} + +function UpdatedWithTooltip({ + updatedAt, + tooltip, +}: { + updatedAt: Date | string | null | undefined; + tooltip: string; +}) { + return ( + + + + {formatRelative(updatedAt)} + + + {tooltip} + + ); +} + function healthEntryForProvider( health: SecretProviderHealthResponse | null, providerId: SecretProvider, @@ -1137,6 +1250,172 @@ export function Secrets() { })); } + function openCompanySecret(secret: CompanySecret) { + setSecretDetailTab("details"); + setSelectedSecretId(secret.id); + setSelectedDefinitionId(null); + } + + function openUserDefinition(definition: UserSecretDefinition) { + setSecretDetailTab("details"); + setSelectedDefinitionId(definition.id); + setSelectedSecretId(null); + } + + function openRotateSecret(secret: CompanySecret) { + openCompanySecret(secret); + setRotateOpen(true); + setRotateValue(""); + setRotateExternalRef(""); + setRotateProviderConfigId( + secret.providerConfigId ?? getDefaultProviderConfigId(providerConfigs, secret.provider), + ); + setRotateError(null); + } + + function copySecretKey(key: string) { + void copyTextToClipboard(key) + .then(() => pushToast({ title: "Secret key copied", body: key, tone: "success" })) + .catch((error) => + pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy secret key", + tone: "error", + }), + ); + } + + function renderRowActions(row: UnifiedSecretRow) { + const name = row.kind === "company" ? row.secret.name : row.definition.name; + return ( + + + + + + { + if (row.kind === "company") openCompanySecret(row.secret); + else openUserDefinition(row.definition); + }} + > + View details + + {row.kind === "company" ? ( + <> + setUsageDialogSecretId(row.secret.id)}> + View references ({row.secret.referenceCount ?? 0}) + + openRotateSecret(row.secret)}> + + {row.secret.managedMode === "external_reference" ? "Update reference" : "Update value"} + + + + statusMutation.mutate({ + id: row.secret.id, + status: row.secret.status === "active" ? "disabled" : "active", + }) + } + > + {row.secret.status === "active" ? : } + {row.secret.status === "active" ? "Disable" : "Activate"} + + + statusMutation.mutate({ + id: row.secret.id, + status: row.secret.status === "archived" ? "active" : "archived", + }) + } + > + {row.secret.status === "archived" ? ( + + ) : ( + + )} + {row.secret.status === "archived" ? "Unarchive" : "Archive"} + + + setDeleteConfirm(row.secret)}> + Delete secret + + + ) : ( + <> + + setSetMyValueFor( + myUserSecrets.find((entry) => entry.definition.id === row.definition.id) ?? { + definition: row.definition, + secret: null, + }, + ) + } + > + + {myUserSecrets.find((entry) => entry.definition.id === row.definition.id)?.secret + ? "Update my value" + : "Set my value"} + + openEditDefinition(row.definition)}> + Edit definition + + + + definitionStatusMutation.mutate({ + definition: row.definition, + status: row.definition.status === "active" ? "disabled" : "active", + }) + } + > + {row.definition.status === "active" ? ( + + ) : ( + + )} + {row.definition.status === "active" ? "Disable" : "Activate"} + + + definitionStatusMutation.mutate({ + definition: row.definition, + status: row.definition.status === "archived" ? "active" : "archived", + }) + } + > + {row.definition.status === "archived" ? ( + + ) : ( + + )} + {row.definition.status === "archived" ? "Unarchive" : "Archive"} + + + setDefinitionDeleteConfirm(row.definition)}> + Delete definition + + + )} + + + ); + } + if (!selectedCompanyId) { return (
Select a company to manage secrets.
@@ -1144,6 +1423,7 @@ export function Secrets() { } return ( +
@@ -1226,143 +1506,177 @@ export function Secrets() { ) : filteredRows.length === 0 ? ( ) : ( - - - - - - - - - - - - - - - - - - {filteredRows.map((row) => ( - { - setSecretDetailTab("details"); - if (row.kind === "company") { - setSelectedSecretId(row.secret.id); - setSelectedDefinitionId(null); - } else { - setSelectedDefinitionId(row.definition.id); - setSelectedSecretId(null); - } - }} +
+
+
-
- - - - - - - - - - - - ))} - -
NameProvided byModeProviderStatusCoverage / versionLast updatedLast resolvedReferencesReference
-
- {row.kind === "company" ? row.secret.name : row.definition.name} -
- - {row.kind === "company" ? row.secret.key : row.definition.key} - -
- {row.kind === "company" ? ( - - Company - - ) : ( - - )} - - {row.kind === "company" ? modeLabel(row.secret.managedMode) : "Personal value"} - -
- {row.kind === "company" ? providerLabel(providers, row.secret.provider) : "—"} -
-
- - {row.kind === "company" ? row.secret.status : row.definition.status} - - - {row.kind === "company" ? ( - v{row.secret.latestVersion} - ) : ( - - )} - - {formatRelative(row.kind === "company" ? row.secret.lastRotatedAt : row.definition.updatedAt)} - - {row.kind === "company" ? formatRelative(row.secret.lastResolvedAt) : "—"} - - {row.kind === "company" ? ( - - ) : ( - Env key - )} - - {row.kind === "user" ? ( - Each member stores their own value - ) : row.secret.managedMode === "external_reference" ? ( - - - {row.secret.externalRef ?? "—"} - - ) : ( - Owned - )} - - -
+
+
+
+ {row.kind === "company" ? row.secret.name : row.definition.name} +
+ + {row.kind === "company" ? row.secret.key : row.definition.key} + +
+
event.stopPropagation()}>{renderRowActions(row)}
+
+
+ {row.kind === "company" ? ( + <> + + Company + + + + + ) : ( + <> + + + + + )} +
+
+ + {row.kind === "company" ? ( + <> + v{row.secret.latestVersion} ·{" "} + {row.secret.managedMode === "external_reference" ? "linked" : "managed"} + + ) : ( + "Member-owned values" + )} + + Updated {formatRelative(row.kind === "company" ? row.secret.updatedAt : row.definition.updatedAt)} +
+
+ ); + })} +
+ )} @@ -1409,82 +1723,91 @@ export function Secrets() { {selectedSecret ? ( <> - - - - {selectedSecret.name} - - {selectedSecret.status} - + + + + {selectedSecret.name} + - - {providerLabel(providers, selectedSecret.provider)} · v{selectedSecret.latestVersion} · {modeLabel(selectedSecret.managedMode)} + + {providerLabel(providers, selectedSecret.provider)} secret {selectedSecret.key} +
+ + {selectedSecret.key} + + +
+
+ + Company + + {modeLabel(selectedSecret.managedMode)} + {providerLabel(providers, selectedSecret.provider)} + v{selectedSecret.latestVersion} +
-
+
- {selectedSecret.status === "active" ? ( - - ) : ( - - )} - {selectedSecret.status === "archived" ? ( - - ) : ( - - )} - + + + + + + + statusMutation.mutate({ + id: selectedSecret.id, + status: selectedSecret.status === "active" ? "disabled" : "active", + }) + } + > + {selectedSecret.status === "active" ? ( + + ) : ( + + )} + {selectedSecret.status === "active" ? "Disable" : "Activate"} + + + statusMutation.mutate({ + id: selectedSecret.id, + status: selectedSecret.status === "archived" ? "active" : "archived", + }) + } + > + {selectedSecret.status === "archived" ? ( + + ) : ( + + )} + {selectedSecret.status === "archived" ? "Unarchive" : "Archive"} + + + setDeleteConfirm(selectedSecret)}> + Delete secret + + +
@@ -1501,7 +1824,7 @@ export function Secrets() {
- + @@ -1518,22 +1841,39 @@ export function Secrets() { ) : selectedDefinition ? ( <> - - - - {selectedDefinition.name} - - {selectedDefinition.status} - + + + + {selectedDefinition.name} + - - Each user · {selectedDefinition.key} + + Each user secret definition {selectedDefinition.key} +
+ + {selectedDefinition.key} + + +
+
+ + + + +
-
+
- - {selectedDefinition.status === "active" ? ( - - ) : ( - - )} - {selectedDefinition.status === "archived" ? ( - - ) : ( - - )} - + + + + + + openEditDefinition(selectedDefinition)}> + Edit definition + + + + definitionStatusMutation.mutate({ + definition: selectedDefinition, + status: selectedDefinition.status === "active" ? "disabled" : "active", + }) + } + > + {selectedDefinition.status === "active" ? ( + + ) : ( + + )} + {selectedDefinition.status === "active" ? "Disable" : "Activate"} + + + definitionStatusMutation.mutate({ + definition: selectedDefinition, + status: selectedDefinition.status === "archived" ? "active" : "archived", + }) + } + > + {selectedDefinition.status === "archived" ? ( + + ) : ( + + )} + {selectedDefinition.status === "archived" ? "Unarchive" : "Archive"} + + + setDefinitionDeleteConfirm(selectedDefinition)}> + Delete definition + + +
@@ -1630,7 +1950,7 @@ export function Secrets() {
- +
+ ); } @@ -3119,9 +3440,11 @@ function TextField({ function CoverageInline({ companyId, definitionId, + compact = false, }: { companyId: string; definitionId: string; + compact?: boolean; }) { const coverageQuery = useQuery({ queryKey: queryKeys.secrets.userDefinitionCoverage(companyId, definitionId), @@ -3132,19 +3455,31 @@ function CoverageInline({ if (coverageQuery.isPending) return Loading…; if (coverageQuery.isError) return Coverage unavailable; return ( - + - {coverageSummaryLabel(summary)} + + {compact && summary + ? `${summary.configuredCount}/${summary.configuredCount + summary.missingCount + summary.inactiveCount} set` + : coverageSummaryLabel(summary)} + {summary && summary.missingCount > 0 ? ( - · {summary.missingCount} missing + + · {compact ? `${summary.missingCount} miss` : `${summary.missingCount} missing`} + ) : null} ); } -function UserSecretDetailsTab({ definition }: { definition: UserSecretDefinition }) { +function UserSecretDetailsTab({ + companyId, + definition, +}: { + companyId: string; + definition: UserSecretDefinition; +}) { return ( -
+
{definition.description ?? —} @@ -3152,18 +3487,16 @@ function UserSecretDetailsTab({ definition }: { definition: UserSecretDefinition {definition.key} - {definition.status} + + + + {formatRelative(definition.createdAt)} {formatRelative(definition.updatedAt)} -
-
- Usage guidance -
-
- {definition.usageGuidance ?? —} -
-
-
+ + {definition.usageGuidance ?? —} + +
No value is stored on this admin row. Each member manages their own value under My secrets.
@@ -3251,35 +3584,40 @@ function UserSecretAccessEventsTab() { function SecretDetailsTab({ secret, + providers, providerConfigs, }: { secret: CompanySecret; + providers: SecretProviderDescriptor[]; providerConfigs: CompanySecretProviderConfig[]; }) { return ( -
+
{secret.description ?? —} + Company {modeLabel(secret.managedMode)} - {secret.provider.replaceAll("_", " ")} + {providerLabel(providers, secret.provider)} {providerVaultLabel(providerConfigs, secret.providerConfigId)} + + {secret.externalRef ? ( + {secret.externalRef} + ) : ( + — + )} + v{secret.latestVersion} + + {(secret.referenceCount ?? 0) === 1 + ? "1 binding" + : `${secret.referenceCount ?? 0} bindings`} + {formatRelative(secret.createdAt)} {formatRelative(secret.updatedAt)} {formatRelative(secret.lastRotatedAt)} {formatRelative(secret.lastResolvedAt)} - {secret.externalRef ? ( -
-
- {secret.managedMode === "external_reference" ? "Linked provider reference" : "Provider-managed path"} -
-
- {secret.externalRef} -
-
- ) : null} -
+
{modeDescription(secret.managedMode)} Paperclip never re-displays stored values.
@@ -3288,9 +3626,9 @@ function SecretDetailsTab({ function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
+
{label}
-
{children}
+
{children}
); }