mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Show secret reference counts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
20c0b61866
commit
e38bf7f05d
9 files changed
+434
-14
No files matched your search
@@ -539,6 +539,8 @@ export type {
|
||||
SecretProviderConfigHealthDetails,
|
||||
SecretProviderConfigHealthResponse,
|
||||
CompanySecretBinding,
|
||||
CompanySecretBindingTarget,
|
||||
CompanySecretUsageBinding,
|
||||
CompanySecretVersion,
|
||||
SecretAccessEvent,
|
||||
SecretAccessOutcome,
|
||||
|
||||
@@ -249,6 +249,8 @@ export type {
|
||||
SecretProviderConfigHealthDetails,
|
||||
SecretProviderConfigHealthResponse,
|
||||
CompanySecretBinding,
|
||||
CompanySecretBindingTarget,
|
||||
CompanySecretUsageBinding,
|
||||
CompanySecretVersion,
|
||||
SecretAccessEvent,
|
||||
SecretAccessOutcome,
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface CompanySecret {
|
||||
deletedAt: Date | null;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
referenceCount?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -163,6 +164,18 @@ export interface CompanySecretBinding {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CompanySecretBindingTarget {
|
||||
type: SecretBindingTargetType;
|
||||
id: string;
|
||||
label: string;
|
||||
href: string | null;
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
export interface CompanySecretUsageBinding extends CompanySecretBinding {
|
||||
target: CompanySecretBindingTarget;
|
||||
}
|
||||
|
||||
export interface SecretAccessEvent {
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companySecretBindings,
|
||||
companySecretProviderConfigs,
|
||||
@@ -47,6 +48,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -123,6 +125,47 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
).rejects.toThrow(/already exists/i);
|
||||
});
|
||||
|
||||
it("reports reference counts and resolves binding target labels", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `referenced-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "runtime-secret",
|
||||
});
|
||||
const [agent] = await db
|
||||
.insert(agents)
|
||||
.values({
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
})
|
||||
.returning();
|
||||
|
||||
await svc.syncEnvBindingsForTarget(
|
||||
companyId,
|
||||
{ targetType: "agent", targetId: agent!.id },
|
||||
{
|
||||
OPENAI_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" },
|
||||
},
|
||||
);
|
||||
|
||||
const listed = await svc.list(companyId);
|
||||
expect(listed.find((row) => row.id === secret.id)?.referenceCount).toBe(1);
|
||||
|
||||
const bindings = await svc.listBindingReferences(companyId, secret.id);
|
||||
expect(bindings).toHaveLength(1);
|
||||
expect(bindings[0]?.target).toMatchObject({
|
||||
type: "agent",
|
||||
id: agent!.id,
|
||||
label: "CodexCoder",
|
||||
href: "/agents/codexcoder",
|
||||
status: "idle",
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces binding context and records value-free access events", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
||||
@@ -347,7 +347,7 @@ export function secretRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const bindings = await svc.listBindings(existing.companyId, existing.id);
|
||||
const bindings = await svc.listBindingReferences(existing.companyId, existing.id);
|
||||
res.json({ secretId: existing.id, bindings });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
companySecretBindings,
|
||||
companySecretProviderConfigs,
|
||||
companySecrets,
|
||||
companySecretVersions,
|
||||
environments,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projects,
|
||||
routines,
|
||||
secretAccessEvents,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
AgentEnvConfig,
|
||||
CompanySecretBindingTarget,
|
||||
EnvBinding,
|
||||
SecretBindingTargetType,
|
||||
SecretProvider,
|
||||
@@ -19,7 +26,10 @@ import type {
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
createSecretProviderConfigSchema,
|
||||
deriveProjectUrlKey,
|
||||
envBindingSchema,
|
||||
isUuidLike,
|
||||
normalizeAgentUrlKey,
|
||||
secretProviderConfigPayloadSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -497,6 +507,171 @@ export function secretService(db: Db) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function collectTargetIds(
|
||||
bindings: Array<typeof companySecretBindings.$inferSelect>,
|
||||
targetType: SecretBindingTargetType,
|
||||
opts?: { uuidOnly?: boolean },
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
bindings
|
||||
.filter((binding) => binding.targetType === targetType)
|
||||
.map((binding) => binding.targetId)
|
||||
.filter((id) => !opts?.uuidOnly || isUuidLike(id)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function fallbackBindingTarget(binding: typeof companySecretBindings.$inferSelect): CompanySecretBindingTarget {
|
||||
return {
|
||||
type: binding.targetType as SecretBindingTargetType,
|
||||
id: binding.targetId,
|
||||
label: binding.targetId,
|
||||
href: null,
|
||||
status: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildBindingTargetMap(
|
||||
companyId: string,
|
||||
bindings: Array<typeof companySecretBindings.$inferSelect>,
|
||||
) {
|
||||
const targetMap = new Map<string, CompanySecretBindingTarget>();
|
||||
const setTarget = (target: CompanySecretBindingTarget) => {
|
||||
targetMap.set(`${target.type}:${target.id}`, target);
|
||||
};
|
||||
|
||||
const agentIds = collectTargetIds(bindings, "agent", { uuidOnly: true });
|
||||
if (agentIds.length > 0) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
name: agents.name,
|
||||
title: agents.title,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds)));
|
||||
for (const row of rows) {
|
||||
setTarget({
|
||||
type: "agent",
|
||||
id: row.id,
|
||||
label: row.title ? `${row.name} (${row.title})` : row.name,
|
||||
href: `/agents/${normalizeAgentUrlKey(row.name) ?? row.id}`,
|
||||
status: row.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const projectIds = collectTargetIds(bindings, "project", { uuidOnly: true });
|
||||
if (projectIds.length > 0) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
status: projects.status,
|
||||
})
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)));
|
||||
for (const row of rows) {
|
||||
setTarget({
|
||||
type: "project",
|
||||
id: row.id,
|
||||
label: row.name,
|
||||
href: `/projects/${deriveProjectUrlKey(row.name, row.id)}`,
|
||||
status: row.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const environmentIds = collectTargetIds(bindings, "environment", { uuidOnly: true });
|
||||
if (environmentIds.length > 0) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: environments.id,
|
||||
name: environments.name,
|
||||
status: environments.status,
|
||||
})
|
||||
.from(environments)
|
||||
.where(and(eq(environments.companyId, companyId), inArray(environments.id, environmentIds)));
|
||||
for (const row of rows) {
|
||||
setTarget({
|
||||
type: "environment",
|
||||
id: row.id,
|
||||
label: row.name,
|
||||
href: "/company/settings/environments",
|
||||
status: row.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const routineIds = collectTargetIds(bindings, "routine", { uuidOnly: true });
|
||||
if (routineIds.length > 0) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: routines.id,
|
||||
title: routines.title,
|
||||
status: routines.status,
|
||||
})
|
||||
.from(routines)
|
||||
.where(and(eq(routines.companyId, companyId), inArray(routines.id, routineIds)));
|
||||
for (const row of rows) {
|
||||
setTarget({
|
||||
type: "routine",
|
||||
id: row.id,
|
||||
label: row.title,
|
||||
href: `/routines/${row.id}`,
|
||||
status: row.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const issueIds = collectTargetIds(bindings, "issue", { uuidOnly: true });
|
||||
if (issueIds.length > 0) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
||||
for (const row of rows) {
|
||||
setTarget({
|
||||
type: "issue",
|
||||
id: row.id,
|
||||
label: row.identifier ? `${row.identifier} ${row.title}` : row.title,
|
||||
href: `/issues/${row.identifier ?? row.id}`,
|
||||
status: row.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const runIds = collectTargetIds(bindings, "run", { uuidOnly: true });
|
||||
if (runIds.length > 0) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
status: heartbeatRuns.status,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, runIds)));
|
||||
for (const row of rows) {
|
||||
setTarget({
|
||||
type: "run",
|
||||
id: row.id,
|
||||
label: `Run ${row.id.slice(0, 8)}`,
|
||||
href: `/agents/${row.agentId}/runs/${row.id}`,
|
||||
status: row.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return targetMap;
|
||||
}
|
||||
|
||||
return {
|
||||
listProviders: () => listSecretProviders(),
|
||||
|
||||
@@ -679,12 +854,28 @@ export function secretService(db: Db) {
|
||||
return { ...health, checkedAt };
|
||||
},
|
||||
|
||||
list: (companyId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.companyId, companyId))
|
||||
.orderBy(desc(companySecrets.createdAt)),
|
||||
list: async (companyId: string) => {
|
||||
const [secrets, referenceCounts] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.companyId, companyId))
|
||||
.orderBy(desc(companySecrets.createdAt)),
|
||||
db
|
||||
.select({
|
||||
secretId: companySecretBindings.secretId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(companySecretBindings)
|
||||
.where(eq(companySecretBindings.companyId, companyId))
|
||||
.groupBy(companySecretBindings.secretId),
|
||||
]);
|
||||
const countsBySecretId = new Map(referenceCounts.map((row) => [row.secretId, row.count]));
|
||||
return secrets.map((secret) => ({
|
||||
...secret,
|
||||
referenceCount: countsBySecretId.get(secret.id) ?? 0,
|
||||
}));
|
||||
},
|
||||
|
||||
listBindings: (companyId: string, secretId?: string) =>
|
||||
db
|
||||
@@ -697,6 +888,21 @@ export function secretService(db: Db) {
|
||||
)
|
||||
.orderBy(desc(companySecretBindings.createdAt)),
|
||||
|
||||
listBindingReferences: async (companyId: string, secretId: string) => {
|
||||
const bindings = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, secretId)))
|
||||
.orderBy(desc(companySecretBindings.createdAt));
|
||||
const targetMap = await buildBindingTargetMap(companyId, bindings);
|
||||
return bindings.map((binding) => ({
|
||||
...binding,
|
||||
target:
|
||||
targetMap.get(`${binding.targetType}:${binding.targetId}`) ??
|
||||
fallbackBindingTarget(binding),
|
||||
}));
|
||||
},
|
||||
|
||||
listAccessEvents: (companyId: string, secretId: string) =>
|
||||
db
|
||||
.select()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
CompanySecret,
|
||||
CompanySecretBinding,
|
||||
CompanySecretUsageBinding,
|
||||
CompanySecretProviderConfig,
|
||||
SecretAccessEvent,
|
||||
SecretManagedMode,
|
||||
@@ -14,7 +14,7 @@ import { api } from "./client";
|
||||
|
||||
export interface SecretUsageResponse {
|
||||
secretId: string;
|
||||
bindings: CompanySecretBinding[];
|
||||
bindings: CompanySecretUsageBinding[];
|
||||
}
|
||||
|
||||
export interface CreateSecretInput {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -216,4 +217,92 @@ describe("Secrets page layout", () => {
|
||||
vaultRoot.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens reference details from the secrets table count", async () => {
|
||||
mockSecretsApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "secret-openai",
|
||||
companyId: "company-1",
|
||||
key: "openai_api_key",
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
referenceCount: 2,
|
||||
createdAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
mockSecretsApi.usage.mockResolvedValue({
|
||||
secretId: "secret-openai",
|
||||
bindings: [
|
||||
{
|
||||
id: "binding-agent",
|
||||
companyId: "company-1",
|
||||
secretId: "secret-openai",
|
||||
targetType: "agent",
|
||||
targetId: "agent-1",
|
||||
configPath: "env.OPENAI_API_KEY",
|
||||
versionSelector: "latest",
|
||||
required: true,
|
||||
label: null,
|
||||
target: {
|
||||
type: "agent",
|
||||
id: "agent-1",
|
||||
label: "CodexCoder",
|
||||
href: "/agents/codexcoder",
|
||||
status: "idle",
|
||||
},
|
||||
createdAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const referencesButton = container.querySelector(
|
||||
'button[aria-label="View references for OPENAI_API_KEY"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(referencesButton?.textContent).toBe("2");
|
||||
|
||||
await act(async () => {
|
||||
referencesButton?.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");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,9 +24,10 @@ import {
|
||||
Filter,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type {
|
||||
CompanySecret,
|
||||
CompanySecretBinding,
|
||||
CompanySecretUsageBinding,
|
||||
CompanySecretProviderConfig,
|
||||
SecretAccessEvent,
|
||||
SecretManagedMode,
|
||||
@@ -348,6 +349,7 @@ export function Secrets() {
|
||||
const [statusFilter, setStatusFilter] = useState<SecretStatus | "all">("active");
|
||||
const [providerFilter, setProviderFilter] = useState<SecretProvider | "all">("all");
|
||||
const [selectedSecretId, setSelectedSecretId] = useState<string | null>(null);
|
||||
const [usageDialogSecretId, setUsageDialogSecretId] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("managed");
|
||||
const [createForm, setCreateForm] = useState({
|
||||
@@ -418,6 +420,10 @@ export function Secrets() {
|
||||
() => secrets.find((secret) => secret.id === selectedSecretId) ?? null,
|
||||
[secrets, selectedSecretId],
|
||||
);
|
||||
const usageDialogSecret = useMemo(
|
||||
() => secrets.find((secret) => secret.id === usageDialogSecretId) ?? null,
|
||||
[secrets, usageDialogSecretId],
|
||||
);
|
||||
const selectedCreateProvider = useMemo(
|
||||
() => providers.find((provider) => provider.id === createForm.provider) ?? null,
|
||||
[providers, createForm.provider],
|
||||
@@ -484,6 +490,14 @@ export function Secrets() {
|
||||
enabled: Boolean(selectedSecret),
|
||||
});
|
||||
|
||||
const usageDialogQuery = useQuery({
|
||||
queryKey: usageDialogSecret
|
||||
? queryKeys.secrets.usage(usageDialogSecret.id)
|
||||
: ["secrets", "usage-dialog", "__disabled__"],
|
||||
queryFn: () => secretsApi.usage(usageDialogSecret!.id),
|
||||
enabled: Boolean(usageDialogSecret),
|
||||
});
|
||||
|
||||
function invalidateAll(extraIds: string[] = []) {
|
||||
if (!selectedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
|
||||
@@ -812,6 +826,7 @@ export function Secrets() {
|
||||
<th className="px-2 py-2 text-left font-medium">Version</th>
|
||||
<th className="px-2 py-2 text-left font-medium">Last rotated</th>
|
||||
<th className="px-2 py-2 text-left font-medium">Last resolved</th>
|
||||
<th className="px-2 py-2 text-left font-medium">References</th>
|
||||
<th className="px-2 py-2 text-left font-medium">Reference</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
@@ -847,6 +862,20 @@ export function Secrets() {
|
||||
<td className="px-2 py-2.5 text-xs text-muted-foreground">
|
||||
{formatRelative(secret.lastResolvedAt)}
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
aria-label={`View references for ${secret.name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setUsageDialogSecretId(secret.id);
|
||||
}}
|
||||
>
|
||||
{secret.referenceCount ?? 0}
|
||||
</Button>
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-xs">
|
||||
{secret.managedMode === "external_reference" ? (
|
||||
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground">
|
||||
@@ -1009,6 +1038,28 @@ export function Secrets() {
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(usageDialogSecret)}
|
||||
onOpenChange={(open) => !open && setUsageDialogSecretId(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Secret references</DialogTitle>
|
||||
<DialogDescription>
|
||||
{usageDialogSecret
|
||||
? `${usageDialogSecret.name} is referenced by ${usageDialogSecret.referenceCount ?? 0} ${
|
||||
(usageDialogSecret.referenceCount ?? 0) === 1 ? "place" : "places"
|
||||
}.`
|
||||
: null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<SecretUsageTab
|
||||
loading={usageDialogQuery.isPending}
|
||||
bindings={usageDialogQuery.data?.bindings ?? []}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
@@ -1948,7 +1999,7 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
|
||||
);
|
||||
}
|
||||
|
||||
function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: CompanySecretBinding[] }) {
|
||||
function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: CompanySecretUsageBinding[] }) {
|
||||
if (loading) {
|
||||
return <div className="py-6 text-center text-xs text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
@@ -1966,10 +2017,24 @@ function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: Com
|
||||
key={binding.id}
|
||||
className="rounded-md border border-border bg-muted/30 p-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium capitalize">{binding.targetType}</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium capitalize">{binding.target.type}</span>
|
||||
<span className="font-mono text-muted-foreground">v{binding.versionSelector}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-2">
|
||||
{binding.target.href ? (
|
||||
<Link to={binding.target.href} className="truncate font-medium text-primary hover:underline">
|
||||
{binding.target.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate font-medium">{binding.target.label}</span>
|
||||
)}
|
||||
{binding.target.status ? (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px] font-normal">
|
||||
{binding.target.status.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-muted-foreground break-all">
|
||||
{binding.targetId}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user