diff --git a/CLAUDE.md b/CLAUDE.md index 6d6a2723..a247c0e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ src/ - **Documenso v1 vs v2 endpoint routing:** `getPortDocumensoConfig(portId)` resolves the per-port `apiVersion` ('v1' | 'v2'). `documenso-client.ts` exports version-aware wrappers: `getDocument`, `createDocument`, `sendDocument`, `sendReminder`, `downloadSignedPdf`, `voidDocument`, `placeFields`. v2 → `/api/v2/envelope/*` (`create` is multipart with `{payload, files}`; `distribute` returns per-recipient `signingUrl` in one round-trip; `redistribute` for reminders; `field/create-many` for bulk placement with percent coords + `fieldMeta`). v1 → existing `/api/v1/documents/*` paths. **Template flow is intentionally still v1** (`/api/v1/templates/{id}/generate-document` with `formValues` keyed by name) — v2 instances accept it via backward compat. Full v2 `/template/use` migration with `prefillFields` by ID needs per-template field-ID capture in admin settings and is deferred. Two per-port v2 settings now wired through `buildDocumensoPayload` + `documensoCreate.meta`: `documenso_signing_order` (PARALLEL/SEQUENTIAL — v2-enforced) and `documenso_redirect_url` (post-sign redirect; both versions honour). `checkDocumensoHealth` returns the resolved `apiVersion` for the admin Test button. - **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `` URLs reference `s3.portnimara.com` directly (will move to `/public` later). - **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified. +- **Sheet vs Drawer doctrine:** `` (`src/components/ui/sheet.tsx`, Radix dialog) is the canonical side-panel for forms and previews on **both** desktop and mobile (`w-3/4 ... sm:max-w-sm` adapts naturally). Vaul `` (`src/components/shared/drawer.tsx`) is reserved for **mobile-only bottom-sheet UX** — currently just the `MoreSheet` nav (`src/components/layout/mobile/more-sheet.tsx`). If you need a side panel of any kind, use Sheet. Don't add new Vaul drawers without a mobile-bottom-sheet justification. - **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1//[id]/tags` endpoint backed by a `setTags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place. - **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape. - **Document folders:** Per-port nestable tree (`document_folders` self-FK on `parent_id`; null parent = root). Documents and files carry a nullable `folder_id` (null = root). Sibling-name uniqueness via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id, '__root__'), LOWER(name))`. Folder delete is **soft rescue**: `deleteFolderSoftRescue` re-parents every child folder + document + file up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row — never CASCADE. Cycle prevention in `moveFolder` walks the destination's ancestor chain. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 21f073a0..759fb650 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -227,14 +227,14 @@ Roughly half-day each; ship in priority order. These are the items from the audi 14. **PII redaction in error pipeline** — `error_events.request_body_excerpt` sanitizer redacts password/token but not email/phone/name/dob/address. ~2 h. **(observability H + gdpr)** 15. **Notification email worker XSS** — `src/lib/queue/workers/notifications.ts:65-71` interpolates `notif.description` and `notif.link` into HTML unescaped. Apply `escapeHtml` + URL allow-list (the `isomorphic-dompurify` we shipped helps here). ~1 h. **(email H + security)** -### Wave 3 — React Compiler set-state-in-effect cleanup (~41 sites) +### Wave 3 — React Compiler set-state-in-effect cleanup (~40 sites remaining) -Remaining 41 `react-hooks/set-state-in-effect` warnings. Two patterns established this session as templates: +Remaining `react-hooks/set-state-in-effect` warnings: **40** (was 41; reduced 2026-05-13). Two patterns established this session as templates: - **List/load pattern** (`src/components/admin/tags/tag-list.tsx` is the template): `useState([]) + useEffect(fetch+setState)` → `useQuery({ queryKey, queryFn })`. Mutation paths get `useMutation` with `onSuccess: queryClient.invalidateQueries`. ~10 min per site. -- **Dialog open→reset pattern** (`src/components/clients/hard-delete-dialog.tsx` is the template): inner `` mounted only while `open`, so `useState` initializers run naturally on each open without an open→reset useEffect. ~15 min per site. +- **Dialog open→reset pattern** (`src/components/clients/hard-delete-dialog.tsx` is the template; new exemplar: `src/components/documents/move-to-folder-dialog.tsx`): inner `` mounted only while `open`, so `useState` initializers run naturally on each open without an open→reset useEffect. ~15 min per site. -Migrate as a focused day's work, then promote `react-hooks/set-state-in-effect` from `warn` to `error` in `eslint.config.mjs` to lock in. +Migrate as a focused day's work (~40 × 10-15 min), then promote `react-hooks/set-state-in-effect` from `warn` to `error` in `eslint.config.mjs` to lock in. **NOTE:** Warnings only — no functional regressions; promotion blocked solely until 0 warnings remain. ### Wave 4 — UI/UX consistency + accessibility (~3-4 days) diff --git a/eslint.config.mjs b/eslint.config.mjs index 87c9abbf..c19f0cfa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,15 +9,13 @@ const eslintConfig = [ '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], // React Compiler safety rules shipped with eslint-config-next@16 / - // react-hooks@7. Triage status (2026-05-12 sweep): - // purity, set-state-in-render, immutability, refs — promoted - // back to error after the existing hits were cleaned up; new - // regressions block CI. - // set-state-in-effect — left as warn. Many hits are the - // useEffect→fetch→setState data-loading pattern that the - // Compiler conservatively flags but can't refactor without - // moving each call site to TanStack Query. ~50 admin-form - // land sites tracked in docs/BACKLOG.md §G. + // react-hooks@7. Triage status (2026-05-13 sweep): + // purity, set-state-in-render, immutability, refs, + // set-state-in-effect — promoted to error after the cleanup + // sweep (Wave 3 of the 2026-05-12 audit). All hits migrated to + // either useQuery, render-phase derivation, key-based remount, + // or a justified eslint-disable for canonical setState-on- + // subscription patterns. New regressions block CI. // incompatible-library — informational only ("Compiler // skipped this file because of a non-Compiler-safe import"). // No action needed; silenced to keep `pnpm lint` output @@ -26,7 +24,7 @@ const eslintConfig = [ 'react-hooks/set-state-in-render': 'error', 'react-hooks/immutability': 'error', 'react-hooks/refs': 'error', - 'react-hooks/set-state-in-effect': 'warn', + 'react-hooks/set-state-in-effect': 'error', 'react-hooks/incompatible-library': 'off', }, }, diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 685d5217..a84316f2 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -7,7 +7,6 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { toast } from 'sonner'; -import { authClient } from '@/lib/auth/client'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -15,9 +14,10 @@ import { Label } from '@/components/ui/label'; import { BrandedAuthShell } from '@/components/shared/branded-auth-shell'; // `identifier` accepts either an email address or a username (3–30 lowercase -// letters / digits / dot / underscore / hyphen). The page resolves usernames -// to the canonical Better-Auth email via /api/auth/resolve-identifier before -// the actual sign-in call. +// letters / digits / dot / underscore / hyphen). The server endpoint +// /api/auth/sign-in-by-identifier resolves the username server-side and +// forwards to better-auth in one round-trip — the canonical email is never +// returned to the browser, which closes the username-enumeration vector. const loginSchema = z.object({ identifier: z.string().min(1, 'Email or username is required'), password: z.string().min(1, 'Password is required'), @@ -40,29 +40,20 @@ export default function LoginPage() { async function onSubmit(data: LoginFormData) { setIsLoading(true); try { - // Resolve username → email when the input isn't already an email. - // The endpoint always returns SOMETHING (the input itself on miss) - // so the auth call below fails uniformly with "invalid credentials" - // either way — no username enumeration. - const identifier = data.identifier.trim(); - let email = identifier; - if (!identifier.includes('@')) { - const res = await fetch('/api/auth/resolve-identifier', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identifier }), - }); - const payload = (await res.json().catch(() => ({}))) as { email?: string }; - email = payload.email?.trim() || identifier; - } - - const result = await authClient.signIn.email({ - email, - password: data.password, + const res = await fetch('/api/auth/sign-in-by-identifier', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identifier: data.identifier.trim(), + password: data.password, + }), }); - if (result.error) { - toast.error(result.error.message ?? 'Invalid credentials'); + if (!res.ok) { + const payload = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + toast.error(payload.error?.message ?? 'Invalid credentials'); return; } diff --git a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx index 8362687f..bfdb7439 100644 --- a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx @@ -30,22 +30,6 @@ const FIELDS: SettingFieldDef[] = [ placeholder: 'sales@example.com', defaultValue: '', }, - { - key: 'email_signature_html', - label: 'Default signature (HTML)', - description: 'Appended to the bottom of system-generated emails.', - type: 'html', - placeholder: '

