import { useActionMutation, useActionQuery, } from "@agent-native/core/client/hooks"; import { useOrgRole } from "@agent-native/core/client/org"; import { IconCheck, IconLoader2, IconRefresh, IconSettings, } from "@tabler/icons-react"; import { useMemo, useState, type ReactNode } from "react"; import { toast } from "sonner"; import { ActionQueryError } from "./action-query-error"; import { Button } from "./ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Skeleton } from "./ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; interface VaultSecret { id: string; name?: string | null; credentialKey: string; provider?: string | null; description?: string | null; } interface VaultGrant { id: string; secretId: string; appId: string; status?: string | null; } interface AppKeysPopoverProps { appId: string; appName: string; trigger?: ReactNode; align?: "start" | "center" | "end"; side?: "top" | "right" | "bottom" | "left"; } export function AppKeysPopover({ appId, appName, trigger, align = "end", side = "bottom", }: AppKeysPopoverProps) { const [open, setOpen] = useState(false); return ( {trigger ?? ( )} Manage keys event.stopPropagation()} > {open ? : null} ); } export function AppKeysPanel({ appId, appName, }: { appId: string; appName: string; }) { const { org, role, isLoading: orgLoading, error: orgError } = useOrgRole(); const accessReady = !orgLoading && !orgError && !!org; const canManageVault = accessReady && (!org.orgId || role === "owner" || role === "admin"); const secretsQuery = useActionQuery("list-vault-secret-options", {}); const grantsQuery = useActionQuery( "list-vault-grants", { appId }, { enabled: canManageVault }, ); const accessQuery = useActionQuery("get-vault-access-settings", {}); const { data: secrets = [], isLoading: secretsLoading } = secretsQuery; const { data: grants = [], isLoading: grantsLoading, refetch: refetchGrants, } = grantsQuery; const { data: accessSettings, isLoading: accessLoading } = accessQuery; const accessMode = (accessSettings as any)?.mode === "manual" ? "manual" : "all-apps"; const grantBySecretId = useMemo(() => { const map = new Map(); for (const grant of grants as VaultGrant[]) { if (grant.status && grant.status !== "active") continue; map.set(grant.secretId, grant); } return map; }, [grants]); // Track per-secret pending state so a fast double-click on the same row // can't queue two `create-vault-grant` requests (which would silently // create duplicate active grants — a later revoke only clears one). const [pendingSecretIds, setPendingSecretIds] = useState>( () => new Set(), ); const markPending = (secretId: string, pending: boolean) => setPendingSecretIds((prev) => { const next = new Set(prev); if (pending) next.add(secretId); else next.delete(secretId); return next; }); const grantMutation = useActionMutation("create-vault-grant", { onSuccess: () => refetchGrants(), onError: (err) => toast.error(`Could not grant: ${String(err)}`), }); const revokeMutation = useActionMutation("revoke-vault-grant", { onSuccess: () => refetchGrants(), onError: (err) => toast.error(`Could not revoke: ${String(err)}`), }); const syncMutation = useActionMutation("sync-vault-to-app", { onSuccess: (result: any) => { const synced = result?.synced ?? 0; toast.success( synced > 0 ? `Synced ${synced} key${synced === 1 ? "" : "s"} to ${appName}` : `${appName} is up to date`, ); }, onError: (err) => toast.error(`Sync failed: ${String(err)}`), }); const isLoading = orgLoading || secretsLoading || grantsLoading || accessLoading; const error = secretsQuery.error ?? grantsQuery.error ?? accessQuery.error ?? orgError; const grantedCount = grantBySecretId.size; const typedSecrets = secrets as VaultSecret[]; const allApps = accessMode !== "manual"; const toggleSecret = (secret: VaultSecret) => { if (!canManageVault || allApps) return; if (pendingSecretIds.has(secret.id)) return; const existing = grantBySecretId.get(secret.id); markPending(secret.id, true); const onSettled = () => markPending(secret.id, false); if (existing) { revokeMutation.mutate({ grantId: existing.id }, { onSettled }); } else { grantMutation.mutate({ secretId: secret.id, appId }, { onSettled }); } }; return (

Keys for {appName}

{error ? null : !canManageVault ? "Only workspace owners and admins can manage keys." : allApps ? `${typedSecrets.length} available` : `${grantedCount} of ${typedSecrets.length} granted`}

{!error && canManageVault ? ( ) : null}
{error ? ( { void secretsQuery.refetch(); if (canManageVault) void grantsQuery.refetch(); void accessQuery.refetch(); }} /> ) : isLoading ? (
{Array.from({ length: 3 }).map((_, index) => (
))}
) : typedSecrets.length === 0 ? (

No vault keys yet. Add one from the Vault page.

) : ( typedSecrets.map((secret) => { const granted = allApps || grantBySecretId.has(secret.id); const pending = pendingSecretIds.has(secret.id); return ( ); }) )}
); }