'use client'; import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { CheckCircle2, Download, Eye, EyeOff, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; type SettingType = | 'string' | 'password' | 'number' | 'boolean' | 'select' | 'url' | 'email' | 'textarea' | 'user-select'; type SettingSource = 'port' | 'global' | 'env' | 'default'; interface RegistryClientEntry { key: string; section: string; label: string; description: string; type: SettingType; options?: Array<{ value: string; label: string }>; encrypted: boolean; sensitive: boolean; scope: 'port' | 'global'; envFallback?: string; placeholder?: string; defaultValue?: string | number | boolean | null; } interface ResolvedValue { key: string; source: SettingSource; isSet: boolean; value?: unknown; } interface ResolvedResponse { data: { entries: RegistryClientEntry[]; values: Record }; } interface Props { /** Section names from the registry to render (e.g. ['documenso.api', 'documenso.signers']). */ sections: string[]; /** Card-level title; omit to render fields without a card wrapper. */ title?: string; /** Card-level description. */ description?: string; /** Optional slot below the form (e.g. test-connection button). */ extra?: ReactNode; } /** * Generates an editable settings form from the central registry. Renders the * "Using env fallback" badge on each field whose resolved source is `env` * (or `default`), plus a "Copy from env" button when an env value exists to * one-click migrate the value into the admin DB. * * Encrypted / sensitive fields show ••• placeholder text and never receive * the actual cleartext from the server. Saving an empty value on these * fields is a no-op (use the explicit DELETE button to revert). */ export function RegistryDrivenForm({ sections, title, description, extra }: Props) { const queryKey = ['settings', 'resolved', ...sections]; const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ queryKey, queryFn: () => apiFetch( `/api/v1/admin/settings/resolved?sections=${sections.map(encodeURIComponent).join(',')}`, ), }); // Lifted draft state - every field's current input value is held here so // a card-level "Save N changes" button can write them all in one batch. // Sensitive fields seed as empty (we never seed cleartext from the server); // non-sensitive fields seed from the resolved value. const [drafts, setDrafts] = useState>({}); // A field is "dirty" only after the operator types into it. Server-driven // events (eye-toggle reveal, copy-from-env autofill) explicitly clear the // dirty flag for that key so they don't trigger a phantom save. const [dirtyKeys, setDirtyKeys] = useState>(() => new Set()); // Re-seed drafts whenever the resolved-values query refreshes (after a // successful save, revert, or copy-from-env) so values reflect server // state. Preserves any in-progress edits the user is making. useEffect(() => { if (!data) return; // eslint-disable-next-line react-hooks/set-state-in-effect setDrafts((prev) => { const next = { ...prev }; for (const entry of data.data.entries) { if (dirtyKeys.has(entry.key)) continue; // don't trample in-progress edits if (entry.encrypted || entry.sensitive) { next[entry.key] = ''; } else { next[entry.key] = data.data.values[entry.key]?.value ?? ''; } } return next; }); }, [data, dirtyKeys]); const setDraft = useCallback((key: string, value: unknown, opts?: { dirty?: boolean }) => { setDrafts((prev) => ({ ...prev, [key]: value })); if (opts?.dirty !== undefined) { setDirtyKeys((prev) => { const next = new Set(prev); if (opts.dirty) next.add(key); else next.delete(key); return next; }); } }, []); // Card-level bulk save. Fires one PUT per dirty field in parallel so the // common case ("admin tweaks five fields, hits Save") is one round-trip // worth of latency rather than five. Partial failures are surfaced // per-field via toast; the resolved-values query gets invalidated once // even on partial success so the UI reflects what landed. const saveAll = useMutation({ mutationFn: async () => { if (!data) return { succeeded: [] as string[], failed: [] as Array<{ key: string; error: unknown }> }; const dirty = Array.from(dirtyKeys); const settled = await Promise.allSettled( dirty.map(async (key) => { await apiFetch(`/api/v1/admin/settings/${encodeURIComponent(key)}`, { method: 'PUT', body: { value: drafts[key] }, }); return key; }), ); const succeeded: string[] = []; const failed: Array<{ key: string; error: unknown }> = []; settled.forEach((r, i) => { const key = dirty[i]!; if (r.status === 'fulfilled') succeeded.push(key); else failed.push({ key, error: r.reason }); }); return { succeeded, failed }; }, onSuccess: ({ succeeded, failed }) => { // Clear dirty flags for the keys that landed; leave failed ones dirty // so the operator can fix + retry. if (succeeded.length > 0) { setDirtyKeys((prev) => { const next = new Set(prev); for (const k of succeeded) next.delete(k); return next; }); toast.success( succeeded.length === 1 ? `Saved 1 setting` : `Saved ${succeeded.length} settings`, ); } for (const f of failed) { const label = data?.data.entries.find((e) => e.key === f.key)?.label ?? f.key; toastError(f.error, `Failed to save ${label}`); } void queryClient.invalidateQueries({ queryKey }); }, onError: (err) => toastError(err, 'Failed to save settings'), }); const dirtyCount = dirtyKeys.size; const content = (
{isLoading || !data ? (
) : ( <> {groupBySection(data.data.entries).map(([section, entries]) => ( queryClient.invalidateQueries({ queryKey })} /> ))}
{dirtyCount === 0 ? 'No unsaved changes.' : dirtyCount === 1 ? '1 unsaved change.' : `${dirtyCount} unsaved changes.`}
)} {extra ?
{extra}
: null}
); if (!title) return content; return ( {title} {description ? {description} : null} {content} ); } function groupBySection(entries: RegistryClientEntry[]): Array<[string, RegistryClientEntry[]]> { const map = new Map(); for (const e of entries) { const existing = map.get(e.section); if (existing) existing.push(e); else map.set(e.section, [e]); } return Array.from(map.entries()); } function SectionGroup({ entries, values, drafts, setDraft, onResolvedRefresh, }: { entries: RegistryClientEntry[]; values: Record; drafts: Record; setDraft: (key: string, value: unknown, opts?: { dirty?: boolean }) => void; onResolvedRefresh: () => void; }) { return (
{entries.map((entry) => ( setDraft(entry.key, value, opts)} onResolvedRefresh={onResolvedRefresh} /> ))}
); } function SettingField({ entry, resolved, draft, setDraft, onResolvedRefresh, }: { entry: RegistryClientEntry; resolved: ResolvedValue | undefined; draft: unknown; setDraft: (value: unknown, opts?: { dirty?: boolean }) => void; onResolvedRefresh: () => void; }) { const [showSecret, setShowSecret] = useState(false); // Tracks whether `draft` currently holds a server-revealed value (vs. // something the operator just typed). Lets the toggle button hide the // revealed value cleanly without wiping a fresh edit. const [revealedFromServer, setRevealedFromServer] = useState(false); const reveal = useMutation({ mutationFn: async () => { const r = await apiFetch<{ data: { revealed: boolean; value: string | null } }>( `/api/v1/admin/settings/${encodeURIComponent(entry.key)}/reveal`, { method: 'POST' }, ); return r.data; }, onSuccess: (r) => { if (r.revealed && r.value != null) { // Server reveal - populate draft but do NOT mark dirty (the value // already matches what's stored). setDraft(r.value, { dirty: false }); setRevealedFromServer(true); setShowSecret(true); } else { toast.info(`${entry.label} isn't set - nothing to reveal.`); } }, onError: (err) => toastError(err, `Failed to reveal ${entry.label}`), }); const revert = useMutation({ mutationFn: async () => { await apiFetch(`/api/v1/admin/settings/${encodeURIComponent(entry.key)}`, { method: 'DELETE', }); }, onSuccess: () => { toast.success(`${entry.label} reverted to default`); setDraft('', { dirty: false }); onResolvedRefresh(); }, onError: (err) => toastError(err, `Failed to revert ${entry.label}`), }); const copyFromEnv = useMutation({ mutationFn: async () => { const r = await apiFetch<{ data: { copied: boolean; envValue?: string } }>( `/api/v1/admin/settings/${encodeURIComponent(entry.key)}/copy-from-env`, { method: 'POST' }, ); return r.data; }, onSuccess: (r) => { if (r.copied) { toast.success(`${entry.label} copied from env`); if (r.envValue && !entry.sensitive) setDraft(r.envValue, { dirty: false }); } else { toast.info(`No env value to copy for ${entry.label}`); } onResolvedRefresh(); }, onError: (err) => toastError(err, `Failed to copy ${entry.label} from env`), }); const source = resolved?.source ?? 'default'; const showFallbackBadge = source === 'env' || source === 'default'; const canCopyFromEnv = !!entry.envFallback && source === 'env'; return (
{source === 'port' && ( Port override )} {source === 'global' && ( Global )} {showFallbackBadge && resolved?.isSet && ( Using env fallback )} {showFallbackBadge && !resolved?.isSet && ( Not set )}
{entry.description &&

{entry.description}

} { // User typing → mark dirty so the card-level Save button picks it up. setDraft(v, { dirty: true }); // A fresh keystroke supersedes any prior server-reveal. if (revealedFromServer) setRevealedFromServer(false); }} showSecret={showSecret} sensitive={entry.sensitive} placeholder={entry.placeholder} />
{canCopyFromEnv && ( )} {(source === 'port' || source === 'global') && ( )} {entry.sensitive && entry.type === 'password' && ( )}
); } function FieldInput({ entry, value, onChange, showSecret, sensitive, placeholder, }: { entry: RegistryClientEntry; value: unknown; onChange: (v: unknown) => void; showSecret: boolean; sensitive: boolean; placeholder?: string; }) { if (entry.type === 'boolean') { return ( onChange(checked)} /> ); } if (entry.type === 'user-select') { return ( ); } if (entry.type === 'select' && entry.options) { // Radix Select rejects an empty-string `value` because that's its internal // sentinel for "cleared". Pass `undefined` instead so the placeholder // renders cleanly when the resolved value is null/blank. const selectValue = value == null || value === '' ? undefined : String(value); return ( ); } if (entry.type === 'textarea') { return (