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,608 @@
'use client';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Download, Eye, EyeOff, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type SettingType =
| 'string'
| 'password'
| 'number'
| 'boolean'
| 'select'
| 'url'
| 'email'
| 'textarea'
| 'user-select';
type SettingSource = 'port' | 'global' | 'env' | 'default';
interface RegistryClientEntry {
key: string;
section: string;
label: string;
description: string;
type: SettingType;
options?: Array<{ value: string; label: string }>;
encrypted: boolean;
sensitive: boolean;
scope: 'port' | 'global';
envFallback?: string;
placeholder?: string;
defaultValue?: string | number | boolean | null;
}
interface ResolvedValue {
key: string;
source: SettingSource;
isSet: boolean;
value?: unknown;
}
interface ResolvedResponse {
data: { entries: RegistryClientEntry[]; values: Record<string, ResolvedValue> };
}
interface Props {
/** Section names from the registry to render (e.g. ['documenso.api', 'documenso.signers']). */
sections: string[];
/** Card-level title; omit to render fields without a card wrapper. */
title?: string;
/** Card-level description. */
description?: string;
/** Optional slot below the form (e.g. test-connection button). */
extra?: ReactNode;
}
/**
* Generates an editable settings form from the central registry. Renders the
* "Using env fallback" badge on each field whose resolved source is `env`
* (or `default`), plus a "Copy from env" button when an env value exists to
* one-click migrate the value into the admin DB.
*
* Encrypted / sensitive fields show ••• placeholder text and never receive
* the actual cleartext from the server. Saving an empty value on these
* fields is a no-op (use the explicit DELETE button to revert).
*/
export function RegistryDrivenForm({ sections, title, description, extra }: Props) {
const queryKey = ['settings', 'resolved', ...sections];
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<ResolvedResponse>({
queryKey,
queryFn: () =>
apiFetch<ResolvedResponse>(
`/api/v1/admin/settings/resolved?sections=${sections.map(encodeURIComponent).join(',')}`,
),
});
// Lifted draft state — every field's current input value is held here so
// a card-level "Save N changes" button can write them all in one batch.
// Sensitive fields seed as empty (we never seed cleartext from the server);
// non-sensitive fields seed from the resolved value.
const [drafts, setDrafts] = useState<Record<string, unknown>>({});
// A field is "dirty" only after the operator types into it. Server-driven
// events (eye-toggle reveal, copy-from-env autofill) explicitly clear the
// dirty flag for that key so they don't trigger a phantom save.
const [dirtyKeys, setDirtyKeys] = useState<Set<string>>(() => new Set());
// Re-seed drafts whenever the resolved-values query refreshes (after a
// successful save, revert, or copy-from-env) so values reflect server
// state. Preserves any in-progress edits the user is making.
useEffect(() => {
if (!data) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setDrafts((prev) => {
const next = { ...prev };
for (const entry of data.data.entries) {
if (dirtyKeys.has(entry.key)) continue; // don't trample in-progress edits
if (entry.encrypted || entry.sensitive) {
next[entry.key] = '';
} else {
next[entry.key] = data.data.values[entry.key]?.value ?? '';
}
}
return next;
});
}, [data, dirtyKeys]);
const setDraft = useCallback((key: string, value: unknown, opts?: { dirty?: boolean }) => {
setDrafts((prev) => ({ ...prev, [key]: value }));
if (opts?.dirty !== undefined) {
setDirtyKeys((prev) => {
const next = new Set(prev);
if (opts.dirty) next.add(key);
else next.delete(key);
return next;
});
}
}, []);
// Card-level bulk save. Fires one PUT per dirty field in parallel so the
// common case ("admin tweaks five fields, hits Save") is one round-trip
// worth of latency rather than five. Partial failures are surfaced
// per-field via toast; the resolved-values query gets invalidated once
// even on partial success so the UI reflects what landed.
const saveAll = useMutation({
mutationFn: async () => {
if (!data)
return { succeeded: [] as string[], failed: [] as Array<{ key: string; error: unknown }> };
const dirty = Array.from(dirtyKeys);
const settled = await Promise.allSettled(
dirty.map(async (key) => {
await apiFetch(`/api/v1/admin/settings/${encodeURIComponent(key)}`, {
method: 'PUT',
body: { value: drafts[key] },
});
return key;
}),
);
const succeeded: string[] = [];
const failed: Array<{ key: string; error: unknown }> = [];
settled.forEach((r, i) => {
const key = dirty[i]!;
if (r.status === 'fulfilled') succeeded.push(key);
else failed.push({ key, error: r.reason });
});
return { succeeded, failed };
},
onSuccess: ({ succeeded, failed }) => {
// Clear dirty flags for the keys that landed; leave failed ones dirty
// so the operator can fix + retry.
if (succeeded.length > 0) {
setDirtyKeys((prev) => {
const next = new Set(prev);
for (const k of succeeded) next.delete(k);
return next;
});
toast.success(
succeeded.length === 1 ? `Saved 1 setting` : `Saved ${succeeded.length} settings`,
);
}
for (const f of failed) {
const label = data?.data.entries.find((e) => e.key === f.key)?.label ?? f.key;
toastError(f.error, `Failed to save ${label}`);
}
void queryClient.invalidateQueries({ queryKey });
},
onError: (err) => toastError(err, 'Failed to save settings'),
});
const dirtyCount = dirtyKeys.size;
const content = (
<div className="space-y-6">
{isLoading || !data ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : (
<>
{groupBySection(data.data.entries).map(([section, entries]) => (
<SectionGroup
key={section}
entries={entries}
values={data.data.values}
drafts={drafts}
setDraft={setDraft}
onResolvedRefresh={() => queryClient.invalidateQueries({ queryKey })}
/>
))}
<div className="flex items-center justify-between gap-3 border-t pt-4">
<div className="text-xs text-muted-foreground">
{dirtyCount === 0
? 'No unsaved changes.'
: dirtyCount === 1
? '1 unsaved change.'
: `${dirtyCount} unsaved changes.`}
</div>
<Button
onClick={() => saveAll.mutate()}
disabled={saveAll.isPending || dirtyCount === 0}
>
{saveAll.isPending ? (
<>
<Loader2 className="mr-1 size-3 animate-spin" /> Saving
</>
) : dirtyCount > 0 ? (
`Save ${dirtyCount} change${dirtyCount === 1 ? '' : 's'}`
) : (
'Save'
)}
</Button>
</div>
</>
)}
{extra ? <div className="pt-2">{extra}</div> : null}
</div>
);
if (!title) return content;
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader>
<CardContent>{content}</CardContent>
</Card>
);
}
function groupBySection(entries: RegistryClientEntry[]): Array<[string, RegistryClientEntry[]]> {
const map = new Map<string, RegistryClientEntry[]>();
for (const e of entries) {
const existing = map.get(e.section);
if (existing) existing.push(e);
else map.set(e.section, [e]);
}
return Array.from(map.entries());
}
function SectionGroup({
entries,
values,
drafts,
setDraft,
onResolvedRefresh,
}: {
entries: RegistryClientEntry[];
values: Record<string, ResolvedValue>;
drafts: Record<string, unknown>;
setDraft: (key: string, value: unknown, opts?: { dirty?: boolean }) => void;
onResolvedRefresh: () => void;
}) {
return (
<div className="space-y-4">
{entries.map((entry) => (
<SettingField
key={entry.key}
entry={entry}
resolved={values[entry.key]}
draft={drafts[entry.key]}
setDraft={(value, opts) => setDraft(entry.key, value, opts)}
onResolvedRefresh={onResolvedRefresh}
/>
))}
</div>
);
}
function SettingField({
entry,
resolved,
draft,
setDraft,
onResolvedRefresh,
}: {
entry: RegistryClientEntry;
resolved: ResolvedValue | undefined;
draft: unknown;
setDraft: (value: unknown, opts?: { dirty?: boolean }) => void;
onResolvedRefresh: () => void;
}) {
const [showSecret, setShowSecret] = useState(false);
// Tracks whether `draft` currently holds a server-revealed value (vs.
// something the operator just typed). Lets the toggle button hide the
// revealed value cleanly without wiping a fresh edit.
const [revealedFromServer, setRevealedFromServer] = useState(false);
const reveal = useMutation({
mutationFn: async () => {
const r = await apiFetch<{ data: { revealed: boolean; value: string | null } }>(
`/api/v1/admin/settings/${encodeURIComponent(entry.key)}/reveal`,
{ method: 'POST' },
);
return r.data;
},
onSuccess: (r) => {
if (r.revealed && r.value != null) {
// Server reveal — populate draft but do NOT mark dirty (the value
// already matches what's stored).
setDraft(r.value, { dirty: false });
setRevealedFromServer(true);
setShowSecret(true);
} else {
toast.info(`${entry.label} isn't set — nothing to reveal.`);
}
},
onError: (err) => toastError(err, `Failed to reveal ${entry.label}`),
});
const revert = useMutation({
mutationFn: async () => {
await apiFetch(`/api/v1/admin/settings/${encodeURIComponent(entry.key)}`, {
method: 'DELETE',
});
},
onSuccess: () => {
toast.success(`${entry.label} reverted to default`);
setDraft('', { dirty: false });
onResolvedRefresh();
},
onError: (err) => toastError(err, `Failed to revert ${entry.label}`),
});
const copyFromEnv = useMutation({
mutationFn: async () => {
const r = await apiFetch<{ data: { copied: boolean; envValue?: string } }>(
`/api/v1/admin/settings/${encodeURIComponent(entry.key)}/copy-from-env`,
{ method: 'POST' },
);
return r.data;
},
onSuccess: (r) => {
if (r.copied) {
toast.success(`${entry.label} copied from env`);
if (r.envValue && !entry.sensitive) setDraft(r.envValue, { dirty: false });
} else {
toast.info(`No env value to copy for ${entry.label}`);
}
onResolvedRefresh();
},
onError: (err) => toastError(err, `Failed to copy ${entry.label} from env`),
});
const source = resolved?.source ?? 'default';
const showFallbackBadge = source === 'env' || source === 'default';
const canCopyFromEnv = !!entry.envFallback && source === 'env';
return (
<div className="space-y-1.5 border-l-2 border-l-muted pl-4">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={entry.key} className="text-sm font-medium">
{entry.label}
</Label>
<div className="flex items-center gap-1.5">
{source === 'port' && (
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="mr-1 size-3" />
Port override
</Badge>
)}
{source === 'global' && (
<Badge variant="secondary" className="text-xs">
Global
</Badge>
)}
{showFallbackBadge && resolved?.isSet && (
<Badge variant="outline" className="text-xs">
Using env fallback
</Badge>
)}
{showFallbackBadge && !resolved?.isSet && (
<Badge variant="outline" className="text-xs text-muted-foreground">
Not set
</Badge>
)}
</div>
</div>
{entry.description && <p className="text-xs text-muted-foreground">{entry.description}</p>}
<FieldInput
entry={entry}
value={draft}
onChange={(v) => {
// User typing → mark dirty so the card-level Save button picks it up.
setDraft(v, { dirty: true });
// A fresh keystroke supersedes any prior server-reveal.
if (revealedFromServer) setRevealedFromServer(false);
}}
showSecret={showSecret}
sensitive={entry.sensitive}
placeholder={entry.placeholder}
/>
<div className="flex flex-wrap items-center gap-2 pt-1">
{canCopyFromEnv && (
<Button
variant="outline"
size="sm"
onClick={() => copyFromEnv.mutate()}
disabled={copyFromEnv.isPending}
>
<Download className="mr-1 size-3" />
Copy from env
</Button>
)}
{(source === 'port' || source === 'global') && (
<Button
variant="ghost"
size="sm"
onClick={() => revert.mutate()}
disabled={revert.isPending}
>
Revert to fallback
</Button>
)}
{entry.sensitive && entry.type === 'password' && (
<Button
type="button"
variant="ghost"
size="sm"
disabled={reveal.isPending}
onClick={() => {
if (showSecret) {
// Hide. If this draft came from the server reveal, drop it so
// we don't keep cleartext in component state past the toggle.
if (revealedFromServer) {
setDraft('', { dirty: false });
setRevealedFromServer(false);
}
setShowSecret(false);
return;
}
// Show. If the operator hasn't typed anything yet and the
// setting is saved on the server, ask the API for cleartext.
const hasLocalDraft = typeof draft === 'string' && draft.length > 0;
if (
!hasLocalDraft &&
resolved?.isSet &&
(resolved.source === 'port' || resolved.source === 'global')
) {
reveal.mutate();
} else {
setShowSecret(true);
}
}}
>
{reveal.isPending ? (
<Loader2 className="size-3 animate-spin" />
) : showSecret ? (
<EyeOff className="size-3" />
) : (
<Eye className="size-3" />
)}
</Button>
)}
</div>
</div>
);
}
function FieldInput({
entry,
value,
onChange,
showSecret,
sensitive,
placeholder,
}: {
entry: RegistryClientEntry;
value: unknown;
onChange: (v: unknown) => void;
showSecret: boolean;
sensitive: boolean;
placeholder?: string;
}) {
if (entry.type === 'boolean') {
return (
<Switch id={entry.key} checked={!!value} onCheckedChange={(checked) => onChange(checked)} />
);
}
if (entry.type === 'user-select') {
return (
<UserSelectInput
id={entry.key}
value={typeof value === 'string' ? value : ''}
onChange={onChange}
placeholder={placeholder ?? 'No CRM user linked'}
/>
);
}
if (entry.type === 'select' && entry.options) {
// Radix Select rejects an empty-string `value` because that's its internal
// sentinel for "cleared". Pass `undefined` instead so the placeholder
// renders cleanly when the resolved value is null/blank.
const selectValue = value == null || value === '' ? undefined : String(value);
return (
<Select value={selectValue} onValueChange={(v) => onChange(v)}>
<SelectTrigger id={entry.key}>
<SelectValue placeholder={placeholder ?? 'Choose…'} />
</SelectTrigger>
<SelectContent>
{entry.options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (entry.type === 'textarea') {
return (
<Textarea
id={entry.key}
value={String(value ?? '')}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
rows={4}
/>
);
}
return (
<Input
id={entry.key}
type={
entry.type === 'password' && !showSecret
? 'password'
: entry.type === 'number'
? 'number'
: entry.type === 'email'
? 'email'
: entry.type === 'url'
? 'url'
: 'text'
}
value={sensitive && !showSecret && value === '' ? '' : String(value ?? '')}
placeholder={sensitive ? '••••••••' : placeholder}
onChange={(e) =>
onChange(entry.type === 'number' ? Number(e.target.value || 0) : e.target.value)
}
/>
);
}
interface PickerUser {
id: string;
email: string;
name: string;
}
/**
* Renders a Radix Select of every user in the current port. Stores the
* user's UUID. A "no link" sentinel value lets the operator clear the
* binding (Radix can't store empty string as a value, so we map empty ↔
* `__none__` over the wire).
*/
function UserSelectInput({
id,
value,
onChange,
placeholder,
}: {
id: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const { data, isLoading } = useQuery<{ data: PickerUser[] }>({
queryKey: ['admin', 'users', 'picker'],
queryFn: () => apiFetch<{ data: PickerUser[] }>('/api/v1/admin/users/picker'),
staleTime: 60_000,
});
const NONE = '__none__';
const selectValue = value ? value : NONE;
return (
<Select value={selectValue} onValueChange={(v) => onChange(v === NONE ? '' : v)}>
<SelectTrigger id={id}>
<SelectValue placeholder={isLoading ? 'Loading users…' : placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}> No CRM user linked </SelectItem>
{(data?.data ?? []).map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name || u.email} {u.name ? `· ${u.email}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -417,7 +417,14 @@ function ImageUploadField({
<div className="h-20 w-20 shrink-0 rounded-md border bg-muted/30 flex items-center justify-center overflow-hidden">
{}
{value ? (
<img src={value} alt="" className="h-full w-full object-contain" />
<img
src={value}
// M-U11: describe the preview so screen readers don't say
// "image" with no context. Falls back to a generic label
// when no field.label is set.
alt={`${field.label || 'Settings'} preview`}
className="h-full w-full object-contain"
/>
) : (
<span className="text-[10px] text-muted-foreground">No image</span>
)}