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:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
260
src/components/admin/documenso/embedded-signing-card.tsx
Normal file
260
src/components/admin/documenso/embedded-signing-card.tsx
Normal 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's already done; this is just the friendly “Thanks!” 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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
437
src/components/admin/documenso/template-sync-button.tsx
Normal file
437
src/components/admin/documenso/template-sync-button.tsx
Normal 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/{id}</code> on the currently
|
||||
configured Documenso instance, writes the discovered recipient IDs into the slots above,
|
||||
and caches the field name→ID 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'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'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'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't recognize (
|
||||
{lastResult.unmatchedTemplateFields.length})
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
These won'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'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'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's <code>formValues</code>{' '}
|
||||
path will fill nothing. Re-export your PDF with form fields enabled, or
|
||||
place overlays inside Documenso'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user