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:
2026-05-18 13:28:50 +02:00
parent 397dbd1490
commit 4b5f85cb7d
158 changed files with 12255 additions and 1303 deletions

View File

@@ -14,6 +14,7 @@ import {
FileSignature,
FileText,
FileUp,
GitBranch,
Inbox,
ListChecks,
Mail,
@@ -117,6 +118,14 @@ const GROUPS: AdminGroup[] = [
'API credentials, EOI template, and default in-app vs external signing pathway.',
icon: FileSignature,
},
{
href: 'pipeline-rules',
label: 'Pipeline auto-advance',
description:
'Per-trigger control: which lifecycle events (EOI signed, deposit received, contract signed) auto-advance the deal stage.',
icon: GitBranch,
keywords: ['pipeline', 'auto-advance', 'stage rules', 'aggressive', 'conservative'],
},
{
href: 'reminders',
label: 'Reminders',

View File

@@ -1,6 +1,7 @@
'use client';
import { Activity, Clock, Eye, Pencil, Plus, Trash2, User } from 'lucide-react';
import { useState } from 'react';
import { Activity, ChevronDown, Clock, Eye, Pencil, Plus, Trash2, User } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
@@ -72,8 +73,14 @@ interface AuditLogCardProps {
}
export function AuditLogCard({ entry }: AuditLogCardProps) {
const [expanded, setExpanded] = useState(false);
const accentClass = ACTION_ACCENT[entry.action] ?? 'bg-slate-300';
const badgeColor = ACTION_BADGE_COLORS[entry.action] ?? 'bg-gray-500';
const hasDetail =
Boolean(entry.oldValue) ||
Boolean(entry.newValue) ||
Boolean(entry.metadata) ||
Boolean(entry.userAgent);
const entityTitle = `${entry.entityType.charAt(0).toUpperCase()}${entry.entityType.slice(1)}${
entry.entityId ? ` ${entry.entityId.slice(0, 8)}` : ''
@@ -153,7 +160,78 @@ export function AuditLogCard({ entry }: AuditLogCardProps) {
) : null}
</>
) : null}
{hasDetail ? (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="ml-auto inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
aria-expanded={expanded}
>
<ChevronDown
className={cn(
'h-3 w-3 transition-transform',
expanded ? 'rotate-180' : 'rotate-0',
)}
aria-hidden
/>
{expanded ? 'Hide details' : 'Show details'}
</button>
) : null}
</div>
{expanded && hasDetail ? (
<div className="mt-3 space-y-2 rounded-md border bg-muted/30 p-3 text-xs">
{entry.oldValue ? (
<details>
<summary className="cursor-pointer font-semibold text-muted-foreground">
Old value
</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-background p-2 font-mono text-[11px]">
{JSON.stringify(entry.oldValue, null, 2)}
</pre>
</details>
) : null}
{entry.newValue ? (
<details open>
<summary className="cursor-pointer font-semibold text-muted-foreground">
New value
</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-background p-2 font-mono text-[11px]">
{JSON.stringify(entry.newValue, null, 2)}
</pre>
</details>
) : null}
{entry.metadata ? (
<details>
<summary className="cursor-pointer font-semibold text-muted-foreground">
Metadata
</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-background p-2 font-mono text-[11px]">
{JSON.stringify(entry.metadata, null, 2)}
</pre>
</details>
) : null}
{entry.userAgent || entry.ipAddress ? (
<dl className="grid grid-cols-[120px_1fr] gap-x-2 gap-y-0.5">
{entry.ipAddress ? (
<>
<dt className="font-semibold text-muted-foreground">IP address</dt>
<dd className="font-mono">{entry.ipAddress}</dd>
</>
) : null}
{entry.userAgent ? (
<>
<dt className="font-semibold text-muted-foreground">User agent</dt>
<dd className="truncate font-mono" title={entry.userAgent}>
{entry.userAgent}
</dd>
</>
) : null}
</dl>
) : null}
</div>
) : null}
</div>
</div>
</ListCard>

View File

@@ -3,11 +3,12 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { formatDistanceToNow } from 'date-fns';
import { Search, X } from 'lucide-react';
import { History, Search, X } from 'lucide-react';
import { toast } from 'sonner';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -19,6 +20,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { AuditLogCard } from './audit-log-card';
@@ -85,6 +93,9 @@ const SOURCE_LABEL: Record<string, string> = {
job: 'Job',
};
// L-AU03: entity types that mutations can target but the filter dropdown
// didn't expose. Reps querying the audit log for, e.g., an email-account
// toggle (H-05 fix) couldn't pick it from the dropdown.
const ENTITY_TYPES = [
'client',
'interest',
@@ -99,6 +110,13 @@ const ENTITY_TYPES = [
'setting',
'tag',
'webhook',
'yacht',
'company',
'reservation',
'email_account',
'portal_session',
'portal_user',
'file',
];
function useDebounced<T>(value: T, ms = 300): T {
@@ -129,6 +147,10 @@ export function AuditLogList() {
const [userId, setUserId] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
/** Currently-open audit detail row. Drives the side Sheet that
* exposes the full oldValue / newValue / metadata / IP / UA payload
* so reps can inspect a row without leaving the search list. */
const [detailEntry, setDetailEntry] = useState<AuditEntry | null>(null);
const debouncedSearch = useDebounced(search);
const debouncedUserId = useDebounced(userId);
@@ -335,6 +357,27 @@ export function AuditLogList() {
),
size: 130,
},
{
id: 'details',
header: '',
cell: ({ row }) => {
const e = row.original;
const hasDetail =
Boolean(e.oldValue) || Boolean(e.newValue) || Boolean(e.metadata) || Boolean(e.userAgent);
if (!hasDetail) return null;
return (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setDetailEntry(e)}
>
Details
</Button>
);
},
size: 80,
},
];
return (
@@ -359,7 +402,7 @@ export function AuditLogList() {
<Input
id="audit-search"
className="pl-9 h-9"
placeholder="entity id, action, vendor…"
placeholder="entity id, entity type, action, user id…"
value={search}
onChange={(e) => setSearch(e.target.value)}
data-testid="audit-search"
@@ -412,6 +455,22 @@ export function AuditLogList() {
<SelectItem value="webhook_retried">Webhook retried</SelectItem>
<SelectItem value="job_failed">Job failed</SelectItem>
<SelectItem value="cron_run">Cron run</SelectItem>
{/* L-AU02: actions that fire in the code but were missing from
the dropdown — reps couldn't filter on them. */}
<SelectItem value="password_change">Password change</SelectItem>
<SelectItem value="portal_invite">Portal invite</SelectItem>
<SelectItem value="portal_activate">Portal activate</SelectItem>
<SelectItem value="portal_password_reset_request">Portal reset req</SelectItem>
<SelectItem value="portal_password_reset">Portal reset</SelectItem>
<SelectItem value="revoke_invite">Revoke invite</SelectItem>
<SelectItem value="resend_invite">Resend invite</SelectItem>
<SelectItem value="request_gdpr_export">GDPR req</SelectItem>
<SelectItem value="send_gdpr_export">GDPR sent</SelectItem>
<SelectItem value="rule_evaluated">Rule evaluated</SelectItem>
<SelectItem value="outcome_set">Outcome set</SelectItem>
<SelectItem value="outcome_cleared">Outcome cleared</SelectItem>
<SelectItem value="branding.logo.uploaded">Logo uploaded</SelectItem>
<SelectItem value="branding.logo.archived">Logo archived</SelectItem>
</SelectContent>
</Select>
</div>
@@ -522,9 +581,15 @@ export function AuditLogList() {
virtualHeightPx={640}
virtualRowHeightPx={56}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No audit log entries found.</p>
</div>
<EmptyState
icon={History}
title="No audit log entries"
description={
hasActiveFilter
? 'No entries match the current filters. Try clearing them.'
: 'Activity will appear here once users start making changes.'
}
/>
}
/>
</div>
@@ -543,6 +608,73 @@ export function AuditLogList() {
</Button>
</div>
) : null}
<Sheet open={!!detailEntry} onOpenChange={(o) => !o && setDetailEntry(null)}>
<SheetContent side="right" className="overflow-y-auto sm:max-w-xl">
{detailEntry ? (
<>
<SheetHeader>
<SheetTitle>
{detailEntry.action.replace(/_/g, ' ')} {detailEntry.entityType}
</SheetTitle>
<SheetDescription>
{new Date(detailEntry.createdAt).toLocaleString()}
{detailEntry.actor ? ` · ${detailEntry.actor.name}` : ''}
</SheetDescription>
</SheetHeader>
<div className="space-y-4 pt-4 text-sm">
{detailEntry.oldValue ? (
<details>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Old value
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.oldValue, null, 2)}
</pre>
</details>
) : null}
{detailEntry.newValue ? (
<details open>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
New value
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.newValue, null, 2)}
</pre>
</details>
) : null}
{detailEntry.metadata ? (
<details>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Metadata
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.metadata, null, 2)}
</pre>
</details>
) : null}
{detailEntry.ipAddress || detailEntry.userAgent ? (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1 text-xs">
{detailEntry.ipAddress ? (
<>
<dt className="font-semibold text-muted-foreground">IP address</dt>
<dd className="font-mono">{detailEntry.ipAddress}</dd>
</>
) : null}
{detailEntry.userAgent ? (
<>
<dt className="font-semibold text-muted-foreground">User agent</dt>
<dd className="font-mono break-all">{detailEntry.userAgent}</dd>
</>
) : null}
</dl>
) : null}
</div>
</>
) : null}
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import { useState } from 'react';
import { CheckCircle2, HelpCircle, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface TestResult {
ok: boolean;
host?: string;
checks?: Array<{ path: string; status?: number; ok: boolean; error?: string }>;
error?: string;
at: Date;
}
const EMBED_FIELDS: SettingFieldDef[] = [
{
key: 'embedded_signing_host',
label: 'Embedded signing host',
description:
"Origin of the public site that hosts the embedded Documenso signing pages. Outbound emails wrap raw Documenso signing URLs into {host}/sign/<type>/<token> so clients sign on your branded page rather than Documenso's domain. Leave blank to fall back to the app URL. Marketing-website pattern: https://portnimara.com",
type: 'string',
placeholder: 'https://portnimara.com',
defaultValue: '',
},
];
/**
* Admin card for the embedded-signing host setting. Provides:
* - The setting field itself (via SettingsFormCard)
* - A Test connection button that probes the host's `/` and
* `/sign/success` paths to verify the marketing-site cutover is
* ready BEFORE signers get sent there from outbound emails.
* - A Help button that opens a Sheet with the setup instructions —
* what routes the marketing site needs, what URL parameters to
* handle, and the Documenso webhook config that pairs with it.
*/
export function EmbeddedSigningCard() {
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<TestResult | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const handleTest = async () => {
setTesting(true);
setResult(null);
try {
const res = (await apiFetch('/api/v1/admin/embedded-signing/test', {
method: 'POST',
body: {},
})) as {
data: {
ok: boolean;
host?: string;
error?: string;
checks?: Array<{ path: string; status?: number; ok: boolean; error?: string }>;
};
};
setResult({ ...res.data, at: new Date() });
if (res.data.ok) toast.success('Embedded signing host reachable.');
else toast.error('Embedded signing host probe failed — see card.');
} catch (err) {
toastError(err);
setResult({
ok: false,
error: err instanceof Error ? err.message : String(err),
at: new Date(),
});
} finally {
setTesting(false);
}
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div>
<CardTitle>Embedded signing</CardTitle>
<CardDescription>
Where the public-facing branded signing pages live. The CRM rewrites Documenso
signing URLs to point here when sending invitation and reminder emails.
</CardDescription>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setHelpOpen(true)}
className="gap-1.5 [&_svg]:size-3.5"
>
<HelpCircle />
Setup help
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Renders inside our outer Card with its own micro-header.
Title kept terse (empty string would look broken) so the
user still has a visual anchor for the field. */}
<SettingsFormCard title="Host URL" description="" fields={EMBED_FIELDS} />
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={handleTest}
disabled={testing}
className="gap-1.5 [&_svg]:size-3.5"
>
{testing ? <Loader2 className="animate-spin" aria-hidden /> : null}
Test connection
</Button>
<p className="text-xs text-muted-foreground">
Probes <code>/</code> and <code>/sign/success</code> on the configured host.
</p>
</div>
{result ? (
<div
className={`rounded-md border p-3 text-sm ${
result.ok
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
: 'border-rose-200 bg-rose-50 text-rose-900'
}`}
>
<div className="flex items-start gap-2">
{result.ok ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
) : (
<XCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
)}
<div className="flex-1">
<p className="font-medium">{result.ok ? 'Connection ok' : 'Connection failed'}</p>
{result.host ? (
<p className="text-xs">
Host: <code>{result.host}</code>
</p>
) : null}
{result.error ? <p className="text-xs">{result.error}</p> : null}
{result.checks ? (
<ul className="mt-1 space-y-0.5 text-xs">
{result.checks.map((c) => (
<li key={c.path}>
<code>{c.path}</code> {' '}
{c.ok ? (
<span className="text-emerald-800">{c.status ?? 'ok'}</span>
) : (
<span className="text-rose-800">
{c.status ? `${c.status} fail` : (c.error ?? 'fail')}
</span>
)}
</li>
))}
</ul>
) : null}
<p className="mt-1 text-[11px] opacity-70">{result.at.toLocaleTimeString()}</p>
</div>
</div>
</div>
) : null}
</CardContent>
</Card>
<Sheet open={helpOpen} onOpenChange={setHelpOpen}>
<SheetContent side="right" className="overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle>Set up embedded signing</SheetTitle>
<SheetDescription>
How the marketing site has to be wired up so the branded signing flow works.
</SheetDescription>
</SheetHeader>
<div className="space-y-4 pt-4 text-sm leading-6">
<section>
<h3 className="mb-1 font-semibold">1. Choose the host</h3>
<p className="text-muted-foreground">
Pick a public host (e.g. <code>https://portnimara.com</code>) and enter it in the
Embedded signing host field above. The CRM rewrites raw Documenso signing URLs into{' '}
<code>{'{host}/sign/<role>/<token>'}</code> for every outbound invitation + reminder
email.
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">2. Implement the signing route</h3>
<p className="text-muted-foreground">
The marketing site needs to handle <code>/sign/[role]/[token]</code> by forwarding
to the underlying Documenso signing URL (or embedding it in an iframe). Role is one
of <code>client</code> / <code>developer</code> / <code>approver</code> useful for
tracking which slot the signer is in.
</p>
<p className="mt-1 text-muted-foreground">Minimum Next.js example:</p>
<pre className="mt-1 overflow-x-auto rounded bg-muted p-2 font-mono text-[11px]">
{`// app/sign/[role]/[token]/page.tsx
export default function SignPage({ params }) {
const documenseUrl = \`\${env.DOCUMENSO_URL}/sign/\${params.token}\`;
return <iframe src={documenseUrl} className="w-full h-screen" />;
}`}
</pre>
</section>
<section>
<h3 className="mb-1 font-semibold">3. Implement the success route</h3>
<p className="text-muted-foreground">
After signing, Documenso redirects to the URL configured in{' '}
<strong>Post-sign redirect URL</strong>. Default points at{' '}
<code>{'{host}/sign/success'}</code>. Render a confirmation page there (the
signer&apos;s already done; this is just the friendly &ldquo;Thanks!&rdquo; UX).
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">4. Test the connection</h3>
<p className="text-muted-foreground">
Use the Test connection button to verify <code>/</code> and{' '}
<code>/sign/success</code> return 2xx. If either fails, the marketing site
isn&apos;t ready fix the route before flipping live or signers will land on a 404
page from outbound emails.
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">5. Pair the Documenso webhook</h3>
<p className="text-muted-foreground">
Make sure the Documenso webhook points at{' '}
<code>{'{appUrl}/api/webhooks/documenso'}</code> with the matching webhook secret
stored under Documenso API Webhook secret. Without this the EOI status never
updates after signing.
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">6. Cutover</h3>
<p className="text-muted-foreground">
Flip the Embedded signing host field to your live URL and save. Existing in-flight
EOIs keep their pre-cutover signing URLs (the rewrite happens at email-dispatch
time, not at envelope creation), so old signers can still complete on the old host
until they sign or the EOI is cancelled.
</p>
</section>
</div>
</SheetContent>
</Sheet>
</>
);
}

View File

@@ -0,0 +1,437 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Download, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface SyncRecipient {
role: string;
signingOrder: number;
id: number;
name?: string;
email?: string;
mappedSettingKey: string | null;
}
interface AcroFormReport {
envelopeItemId: string;
fields: Array<{ name: string; type: string }>;
matchedFieldNames: string[];
missingFieldNames: string[];
extraFieldNames: string[];
error?: string;
}
interface SyncResult {
syncedAt: string;
templateId: number;
title: string;
recipients: SyncRecipient[];
fieldCount: number;
matchedFields: Array<{ label: string; fieldId: number }>;
unmatchedTemplateFields: Array<{ label: string; fieldId: number }>;
missingFromTemplate: string[];
templateMeta?: {
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
distributionMethod: 'EMAIL' | 'NONE' | null;
redirectUrl: string | null;
};
acroForm: AcroFormReport[];
}
function formatRelative(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms) || ms < 0) return new Date(iso).toLocaleString();
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d ago`;
return new Date(iso).toLocaleDateString();
}
/**
* "Sync from Documenso" admin button — calls GET /template/{id} on the
* configured Documenso instance (via the per-port creds in admin settings),
* pre-fills the recipient slot IDs into the matching documenso_*_recipient_id
* settings, and caches the template's field name→ID map at
* `documenso_eoi_field_map` for v2 prefillFields usage at send time.
*
* Saves the operator from typing 4 numeric IDs by hand and (in v2 mode)
* eliminates the "renaming a field on Documenso silently breaks the EOI"
* class of bug.
*/
export function TemplateSyncButton() {
const queryClient = useQueryClient();
const [templateIdInput, setTemplateIdInput] = useState('');
const [lastResult, setLastResult] = useState<SyncResult | null>(null);
// Seed the result panel from the cached report so the status survives
// page reloads. A subsequent Sync click overwrites both this cache and
// the local state.
const cached = useQuery<{ data: SyncResult | null }>({
queryKey: ['documenso', 'sync-template', 'report'],
queryFn: () =>
apiFetch<{ data: SyncResult | null }>('/api/v1/admin/documenso/sync-template/report'),
staleTime: 60_000,
});
useEffect(() => {
if (!lastResult && cached.data?.data) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLastResult(cached.data.data);
setTemplateIdInput(String(cached.data.data.templateId));
}
}, [cached.data, lastResult]);
const sync = useMutation({
mutationFn: async (templateIdOrEnvelopeId: string) => {
const r = await apiFetch<{ data: SyncResult }>(
`/api/v1/admin/documenso/sync-template/${encodeURIComponent(templateIdOrEnvelopeId)}`,
{ method: 'POST' },
);
return r.data;
},
onSuccess: (result) => {
setLastResult(result);
toast.success(
`Synced "${result.title}" — ${result.recipients.length} recipients, ${result.fieldCount} fields cached`,
);
void queryClient.invalidateQueries({ queryKey: ['settings', 'resolved'] });
void queryClient.invalidateQueries({
queryKey: ['documenso', 'sync-template', 'report'],
});
},
onError: (err) => toastError(err, 'Template sync failed'),
});
const submit = () => {
const raw = templateIdInput.trim();
if (!raw) {
toast.error('Enter a template ID (number) or envelope ID (envelope_…)');
return;
}
// Accept either a numeric template ID or a Documenso 2.x envelope ID.
// The server resolves envelope_xxx → numeric id via the list endpoint.
const isNumeric = /^\d+$/.test(raw);
const isEnvelopeId = /^envelope_[a-z0-9]+$/i.test(raw);
if (!isNumeric && !isEnvelopeId) {
toast.error('Enter a positive integer or a Documenso envelopeId (envelope_…)');
return;
}
sync.mutate(raw);
};
return (
<div className="rounded-md border bg-card p-4 space-y-4">
<div>
<div className="text-sm font-medium">Sync from Documenso</div>
<p className="mt-1 text-xs text-muted-foreground">
Paste either a numeric template ID (<code>123</code>) or the <code>envelope_</code>{' '}
string from your Documenso template URL (e.g. <code>envelope_nfafbkihzhoaihkb</code>). The
CRM fetches the template via <code>GET /template/&#123;id&#125;</code> on the currently
configured Documenso instance, writes the discovered recipient IDs into the slots above,
and caches the field nameID map for v2 <code>prefillFields</code> at send time.
</p>
</div>
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1.5">
<Label htmlFor="documenso-sync-template-id" className="text-xs">
Template ID or envelope ID
</Label>
<Input
id="documenso-sync-template-id"
type="text"
placeholder="123 or envelope_xxxxxxxx"
value={templateIdInput}
onChange={(e) => setTemplateIdInput(e.target.value)}
disabled={sync.isPending}
/>
</div>
<Button onClick={submit} disabled={sync.isPending}>
{sync.isPending ? (
<>
<Loader2 className="mr-2 size-3 animate-spin" /> Syncing
</>
) : (
<>
<Download className="mr-2 size-3" /> Sync
</>
)}
</Button>
</div>
{lastResult && (
<div className="rounded-md border border-emerald-200 bg-emerald-50/60 p-3 text-sm dark:border-emerald-900/40 dark:bg-emerald-950/30">
<div className="flex items-center justify-between gap-2 font-medium text-emerald-700 dark:text-emerald-400">
<div className="flex items-center gap-2">
<CheckCircle2 className="size-4" />{' '}
{lastResult.title || `Template #${lastResult.templateId}`}
</div>
<span className="text-[11px] font-normal text-muted-foreground">
Last synced {formatRelative(lastResult.syncedAt)}
</span>
</div>
<div className="mt-2 space-y-1 text-xs">
<div className="font-medium text-muted-foreground">
Recipients ({lastResult.recipients.length})
</div>
<ul className="space-y-0.5">
{lastResult.recipients.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-mono text-xs">#{r.id}</span>
<span className="text-muted-foreground">·</span>
<span>
{r.role} (order {r.signingOrder})
</span>
{r.name && (
<>
<span className="text-muted-foreground">·</span>
<span>{r.name}</span>
</>
)}
{r.mappedSettingKey ? (
<span className="ml-auto rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
{r.mappedSettingKey}
</span>
) : (
<span className="ml-auto rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-950 dark:text-amber-300">
no slot match
</span>
)}
</li>
))}
</ul>
{lastResult.templateMeta && (
<div className="pt-1.5 rounded-md bg-muted/60 px-2 py-1.5">
<div className="font-medium text-muted-foreground">Template-level settings</div>
<p className="text-[11px] text-muted-foreground">
Read from the template itself on Documenso. These values are bound to the
template, so every envelope generated from it inherits them {' '}
<code>/template/use</code> does <strong>not</strong> accept overrides for these.
Change them in Documenso&apos;s template editor.
</p>
<ul className="mt-1 space-y-0.5 text-[11px]">
<li>
<span className="text-muted-foreground">Signing order:</span>{' '}
<span className="font-mono">
{lastResult.templateMeta.signingOrder ?? 'unset'}
</span>
</li>
<li>
<span className="text-muted-foreground">Distribution method:</span>{' '}
<span className="font-mono">
{lastResult.templateMeta.distributionMethod ?? 'unset'}
</span>
{lastResult.templateMeta.distributionMethod === 'EMAIL' && (
<span className="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-900 dark:bg-amber-950 dark:text-amber-200">
Documenso will email recipients directly the CRM&apos;s branded email
is in addition. Set to NONE on the template to let the CRM be the sole
sender.
</span>
)}
</li>
<li>
<span className="text-muted-foreground">Post-sign redirect:</span>{' '}
<span className="font-mono">
{lastResult.templateMeta.redirectUrl ?? '(none)'}
</span>
</li>
</ul>
</div>
)}
<div className="pt-1 font-medium text-muted-foreground">
Fields: {lastResult.fieldCount} cached for <code>prefillFields</code>
{lastResult.fieldCount === 0 && (
<span className="ml-1 font-normal text-muted-foreground">
that&apos;s fine if your template is a fillable PDF (AcroForm). The CRM will
fill it via <code>formValues</code>-by-name instead, same as on v1.{' '}
<code>prefillFields</code> is only needed if you placed field overlays directly in
the Documenso template editor.
</span>
)}
</div>
{lastResult.matchedFields.length > 0 && (
<div className="pt-1.5">
<div className="font-medium text-emerald-700 dark:text-emerald-400">
CRM will fill ({lastResult.matchedFields.length})
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
{lastResult.matchedFields.map((f) => (
<span
key={f.fieldId}
className="rounded bg-emerald-100 px-1.5 py-0.5 font-mono text-[10px] text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200"
>
{f.label} #{f.fieldId}
</span>
))}
</div>
</div>
)}
{lastResult.unmatchedTemplateFields.length > 0 && (
<div className="pt-1.5">
<div className="font-medium text-amber-700 dark:text-amber-400">
Template fields the CRM doesn&apos;t recognize (
{lastResult.unmatchedTemplateFields.length})
</div>
<p className="text-[11px] text-muted-foreground">
These won&apos;t be filled. Rename them in the Documenso template editor to match
a CRM-expected label (Name, Email, Address, Yacht Name, Length, Width, Draft,
Berth Number, Lease_10, Purchase), or ignore if they&apos;re signature/date fields
the recipient fills in themselves.
</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{lastResult.unmatchedTemplateFields.map((f) => (
<span
key={f.fieldId}
className="rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] text-amber-900 dark:bg-amber-950 dark:text-amber-200"
>
{f.label} #{f.fieldId}
</span>
))}
</div>
</div>
)}
{lastResult.acroForm.length > 0 && (
<div className="pt-2.5 border-t border-emerald-200/60 dark:border-emerald-900/40">
<div className="font-medium text-foreground">
PDF AcroForm fields (the <code>formValues</code> path)
</div>
<p className="pt-0.5 text-[11px] text-muted-foreground">
These are the fillable fields actually in the PDF binary on Documenso. The CRM
fills them by name at send time this is the same mechanism the prod v1 server
uses.
</p>
{lastResult.acroForm.map((report) => (
<div key={report.envelopeItemId} className="mt-1.5 space-y-1">
{report.error ? (
<div className="rounded bg-destructive/10 px-2 py-1 text-[11px] text-destructive">
Couldn&apos;t inspect this PDF: {report.error}
</div>
) : report.fields.length === 0 ? (
<div className="rounded bg-amber-100 px-2 py-1 text-[11px] text-amber-900 dark:bg-amber-950 dark:text-amber-200">
This PDF has no AcroForm fields. The CRM&apos;s <code>formValues</code>{' '}
path will fill nothing. Re-export your PDF with form fields enabled, or
place overlays inside Documenso&apos;s editor and use{' '}
<code>prefillFields</code> instead.
</div>
) : (
<>
{report.matchedFieldNames.length > 0 && (
<div>
<div className="font-medium text-emerald-700 dark:text-emerald-400">
CRM-fillable AcroForm fields ({report.matchedFieldNames.length})
</div>
<div className="flex flex-wrap gap-1.5 pt-0.5">
{report.matchedFieldNames.map((n) => (
<span
key={n}
className="rounded bg-emerald-100 px-1.5 py-0.5 font-mono text-[10px] text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200"
>
{n}
</span>
))}
</div>
</div>
)}
{report.missingFieldNames.length > 0 && (
<div>
<div className="font-medium text-amber-700 dark:text-amber-400">
CRM tokens missing from the PDF ({report.missingFieldNames.length})
</div>
<p className="text-[11px] text-muted-foreground">
These exact names need AcroForm text/checkbox fields in the PDF, or
they&apos;ll be dropped at send time.
</p>
<div className="flex flex-wrap gap-1.5 pt-0.5">
{report.missingFieldNames.map((n) => (
<span
key={n}
className="rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] text-amber-900 dark:bg-amber-950 dark:text-amber-200"
>
{n}
</span>
))}
</div>
</div>
)}
{report.extraFieldNames.length > 0 && (
<div>
<div className="font-medium text-muted-foreground">
PDF fields the CRM has no token for ({report.extraFieldNames.length})
</div>
<p className="text-[11px] text-muted-foreground">
Usually signature blocks or other fields the recipient fills in
directly. Safe to ignore.
</p>
<div className="flex flex-wrap gap-1.5 pt-0.5">
{report.extraFieldNames.map((n) => (
<span
key={n}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
{n}
</span>
))}
</div>
</div>
)}
</>
)}
</div>
))}
</div>
)}
{lastResult.fieldCount > 0 && lastResult.missingFromTemplate.length > 0 && (
<div className="pt-1.5">
<div className="font-medium text-muted-foreground">
CRM data points not in <code>prefillFields</code> (
{lastResult.missingFromTemplate.length})
</div>
<p className="text-[11px] text-muted-foreground">
These would also be available as <code>prefillFields</code> if you added matching
overlays inside Documenso&apos;s template editor.
</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{lastResult.missingFromTemplate.map((label) => (
<span
key={label}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
{label}
</span>
))}
</div>
</div>
)}
</div>
</div>
)}
{sync.isError && !lastResult && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs">
<div className="flex items-center gap-2 font-medium text-destructive">
<XCircle className="size-3" /> Sync failed check the Documenso credentials above and
confirm the template exists on the configured instance.
</div>
</div>
)}
</div>
);
}