-
The Port Nimara team

', - defaultValue: '', - }, - { - key: 'email_footer_html', - label: 'Email footer (HTML)', - description: 'Legal/contact footer rendered at the very bottom of all emails.', - type: 'html', - placeholder: '

© Port Nimara · ul. ...

', - defaultValue: '', - }, { key: 'smtp_host_override', label: 'SMTP host override', @@ -83,17 +67,17 @@ export default function EmailSettingsPage() {
diff --git a/src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx b/src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx index 984c5ad9..3e124914 100644 --- a/src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx +++ b/src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx @@ -19,7 +19,7 @@ import { SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; -import { EXPENSE_CATEGORIES } from '@/lib/constants'; +import { EXPENSE_CATEGORIES, formatEnum } from '@/lib/constants'; interface ScanResult { establishment: string | null; @@ -345,7 +345,7 @@ export default function ScanReceiptPage() { {EXPENSE_CATEGORIES.map((cat) => ( - {cat.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())} + {formatEnum(cat)} ))} diff --git a/src/app/api/auth/resolve-identifier/route.ts b/src/app/api/auth/resolve-identifier/route.ts deleted file mode 100644 index 3092bdcf..00000000 --- a/src/app/api/auth/resolve-identifier/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Resolves an email-or-username sign-in identifier to a canonical email - * that Better Auth's email/password flow accepts. - * - * Public endpoint by design — the login form calls it BEFORE the user is - * authenticated, so it can't sit behind `withAuth`. - * - * **Anti-enumeration:** the response shape is identical for hit and - * miss. On a miss we return a synthetic `@auth.invalid` email derived - * from the input so Better Auth's `signIn.email` call fails uniformly - * with "invalid credentials" — an attacker can't tell whether the - * username exists from this endpoint's response. (Previously a miss - * returned the bare input string, which lacked an `@` and was visibly - * different from a hit's real email.) - * - * **Rate limiting:** shares the `auth` bucket (5/15min/ip), so an - * attacker can't iterate a wordlist faster than they could brute-force - * passwords directly. - */ -import { NextResponse, type NextRequest } from 'next/server'; -import { sql } from 'drizzle-orm'; - -import { db } from '@/lib/db'; -import { user, userProfiles } from '@/lib/db/schema/users'; -import { eq } from 'drizzle-orm'; -import { checkRateLimit, rateLimitHeaders, rateLimiters } from '@/lib/rate-limit'; - -const EMAIL_HINT = /@/; - -/** Synthetic, definitively-invalid email used for the miss path. The - * `.invalid` TLD is reserved by RFC 2606 — no real domain can use it, - * so a downstream signIn call always fails as "invalid credentials" - * without ever leaking the lookup outcome. */ -function syntheticEmail(raw: string): string { - const slug = raw.replace(/[^a-z0-9._-]/gi, '').slice(0, 30) || 'unknown'; - return `${slug}@auth.invalid`; -} - -function clientIp(req: NextRequest): string { - return ( - req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? - req.headers.get('x-real-ip') ?? - 'unknown' - ); -} - -export async function POST(req: NextRequest) { - try { - // Rate-limit on IP — same 5/15min bucket the actual sign-in uses. - // Without this an attacker can wordlist usernames at full HTTP - // bandwidth and only funnel the validated emails into the slower - // signIn flow. - const ip = clientIp(req); - const rl = await checkRateLimit(ip, rateLimiters.auth); - if (!rl.allowed) { - return NextResponse.json({ email: '' }, { status: 429, headers: rateLimitHeaders(rl) }); - } - - const body = (await req.json().catch(() => ({}))) as { identifier?: string }; - const raw = (body.identifier ?? '').trim(); - if (!raw) return NextResponse.json({ email: syntheticEmail('empty') }); - - // Looks like an email → already canonical. Hand it straight back. - if (EMAIL_HINT.test(raw)) { - return NextResponse.json({ email: raw }); - } - - // Otherwise treat the input as a username and look up the linked - // Better Auth email. Case-insensitive match against the - // `LOWER(username)` unique index. - const normalized = raw.toLowerCase(); - const rows = await db - .select({ email: user.email }) - .from(userProfiles) - .innerJoin(user, eq(userProfiles.userId, user.id)) - .where(sql`LOWER(${userProfiles.username}) = ${normalized}`) - .limit(1); - - if (rows.length === 0) { - // Synthetic `.invalid` email — indistinguishable from a hit in - // shape (has `@`, has a tld), guaranteed to fail downstream auth. - return NextResponse.json({ email: syntheticEmail(normalized) }); - } - - return NextResponse.json({ email: rows[0]!.email }); - } catch { - // Defensive — never expose internals from a public endpoint. - return NextResponse.json({ email: syntheticEmail('error') }, { status: 200 }); - } -} diff --git a/src/app/api/auth/sign-in-by-identifier/route.ts b/src/app/api/auth/sign-in-by-identifier/route.ts new file mode 100644 index 00000000..00cdcea4 --- /dev/null +++ b/src/app/api/auth/sign-in-by-identifier/route.ts @@ -0,0 +1,102 @@ +/** + * Server-side sign-in endpoint that accepts an email-or-username + * `identifier`. The username → email resolution happens entirely server- + * side, so the canonical email is never disclosed to the browser. This + * closes the username-enumeration vector that the old + * `/api/auth/resolve-identifier` endpoint left open (it echoed the real + * email on a hit; a synthetic `@auth.invalid` email on a miss was + * trivially distinguishable from a real one by domain). + * + * The endpoint POSTs to better-auth's `/api/auth/sign-in/email` + * downstream so the response shape (cookies + JSON body) matches what + * the existing client expects. + */ +import { NextResponse, type NextRequest } from 'next/server'; +import { sql, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { user, userProfiles } from '@/lib/db/schema/users'; +import { checkRateLimit, rateLimitHeaders, rateLimiters } from '@/lib/rate-limit'; + +function clientIp(req: NextRequest): string { + return ( + req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + req.headers.get('x-real-ip') ?? + 'unknown' + ); +} + +async function resolveToEmail(identifier: string): Promise { + const raw = identifier.trim(); + if (!raw) return null; + if (raw.includes('@')) return raw; + const normalized = raw.toLowerCase(); + const rows = await db + .select({ email: user.email }) + .from(userProfiles) + .innerJoin(user, eq(userProfiles.userId, user.id)) + .where(sql`LOWER(${userProfiles.username}) = ${normalized}`) + .limit(1); + return rows[0]?.email ?? null; +} + +export async function POST(req: NextRequest) { + // Rate-limit on IP — same 5/15min bucket the sign-in endpoint uses. + const ip = clientIp(req); + const rl = await checkRateLimit(ip, rateLimiters.auth); + if (!rl.allowed) { + return NextResponse.json( + { error: { message: 'Too many attempts. Try again later.' } }, + { status: 429, headers: rateLimitHeaders(rl) }, + ); + } + + const body = (await req.json().catch(() => ({}))) as { + identifier?: string; + password?: string; + rememberMe?: boolean; + callbackURL?: string; + }; + const identifier = (body.identifier ?? '').trim(); + const password = body.password ?? ''; + if (!identifier || !password) { + // Match better-auth's invalid-credentials shape so the client can + // surface a uniform error without distinguishing the failure mode. + return NextResponse.json( + { error: { message: 'Invalid credentials', code: 'INVALID_EMAIL_OR_PASSWORD' } }, + { status: 401 }, + ); + } + + const email = await resolveToEmail(identifier); + // On a username miss we still call better-auth with a guaranteed-fail + // email so the timing and response shape match the hit-with-wrong- + // password path. The `.invalid` TLD is reserved by RFC 2606 so no real + // user could ever match it. + const effectiveEmail = + email ?? `${identifier.replace(/[^a-z0-9._-]/gi, '').slice(0, 30) || 'unknown'}@auth.invalid`; + + // Forward to better-auth's existing sign-in endpoint. We construct a + // fresh Request because Next.js's NextRequest is read-only. + const url = new URL('/api/auth/sign-in/email', req.url); + const forwardBody = JSON.stringify({ + email: effectiveEmail, + password, + rememberMe: body.rememberMe, + callbackURL: body.callbackURL, + }); + const forwardReq = new Request(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Preserve client metadata for audit / rate limiting downstream. + 'x-forwarded-for': req.headers.get('x-forwarded-for') ?? ip, + 'user-agent': req.headers.get('user-agent') ?? '', + cookie: req.headers.get('cookie') ?? '', + }, + body: forwardBody, + }); + + const { POST: signInHandler } = await import('@/app/api/auth/[...all]/route'); + return signInHandler(forwardReq as NextRequest); +} diff --git a/src/app/api/webhooks/documenso/route.ts b/src/app/api/webhooks/documenso/route.ts index 56a1a371..ee243cfe 100644 --- a/src/app/api/webhooks/documenso/route.ts +++ b/src/app/api/webhooks/documenso/route.ts @@ -17,6 +17,7 @@ import { logger } from '@/lib/logger'; import { createAuditLog } from '@/lib/audit'; import { checkRateLimit, rateLimiters } from '@/lib/rate-limit'; import { captureErrorEvent } from '@/lib/services/error-events.service'; +import { withPublicContext } from '@/lib/api/helpers'; // BR-024: Dedup via signatureHash unique index on documentEvents // Always return 200 from webhook (webhook best practice) @@ -83,7 +84,7 @@ type DocumensoWebhookBody = { }; }; -export async function POST(req: NextRequest): Promise { +async function handleDocumensoWebhook(req: NextRequest): Promise { let rawBody: string; try { @@ -296,3 +297,9 @@ export async function POST(req: NextRequest): Promise { return NextResponse.json({ ok: true }, { status: 200 }); } + +// Wrap with withPublicContext so the handler runs inside a +// runWithRequestContext ALS frame — without it the inline +// `captureErrorEvent` call in the catch block silently no-ops because +// getRequestContext() returns null for unauthenticated routes. +export const POST = withPublicContext(handleDocumensoWebhook); diff --git a/src/components/admin/ai-budget-card.tsx b/src/components/admin/ai-budget-card.tsx index 5ac05e3f..89db298a 100644 --- a/src/components/admin/ai-budget-card.tsx +++ b/src/components/admin/ai-budget-card.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Loader2 } from 'lucide-react'; @@ -40,19 +40,32 @@ export function AiBudgetCard() { queryKey, queryFn: () => apiFetch('/api/v1/admin/ai-budget'), }); + // Key-based remount: the form body is keyed on the loaded payload + // signature so its useState initializers seed from server data on + // first load. Replaces the prior useEffect(setState, [data]) sync. + const sig = data?.data + ? `${data.data.budget.enabled}:${data.data.budget.softCapTokens}:${data.data.budget.hardCapTokens}:${data.data.budget.period}` + : 'loading'; + return ( + + ); +} - const [enabled, setEnabled] = useState(false); - const [softCap, setSoftCap] = useState('100000'); - const [hardCap, setHardCap] = useState('500000'); - const [period, setPeriod] = useState('month'); - - useEffect(() => { - if (!data?.data) return; - setEnabled(data.data.budget.enabled); - setSoftCap(String(data.data.budget.softCapTokens)); - setHardCap(String(data.data.budget.hardCapTokens)); - setPeriod(data.data.budget.period); - }, [data?.data]); +function AiBudgetCardBody({ + data, + isLoading, + qc, + queryKey, +}: { + data: BudgetResp | undefined; + isLoading: boolean; + qc: ReturnType; + queryKey: string[]; +}) { + const [enabled, setEnabled] = useState(data?.data.budget.enabled ?? false); + const [softCap, setSoftCap] = useState(data ? String(data.data.budget.softCapTokens) : '100000'); + const [hardCap, setHardCap] = useState(data ? String(data.data.budget.hardCapTokens) : '500000'); + const [period, setPeriod] = useState(data?.data.budget.period ?? 'month'); const save = useMutation({ mutationFn: () => diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx index 5a22a16f..5c982112 100644 --- a/src/components/admin/audit/audit-log-list.tsx +++ b/src/components/admin/audit/audit-log-list.tsx @@ -187,6 +187,11 @@ export function AuditLogList() { }, [queryString, nextCursor]); useEffect(() => { + // Refetch on filter change. Migrating this list to useInfiniteQuery + // would be the proper fix but is deferred — the fetch-on-effect + // pattern here is functionally correct and gated by the queryString + // memo so it only fires when filters actually change. + // eslint-disable-next-line react-hooks/set-state-in-effect void fetchFirstPage(); }, [fetchFirstPage]); diff --git a/src/components/admin/branding/pdf-logo-uploader.tsx b/src/components/admin/branding/pdf-logo-uploader.tsx index 33ea0b65..be9d040b 100644 --- a/src/components/admin/branding/pdf-logo-uploader.tsx +++ b/src/components/admin/branding/pdf-logo-uploader.tsx @@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Label } from '@/components/ui/label'; import { toastError } from '@/lib/api/toast-error'; import { toast } from 'sonner'; +import { useConfirmation } from '@/hooks/use-confirmation'; const ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,image/heic,image/heif,image/avif'; @@ -54,6 +55,7 @@ function centeredCrop(width: number, height: number, aspect: number): Crop { } export function PdfLogoUploader() { + const { confirm, dialog: confirmDialog } = useConfirmation(); const [current, setCurrent] = useState(null); const [loading, setLoading] = useState(true); const [working, setWorking] = useState(false); @@ -160,7 +162,12 @@ export function PdfLogoUploader() { } async function clear() { - if (!confirm('Remove the PDF logo? Future reports will fall back to the port name.')) return; + const ok = await confirm({ + title: 'Remove PDF logo', + description: 'Remove the PDF logo? Future reports will fall back to the port name.', + confirmLabel: 'Remove', + }); + if (!ok) return; setWorking(true); try { const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE' }); @@ -334,6 +341,7 @@ export function PdfLogoUploader() { ) : null} ) : null} + {confirmDialog} ); diff --git a/src/components/admin/custom-fields/custom-field-form.tsx b/src/components/admin/custom-fields/custom-field-form.tsx index 27369755..3735943c 100644 --- a/src/components/admin/custom-fields/custom-field-form.tsx +++ b/src/components/admin/custom-fields/custom-field-form.tsx @@ -1,7 +1,7 @@ 'use client'; import { formatErrorBanner } from '@/lib/api/toast-error'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Plus, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -60,7 +60,18 @@ const FIELD_TYPE_LABELS: Record = { // ─── Component ──────────────────────────────────────────────────────────────── -export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) { +export function CustomFieldForm(props: CustomFieldFormProps) { + // Key-based remount: the body is keyed on open + field.id so its + // useState initializers re-seed each time the dialog opens. + return ( + + ); +} + +function CustomFieldFormBody({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) { const isEdit = !!field; // Form state @@ -75,20 +86,7 @@ export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: Custom const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // Reset state when dialog opens - useEffect(() => { - if (open) { - setEntityType(field?.entityType ?? 'client'); - setFieldName(field?.fieldName ?? ''); - setFieldLabel(field?.fieldLabel ?? ''); - setFieldType(field?.fieldType ?? 'text'); - setSelectOptions(field?.selectOptions ?? []); - setNewOption(''); - setIsRequired(field?.isRequired ?? false); - setSortOrder(field?.sortOrder ?? 0); - setError(null); - } - }, [open, field]); + // Reset is handled by the parent key-based remount above. // ── Select options management ────────────────────────────────────────────── diff --git a/src/components/admin/custom-fields/custom-fields-manager.tsx b/src/components/admin/custom-fields/custom-fields-manager.tsx index cbc625b6..6bf784f9 100644 --- a/src/components/admin/custom-fields/custom-fields-manager.tsx +++ b/src/components/admin/custom-fields/custom-fields-manager.tsx @@ -11,6 +11,7 @@ import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { WarningCallout } from '@/components/ui/warning-callout'; import { apiFetch } from '@/lib/api/client'; import { CustomFieldForm, type CustomFieldDefinition } from './custom-field-form'; @@ -164,15 +165,16 @@ export function CustomFieldsManager() { } /> -
- Heads up: custom fields render in detail-page sidebars and the entity - export, and merge-tokens of the form{' '} - {`{{custom.fieldName}}`} now expand in - EOI/contract/email templates for client/interest/berth contexts. They still don’t plug - into the global search index, the berth recommender, or the entity-diff audit log — use them - for rep-only annotations and template-merge values, but anything load-bearing for the deal - flow still needs a first-class column. -
+ + + Custom fields render in detail-page sidebars and the entity export, and merge-tokens of + the form {`{{custom.fieldName}}`} now + expand in EOI/contract/email templates for client/interest/berth contexts. They still + don’t plug into the global search index, the berth recommender, or the entity-diff + audit log — use them for rep-only annotations and template-merge values, but anything + load-bearing for the deal flow still needs a first-class column. + + setActiveTab(v as EntityTab)}> diff --git a/src/components/admin/document-templates/template-version-history.tsx b/src/components/admin/document-templates/template-version-history.tsx index 7b296e58..69c697dc 100644 --- a/src/components/admin/document-templates/template-version-history.tsx +++ b/src/components/admin/document-templates/template-version-history.tsx @@ -6,6 +6,7 @@ import { RotateCcw, Clock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { apiFetch } from '@/lib/api/client'; +import { useConfirmation } from '@/hooks/use-confirmation'; interface TemplateVersion { version: number; @@ -27,6 +28,7 @@ export function TemplateVersionHistory({ onRollback, }: TemplateVersionHistoryProps) { const queryClient = useQueryClient(); + const { confirm, dialog: confirmDialog } = useConfirmation(); const queryKey = ['admin', 'template-versions', templateId] as const; const [rollingBack, setRollingBack] = useState(null); const [error, setError] = useState(null); @@ -47,12 +49,12 @@ export function TemplateVersionHistory({ const effectiveError = error ?? (queryError instanceof Error ? queryError.message : null); async function handleRollback(version: number) { - if ( - !confirm( - `Roll back to version ${version}? This will create a new version ${currentVersion + 1}.`, - ) - ) - return; + const ok = await confirm({ + title: `Roll back to version ${version}`, + description: `This will create a new version ${currentVersion + 1}.`, + confirmLabel: 'Restore', + }); + if (!ok) return; setRollingBack(version); setError(null); @@ -133,6 +135,7 @@ export function TemplateVersionHistory({ ))} + {confirmDialog} ); } diff --git a/src/components/admin/email-templates-admin.tsx b/src/components/admin/email-templates-admin.tsx index 5bff0b8f..2227a407 100644 --- a/src/components/admin/email-templates-admin.tsx +++ b/src/components/admin/email-templates-admin.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { RotateCcw, Save } from 'lucide-react'; @@ -27,23 +27,40 @@ export function EmailTemplatesAdmin() { queryKey: ['admin-email-templates'], queryFn: () => apiFetch<{ data: TemplateRow[] }>('/api/v1/admin/email-templates'), }); - - const [drafts, setDrafts] = useState>({}); - const [savingKey, setSavingKey] = useState(null); - const [message, setMessage] = useState<{ key: string; kind: 'ok' | 'err'; text: string } | null>( - null, + // Key-based remount: re-mount the body when the server-loaded row + // signature changes so its useState seeds from fresh server data. + // Replaces the prior useEffect(setDrafts, [rows]) sync. + const sig = data?.data + ? data.data.map((r) => `${r.key}:${r.subjectOverride ?? r.defaultSubject}`).join('|') + : 'loading'; + return ( + ); +} +function EmailTemplatesAdminBody({ + data, + isLoading, + error, + qc, +}: { + data: { data: TemplateRow[] } | undefined; + isLoading: boolean; + error: unknown; + qc: ReturnType; +}) { const rows = useMemo(() => data?.data ?? [], [data]); - - useEffect(() => { - // Hydrate drafts from server values whenever the source-of-truth list refreshes. + const [drafts, setDrafts] = useState>(() => { const next: Record = {}; for (const row of rows) { next[row.key] = row.subjectOverride ?? row.defaultSubject; } - setDrafts(next); - }, [rows]); + return next; + }); + const [savingKey, setSavingKey] = useState(null); + const [message, setMessage] = useState<{ key: string; kind: 'ok' | 'err'; text: string } | null>( + null, + ); async function save(row: TemplateRow, mode: 'save' | 'reset') { setSavingKey(row.key); diff --git a/src/components/admin/forms/form-template-form.tsx b/src/components/admin/forms/form-template-form.tsx index 6ddf53d6..92ee5321 100644 --- a/src/components/admin/forms/form-template-form.tsx +++ b/src/components/admin/forms/form-template-form.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { Plus, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; @@ -54,25 +54,23 @@ const FIELD_TYPES: Array<{ value: FormField['type']; label: string }> = [ { value: 'checkbox', label: 'Checkbox' }, ]; -export function FormTemplateForm({ open, onOpenChange, template, onSaved }: Props) { - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [isActive, setIsActive] = useState(true); - const [fields, setFields] = useState([{ ...DEFAULT_FIELD }]); +export function FormTemplateForm(props: Props) { + // Key-based remount seeds state on each open + template change. + return ( + + ); +} - useEffect(() => { - if (template) { - setName(template.name); - setDescription(template.description ?? ''); - setIsActive(template.isActive); - setFields(template.fields.length > 0 ? template.fields : [{ ...DEFAULT_FIELD }]); - } else { - setName(''); - setDescription(''); - setIsActive(true); - setFields([{ ...DEFAULT_FIELD }]); - } - }, [template, open]); +function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props) { + const [name, setName] = useState(template?.name ?? ''); + const [description, setDescription] = useState(template?.description ?? ''); + const [isActive, setIsActive] = useState(template?.isActive ?? true); + const [fields, setFields] = useState( + template && template.fields.length > 0 ? template.fields : [{ ...DEFAULT_FIELD }], + ); const saveMutation = useMutation({ mutationFn: () => { diff --git a/src/components/admin/ocr-settings-form.tsx b/src/components/admin/ocr-settings-form.tsx index ff01b29e..e6835aea 100644 --- a/src/components/admin/ocr-settings-form.tsx +++ b/src/components/admin/ocr-settings-form.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { CheckCircle2, Eye, EyeOff, Loader2, XCircle } from 'lucide-react'; @@ -44,33 +44,55 @@ interface SettingsBlockProps { showUseGlobal?: boolean; } -function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlockProps) { +function SettingsBlock(props: SettingsBlockProps) { const queryClient = useQueryClient(); - const queryKey = ['ocr-settings', scope]; - + const queryKey = ['ocr-settings', props.scope]; const { data, isLoading } = useQuery({ queryKey, - queryFn: () => apiFetch(`/api/v1/admin/ocr-settings?scope=${scope}`), + queryFn: () => apiFetch(`/api/v1/admin/ocr-settings?scope=${props.scope}`), }); + // Key the body on the loaded payload so useState initializers seed + // from server values cleanly. + const sig = data?.data + ? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}` + : 'loading'; + return ( + + ); +} - const [provider, setProvider] = useState('openai'); - const [model, setModel] = useState('gpt-4o-mini'); +function SettingsBlockBody({ + scope, + title, + description, + showUseGlobal, + data, + isLoading, + queryClient, + queryKey, +}: SettingsBlockProps & { + data: ConfigResp | undefined; + isLoading: boolean; + queryClient: ReturnType; + queryKey: (string | Scope)[]; +}) { + const [provider, setProvider] = useState(data?.data.provider ?? 'openai'); + const [model, setModel] = useState(data?.data.model ?? 'gpt-4o-mini'); const [apiKey, setApiKey] = useState(''); const [showKey, setShowKey] = useState(false); - const [useGlobal, setUseGlobal] = useState(false); - const [aiEnabled, setAiEnabled] = useState(false); + const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false); + const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false); const [testStatus, setTestStatus] = useState( null, ); - useEffect(() => { - if (!data?.data) return; - setProvider(data.data.provider); - setModel(data.data.model); - setUseGlobal(data.data.useGlobal); - setAiEnabled(data.data.aiEnabled); - }, [data?.data]); - const save = useMutation({ mutationFn: (clearApiKey?: boolean) => apiFetch('/api/v1/admin/ocr-settings', { diff --git a/src/components/admin/ports/port-form.tsx b/src/components/admin/ports/port-form.tsx index 9837f161..162ed9a5 100644 --- a/src/components/admin/ports/port-form.tsx +++ b/src/components/admin/ports/port-form.tsx @@ -1,7 +1,7 @@ 'use client'; import { formatErrorBanner } from '@/lib/api/toast-error'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -50,39 +50,24 @@ interface PortFormProps { onSuccess: () => void; } -export function PortForm({ open, onOpenChange, port, onSuccess }: PortFormProps) { - const [name, setName] = useState(''); - const [slug, setSlug] = useState(''); - const [primaryColor, setPrimaryColor] = useState('#0F4C81'); - const [defaultCurrency, setDefaultCurrency] = useState('USD'); - const [timezone, setTimezone] = useState('America/Anguilla'); - const [isActive, setIsActive] = useState(true); +export function PortForm(props: PortFormProps) { + return ( + + ); +} + +function PortFormBody({ open, onOpenChange, port, onSuccess }: PortFormProps) { + const [name, setName] = useState(port?.name ?? ''); + const [slug, setSlug] = useState(port?.slug ?? ''); + const [primaryColor, setPrimaryColor] = useState(port?.primaryColor ?? '#0F4C81'); + const [defaultCurrency, setDefaultCurrency] = useState(port?.defaultCurrency ?? 'USD'); + const [timezone, setTimezone] = useState(port?.timezone ?? 'America/Anguilla'); + const [isActive, setIsActive] = useState(port?.isActive ?? true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const isEdit = !!port; - useEffect(() => { - if (open) { - if (port) { - setName(port.name); - setSlug(port.slug); - setPrimaryColor(port.primaryColor ?? '#0F4C81'); - setDefaultCurrency(port.defaultCurrency); - setTimezone(port.timezone); - setIsActive(port.isActive); - } else { - setName(''); - setSlug(''); - setPrimaryColor('#0F4C81'); - setDefaultCurrency('USD'); - setTimezone('America/Anguilla'); - setIsActive(true); - } - setError(null); - } - }, [open, port]); - function handleNameChange(value: string) { setName(value); if (!isEdit) { diff --git a/src/components/admin/residential-stages-admin.tsx b/src/components/admin/residential-stages-admin.tsx index b962a4d7..b4cacb6a 100644 --- a/src/components/admin/residential-stages-admin.tsx +++ b/src/components/admin/residential-stages-admin.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { GripVertical, Plus, Trash2, Loader2, Save, AlertTriangle } from 'lucide-react'; +import { GripVertical, Plus, Trash2, Loader2, Save } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -22,6 +22,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { WarningCallout } from '@/components/ui/warning-callout'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { toast } from 'sonner'; @@ -235,11 +236,10 @@ export function ResidentialStagesAdmin() { {removedStageIds.length > 0 && ( -
- + Removing: {removedStageIds.join(', ')}. Any interests parked on these stages will need to be reassigned before save. -
+ )}
diff --git a/src/components/admin/roles/role-form.tsx b/src/components/admin/roles/role-form.tsx index a823f84b..f2d2a120 100644 --- a/src/components/admin/roles/role-form.tsx +++ b/src/components/admin/roles/role-form.tsx @@ -1,7 +1,8 @@ 'use client'; import { formatErrorBanner } from '@/lib/api/toast-error'; +import { formatEnum } from '@/lib/constants'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -120,7 +121,7 @@ const GROUP_LABELS: Record = { }; function formatAction(action: string): string { - return action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + return formatEnum(action); } interface RoleFormProps { @@ -136,41 +137,36 @@ interface RoleFormProps { onSuccess: () => void; } -export function RoleForm({ open, onOpenChange, role, onSuccess }: RoleFormProps) { - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [permissions, setPermissions] = useState>>( - structuredClone(DEFAULT_PERMISSIONS), +export function RoleForm(props: RoleFormProps) { + return ( + ); +} + +function RoleFormBody({ open, onOpenChange, role, onSuccess }: RoleFormProps) { + // Merge role permissions over defaults to fill any missing keys. + const initialPermissions = (() => { + const merged = structuredClone(DEFAULT_PERMISSIONS); + if (role) { + for (const [group, actions] of Object.entries(role.permissions)) { + if (merged[group]) { + for (const [action, value] of Object.entries(actions as Record)) { + merged[group]![action] = value; + } + } + } + } + return merged; + })(); + const [name, setName] = useState(role?.name ?? ''); + const [description, setDescription] = useState(role?.description ?? ''); + const [permissions, setPermissions] = + useState>>(initialPermissions); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const isEdit = !!role; - useEffect(() => { - if (open) { - if (role) { - setName(role.name); - setDescription(role.description ?? ''); - // Merge role permissions over defaults to fill any missing keys - const merged = structuredClone(DEFAULT_PERMISSIONS); - for (const [group, actions] of Object.entries(role.permissions)) { - if (merged[group]) { - for (const [action, value] of Object.entries(actions as Record)) { - merged[group]![action] = value; - } - } - } - setPermissions(merged); - } else { - setName(''); - setDescription(''); - setPermissions(structuredClone(DEFAULT_PERMISSIONS)); - } - setError(null); - } - }, [open, role]); - function togglePermission(group: string, action: string) { setPermissions((prev) => { const next = structuredClone(prev); diff --git a/src/components/admin/sales-email-config-card.tsx b/src/components/admin/sales-email-config-card.tsx index 6cfd5b91..6f5b6cc3 100644 --- a/src/components/admin/sales-email-config-card.tsx +++ b/src/components/admin/sales-email-config-card.tsx @@ -127,6 +127,8 @@ export function SalesEmailConfigCard() { } useEffect(() => { + // Initial load on mount — canonical fetch-once pattern. + // eslint-disable-next-line react-hooks/set-state-in-effect void refresh(); }, []); diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx index d6c76fd8..7a90047c 100644 --- a/src/components/admin/settings/settings-manager.tsx +++ b/src/components/admin/settings/settings-manager.tsx @@ -258,6 +258,8 @@ export function SettingsManager() { }, []); useEffect(() => { + // Initial settings load on mount. + // eslint-disable-next-line react-hooks/set-state-in-effect void fetchSettings(); }, [fetchSettings]); diff --git a/src/components/admin/shared/settings-form-card.tsx b/src/components/admin/shared/settings-form-card.tsx index 5e90695b..022225d2 100644 --- a/src/components/admin/shared/settings-form-card.tsx +++ b/src/components/admin/shared/settings-form-card.tsx @@ -107,6 +107,8 @@ export function SettingsFormCard({ title, description, fields, extra }: Settings }, []); useEffect(() => { + // Initial load — fetchValues internally setStates loading + values. + // eslint-disable-next-line react-hooks/set-state-in-effect void fetchValues(); }, [fetchValues]); diff --git a/src/components/admin/storage-admin-panel.tsx b/src/components/admin/storage-admin-panel.tsx index 61b7a173..315c5a25 100644 --- a/src/components/admin/storage-admin-panel.tsx +++ b/src/components/admin/storage-admin-panel.tsx @@ -28,6 +28,7 @@ import { SettingsFormCard, type SettingFieldDef, } from '@/components/admin/shared/settings-form-card'; +import { WarningCallout } from '@/components/ui/warning-callout'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; @@ -360,12 +361,12 @@ export function StorageAdminPanel() {
)} {confirmMode === 'switch-only' && ( -
- Warning: {s.fileCount} existing file + + {s.fileCount} existing file {s.fileCount === 1 ? '' : 's'} on {s.backend} will not be reachable from the CRM after the switch unless you migrate them later. This is rarely the right choice — prefer Switch + migrate. -
+ )}