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

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -25,8 +25,25 @@ const loginSchema = z.object({
type LoginFormData = z.infer<typeof loginSchema>;
/**
* H-02: Validate a redirect target before pushing the user to it. The
* middleware appends `?redirect=<path>` when a session check fails on a
* protected route; an unsanitized router.push of that value would let a
* crafted URL bounce the user to an external host or protocol-relative
* `//evil.com` after a successful sign-in. Only same-origin, single-leading-
* slash paths pass.
*/
function safeRedirectTarget(raw: string | null): string {
if (!raw) return '/dashboard';
// Allow only paths starting with a single `/` (rules out `//evil.com`
// protocol-relative URLs and `https://…` absolute ones).
if (!raw.startsWith('/') || raw.startsWith('//')) return '/dashboard';
return raw;
}
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
// Fresh-DB bootstrap detection: if no super-admin exists yet, /setup
@@ -76,7 +93,8 @@ export default function LoginPage() {
return;
}
router.push('/dashboard');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(safeRedirectTarget(searchParams.get('redirect')) as any);
} catch {
toast.error('Something went wrong. Please try again.');
} finally {

View File

@@ -1,8 +1,8 @@
'use client';
import { Suspense, useState } from 'react';
import { Suspense, useState, useSyncExternalStore } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -27,10 +27,35 @@ const passwordSchema = z
type SetPasswordFormData = z.infer<typeof passwordSchema>;
/**
* H-03: tokens travel in the URL fragment (`#token=…`) so they never land
* in HTTP access logs or HTTP-Referer headers. Pre-fragment links still
* carry `?token=…` and stay functional until every outstanding invite
* expires — drop the `?token=` fallback after that grace period.
*/
function readTokenFromUrl(): string {
if (typeof window === 'undefined') return '';
const hash = window.location.hash.replace(/^#/, '');
if (hash) {
const params = new URLSearchParams(hash);
const fromFragment = params.get('token');
if (fromFragment) return fromFragment;
}
const search = new URLSearchParams(window.location.search);
return search.get('token') ?? '';
}
const subscribeNoop = () => () => undefined;
function SetPasswordInner() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
// useSyncExternalStore so the fragment-only token is read post-hydration
// (server snapshot returns null; client returns the actual value).
const token = useSyncExternalStore<string | null>(
subscribeNoop,
() => readTokenFromUrl(),
() => null,
);
const [isLoading, setIsLoading] = useState(false);
const {
@@ -73,6 +98,17 @@ function SetPasswordInner() {
}
}
// Pre-hydration: token is null. Show a loading placeholder so the user
// doesn't see a flash of "Link is missing" while the fragment is being
// read on the client.
if (token === null) {
return (
<BrandedAuthShell>
<div className="text-center text-sm text-gray-500">Loading</div>
</BrandedAuthShell>
);
}
if (!token) {
return (
<BrandedAuthShell>

View File

@@ -1,57 +1,11 @@
import Link from 'next/link';
import { Bot, FileText, Brain, ExternalLink } from 'lucide-react';
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
const MASTER_FIELDS: SettingFieldDef[] = [
{
key: 'ai_enabled',
label: 'AI features enabled',
description:
'Master switch. When OFF, every AI surface (receipt OCR fallback, berth-PDF AI parse, future embedding-driven recommendations) is bypassed. Provider keys stay configured but unused.',
type: 'boolean',
defaultValue: true,
},
{
key: 'ai_monthly_token_cap',
label: 'Monthly token cap (this port)',
description:
'Soft cap on total AI tokens consumed per calendar month across every feature. When exceeded, AI features fall back to non-AI paths and surface a banner. Set 0 for no cap.',
type: 'number',
defaultValue: 0,
},
];
const PROVIDER_FIELDS: SettingFieldDef[] = [
{
key: 'openai_api_key',
label: 'OpenAI API key',
description:
'Used by Receipt OCR fallback and (future) berth-PDF AI parse. Stored AES-encrypted at rest; the field shows blank after save.',
type: 'password',
placeholder: 'sk-…',
defaultValue: '',
},
{
key: 'openai_default_model',
label: 'Default OpenAI model',
description: 'Used when a feature does not specify an explicit model.',
type: 'select',
defaultValue: 'gpt-4o-mini',
options: [
{ value: 'gpt-4o-mini', label: 'gpt-4o-mini — cheap, fast, vision-capable' },
{ value: 'gpt-4o', label: 'gpt-4o — full-strength multimodal' },
{ value: 'gpt-4-turbo', label: 'gpt-4-turbo — legacy text reasoning' },
],
},
];
interface FeatureLink {
href: string;
icon: typeof Bot;
@@ -85,16 +39,16 @@ export default function AiAdminPage() {
eyebrow="ADMIN"
/>
<SettingsFormCard
<RegistryDrivenForm
title="Master controls"
description="Hard kill switch + budget guardrails covering every AI surface in this port."
fields={MASTER_FIELDS}
sections={['ai.master']}
/>
<SettingsFormCard
<RegistryDrivenForm
title="Provider credentials"
description="Shared API keys used by AI-enabled features. Per-feature pages can override the model on a feature-by-feature basis."
fields={PROVIDER_FIELDS}
description="Shared API keys used by AI-enabled features. AES-encrypted at rest. Per-feature pages can override the model on a feature-by-feature basis."
sections={['ai.providers']}
/>
<Card>

View File

@@ -4,148 +4,15 @@ import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-button';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const API_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_api_url_override',
label: 'API URL override',
description:
'Optional. Falls back to DOCUMENSO_API_URL env when blank. Bare host only — never include /api/v1; the client appends versioned paths based on the API version below.',
type: 'string',
placeholder: 'https://documenso.example.com',
defaultValue: '',
},
{
key: 'documenso_api_key_override',
label: 'API key override',
description: 'Optional. Falls back to DOCUMENSO_API_KEY env when blank. Stored in plain text.',
type: 'password',
defaultValue: '',
},
{
key: 'documenso_api_version_override',
label: 'API version',
description:
'Which Documenso REST API this port targets. v1 = Documenso 1.13.x stable. v2 = Documenso 2.x with the envelope model and richer per-field metadata. Test the connection after switching. See the v2 benefits card above for what changes when you flip this — and note that template-based EOI generation still uses the v1 formValues shape regardless of this setting (v2 template/use migration is on the roadmap).',
type: 'select',
options: [
{ value: 'v1', label: 'v1 — Documenso 1.13.x (default, stable)' },
{ value: 'v2', label: 'v2 — Documenso 2.x (envelope, recommended for new ports)' },
],
defaultValue: 'v1',
},
];
const SIGNER_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_developer_name',
label: 'Developer signer — name',
description:
'The party who signs after the client (typically the marina developer or owner). Used as the static "developer" recipient in templated documents (EOI). Was hardcoded as "David Mizrahi" in the legacy single-tenant system.',
type: 'string',
placeholder: 'David Mizrahi',
defaultValue: '',
},
{
key: 'documenso_developer_email',
label: 'Developer signer — email',
description: 'Email used to send the developer signing request via Documenso.',
type: 'string',
placeholder: 'dm@portnimara.com',
defaultValue: '',
},
{
key: 'documenso_developer_label',
label: 'Developer signer — display label',
description:
'How the developer slot is referenced in email subjects + signer-progress UI copy. Defaults to "Developer" when blank.',
type: 'string',
placeholder: 'Developer',
defaultValue: '',
},
{
key: 'documenso_developer_user_id',
label: 'Developer signer — linked CRM user (optional)',
description:
"Project Director RBAC binding. When set, the webhook handler fires an in-CRM notification for this user when it's their turn to sign — alongside the branded email. Leave blank if the developer slot doesn't map to a CRM user (e.g. external developer). Use the user's UUID from /admin/users.",
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
{
key: 'documenso_approver_name',
label: 'Approver — name',
description:
'The final approver who signs after the developer (typically a sales/legal lead). Was hardcoded as "Abbie May" in the legacy system.',
type: 'string',
placeholder: 'Abbie May',
defaultValue: '',
},
{
key: 'documenso_approver_email',
label: 'Approver — email',
description: 'Email used to route the final approval signing request.',
type: 'string',
placeholder: 'sales@portnimara.com',
defaultValue: '',
},
{
key: 'documenso_approver_label',
label: 'Approver — display label',
description:
'How the approver slot is referenced in email subjects + signer-progress UI copy. Defaults to "Approver" when blank.',
type: 'string',
placeholder: 'Approver',
defaultValue: '',
},
{
key: 'documenso_approver_user_id',
label: 'Approver — linked CRM user (optional)',
description:
"Same as developer's linked user — when set, fires an in-CRM notification when it's the approver's turn. Use the user's UUID from /admin/users.",
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
];
const EOI_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_eoi_template_id',
label: 'EOI Documenso template ID',
description: 'Numeric template ID used by the Documenso EOI pathway.',
type: 'string',
placeholder: '12345',
defaultValue: '',
},
{
key: 'eoi_default_pathway',
label: 'Default EOI pathway',
description:
'Which pathway is used when an EOI is generated without an explicit choice. Documenso = signed via Documenso, In-app = filled locally with pdf-lib.',
type: 'select',
options: [
{ value: 'documenso-template', label: 'Documenso template' },
{ value: 'inapp', label: 'In-app (pdf-lib)' },
],
defaultValue: 'documenso-template',
},
{
key: 'eoi_send_mode',
label: 'Initial signing-invitation email behaviour',
description:
'Auto = the system sends our branded "please sign" email immediately when an EOI/contract/reservation is generated. Manual = the document is generated and the signing URL appears in the UI; a rep clicks "Send invitation" to dispatch. Auto is the lower-friction option for high-volume teams; manual lets reps review before sending. Applies to all document types, not just EOI.',
type: 'select',
options: [
{ value: 'manual', label: 'Manual (rep clicks Send after generation)' },
{ value: 'auto', label: 'Auto (send branded email on generate)' },
],
defaultValue: 'manual',
},
];
// API_FIELDS removed — replaced by <RegistryDrivenForm sections={['documenso.api']} />
// which adds the new webhook-secret field + AES encrypts the API key at rest.
const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
{
@@ -168,30 +35,22 @@ const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
},
];
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: '',
},
];
// Embedded signing field config + Test + Setup help all live inside
// `<EmbeddedSigningCard />` (imported above). Kept out of the field list
// here so the admin page reads as a flat sequence of cards.
const V2_FEATURE_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_signing_order',
label: 'Signing order',
description:
'PARALLEL = recipients can sign in any order (faster, current default). SEQUENTIAL = Documenso refuses to email recipient N+1 until recipient N has signed, enforcing client → developer → approver order on EOIs. Only applies when API version above is v2 v1 instances ignore this and always behave as PARALLEL.',
'Whether all signers receive the invitation at once (PARALLEL — anyone can sign first) or only the next pending signer gets the email once the previous one finishes (SEQUENTIAL). Applied at envelope-create time on both v1 and v2: v1 honours meta.signingOrder on /templates/{id}/generate-document; v2 honours it via /envelope/update right after /template/use.',
type: 'select',
options: [
{ value: '', label: 'PARALLEL (default)' },
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL — enforce signing order (v2 only)' },
{ value: 'PARALLEL', label: 'PARALLEL — all signers invited at once' },
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL — one at a time in order' },
],
defaultValue: '',
defaultValue: 'PARALLEL',
},
{
key: 'documenso_redirect_url',
@@ -369,10 +228,10 @@ export default function DocumensoSettingsPage() {
</CardContent>
</Card>
<SettingsFormCard
<RegistryDrivenForm
title="Documenso API"
description="Per-port API credentials. Leave blank to use the global env defaults."
fields={API_FIELDS}
description="Per-port API credentials. AES-encrypted at rest. Leave blank to inherit from the env fallback (badged below each field)."
sections={['documenso.api']}
extra={<DocumensoTestButton />}
/>
@@ -382,16 +241,17 @@ export default function DocumensoSettingsPage() {
fields={V2_FEATURE_FIELDS}
/>
<SettingsFormCard
<RegistryDrivenForm
sections={['documenso.signers']}
title="Signers (developer + approver)"
description="Identity of the static signers in your Documenso templates. The client is always pulled from the interest's linked client record; these values fill the developer (signing order 2) and approver (signing order 3) slots."
fields={SIGNER_FIELDS}
description="Identity bound to the developer (signing order 2) and approver (signing order 3) slots in your Documenso templates. Leave name + email blank to fall through to whatever you set on the Documenso template itself; set them here to override the template's stored values at send time. Recipient IDs are populated automatically by 'Sync from Documenso' below. Linking a CRM user is optional — when set, the platform fires an in-CRM notification for that user when it's their turn to sign."
/>
<SettingsFormCard
<RegistryDrivenForm
sections={['documenso.templates']}
title="EOI generation"
description="Default pathway, template, and email behaviour when an interest's EOI is generated."
fields={EOI_FIELDS}
description="Default pathway, template, and email behaviour when an interest's EOI is generated. Recipient + field discovery happens via 'Sync from Documenso' below — that also populates the template ID for you."
extra={<TemplateSyncButton />}
/>
<SettingsFormCard
@@ -400,11 +260,7 @@ export default function DocumensoSettingsPage() {
fields={CONTRACT_RESERVATION_FIELDS}
/>
<SettingsFormCard
title="Embedded signing"
description="Where the public-facing branded signing pages live. The CRM rewrites Documenso signing URLs to point here when sending invitation and reminder emails."
fields={EMBED_FIELDS}
/>
<EmbeddedSigningCard />
</div>
);
}

View File

@@ -1,68 +1,8 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
const FIELDS: SettingFieldDef[] = [
{
key: 'email_from_name',
label: 'From name',
description: 'Display name shown in the From: header on outgoing email.',
type: 'string',
placeholder: 'Port Nimara',
defaultValue: '',
},
{
key: 'email_from_address',
label: 'From address',
description: 'Sender email address. Falls back to SMTP_FROM env when blank.',
type: 'string',
placeholder: 'noreply@example.com',
defaultValue: '',
},
{
key: 'email_reply_to',
label: 'Reply-to address',
description: 'Optional Reply-To: header for replies (e.g. sales@example.com).',
type: 'string',
placeholder: 'sales@example.com',
defaultValue: '',
},
{
key: 'smtp_host_override',
label: 'SMTP host override',
description: 'Optional. Falls back to SMTP_HOST env when blank.',
type: 'string',
placeholder: 'mail.example.com',
defaultValue: '',
},
{
key: 'smtp_port_override',
label: 'SMTP port override',
description: 'Optional. Falls back to SMTP_PORT env when blank.',
type: 'number',
placeholder: '587',
defaultValue: null,
},
{
key: 'smtp_user_override',
label: 'SMTP username override',
description: 'Optional. Falls back to SMTP_USER env when blank.',
type: 'string',
defaultValue: '',
},
{
key: 'smtp_pass_override',
label: 'SMTP password override',
description: 'Optional. Stored in plain text - only set when overriding env credentials.',
type: 'password',
defaultValue: '',
},
];
export default function EmailSettingsPage() {
return (
<div className="space-y-6">
@@ -70,15 +10,18 @@ export default function EmailSettingsPage() {
title="Email Settings"
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
/>
<SettingsFormCard
{/* Registry-driven so each field shows the "Using env fallback /
port / global / default" badge inline — admins can tell at a
glance which fields are coming from .env vs. UI overrides. */}
<RegistryDrivenForm
sections={['email.from']}
title="From address"
description="Identity headers used by system-generated emails."
fields={FIELDS.slice(0, 3)}
/>
<SettingsFormCard
<RegistryDrivenForm
sections={['email.smtp']}
title="SMTP transport overrides"
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults."
fields={FIELDS.slice(3)}
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
/>
<SalesEmailConfigCard />
<EmailRoutingCard />

View File

@@ -1,14 +1,23 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { ShieldX } from 'lucide-react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema/users';
import { Button } from '@/components/ui/button';
/**
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may
* access any page under /[portSlug]/admin.
*
* H-15: previously this layout silently redirected non-admins to
* `/dashboard`, which left them staring at the dashboard with no
* explanation of why their bookmark / shared admin link "didn't work".
* Render an explicit 403 page instead so the URL stays on the failed
* route and the user can see why their request was denied.
*/
export default async function AdminLayout({
children,
@@ -29,7 +38,23 @@ export default async function AdminLayout({
});
if (!profile?.isSuperAdmin) {
redirect(`/${portSlug}/dashboard`);
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-4 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
<ShieldX className="h-7 w-7 text-destructive" aria-hidden />
</div>
<div className="space-y-1">
<h1 className="text-xl font-semibold">Access denied</h1>
<p className="max-w-md text-sm text-muted-foreground">
This area is for super-administrators only. If you believe you should have access, ask
an administrator to grant the super-admin role on your account.
</p>
</div>
<Button asChild>
<Link href={`/${portSlug}/dashboard`}>Back to dashboard</Link>
</Button>
</div>
);
}
return <>{children}</>;

View File

@@ -0,0 +1,264 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type Mode = 'auto' | 'suggest' | 'off';
const TRIGGERS: Array<{
key: string;
label: string;
description: string;
defaultMode: Mode;
}> = [
{
key: 'eoi_sent',
label: 'EOI sent',
description: 'Rep generates an EOI for signing — moves the deal to "EOI" stage.',
defaultMode: 'auto',
},
{
key: 'eoi_signed',
label: 'EOI signed (all parties)',
description:
'All signatories complete the EOI — moves the deal to "Reservation" stage. Conventional CRM behaviour.',
defaultMode: 'auto',
},
{
key: 'reservation_signed',
label: 'Reservation agreement signed',
description:
'Reservation paperwork signed by all parties — keeps the deal at "Reservation" with sub-status signed.',
defaultMode: 'auto',
},
{
key: 'deposit_received',
label: 'Deposit received in full',
description:
'Deposit total reaches the expected amount — moves the deal to "Deposit Paid" stage.',
defaultMode: 'auto',
},
{
key: 'contract_signed',
label: 'Sales contract signed',
description: 'Final contract signed by all parties — moves the deal to "Contract" stage.',
defaultMode: 'auto',
},
];
const PRESETS = {
aggressive: 'auto',
conservative: 'suggest',
} as const;
type PresetName = keyof typeof PRESETS;
export default function PipelineRulesPage() {
const queryClient = useQueryClient();
const [rules, setRules] = useState<Record<string, Mode>>(() =>
Object.fromEntries(TRIGGERS.map((t) => [t.key, t.defaultMode])),
);
const { data, isLoading } = useQuery<{
data: { values: Record<string, { value?: Record<string, Mode> | null }> };
}>({
queryKey: ['admin', 'settings', 'pipeline.auto_advance'],
queryFn: () =>
apiFetch<{
data: { values: Record<string, { value?: Record<string, Mode> | null }> };
}>('/api/v1/admin/settings/resolved?sections=pipeline.auto_advance'),
});
// Hydrate the local form once the server-side state arrives. We treat
// missing keys as the registered default — the page's persisted JSON
// doesn't have to enumerate every trigger, just the overrides.
useEffect(() => {
const persisted = data?.data?.values?.stage_advance_rules?.value;
if (!persisted || typeof persisted !== 'object') return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setRules((prev) => {
const next = { ...prev };
for (const t of TRIGGERS) {
const v = persisted[t.key];
if (v === 'auto' || v === 'suggest' || v === 'off') next[t.key] = v;
}
return next;
});
}, [data]);
const saveMutation = useMutation({
mutationFn: () =>
apiFetch('/api/v1/admin/settings/stage_advance_rules', {
method: 'PUT',
body: { value: rules },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
toast.success('Pipeline rules saved.');
},
onError: (err) => toastError(err),
});
const applyPreset = (preset: PresetName) => {
const target = PRESETS[preset];
setRules(Object.fromEntries(TRIGGERS.map((t) => [t.key, target])));
};
const setMode = (key: string, mode: Mode) => {
setRules((prev) => ({ ...prev, [key]: mode }));
};
const allMatch = (mode: Mode) => TRIGGERS.every((t) => rules[t.key] === mode);
const currentPreset: PresetName | 'custom' = allMatch('auto')
? 'aggressive'
: allMatch('suggest')
? 'conservative'
: 'custom';
return (
<div className="space-y-6">
<PageHeader
title="Pipeline auto-advance rules"
description="Control which lifecycle events (signing, payments) automatically advance the deal stage on the kanban. Choose a preset or fine-tune per trigger."
/>
<Card>
<CardHeader>
<CardTitle className="text-base">Preset</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3">
<PresetButton
name="aggressive"
label="Aggressive (default)"
description="Every trigger auto-advances the stage. Matches conventional CRM behaviour and saves rep clicks."
active={currentPreset === 'aggressive'}
onClick={() => applyPreset('aggressive')}
/>
<PresetButton
name="conservative"
label="Conservative"
description="Every trigger sends a notification suggesting the move. Reps click Approve to advance."
active={currentPreset === 'conservative'}
onClick={() => applyPreset('conservative')}
/>
<div
className={`rounded-lg border p-3 ${
currentPreset === 'custom'
? 'border-primary bg-primary/5'
: 'border-muted bg-muted/20'
}`}
>
<p className="text-sm font-semibold">Custom</p>
<p className="text-xs text-muted-foreground">
Mix and match the per-trigger toggles below override the preset.
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Per-trigger settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" aria-hidden /> Loading
</div>
) : (
TRIGGERS.map((t) => (
<div
key={t.key}
className="flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex-1">
<p className="text-sm font-medium">{t.label}</p>
<p className="text-xs text-muted-foreground">{t.description}</p>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`mode-${t.key}`} className="sr-only">
Mode
</Label>
<Select
value={rules[t.key] ?? t.defaultMode}
onValueChange={(v) => setMode(t.key, v as Mode)}
>
<SelectTrigger id={`mode-${t.key}`} className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-advance</SelectItem>
<SelectItem value="suggest">Suggest only</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
</div>
</div>
))
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="gap-1.5 [&_svg]:size-3.5"
>
{saveMutation.isPending ? <Loader2 className="animate-spin" aria-hidden /> : <Save />}
Save rules
</Button>
</div>
</div>
);
}
function PresetButton({
name,
label,
description,
active,
onClick,
}: {
name: PresetName;
label: string;
description: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-lg border p-3 text-left transition-colors ${
active
? 'border-primary bg-primary/5 ring-2 ring-primary/40'
: 'border-muted hover:border-foreground/30 hover:bg-muted/30'
}`}
aria-pressed={active}
>
<p className="text-sm font-semibold">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
<p className="mt-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{name === 'aggressive' ? 'auto for all triggers' : 'suggest for all triggers'}
</p>
</button>
);
}

View File

@@ -6,6 +6,16 @@ import { Plus, Trash2 } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
@@ -163,33 +173,39 @@ export default function InvoicesPage() {
/>
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-xs z-50 flex items-center justify-center">
<div className="bg-background border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
<h3 className="font-semibold">Delete Invoice?</h3>
<p className="text-sm text-muted-foreground">
{/* M-U09: was a hand-rolled overlay; standardized on AlertDialog so
the focus-trap, Escape-to-close, and a11y semantics match every
other destructive-action surface in the app. */}
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete invoice?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete invoice{' '}
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. This
<span className="font-mono font-medium">{deleteTarget?.invoiceNumber}</span>. This
action cannot be undone.
</p>
<div className="flex items-center gap-2 justify-end">
<Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="mr-1.5 h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={deleteMutation.isPending}
onClick={() => {
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive"
>
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden />
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
import { PermissionsProvider } from '@/providers/permissions-provider';
import { AppShell } from '@/components/layout/app-shell';
import { DevModeBanner } from '@/components/shared/dev-mode-banner';
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
import { classifyFormFactor } from '@/lib/form-factor';
@@ -48,6 +49,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
<SocketProvider>
<RealtimeToasts />
<WebVitalsReporter />
{/* Sticky banner across the app whenever EMAIL_REDIRECT_TO is
set so reps + admins always know outbound mail is being
rerouted. Production hides itself (env.ts forbids the
flag in prod) so the banner is dev/staging-only. */}
<DevModeBanner />
{/* #26: AppShell mounts ONE responsive tree (desktop OR
* mobile) per render — never both — so pages don't pay the
* double-state, double-fetch, double-Tabs-provider tax. */}

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
import { FileText } from 'lucide-react';
import { FileText, CheckCircle2, XCircle, Circle } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
@@ -71,29 +71,38 @@ export default async function PortalDocumentsPage() {
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">
Signers
</p>
{doc.signers.map((signer, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<span
className={
signer.status === 'signed'
? 'text-green-600'
: signer.status === 'declined'
? 'text-red-500'
: 'text-gray-500'
}
>
{signer.status === 'signed'
? '✓'
: signer.status === 'declined'
? '✗'
: '○'}
</span>
<span className="text-gray-700">{signer.signerName}</span>
<span className="text-gray-400 capitalize">
({signer.signerRole.replace(/_/g, ' ')})
</span>
</div>
))}
{doc.signers.map((signer, idx) => {
const StatusIcon =
signer.status === 'signed'
? CheckCircle2
: signer.status === 'declined'
? XCircle
: Circle;
const statusLabel =
signer.status === 'signed'
? 'Signed'
: signer.status === 'declined'
? 'Declined'
: 'Pending';
const statusColor =
signer.status === 'signed'
? 'text-green-600'
: signer.status === 'declined'
? 'text-red-500'
: 'text-gray-500';
return (
<div key={idx} className="flex items-center gap-2 text-sm">
<StatusIcon
className={`h-4 w-4 ${statusColor}`}
aria-label={statusLabel}
/>
<span className="text-gray-700">{signer.signerName}</span>
<span className="text-gray-400 capitalize">
({signer.signerRole.replace(/_/g, ' ')})
</span>
</div>
);
})}
</div>
)}

View File

@@ -45,9 +45,18 @@ export async function POST(req: NextRequest) {
const ip = clientIp(req);
const rl = await checkRateLimit(ip, rateLimiters.auth);
if (!rl.allowed) {
// H-04: RFC 6585 §4 requires Retry-After on 429 so automated clients
// can back off correctly. rateLimitHeaders only emits the X-RateLimit-*
// triplet; checkRateLimit's helper enforcePublicRateLimit adds this
// header, but this route uses checkRateLimit directly so the header
// has to be added explicitly.
const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return NextResponse.json(
{ error: { message: 'Too many attempts. Try again later.' } },
{ status: 429, headers: rateLimitHeaders(rl) },
{
status: 429,
headers: { ...rateLimitHeaders(rl), 'Retry-After': String(retryAfter) },
},
);
}

View File

@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { findTemplateIdByEnvelopeId } from '@/lib/services/documenso-client';
import { syncDocumensoTemplate } from '@/lib/services/documenso-template-sync.service';
/**
* POST /api/v1/admin/documenso/sync-template/:templateId
*
* Calls Documenso's GET /template/{id} via the configured per-port creds,
* pre-fills the matching documenso_*_recipient_id settings, and caches the
* field name→ID map at documenso_eoi_field_map for v2 prefillFields usage.
*
* Accepts either a numeric template ID (`123`) or a Documenso 2.x envelope
* ID (`envelope_xxxxxxxx`) — the latter is what the Documenso UI URL shows,
* so paste-from-URL works out of the box on v2 instances. Envelope IDs get
* resolved to their numeric template id via `findTemplateIdByEnvelopeId`
* before the sync runs.
*
* Admin-only via `admin.manage_settings`. Audit-logged through the per-field
* writeSetting calls inside the service.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const raw = params.templateId ?? '';
let templateId: number;
if (/^envelope_/.test(raw)) {
const resolved = await findTemplateIdByEnvelopeId(raw, ctx.portId);
if (!resolved) {
throw new NotFoundError(`Template "${raw}" — no matching envelopeId found`);
}
templateId = resolved;
} else {
templateId = Number(raw);
if (!Number.isInteger(templateId) || templateId <= 0) {
throw new ValidationError(
'templateId must be a positive integer or a Documenso envelopeId (envelope_…)',
);
}
}
const result = await syncDocumensoTemplate(templateId, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync.service';
/**
* GET /api/v1/admin/documenso/sync-template/report
*
* Returns the cached sync result from the most recent successful Sync run,
* so the admin panel's status box survives a page reload without re-hitting
* Documenso. Returns `{ data: null }` when no sync has run for this port.
*
* Admin-only via `admin.manage_settings` — same gate as the sync write
* endpoint, since the report contains template recipient identities and
* AcroForm field names that aren't OK to leak outside the admin surface.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const report = await getEoiTemplateSyncReport(ctx.portId);
return NextResponse.json({ data: report });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { sendEmail } from '@/lib/email';
import { logger } from '@/lib/logger';
const bodySchema = z.object({
to: z.string().email().optional(),
});
/**
* Fire a test email through the per-port sales SMTP credentials. Used by
* the admin "Test SMTP" button on the Sales email config card to verify
* connectivity / auth without waiting for the next real send to fail.
*
* Sends a small text/HTML message to either the body-supplied `to` or
* (default) the admin's own email so they get the verification in their
* inbox. Returns { ok: true } on success or { ok: false, error } on
* failure — the admin UI rates accordingly.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const body = await parseBody(req, bodySchema);
const recipient = body.to ?? ctx.user.email;
if (!recipient) {
return NextResponse.json(
{ data: { ok: false, error: 'No recipient resolved — sign-in email is empty' } },
{ status: 200 },
);
}
try {
const subject = `Port Nimara CRM — SMTP test (${new Date().toLocaleTimeString()})`;
const html = `<p>Hello,</p><p>This is a test message sent from your CRM's <strong>Sales SMTP</strong> configuration. If you received this, your SMTP credentials work.</p><p style="color:#666;font-size:12px;">Timestamp: ${new Date().toISOString()}</p>`;
const text = `This is a test message sent from your CRM's Sales SMTP configuration. If you received this, your SMTP credentials work.\n\nTimestamp: ${new Date().toISOString()}`;
await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
return NextResponse.json({ data: { ok: true, to: recipient } });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn({ portId: ctx.portId, err: message }, 'Sales SMTP test send failed');
return NextResponse.json({ data: { ok: false, error: message } });
}
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { readSetting, SETTING_KEYS } from '@/lib/services/port-config';
import { fetchWithTimeout, FetchTimeoutError } from '@/lib/fetch-with-timeout';
import { logger } from '@/lib/logger';
/**
* POST /api/v1/admin/embedded-signing/test
*
* Verifies that the configured `embedded_signing_host` (the marketing
* site that hosts the branded embedded-signing wrapper) is reachable
* and returns a 2xx for the test path. Used by the admin "Test
* connection" button on the Documenso settings page so an admin can
* tell whether their marketing-site cutover is ready BEFORE signers
* get sent there from outbound emails.
*
* Two checks:
* 1. Bare host returns 2xx — the site is up.
* 2. `/sign/health` (or `/`) returns 2xx within 5s — soft probe; not
* every marketing site exposes /sign/health, so we degrade to a
* root probe when the dedicated path 404s.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const host = await readSetting<string>(SETTING_KEYS.embeddedSigningHost, ctx.portId);
if (!host) {
return NextResponse.json({
data: {
ok: false,
error: 'No embedded_signing_host configured. Set the URL in Documenso settings first.',
},
});
}
const checked: Array<{ path: string; status?: number; ok: boolean; error?: string }> = [];
const probe = async (path: string) => {
try {
const res = await fetchWithTimeout(`${host.replace(/\/$/, '')}${path}`, {
method: 'GET',
redirect: 'manual',
});
checked.push({
path,
status: res.status,
ok: res.ok || (res.status >= 300 && res.status < 400),
});
return res.status;
} catch (err) {
const msg =
err instanceof FetchTimeoutError
? `timed out after ${err.timeoutMs}ms`
: err instanceof Error
? err.message
: String(err);
checked.push({ path, ok: false, error: msg });
return null;
}
};
// Try root first — it's the most universal signal of "the site is
// up." Then probe /sign/success which the post-signing redirect
// typically points to, so admins can also catch a stale path.
await probe('/');
await probe('/sign/success');
const anyOk = checked.some((c) => c.ok);
if (!anyOk) {
logger.warn({ portId: ctx.portId, host, checked }, 'Embedded signing host probe failed');
}
return NextResponse.json({
data: {
ok: anyOk,
host,
checks: checked,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { copyFromEnv } from '@/lib/settings/resolver';
/**
* POST /api/v1/admin/settings/:key/copy-from-env
*
* One-click migration helper used by the admin form's "Copy from env"
* button. Reads the env var named in the registry entry's `envFallback`
* field and writes it as the current scope's row. Returns `{ copied: false }`
* if the env var is unset / empty.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const result = await copyFromEnv(params.key!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { registryFor } from '@/lib/settings/registry';
import { getSetting } from '@/lib/settings/resolver';
/**
* POST /api/v1/admin/settings/:key/reveal
*
* Returns the decrypted cleartext for an encrypted / sensitive setting.
* Used by the eye-toggle on encrypted fields in the registry-driven admin
* form so the operator can verify what they saved earlier.
*
* Gated on `admin.manage_settings` (the same permission required to write
* the value — so this never widens an existing trust boundary). Every
* reveal is audit-logged with the request id so a super-admin can trace
* who looked at what and when.
*
* Refuses to reveal values resolved from `env` or `default` — those would
* leak server-process secrets via the API.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const key = params.key!;
const entry = registryFor(key);
if (!entry) throw new NotFoundError(`Unknown setting: ${key}`);
if (!entry.encrypted && !entry.sensitive) {
// Non-sensitive values are already returned in the resolved-list
// endpoint, so a dedicated reveal isn't needed (and could be
// misused to bypass observability).
return NextResponse.json({ data: { revealed: false, value: null } }, { status: 200 });
}
// Resolve through the standard chain so the user sees exactly what
// the runtime would. The resolver decrypts on the way out.
const value = await getSetting<string>(key, ctx.portId);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'view',
entityType: 'setting',
entityId: key,
metadata: { settingKey: key, op: 'reveal' },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: { revealed: true, value: value ?? null } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { writeSetting, deleteSetting } from '@/lib/settings/resolver';
const putSchema = z.object({
value: z.unknown(),
});
/**
* PUT /api/v1/admin/settings/:key
*
* Writes a registry-known setting. The resolver validates against the
* entry's Zod schema, encrypts at rest if registered as such, and writes
* an audit log with secrets masked.
*
* Body: { value: <whatever the entry's type accepts> }
*
* Empty / null `value` on a non-sensitive field DELETEs the row (reverts
* to global → env → default). On a sensitive/encrypted field, empty is a
* no-op so an unchanged save through the ••• placeholder doesn't wipe
* the stored ciphertext. Use the DELETE endpoint to explicitly revert.
*/
export const PUT = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
try {
const { value } = await parseBody(req, putSchema);
await writeSetting(params.key!, value, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* DELETE /api/v1/admin/settings/:key
*
* Removes the row, reverting the resolver to global → env → default.
* 404 if no row exists at the appropriate scope.
*/
export const DELETE = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
await deleteSetting(params.key!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { entriesForSections } from '@/lib/settings/registry';
import { resolveForAdminAPI } from '@/lib/settings/resolver';
/**
* GET /api/v1/admin/settings/resolved?sections=documenso.api,documenso.signers
*
* Returns the resolved value + source (port/global/env/default) for every
* registry entry in the requested sections. Drives the registry-driven
* admin form: the `source` field gates the "Using env fallback" badge.
*
* Sensitive fields surface `isSet` only — never the decrypted value.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const url = new URL(req.url);
const sectionsParam = url.searchParams.get('sections');
if (!sectionsParam) {
return NextResponse.json({ data: { entries: [], values: {} } }, { status: 200 });
}
const sections = sectionsParam
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const entries = entriesForSections(sections);
const keys = entries.map((e) => e.key);
const resolved = await resolveForAdminAPI(keys, ctx.portId);
// Return the entry metadata so the client can render labels/types
// without bundling the registry into the client JS. Strip the
// `validator` + `transform` function references — they're not
// JSON-serializable.
const entriesForClient = entries.map((e) => ({
key: e.key,
section: e.section,
label: e.label,
description: e.description,
type: e.type,
options: e.options,
encrypted: !!e.encrypted,
sensitive: !!(e.sensitive || e.encrypted),
scope: e.scope,
envFallback: e.envFallback,
placeholder: e.placeholder,
defaultValue: e.defaultValue,
}));
const values: Record<string, unknown> = {};
for (const [k, r] of resolved.entries()) {
values[k] = r;
}
return NextResponse.json({ data: { entries: entriesForClient, values } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { user, userPortRoles, userProfiles } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
/**
* GET /api/v1/admin/users/picker
*
* Lightweight list of users in the active port, used by admin form
* user-select dropdowns (e.g. linking a CRM user to a Documenso recipient
* slot). Returns only the fields needed to render an option: id, email,
* name. Excludes deactivated users.
*
* Gated on `admin.manage_settings` — anyone editing per-port admin
* settings can already see all the configured Documenso recipient
* email/name values, so revealing the user roster to them doesn't
* widen the trust boundary. Tighter than the full `admin/users` GET
* (which is `admin.manage_users`-gated).
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const rows = await db
.select({
id: user.id,
email: user.email,
name: user.name,
isActive: userProfiles.isActive,
})
.from(user)
.innerJoin(userPortRoles, eq(userPortRoles.userId, user.id))
.leftJoin(userProfiles, eq(userProfiles.userId, user.id))
.where(and(eq(userPortRoles.portId, ctx.portId)));
// Dedupe by id (a user with multiple role rows in this port would
// otherwise repeat) and drop deactivated profiles.
const seen = new Set<string>();
const data = rows
.filter((r) => r.isActive !== false)
.filter((r) => {
if (seen.has(r.id)) return false;
seen.add(r.id);
return true;
})
.map(({ id, email, name }) => ({ id, email, name }));
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -69,6 +69,7 @@ export async function getMatchCandidatesHandler(
id: clients.id,
fullName: clients.fullName,
nationalityIso: clients.nationalityIso,
archivedAt: clients.archivedAt,
})
.from(clients)
.where(and(eq(clients.portId, ctx.portId)));
@@ -142,6 +143,13 @@ export async function getMatchCandidatesHandler(
interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1);
}
// Build a lookup from the original pool for archived flag — the dedup
// candidate type intentionally doesn't carry it, but the suggestion card
// needs to differentiate "use this live client" from "restore this
// archived client". Without this the UX swallows soft-deleted dupes.
const archivedById = new Map<string, Date | null>();
for (const c of liveClients) archivedById.set(c.id, c.archivedAt ?? null);
const data = useful.map((m) => ({
clientId: m.candidate.id,
fullName: m.candidate.fullName,
@@ -151,6 +159,7 @@ export async function getMatchCandidatesHandler(
interestCount: interestsByClient.get(m.candidate.id) ?? 0,
emails: m.candidate.emails,
phonesE164: m.candidate.phonesE164,
archivedAt: archivedById.get(m.candidate.id)?.toISOString() ?? null,
}));
return NextResponse.json({ data });

View File

@@ -26,6 +26,7 @@ export const POST = withAuth(
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
{ dimensionUnit: body.dimensionUnit },
);
return NextResponse.json({ data: result }, { status: 201 });
} catch (error) {

View File

@@ -1,18 +1,44 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { cancelDocument } from '@/lib/services/documents.service';
const cancelBodySchema = z
.object({
reason: z.string().max(2000).optional().nullable(),
notifyRecipients: z.array(z.string().uuid()).max(20).optional(),
})
.strict()
.optional();
export const POST = withAuth(
withPermission('documents', 'edit', async (_req, ctx, params) => {
withPermission('documents', 'edit', async (req, ctx, params) => {
try {
const doc = await cancelDocument(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
// Body is optional — legacy callers POST with `{}`. parseBody returns
// null when the request has no body; default to empty options.
let body: z.infer<typeof cancelBodySchema> = undefined;
try {
body = await parseBody(req, cancelBodySchema);
} catch {
body = undefined;
}
const doc = await cancelDocument(
params.id!,
ctx.portId,
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
{
reason: body?.reason ?? null,
notifyRecipients: body?.notifyRecipients ?? [],
},
);
return NextResponse.json({ data: doc });
} catch (error) {
return errorResponse(error);

View File

@@ -54,15 +54,99 @@ export const POST = withAuth(
.where(eq(documentSigners.documentId, documentId))
.orderBy(asc(documentSigners.signingOrder));
const target = body.recipientId
let target = body.recipientId
? signers.find((s) => s.id === body.recipientId)
: signers.find((s) => s.status === 'pending');
if (!target) {
throw new ValidationError('No pending signer found to invite');
}
// Self-heal flow when target.signingUrl is null. Two scenarios:
// 1. Envelope was created before the auto-distribute fix shipped
// — never distributed, so we must call /envelope/distribute
// to mint URLs.
// 2. Envelope WAS auto-distributed at generate time, but the
// response we got didn't carry signingUrls into our DB row
// (transient Documenso bug, or response shape mismatch).
// In that case the envelope is already PENDING and a second
// /distribute call returns 4xx ("already distributed").
//
// Defensive flow: try `getEnvelope` FIRST (cheap, always works).
// If recipients carry signingUrls, persist + skip distribute.
// If not, fall through to distribute, but catch 4xx so we don't
// surface a confusing "Documenso upstream error" to the rep —
// instead we re-fetch via GET one more time and accept whatever
// URLs the envelope has.
if (!target.signingUrl && doc.documensoId) {
const { distributeEnvelopeV2, getDocument } =
await import('@/lib/services/documenso-client');
const persistUrlsForDocument = async (
recipients: Array<{
signingOrder: number;
signingUrl?: string;
embeddedUrl?: string;
token?: string;
}>,
) => {
for (const r of recipients) {
if (!r.signingUrl) continue;
await db
.update(documentSigners)
.set({
signingUrl: r.signingUrl,
embeddedUrl: r.embeddedUrl ?? null,
signingToken: r.token ?? null,
})
.where(
and(
eq(documentSigners.documentId, documentId),
eq(documentSigners.signingOrder, r.signingOrder),
),
);
}
};
// Step 1: cheap GET.
let recovered = false;
try {
const fetched = await getDocument(doc.documensoId, ctx.portId);
if (fetched.recipients.some((r) => r.signingUrl)) {
await persistUrlsForDocument(fetched.recipients);
recovered = true;
}
} catch {
// ignore — fall through to distribute attempt
}
// Step 2: distribute, only if GET didn't recover URLs.
if (!recovered) {
try {
const distributed = await distributeEnvelopeV2(doc.documensoId, ctx.portId);
await persistUrlsForDocument(distributed.recipients);
} catch {
// Probably "already distributed" — last-ditch GET.
try {
const fetched = await getDocument(doc.documensoId, ctx.portId);
await persistUrlsForDocument(fetched.recipients);
} catch {
// give up; the validator below surfaces a clean error
}
}
}
// Re-read target so its signingUrl is now populated.
const refreshed = await db
.select()
.from(documentSigners)
.where(eq(documentSigners.id, target.id))
.limit(1);
target = refreshed[0] ?? target;
}
if (!target.signingUrl) {
throw new ValidationError(
'Signer has no Documenso URL yet — generate or send the document first',
'Signer has no Documenso URL yet — try regenerating the EOI; v2 envelopes require distribution before the signing link exists.',
);
}

View File

@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync.service';
/**
* GET `/api/v1/documents/signing-defaults`
@@ -21,6 +22,21 @@ export const GET = withAuth(
withPermission('documents', 'send_for_signing', async (_req, ctx) => {
try {
const cfg = await getPortDocumensoConfig(ctx.portId);
// Signing order resolution chain (highest → lowest priority):
// 1. Cached `documento_eoi_template_sync_report.templateMeta.signingOrder`
// — populated by the admin "Sync from Documenso" button and
// represents the live template's bound order. On v2 this is the
// authoritative value because `/template/use` doesn't accept a
// per-call override.
// 2. Per-port `documenso_signing_order` setting from
// getPortDocumensoConfig (used by v1 + as a UI fallback when the
// admin hasn't run a sync yet).
// 3. Documenso's own default (`PARALLEL` = concurrent signing).
const syncReport = await getEoiTemplateSyncReport(ctx.portId).catch(() => null);
const signingOrder: 'PARALLEL' | 'SEQUENTIAL' =
syncReport?.templateMeta?.signingOrder ?? cfg.signingOrder ?? 'PARALLEL';
return NextResponse.json({
data: {
developer: {
@@ -34,6 +50,16 @@ export const GET = withAuth(
label: cfg.approverLabel ?? 'Approver',
},
sendMode: cfg.sendMode,
signingOrder,
// Surface where the value came from so the UI tooltip can be
// honest about the source. Helps reps debug "I changed it in
// Documenso but the CRM still says X" — they need to re-run
// Sync to pull the change.
signingOrderSource: syncReport?.templateMeta?.signingOrder
? 'template'
: cfg.signingOrder
? 'port-setting'
: 'default',
},
});
} catch (error) {

View File

@@ -10,7 +10,12 @@ export const PATCH = withAuth(
withPermission('email', 'configure_account', async (req, ctx, params) => {
try {
const body = await parseBody(req, toggleAccountSchema);
const account = await toggleAccount(params.accountId!, ctx.userId, body);
const account = await toggleAccount(params.accountId!, ctx.userId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: account });
} catch (error) {
return errorResponse(error);

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { env } from '@/lib/env';
/**
* GET /api/v1/internal/dev-flags
*
* Read-only feed of dev-mode safety flags that the UI surfaces as
* always-visible badges. Authenticated (any signed-in user) — these
* flags affect every outbound email so reps need to see them too,
* not just admins.
*
* Today returns just `emailRedirectTo`. Add more flags here (e.g.
* MOCK_DOCUMENSO, FAKE_PAYMENTS, READ_ONLY_DB) as they appear.
*/
export const GET = withAuth(async () => {
return NextResponse.json({
data: {
emailRedirectTo: env.EMAIL_REDIRECT_TO ?? null,
isDev: env.NODE_ENV !== 'production',
},
});
});