import { useMemo, useState } from "react" import { credentialsApi } from "@/api/credentials" import { zodResolver } from "@hookform/resolvers/zod" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { AlertTriangle, Eye, Loader2, MoreHorizontal, Pencil, Plus, Search, Trash2, } from "lucide-react" import { Controller, useForm } from "react-hook-form" import { toast } from "sonner" import { z } from "zod" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { Textarea } from "@/components/ui/textarea" // -------------------- Constants -------------------- const AWS_ALLOWED_SERVICES = ["route53", "s3", "ec2", "iam", "rds", "dynamodb"] as const type AwsSvc = (typeof AWS_ALLOWED_SERVICES)[number] // -------------------- Schemas -------------------- const createCredentialSchema = z .object({ provider: z.enum(["aws", "cloudflare", "hetzner", "digitalocean", "generic"]), kind: z.enum(["aws_access_key", "api_token", "basic_auth", "oauth2"]), schema_version: z.number().default(1), name: z.string().min(1, "Name is required").max(100), scope_kind: z.enum(["provider", "service", "resource"]), scope_version: z.number().default(1), scope: z.any(), account_id: z .string() .optional() .or(z.literal("")) .transform((v) => (v ? v : undefined)), region: z .string() .optional() .or(z.literal("")) .transform((v) => (v ? v : undefined)), secret: z.any(), }) .superRefine((val, ctx) => { if (val.provider === "aws") { if (val.scope_kind === "service") { const svc = (val.scope as any)?.service if (!AWS_ALLOWED_SERVICES.includes(svc)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["scope"], message: `For AWS service scope, "service" must be one of: ${AWS_ALLOWED_SERVICES.join(", ")}`, }) } } if (val.scope_kind === "resource") { const arn = (val.scope as any)?.arn if (typeof arn !== "string" || !arn.startsWith("arn:")) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["scope"], message: `For AWS resource scope, "arn" must start with "arn:"`, }) } } if (val.kind === "aws_access_key") { const sk = val.secret ?? {} const id = sk.access_key_id if (typeof id !== "string" || !/^[A-Z0-9]{20}$/.test(id)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["secret"], message: `access_key_id must be 20 chars (A-Z0-9)`, }) } if (typeof sk.secret_access_key !== "string" || sk.secret_access_key.length < 10) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["secret"], message: `secret_access_key is required`, }) } } } if (val.kind === "api_token") { const token = (val.secret ?? {}).token if (!token) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["secret"], message: `token is required`, }) } } if (val.kind === "basic_auth") { const s = val.secret ?? {} if (!s.username || !s.password) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["secret"], message: `username and password are required`, }) } } if (val.kind === "oauth2") { const s = val.secret ?? {} if (!s.client_id || !s.client_secret || !s.refresh_token) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["secret"], message: `client_id, client_secret, and refresh_token are required`, }) } } if (val.scope_kind !== "provider" && !val.scope) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["scope"], message: `scope is required`, }) } }) type CreateCredentialValues = z.infer const updateCredentialSchema = createCredentialSchema.partial().extend({ name: z.string().min(1, "Name is required").max(100).optional(), }) // -------------------- Helpers -------------------- function pretty(obj: unknown) { try { return JSON.stringify(obj, null, 2) } catch { return "" } } function extractErr(e: any): string { const raw = (e as any)?.body ?? (e as any)?.response ?? (e as any)?.message if (typeof raw === "string") return raw try { const msg = (e as any)?.response?.data?.message || (e as any)?.message if (msg) return String(msg) } catch {} return "Unknown error" } function isAwsServiceScope({ provider, scope_kind }: { provider?: string; scope_kind?: string }) { return provider === "aws" && scope_kind === "service" } function isAwsResourceScope({ provider, scope_kind }: { provider?: string; scope_kind?: string }) { return provider === "aws" && scope_kind === "resource" } function isProviderScope({ scope_kind }: { scope_kind?: string }) { return scope_kind === "provider" } function defaultCreateValues(): CreateCredentialValues { return { provider: "aws", kind: "aws_access_key", schema_version: 1, name: "", scope_kind: "provider", scope_version: 1, scope: {}, account_id: "", region: "", secret: {}, } } // Build exact POST body as the SDK sends it function buildCreateBody(v: CreateCredentialValues) { return { provider: v.provider, kind: v.kind, schema_version: v.schema_version ?? 1, name: v.name, scope_kind: v.scope_kind, scope_version: v.scope_version ?? 1, scope: v.scope ?? {}, account_id: v.account_id, region: v.region, secret: v.secret ?? {}, } } // Build exact PATCH body (only provided fields) function buildUpdateBody(v: z.infer) { const body: any = {} const keys: (keyof typeof v)[] = [ "name", "account_id", "region", "scope_kind", "scope_version", "scope", "secret", "provider", "kind", "schema_version", ] for (const k of keys) { if (typeof v[k] !== "undefined" && v[k] !== "") body[k] = v[k] } return body } // -------------------- Page -------------------- export const CredentialPage = () => { const [filter, setFilter] = useState("") const [createOpen, setCreateOpen] = useState(false) const [editOpen, setEditOpen] = useState(false) const [revealOpen, setRevealOpen] = useState(false) const [revealJson, setRevealJson] = useState(null) const [editingId, setEditingId] = useState(null) const [useRawSecretJSON, setUseRawSecretJSON] = useState(false) const [useRawEditSecretJSON, setUseRawEditSecretJSON] = useState(false) // Preview modals const [previewCreateOpen, setPreviewCreateOpen] = useState(false) const [previewCreateBody, setPreviewCreateBody] = useState(null) const [previewUpdateOpen, setPreviewUpdateOpen] = useState(false) const [previewUpdateBody, setPreviewUpdateBody] = useState(null) const qc = useQueryClient() // List const credentialQ = useQuery({ queryKey: ["credentials"], queryFn: () => credentialsApi.listCredentials(), }) // Create const createMutation = useMutation({ mutationFn: (body: CreateCredentialValues) => credentialsApi.createCredential(buildCreateBody(body) as any), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["credentials"] }) toast.success("Credential created") setCreateOpen(false) createForm.reset(defaultCreateValues()) setUseRawSecretJSON(false) }, onError: (err: any) => { toast.error("Failed to create credential", { description: extractErr(err) }) }, }) // Update const updateMutation = useMutation({ mutationFn: (payload: { id: string; body: z.infer }) => credentialsApi.updateCredential(payload.id, buildUpdateBody(payload.body)), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["credentials"] }) toast.success("Credential updated") setEditOpen(false) setEditingId(null) setUseRawEditSecretJSON(false) }, onError: (err: any) => { toast.error("Failed to update credential", { description: extractErr(err) }) }, }) // Delete const deleteMutation = useMutation({ mutationFn: (id: string) => credentialsApi.deleteCredential(id), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["credentials"] }) toast.success("Credential deleted") }, onError: (err: any) => { toast.error("Failed to delete credential", { description: extractErr(err) }) }, }) // Reveal const revealMutation = useMutation({ mutationFn: (id: string) => credentialsApi.revealCredential(id), onSuccess: (data) => { setRevealJson(data) setRevealOpen(true) }, onError: (err: any) => { toast.error("Failed to reveal secret", { description: extractErr(err) }) }, }) // ---------- Forms ---------- const createForm = useForm({ resolver: zodResolver(createCredentialSchema), defaultValues: defaultCreateValues(), mode: "onBlur", }) const editForm = useForm>({ resolver: zodResolver(updateCredentialSchema), defaultValues: {}, mode: "onBlur", }) function openEdit(row: any) { setEditingId(row.id) editForm.reset({ provider: row.provider, kind: row.kind, schema_version: row.schema_version ?? 1, name: row.name, scope_kind: row.scope_kind, scope_version: row.scope_version ?? 1, account_id: row.account_id ?? "", region: row.region ?? "", scope: row.scope ?? (row.scope_kind === "provider" ? {} : undefined), secret: undefined, } as any) setUseRawEditSecretJSON(false) setEditOpen(true) } // Derived lists const filtered = useMemo(() => { const items = credentialQ.data ?? [] if (!filter.trim()) return items const f = filter.toLowerCase() return items.filter((c: any) => [ c.name, c.provider, c.kind, c.scope_kind, c.account_id, c.region, JSON.stringify(c.scope ?? {}), ] .filter(Boolean) .map((x: any) => String(x).toLowerCase()) .some((s: string) => s.includes(f)) ) }, [credentialQ.data, filter]) // -------------------- UI -------------------- if (credentialQ.isLoading) return (
Loading credentials…
) if (credentialQ.error) return (
Error loading credentials.
{JSON.stringify(credentialQ.error, null, 2)}
) // Create form watchers const provider = createForm.watch("provider") const kind = createForm.watch("kind") const scopeKind = createForm.watch("scope_kind") const setCreateScope = (obj: any) => createForm.setValue("scope", obj, { shouldDirty: true, shouldValidate: true }) const setCreateSecret = (obj: any) => createForm.setValue("secret", obj, { shouldDirty: true, shouldValidate: true }) function ensureCreateDefaultsForSecret() { if (useRawSecretJSON) return if (provider === "aws" && kind === "aws_access_key") { const s = createForm.getValues("secret") ?? {} setCreateSecret({ access_key_id: s.access_key_id ?? "", secret_access_key: s.secret_access_key ?? "", }) } else if (kind === "api_token") { const s = createForm.getValues("secret") ?? {} setCreateSecret({ token: s.token ?? "" }) } else if (kind === "basic_auth") { const s = createForm.getValues("secret") ?? {} setCreateSecret({ username: s.username ?? "", password: s.password ?? "" }) } else if (kind === "oauth2") { const s = createForm.getValues("secret") ?? {} setCreateSecret({ client_id: s.client_id ?? "", client_secret: s.client_secret ?? "", refresh_token: s.refresh_token ?? "", }) } } function onChangeCreateScopeKind(next: "provider" | "service" | "resource") { createForm.setValue("scope_kind", next) if (next === "provider") setCreateScope({}) if (next === "service") setCreateScope({ service: "route53" as AwsSvc }) if (next === "resource") setCreateScope({ arn: "" }) } return (

Credentials

Store provider credentials. Secrets are encrypted server-side; revealing is a one-time read.

setFilter(e.target.value)} placeholder="Search by name, provider, kind, scope…" className="w-64 pl-8" />
Create Credential
{ const parsed = createCredentialSchema.safeParse(values) if (!parsed.success) { toast.error("Please fix validation errors") return } createMutation.mutate(parsed.data) })} className="space-y-5 pt-2" >
( Provider )} /> ( Kind )} /> ( Scope Kind )} /> ( Name )} /> ( Account ID (optional) )} /> ( Region (optional) )} />
{/* Scope UI (create) */} {!isProviderScope({ scope_kind: scopeKind }) && ( <> {isAwsServiceScope({ provider, scope_kind: scopeKind }) ? ( Service ( )} />

Must be one of: {AWS_ALLOWED_SERVICES.join(", ")}.

) : isAwsResourceScope({ provider, scope_kind: scopeKind }) ? ( Resource ARN ( field.onChange({ arn: e.target.value })} placeholder="arn:aws:service:region:account:resource" /> )} /> ) : ( ( Scope (JSON)