fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).
CRITICAL (3):
- C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
no longer silently drop interest links
- C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
- C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
callers must go through /stage with the override-guard chain
HIGH (14/15):
- H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
interests/documents/reservations/reminders/invoices (migration 0070)
- H-02 login page reads ?redirect= param with same-origin guard
- H-03 CRM invite token moves to URL fragment so it never lands in
nginx access logs / Referer headers
- H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
- H-05 toggleAccount writes an audit row
- H-06 upsertSetting masks any value whose key ends with _encrypted
- H-07 archiveClient cascade fires per-interest audit rows
- H-08 createSalesTransporter applies SMTP_TIMEOUTS
- H-09 AppShell stable children — viewport flip across breakpoint no
longer destroys in-progress form drafts
- H-10 portal documents page swaps Unicode glyph status icons for
Lucide CheckCircle2/XCircle/Circle + aria-labels
- H-12 list components swap alert(...) for toast.warning(...)
- H-13 5 icon-only buttons gain aria-label
- H-14 parseBody treats empty bodies as {}
- H-15 admin layout renders a 403 panel instead of silent bounce
- H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet
MEDIUM (28+):
- M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
WHEREs across custom-fields, notes (all 6 entity types x update +
delete), client-contacts, yacht ownerClient lookup, webhook reads
- M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
- M-EM01 portal-auth emails thread through portId
- M-EM02 sendEmail accepts cc/bcc params
- M-EM04 notification_digest catalog key
- M-IN01 portal presigned download URLs use 4h TTL
- M-IN02 OpenAI client lazy-instantiated
- M-IN04 stale pdfme refs updated to pdf-lib AcroForm
- M-IN05 umami.testConnection returns tagged union
- M-L01 reservations tenure_type unified with berths
- M-L02 report-generators canonicalize stage values
- M-AU01 audit log placeholder copy fixed
- M-AU04 outcome_set / outcome_cleared distinct audit verbs
- M-NEW-2 activity feed entity name+type separator
- M-R01 portal allowlist narrowed + portal_session backstop in proxy
- M-SC02 companies archived partial index
- M-SC04 audit_logs.searchText documented as DB-managed
- M-S01 storage_s3_access_key_encrypted admin field
- M-U01 audit log empty state uses <EmptyState>
- M-U09 invoice delete dialog -> <AlertDialog>
- M-U10 toast.success on ClientForm + InterestForm create/edit
- M-U11 settings-form-card logo preview alt text
- M-U14 mobile topbar title on clients/yachts/interests/berths
- M-U15 Invoices in mobile More-sheet
LOW (6/8):
- L-AU01 severity defaults for security-relevant verbs
- L-AU02 +13 missing actions in admin audit filter
- L-AU03 +7 missing entity types in admin audit filter
- L-AU04 dead listAuditLogs stubbed
- L-D02 CLAUDE.md Owner-wins chain tightened
Bonus — Document detail polish (#67 partial, 3/6 deliverables):
- state-aware action button per signer
- watcher Add UI with display-name resolution
- cleanSignerName cleanup
Prior session work bundled in:
- Documenso v2 webhook + envelope-ID normalization + sequential signing
- SigningProgress UI redesign (avatars, per-signer state, timestamps)
- env->admin settings registry + RegistryDrivenForm + encrypted creds
- Embedded-signing card + Test connection + setup help
- Dev-mode EMAIL_REDIRECT_TO banner
- Pipeline rules admin page
- Sales email config card
- Audit log details Sheet
- EOI tab: Finalising badge, absolute timestamps, sequential indicator
- Notes pipeline_stage_at_creation (migration 0069)
- Documenso numeric ID dual-key webhook (migration 0068)
- Dimensions criterion copy (migration 0067)
Tests: 1374/1374 vitest pass. tsc clean. lint clean.
See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
608
src/components/admin/shared/registry-driven-form.tsx
Normal file
608
src/components/admin/shared/registry-driven-form.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
'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<string, ResolvedValue> };
|
||||
}
|
||||
|
||||
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<ResolvedResponse>({
|
||||
queryKey,
|
||||
queryFn: () =>
|
||||
apiFetch<ResolvedResponse>(
|
||||
`/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<Record<string, unknown>>({});
|
||||
// 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<Set<string>>(() => 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 = (
|
||||
<div className="space-y-6">
|
||||
{isLoading || !data ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{groupBySection(data.data.entries).map(([section, entries]) => (
|
||||
<SectionGroup
|
||||
key={section}
|
||||
entries={entries}
|
||||
values={data.data.values}
|
||||
drafts={drafts}
|
||||
setDraft={setDraft}
|
||||
onResolvedRefresh={() => queryClient.invalidateQueries({ queryKey })}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-between gap-3 border-t pt-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{dirtyCount === 0
|
||||
? 'No unsaved changes.'
|
||||
: dirtyCount === 1
|
||||
? '1 unsaved change.'
|
||||
: `${dirtyCount} unsaved changes.`}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => saveAll.mutate()}
|
||||
disabled={saveAll.isPending || dirtyCount === 0}
|
||||
>
|
||||
{saveAll.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1 size-3 animate-spin" /> Saving…
|
||||
</>
|
||||
) : dirtyCount > 0 ? (
|
||||
`Save ${dirtyCount} change${dirtyCount === 1 ? '' : 's'}`
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{extra ? <div className="pt-2">{extra}</div> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!title) return content;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function groupBySection(entries: RegistryClientEntry[]): Array<[string, RegistryClientEntry[]]> {
|
||||
const map = new Map<string, RegistryClientEntry[]>();
|
||||
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<string, ResolvedValue>;
|
||||
drafts: Record<string, unknown>;
|
||||
setDraft: (key: string, value: unknown, opts?: { dirty?: boolean }) => void;
|
||||
onResolvedRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry) => (
|
||||
<SettingField
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
resolved={values[entry.key]}
|
||||
draft={drafts[entry.key]}
|
||||
setDraft={(value, opts) => setDraft(entry.key, value, opts)}
|
||||
onResolvedRefresh={onResolvedRefresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-1.5 border-l-2 border-l-muted pl-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={entry.key} className="text-sm font-medium">
|
||||
{entry.label}
|
||||
</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{source === 'port' && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 size-3" />
|
||||
Port override
|
||||
</Badge>
|
||||
)}
|
||||
{source === 'global' && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Global
|
||||
</Badge>
|
||||
)}
|
||||
{showFallbackBadge && resolved?.isSet && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Using env fallback
|
||||
</Badge>
|
||||
)}
|
||||
{showFallbackBadge && !resolved?.isSet && (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
Not set
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{entry.description && <p className="text-xs text-muted-foreground">{entry.description}</p>}
|
||||
|
||||
<FieldInput
|
||||
entry={entry}
|
||||
value={draft}
|
||||
onChange={(v) => {
|
||||
// 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}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
{canCopyFromEnv && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyFromEnv.mutate()}
|
||||
disabled={copyFromEnv.isPending}
|
||||
>
|
||||
<Download className="mr-1 size-3" />
|
||||
Copy from env
|
||||
</Button>
|
||||
)}
|
||||
{(source === 'port' || source === 'global') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => revert.mutate()}
|
||||
disabled={revert.isPending}
|
||||
>
|
||||
Revert to fallback
|
||||
</Button>
|
||||
)}
|
||||
{entry.sensitive && entry.type === 'password' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={reveal.isPending}
|
||||
onClick={() => {
|
||||
if (showSecret) {
|
||||
// Hide. If this draft came from the server reveal, drop it so
|
||||
// we don't keep cleartext in component state past the toggle.
|
||||
if (revealedFromServer) {
|
||||
setDraft('', { dirty: false });
|
||||
setRevealedFromServer(false);
|
||||
}
|
||||
setShowSecret(false);
|
||||
return;
|
||||
}
|
||||
// Show. If the operator hasn't typed anything yet and the
|
||||
// setting is saved on the server, ask the API for cleartext.
|
||||
const hasLocalDraft = typeof draft === 'string' && draft.length > 0;
|
||||
if (
|
||||
!hasLocalDraft &&
|
||||
resolved?.isSet &&
|
||||
(resolved.source === 'port' || resolved.source === 'global')
|
||||
) {
|
||||
reveal.mutate();
|
||||
} else {
|
||||
setShowSecret(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{reveal.isPending ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : showSecret ? (
|
||||
<EyeOff className="size-3" />
|
||||
) : (
|
||||
<Eye className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Switch id={entry.key} checked={!!value} onCheckedChange={(checked) => onChange(checked)} />
|
||||
);
|
||||
}
|
||||
if (entry.type === 'user-select') {
|
||||
return (
|
||||
<UserSelectInput
|
||||
id={entry.key}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder ?? 'No CRM user linked'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Select value={selectValue} onValueChange={(v) => onChange(v)}>
|
||||
<SelectTrigger id={entry.key}>
|
||||
<SelectValue placeholder={placeholder ?? 'Choose…'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{entry.options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
if (entry.type === 'textarea') {
|
||||
return (
|
||||
<Textarea
|
||||
id={entry.key}
|
||||
value={String(value ?? '')}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
id={entry.key}
|
||||
type={
|
||||
entry.type === 'password' && !showSecret
|
||||
? 'password'
|
||||
: entry.type === 'number'
|
||||
? 'number'
|
||||
: entry.type === 'email'
|
||||
? 'email'
|
||||
: entry.type === 'url'
|
||||
? 'url'
|
||||
: 'text'
|
||||
}
|
||||
value={sensitive && !showSecret && value === '' ? '' : String(value ?? '')}
|
||||
placeholder={sensitive ? '••••••••' : placeholder}
|
||||
onChange={(e) =>
|
||||
onChange(entry.type === 'number' ? Number(e.target.value || 0) : e.target.value)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PickerUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Radix Select of every user in the current port. Stores the
|
||||
* user's UUID. A "no link" sentinel value lets the operator clear the
|
||||
* binding (Radix can't store empty string as a value, so we map empty ↔
|
||||
* `__none__` over the wire).
|
||||
*/
|
||||
function UserSelectInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const { data, isLoading } = useQuery<{ data: PickerUser[] }>({
|
||||
queryKey: ['admin', 'users', 'picker'],
|
||||
queryFn: () => apiFetch<{ data: PickerUser[] }>('/api/v1/admin/users/picker'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const NONE = '__none__';
|
||||
const selectValue = value ? value : NONE;
|
||||
return (
|
||||
<Select value={selectValue} onValueChange={(v) => onChange(v === NONE ? '' : v)}>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder={isLoading ? 'Loading users…' : placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>— No CRM user linked —</SelectItem>
|
||||
{(data?.data ?? []).map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.name || u.email} {u.name ? `· ${u.email}` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -417,7 +417,14 @@ function ImageUploadField({
|
||||
<div className="h-20 w-20 shrink-0 rounded-md border bg-muted/30 flex items-center justify-center overflow-hidden">
|
||||
{}
|
||||
{value ? (
|
||||
<img src={value} alt="" className="h-full w-full object-contain" />
|
||||
<img
|
||||
src={value}
|
||||
// M-U11: describe the preview so screen readers don't say
|
||||
// "image" with no context. Falls back to a generic label
|
||||
// when no field.label is set.
|
||||
alt={`${field.label || 'Settings'} preview`}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">No image</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user