import { createContext, useContext, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, KeyRound, Loader2, Plus, X } from "lucide-react"; import type { CompanySecret, SecretVersionSelector } from "@paperclipai/shared"; import { secretsApi } from "../api/secrets"; import { queryKeys } from "../lib/queryKeys"; import { useCompany } from "../context/CompanyContext"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { cn } from "../lib/utils"; export interface SecretBindingValue { secretId: string; version?: SecretVersionSelector; } /** * Metadata for bound secrets the current company's list cannot show — e.g. * an instance-scoped environment referencing a secret owned by another * company. Keyed by secret id. Editors that can read instance-level * secret-ref descriptors provide it; everywhere else the context is absent * and the picker falls back to its generic missing-secret treatment. */ export interface SecretRefHint { name: string; status: string; companyId: string; companyName: string | null; } /** * `status` reports the descriptor request itself, so the picker never claims * a secret is missing while the lookup is still loading or has failed — * only a `ready` map is authoritative about unknown ids. */ export interface SecretRefHintsContextValue { status: "loading" | "error" | "ready"; hints: Record; } export const SecretRefHintsContext = createContext(undefined); interface SecretBindingPickerProps { value: SecretBindingValue | null; onChange: (next: SecretBindingValue | null) => void; label?: string; placeholder?: string; allowVersionSelector?: boolean; emptyHint?: string; className?: string; disabled?: boolean; /** * Optional whitelist of secret statuses to show. Defaults to "active". * Pass null to disable the filter and show every secret in the company. */ statusFilter?: Array | null; } const VERSION_LATEST: SecretVersionSelector = "latest"; function describeSecret(secret: CompanySecret): string { const provider = secret.provider.replaceAll("_", " "); if (secret.managedMode === "external_reference") { return `External · ${provider}`; } return provider; } function statusTone(status: CompanySecret["status"]): string { switch (status) { case "active": return "text-emerald-600 dark:text-emerald-400"; case "disabled": return "text-amber-600 dark:text-amber-400"; case "archived": return "text-muted-foreground"; case "deleted": return "text-destructive"; default: return "text-muted-foreground"; } } export function SecretBindingPicker({ value, onChange, label = "Secret", placeholder = "Select secret", allowVersionSelector = true, emptyHint = "No matching secrets. Create one to bind it here.", className, disabled, statusFilter = ["active"], }: SecretBindingPickerProps) { const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); const [createOpen, setCreateOpen] = useState(false); const [createName, setCreateName] = useState(""); const [createValue, setCreateValue] = useState(""); const [createDescription, setCreateDescription] = useState(""); const [createError, setCreateError] = useState(null); const secretsQuery = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "__disabled__"], queryFn: () => secretsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId), }); const filteredSecrets = useMemo(() => { const all = secretsQuery.data ?? []; if (statusFilter === null) return all; return all.filter((secret) => statusFilter.includes(secret.status)); }, [secretsQuery.data, statusFilter]); const selectedSecret = useMemo(() => { if (!value) return null; return (secretsQuery.data ?? []).find((secret) => secret.id === value.secretId) ?? null; }, [secretsQuery.data, value]); const selectedMissing = Boolean(value && !selectedSecret); const hintsContext = useContext(SecretRefHintsContext); const missingHint = selectedMissing && value ? hintsContext?.hints[value.secretId] : undefined; // Only an active cross-company secret is healthy: runtime resolution // rejects disabled/archived/deleted secrets, so those must not be // presented as working bindings. const crossCompanyHint = missingHint && missingHint.status === "active" ? missingHint : undefined; const hintsPending = selectedMissing && !missingHint && hintsContext !== undefined && hintsContext.status !== "ready"; const calmMissing = Boolean(crossCompanyHint) || hintsPending; const createMutation = useMutation({ mutationFn: () => secretsApi.create(selectedCompanyId!, { name: createName.trim(), value: createValue, description: createDescription.trim() || null, }), onSuccess: (created) => { queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId!) }); onChange({ secretId: created.id, version: VERSION_LATEST }); setCreateOpen(false); setCreateName(""); setCreateValue(""); setCreateDescription(""); setCreateError(null); }, onError: (error) => { setCreateError(error instanceof Error ? error.message : "Failed to create secret"); }, }); const versionDisplay = (selector: SecretVersionSelector | undefined) => { if (selector === undefined || selector === VERSION_LATEST) return "latest"; return `v${selector}`; }; return (
{label ? (
{label} {value ? ( ) : null}
) : null}
{allowVersionSelector ? ( ) : null}
{selectedSecret ? (

{selectedSecret.status !== "active" ? `Status: ${selectedSecret.status}. ` : null} Bound to {versionDisplay(value?.version)} · {selectedSecret.key}

) : crossCompanyHint ? (

Owned by {crossCompanyHint.companyName ? `the ${crossCompanyHint.companyName} organization` : "another organization"}. The binding keeps working; selecting a secret from this list re-points it here.

) : missingHint ? (

{missingHint.status === "deleted" ? "The previously selected secret was deleted. Pick another or remove the binding." : `This secret is ${missingHint.status}; runs cannot resolve it until it is active again.`}

) : hintsPending ? (

{hintsContext?.status === "error" ? "Could not load this secret reference's details." : "Checking this secret reference…"}

) : selectedMissing ? (

The previously selected secret is no longer available. Pick another or remove the binding.

) : (filteredSecrets.length === 0 && !secretsQuery.isPending) ? (

{emptyHint}

) : null} Create new secret
setCreateName(event.target.value)} placeholder="OPENAI_API_KEY" autoFocus />