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

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