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 { Eye, Loader2, MoreHorizontal, Pencil, Plus, Search, Trash2 } from "lucide-react" import { 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 { 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 { Textarea } from "@/components/ui/textarea" // ---------- Schemas ---------- const jsonTransform = z .string() .min(2, "JSON required") .refine((v) => { try { JSON.parse(v) return true } catch { return false } }, "Invalid JSON") .transform((v) => JSON.parse(v)) 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: jsonTransform, 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)), // Secrets are always JSON — makes rotate easy on update form too secret: jsonTransform, }) type CreateCredentialInput = z.input type CreateCredentialValues = z.infer const updateCredentialSchema = createCredentialSchema.partial().extend({ // allow rotating secret independently secret: jsonTransform.optional(), 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 toFormDefaults>(initial: Partial) { return { schema_version: 1, scope_version: 1, ...initial, } as any } // ---------- 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 qc = useQueryClient() // List const credentialQ = useQuery({ queryKey: ["credentials"], queryFn: () => credentialsApi.listCredentials(), }) // Create const createMutation = useMutation({ mutationFn: (body: CreateCredentialValues) => credentialsApi.createCredential({ provider: body.provider, kind: body.kind, schema_version: body.schema_version ?? 1, name: body.name, scope_kind: body.scope_kind, scope_version: body.scope_version ?? 1, scope: body.scope, account_id: body.account_id, region: body.region, secret: body.secret, }), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["credentials"] }) toast.success("Credential created") setCreateOpen(false) createForm.reset(createDefaults) // clear JSON textareas etc }, onError: (err: any) => { toast.error("Failed to create credential", { description: err?.message ?? "Unknown error", }) }, }) // Update const updateMutation = useMutation({ mutationFn: (payload: { id: string; body: z.infer }) => credentialsApi.updateCredential(payload.id, payload.body), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["credentials"] }) toast.success("Credential updated") setEditOpen(false) setEditingId(null) }, onError: (err: any) => { toast.error("Failed to update credential", { description: err?.message ?? "Unknown error", }) }, }) // 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: err?.message ?? "Unknown error", }) }, }) // Reveal (one-time read) 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: err?.message ?? "Unknown error", }) }, }) // ---------- Forms ---------- const createDefaults: CreateCredentialInput = toFormDefaults({ provider: "aws", kind: "aws_access_key", schema_version: 1, scope_kind: "provider", scope_version: 1, name: "", // IMPORTANT: default valid JSON strings so zod.transform succeeds scope: "{}" as any, secret: "{}" as any, account_id: "", region: "", }) const createForm = useForm({ resolver: zodResolver(createCredentialSchema), defaultValues: createDefaults, mode: "onBlur", }) const editForm = useForm>({ resolver: zodResolver(updateCredentialSchema), defaultValues: { // populated on open }, 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 ?? "", // show JSON in textareas scope: pretty(row.scope ?? {}), // secret is optional on update; leave empty to avoid rotate secret: undefined, } as any) setEditOpen(true) } 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)}
) return (

Credentials

setFilter(e.target.value)} placeholder="Search by name, provider, kind, scope…" className="w-64 pl-8" />
Create Credential
createMutation.mutate(values as CreateCredentialValues) )} className="space-y-4 pt-2" >
( Provider )} /> ( Kind )} /> ( Scope Kind )} /> ( Name )} /> ( Account ID (optional) )} /> ( Region (optional) )} />
( Scope (JSON)