View File

@@ -66,7 +66,7 @@ export function EmailRoutingCard() {
mutationFn: (routing: Record<string, Sender>) =>
apiFetch<RoutingResponse>('/api/v1/admin/email/routing', {
method: 'PATCH',
body: JSON.stringify({ routing }),
body: { routing },
}),
onSuccess: (resp) => {
qc.setQueryData(['admin', 'email', 'routing'], resp);

View File

@@ -90,6 +90,7 @@ export function FormTemplateList() {
<Button
variant="ghost"
size="icon"
aria-label="Edit form template"
onClick={() => {
setEditing(t);
setFormOpen(true);
@@ -99,7 +100,12 @@ export function FormTemplateList() {
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="icon" className="text-destructive">
<Button
variant="ghost"
size="icon"
className="text-destructive"
aria-label="Delete form template"
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
}

View File

@@ -13,7 +13,7 @@
* wire means "clear".
*/
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -94,6 +94,8 @@ const EMPTY_FORM: FormState = {
export function SalesEmailConfigCard() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [lastTest, setLastTest] = useState<{ ok: boolean; message: string; at: Date } | null>(null);
const [smtpPassSet, setSmtpPassSet] = useState(false);
const [imapPassSet, setImapPassSet] = useState(false);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
@@ -136,6 +138,38 @@ export function SalesEmailConfigCard() {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleTestSmtp() {
setTesting(true);
setLastTest(null);
try {
const res = (await apiFetch('/api/v1/admin/email/sales-config/test-smtp', {
method: 'POST',
body: {},
})) as { data: { ok: boolean; to?: string; error?: string } };
if (res.data.ok) {
setLastTest({
ok: true,
message: `Test email sent to ${res.data.to ?? 'your inbox'}. Check delivery.`,
at: new Date(),
});
toast.success('Test SMTP send queued.');
} else {
setLastTest({
ok: false,
message: res.data.error ?? 'Unknown error',
at: new Date(),
});
toast.error('SMTP test failed — see card for details.');
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setLastTest({ ok: false, message: msg, at: new Date() });
toastError(err);
} finally {
setTesting(false);
}
}
async function handleSave() {
setSaving(true);
try {
@@ -385,7 +419,37 @@ export function SalesEmailConfigCard() {
</CardContent>
</Card>
<div className="flex justify-end">
{lastTest ? (
<div
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${
lastTest.ok
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
: 'border-rose-200 bg-rose-50 text-rose-900'
}`}
>
{lastTest.ok ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
) : (
<XCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
)}
<div className="flex-1">
<p className="font-medium">{lastTest.ok ? 'SMTP test sent' : 'SMTP test failed'}</p>
<p className="text-xs">{lastTest.message}</p>
<p className="mt-0.5 text-[11px] opacity-70">{lastTest.at.toLocaleTimeString()}</p>
</div>
</div>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={handleTestSmtp}
disabled={testing || saving}
title="Send a test message to your account via the configured SMTP credentials."
>
{testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
Test SMTP
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />}
Save sales email settings

View 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>
);
}

View File

@@ -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>
)}

View File

@@ -28,6 +28,7 @@ import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { WarningCallout } from '@/components/ui/warning-callout';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -77,13 +78,31 @@ const S3_FIELDS: SettingFieldDef[] = [
defaultValue: '',
},
{
// Reads from the legacy plaintext key for backward compat. New writes
// go through the registry-driven form below (which uses the
// `*_encrypted` key + AES at rest). After running the migration script
// (`pnpm tsx scripts/encrypt-plaintext-credentials.ts`) this field is
// empty and the encrypted form takes over.
key: 'storage_s3_access_key',
label: 'S3 access key',
description: 'IAM access key id (or provider equivalent).',
label: 'S3 access key (legacy plaintext — deprecated)',
description:
'Deprecated. Use the AES-encrypted access key field below instead. After running the migration script, this row is removed and only the encrypted form is used.',
type: 'string',
placeholder: 'AKIA…',
defaultValue: '',
},
{
// M-S01: encrypted at rest like the secret key. The legacy plaintext
// field above is reflected for backward compat but new writes go
// through this AES envelope.
key: 'storage_s3_access_key_encrypted',
label: 'S3 access key (encrypted)',
description:
'Stored AES-encrypted at rest; the field shows blank after save and is replaced only when you type a new value. Run `pnpm tsx scripts/encrypt-plaintext-credentials.ts` to migrate the legacy plaintext value into this field.',
type: 'password',
placeholder: '(unchanged)',
defaultValue: '',
},
{
key: 'storage_s3_secret_key_encrypted',
label: 'S3 secret key',
@@ -133,7 +152,7 @@ export function StorageAdminPanel() {
mutationFn: async (opts: { from: BackendName; to: BackendName }) =>
apiFetch<{ data: MigrationResult }>('/api/v1/admin/storage/migrate', {
method: 'POST',
body: JSON.stringify({ ...opts, dryRun: true }),
body: { ...opts, dryRun: true },
}),
onSuccess: (result) => {
setDryRun(result.data);
@@ -146,7 +165,7 @@ export function StorageAdminPanel() {
mutationFn: async (opts: { from: BackendName; to: BackendName; skipMigration: boolean }) =>
apiFetch<{ data: MigrationResult }>('/api/v1/admin/storage/migrate', {
method: 'POST',
body: JSON.stringify({ ...opts, dryRun: false }),
body: { ...opts, dryRun: false },
}),
onSuccess: (result) => {
setConfirmOpen(false);
@@ -203,6 +222,16 @@ export function StorageAdminPanel() {
description="Where the CRM stores per-berth PDFs, brochures, GDPR exports, profile photos, and other binary files."
/>
{/* AES-encrypted access key — write path. The legacy plaintext access
key field below is read-only deprecation; new writes should go
through this card. After running the encrypt-plaintext-credentials
migration script, the legacy field becomes empty. */}
<RegistryDrivenForm
title="S3 access key (encrypted)"
description="AES-encrypted at rest. Type your access key here — it replaces the deprecated plaintext field below and fixes audit finding S-23."
sections={['storage.s3']}
/>
{/* STEP 1: configure connection details for the OTHER backend so the
admin can prep + test BEFORE attempting any switch. */}
<SettingsFormCard