2026-04-08 15:47:11 -04:00
|
|
|
'use client';
|
fix(audit-tier-2-routes): manual NextResponse.json error sweep + admin form banners
Two final waves of error-surface hygiene closing the audit's MED §12 +
HIGH §15 + HIGH §17 findings:
* 50 route files swept (61 sites): manual NextResponse.json({error,
status: 4xx|5xx}) early-returns replaced by typed throws +
errorResponse(err) at the catch.
- Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action)
helper from src/lib/api/helpers.ts so denials hit the audit log.
- Path-param + body validation 400s become ValidationError throws.
- 404s become NotFoundError or CodedError('NOT_FOUND') for AI
feature-flag paths.
- 11 manual 5xx returns now re-throw so error_events captures the
request-id (the admin error inspector becomes usable from real
incidents).
- website-analytics 200-with-error anti-pattern flipped to 409 +
UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR.
- 11 sites intentionally preserved: storage/[token] anti-enumeration
token-failure paths, webhook-secret 401, "Unknown port" 400 in
public intake.
* 7 admin forms (roles, users, ports, webhooks, custom-fields,
document-templates, tags) gain a formatErrorBanner() helper from
src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID"
banner — the rep can copy the request id when reporting a failed
save. Banners get whitespace-pre-line so newlines render.
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1)
+ HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:36:59 +02:00
|
|
|
import { formatErrorBanner } from '@/lib/api/toast-error';
|
2026-04-08 15:47:11 -04:00
|
|
|
|
|
|
|
|
import { useState, useEffect } from 'react';
|
2026-05-12 16:14:12 +02:00
|
|
|
import Link from 'next/link';
|
2026-04-08 15:47:11 -04:00
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from '@/components/ui/select';
|
|
|
|
|
import { Switch } from '@/components/ui/switch';
|
|
|
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
2026-05-12 16:14:12 +02:00
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
} from '@/components/ui/alert-dialog';
|
|
|
|
|
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
|
|
|
|
import { useUIStore } from '@/stores/ui-store';
|
2026-04-08 15:47:11 -04:00
|
|
|
import { apiFetch } from '@/lib/api/client';
|
2026-05-12 16:14:12 +02:00
|
|
|
import { formatRole } from '@/lib/constants';
|
2026-04-08 15:47:11 -04:00
|
|
|
|
|
|
|
|
interface Role {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface UserFormProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
user?: {
|
|
|
|
|
userId: string;
|
|
|
|
|
displayName: string;
|
2026-05-12 16:14:12 +02:00
|
|
|
fullName?: string | null;
|
|
|
|
|
firstName?: string | null;
|
|
|
|
|
lastName?: string | null;
|
2026-04-08 15:47:11 -04:00
|
|
|
email: string;
|
|
|
|
|
phone: string | null;
|
|
|
|
|
isActive: boolean;
|
|
|
|
|
role: { id: string; name: string };
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
residentialAccess?: boolean;
|
2026-04-08 15:47:11 -04:00
|
|
|
} | null;
|
|
|
|
|
onSuccess: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|
|
|
|
const [roles, setRoles] = useState<Role[]>([]);
|
2026-05-12 16:14:12 +02:00
|
|
|
const [firstName, setFirstName] = useState('');
|
|
|
|
|
const [lastName, setLastName] = useState('');
|
2026-04-08 15:47:11 -04:00
|
|
|
const [email, setEmail] = useState('');
|
2026-05-12 16:14:12 +02:00
|
|
|
const [originalEmail, setOriginalEmail] = useState('');
|
|
|
|
|
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
|
2026-04-08 15:47:11 -04:00
|
|
|
const [password, setPassword] = useState('');
|
|
|
|
|
const [displayName, setDisplayName] = useState('');
|
2026-05-12 16:14:12 +02:00
|
|
|
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(null);
|
2026-04-08 15:47:11 -04:00
|
|
|
const [roleId, setRoleId] = useState('');
|
|
|
|
|
const [isActive, setIsActive] = useState(true);
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
const [residentialAccess, setResidentialAccess] = useState(false);
|
2026-04-08 15:47:11 -04:00
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
2026-05-12 16:14:12 +02:00
|
|
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
2026-04-08 15:47:11 -04:00
|
|
|
const isEdit = !!user;
|
2026-05-12 16:14:12 +02:00
|
|
|
const fullName = `${firstName} ${lastName}`.trim();
|
2026-04-08 15:47:11 -04:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open) {
|
|
|
|
|
void apiFetch<{ data: Role[] }>('/api/v1/admin/roles').then((res) => setRoles(res.data));
|
|
|
|
|
}
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open) {
|
|
|
|
|
if (user) {
|
2026-05-12 16:14:12 +02:00
|
|
|
// Prefer canonical first/last from the API; fall back to a best-
|
|
|
|
|
// effort split of displayName for older records that pre-date the
|
|
|
|
|
// first_name/last_name columns.
|
|
|
|
|
const first = user.firstName ?? '';
|
|
|
|
|
const last = user.lastName ?? '';
|
|
|
|
|
if (first || last) {
|
|
|
|
|
setFirstName(first);
|
|
|
|
|
setLastName(last);
|
|
|
|
|
} else if (user.fullName) {
|
|
|
|
|
const parts = user.fullName.split(/\s+/);
|
|
|
|
|
setFirstName(parts[0] ?? '');
|
|
|
|
|
setLastName(parts.slice(1).join(' '));
|
|
|
|
|
} else {
|
|
|
|
|
const parts = user.displayName.split(/\s+/);
|
|
|
|
|
setFirstName(parts[0] ?? '');
|
|
|
|
|
setLastName(parts.slice(1).join(' '));
|
|
|
|
|
}
|
2026-04-08 15:47:11 -04:00
|
|
|
setEmail(user.email);
|
2026-05-12 16:14:12 +02:00
|
|
|
setOriginalEmail(user.email);
|
2026-04-08 15:47:11 -04:00
|
|
|
setDisplayName(user.displayName);
|
2026-05-12 16:14:12 +02:00
|
|
|
setPhoneValue(user.phone ? { e164: user.phone, country: 'US' } : null);
|
2026-04-08 15:47:11 -04:00
|
|
|
setRoleId(user.role.id);
|
|
|
|
|
setIsActive(user.isActive);
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
setResidentialAccess(user.residentialAccess ?? false);
|
2026-04-08 15:47:11 -04:00
|
|
|
setPassword('');
|
|
|
|
|
} else {
|
2026-05-12 16:14:12 +02:00
|
|
|
setFirstName('');
|
|
|
|
|
setLastName('');
|
2026-04-08 15:47:11 -04:00
|
|
|
setEmail('');
|
2026-05-12 16:14:12 +02:00
|
|
|
setOriginalEmail('');
|
2026-04-08 15:47:11 -04:00
|
|
|
setDisplayName('');
|
2026-05-12 16:14:12 +02:00
|
|
|
setPhoneValue(null);
|
2026-04-08 15:47:11 -04:00
|
|
|
setRoleId('');
|
|
|
|
|
setIsActive(true);
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
setResidentialAccess(false);
|
2026-04-08 15:47:11 -04:00
|
|
|
setPassword('');
|
|
|
|
|
}
|
|
|
|
|
setError(null);
|
|
|
|
|
}
|
|
|
|
|
}, [open, user]);
|
|
|
|
|
|
2026-05-12 16:14:12 +02:00
|
|
|
function handleSubmit(e: React.FormEvent) {
|
2026-04-08 15:47:11 -04:00
|
|
|
e.preventDefault();
|
2026-05-12 16:14:12 +02:00
|
|
|
// Admin email change for an existing user goes through a confirmation
|
|
|
|
|
// dialog because it locks the original sign-in identity out — the
|
|
|
|
|
// submit path runs after the admin acknowledges. New-user creation
|
|
|
|
|
// and same-email saves go straight through.
|
|
|
|
|
if (isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase()) {
|
|
|
|
|
setEmailConfirmOpen(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void persist();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function persist() {
|
2026-04-08 15:47:11 -04:00
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
2026-05-12 16:14:12 +02:00
|
|
|
const phoneE164 = phoneValue?.e164 ?? null;
|
2026-04-08 15:47:11 -04:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (isEdit) {
|
2026-05-12 16:14:12 +02:00
|
|
|
const emailChanged = email.trim().toLowerCase() !== originalEmail.toLowerCase();
|
2026-04-08 15:47:11 -04:00
|
|
|
await apiFetch(`/api/v1/admin/users/${user.userId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: {
|
2026-05-12 16:14:12 +02:00
|
|
|
firstName: firstName || null,
|
|
|
|
|
lastName: lastName || null,
|
|
|
|
|
fullName: fullName || displayName,
|
2026-04-08 15:47:11 -04:00
|
|
|
displayName,
|
2026-05-12 16:14:12 +02:00
|
|
|
email: emailChanged ? email.trim() : undefined,
|
|
|
|
|
phone: phoneE164,
|
2026-04-08 15:47:11 -04:00
|
|
|
roleId,
|
|
|
|
|
isActive,
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
residentialAccess,
|
2026-05-12 16:14:12 +02:00
|
|
|
notifyEmailChange: emailChanged ? true : undefined,
|
2026-04-08 15:47:11 -04:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
await apiFetch('/api/v1/admin/users', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: {
|
2026-05-12 16:14:12 +02:00
|
|
|
name: fullName || displayName,
|
|
|
|
|
firstName: firstName || null,
|
|
|
|
|
lastName: lastName || null,
|
2026-04-08 15:47:11 -04:00
|
|
|
email,
|
|
|
|
|
password,
|
|
|
|
|
displayName,
|
2026-05-12 16:14:12 +02:00
|
|
|
phone: phoneE164 ?? undefined,
|
2026-04-08 15:47:11 -04:00
|
|
|
roleId,
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
residentialAccess,
|
2026-04-08 15:47:11 -04:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
onSuccess();
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
} catch (err: unknown) {
|
fix(audit-tier-2-routes): manual NextResponse.json error sweep + admin form banners
Two final waves of error-surface hygiene closing the audit's MED §12 +
HIGH §15 + HIGH §17 findings:
* 50 route files swept (61 sites): manual NextResponse.json({error,
status: 4xx|5xx}) early-returns replaced by typed throws +
errorResponse(err) at the catch.
- Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action)
helper from src/lib/api/helpers.ts so denials hit the audit log.
- Path-param + body validation 400s become ValidationError throws.
- 404s become NotFoundError or CodedError('NOT_FOUND') for AI
feature-flag paths.
- 11 manual 5xx returns now re-throw so error_events captures the
request-id (the admin error inspector becomes usable from real
incidents).
- website-analytics 200-with-error anti-pattern flipped to 409 +
UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR.
- 11 sites intentionally preserved: storage/[token] anti-enumeration
token-failure paths, webhook-secret 401, "Unknown port" 400 in
public intake.
* 7 admin forms (roles, users, ports, webhooks, custom-fields,
document-templates, tags) gain a formatErrorBanner() helper from
src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID"
banner — the rep can copy the request id when reporting a failed
save. Banners get whitespace-pre-line so newlines render.
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1)
+ HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:36:59 +02:00
|
|
|
const message = formatErrorBanner(err);
|
2026-04-08 15:47:11 -04:00
|
|
|
setError(message);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<SheetContent className="overflow-y-auto">
|
|
|
|
|
<SheetHeader>
|
|
|
|
|
<SheetTitle>{isEdit ? 'Edit User' : 'New User'}</SheetTitle>
|
|
|
|
|
</SheetHeader>
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
2026-05-12 16:14:12 +02:00
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="user-first-name">First name</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="user-first-name"
|
|
|
|
|
value={firstName}
|
|
|
|
|
onChange={(e) => setFirstName(e.target.value)}
|
|
|
|
|
placeholder="Jane"
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="user-last-name">Last name</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="user-last-name"
|
|
|
|
|
value={lastName}
|
|
|
|
|
onChange={(e) => setLastName(e.target.value)}
|
|
|
|
|
placeholder="Doe"
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-08 15:47:11 -04:00
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
2026-05-12 16:14:12 +02:00
|
|
|
<Label htmlFor="user-display-name">Display name</Label>
|
2026-04-08 15:47:11 -04:00
|
|
|
<Input
|
|
|
|
|
id="user-display-name"
|
|
|
|
|
value={displayName}
|
|
|
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
2026-05-12 16:14:12 +02:00
|
|
|
placeholder={fullName || 'Jane Doe'}
|
2026-04-08 15:47:11 -04:00
|
|
|
required
|
|
|
|
|
/>
|
2026-05-12 16:14:12 +02:00
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
How this user appears across the app — usually their full name, but they can pick a
|
|
|
|
|
nickname.
|
|
|
|
|
</p>
|
2026-04-08 15:47:11 -04:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
2026-05-12 16:14:12 +02:00
|
|
|
<Label htmlFor="user-email">Email</Label>
|
2026-04-08 15:47:11 -04:00
|
|
|
<Input
|
2026-05-12 16:14:12 +02:00
|
|
|
id="user-email"
|
|
|
|
|
type="email"
|
|
|
|
|
value={email}
|
|
|
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
|
|
|
placeholder="user@example.com"
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
{isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase() ? (
|
|
|
|
|
<p className="text-xs text-amber-600">
|
|
|
|
|
You'll be asked to confirm — the original address will receive an automated
|
|
|
|
|
notice that you, the admin, changed their sign-in email.
|
|
|
|
|
</p>
|
|
|
|
|
) : isEdit ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Changing this address is an admin-only override; the user will be notified at the
|
|
|
|
|
old address.
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!isEdit && (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="user-password">Password</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="user-password"
|
|
|
|
|
type="password"
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
placeholder="Min 12 characters"
|
|
|
|
|
minLength={12}
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="user-phone">Phone</Label>
|
|
|
|
|
<PhoneInput
|
2026-04-08 15:47:11 -04:00
|
|
|
id="user-phone"
|
2026-05-12 16:14:12 +02:00
|
|
|
value={phoneValue}
|
|
|
|
|
onChange={setPhoneValue}
|
|
|
|
|
placeholder="Phone number"
|
2026-04-08 15:47:11 -04:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="user-role">Role</Label>
|
|
|
|
|
<Select value={roleId} onValueChange={setRoleId} required>
|
|
|
|
|
<SelectTrigger id="user-role">
|
|
|
|
|
<SelectValue placeholder="Select a role" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{roles.map((r) => (
|
|
|
|
|
<SelectItem key={r.id} value={r.id}>
|
2026-05-12 16:14:12 +02:00
|
|
|
{formatRole(r.name)}
|
2026-04-08 15:47:11 -04:00
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="user-residential">Residential access</Label>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Grant this user access to residential clients and interests in addition to their
|
|
|
|
|
primary role.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
id="user-residential"
|
|
|
|
|
checked={residentialAccess}
|
|
|
|
|
onCheckedChange={setResidentialAccess}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-08 15:47:11 -04:00
|
|
|
{isEdit && (
|
|
|
|
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
|
|
|
<div>
|
2026-05-12 16:14:12 +02:00
|
|
|
<Label htmlFor="user-active">Account active</Label>
|
|
|
|
|
<p className="text-xs text-muted-foreground">Disabled users cannot sign in.</p>
|
2026-04-08 15:47:11 -04:00
|
|
|
</div>
|
|
|
|
|
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-12 16:14:12 +02:00
|
|
|
{isEdit && portSlug && (
|
|
|
|
|
<div className="rounded-lg border bg-muted/30 p-3">
|
|
|
|
|
<p className="text-sm font-medium">Fine-tuned permissions</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
The selected role grants a baseline. To add or remove a specific permission for
|
|
|
|
|
this user only, open the role & permissions page.
|
|
|
|
|
</p>
|
|
|
|
|
<Link
|
|
|
|
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
|
|
|
href={`/${portSlug}/admin/roles?focusUser=${user.userId}` as any}
|
|
|
|
|
className="mt-2 inline-block text-xs font-medium text-primary hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Manage permissions →
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
fix(audit-tier-2-routes): manual NextResponse.json error sweep + admin form banners
Two final waves of error-surface hygiene closing the audit's MED §12 +
HIGH §15 + HIGH §17 findings:
* 50 route files swept (61 sites): manual NextResponse.json({error,
status: 4xx|5xx}) early-returns replaced by typed throws +
errorResponse(err) at the catch.
- Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action)
helper from src/lib/api/helpers.ts so denials hit the audit log.
- Path-param + body validation 400s become ValidationError throws.
- 404s become NotFoundError or CodedError('NOT_FOUND') for AI
feature-flag paths.
- 11 manual 5xx returns now re-throw so error_events captures the
request-id (the admin error inspector becomes usable from real
incidents).
- website-analytics 200-with-error anti-pattern flipped to 409 +
UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR.
- 11 sites intentionally preserved: storage/[token] anti-enumeration
token-failure paths, webhook-secret 401, "Unknown port" 400 in
public intake.
* 7 admin forms (roles, users, ports, webhooks, custom-fields,
document-templates, tags) gain a formatErrorBanner() helper from
src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID"
banner — the rep can copy the request id when reporting a failed
save. Banners get whitespace-pre-line so newlines render.
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1)
+ HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:36:59 +02:00
|
|
|
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
|
2026-04-08 15:47:11 -04:00
|
|
|
|
|
|
|
|
<SheetFooter>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
|
2026-05-12 16:14:12 +02:00
|
|
|
{loading ? 'Saving...' : isEdit ? 'Save changes' : 'Create user'}
|
2026-04-08 15:47:11 -04:00
|
|
|
</Button>
|
|
|
|
|
</SheetFooter>
|
|
|
|
|
</form>
|
2026-05-12 16:14:12 +02:00
|
|
|
|
|
|
|
|
<AlertDialog open={emailConfirmOpen} onOpenChange={setEmailConfirmOpen}>
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle>Change this user's sign-in email?</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
You're about to change <span className="font-medium">{originalEmail}</span> to{' '}
|
|
|
|
|
<span className="font-medium">{email}</span>. From now on, they must sign in with
|
|
|
|
|
the new address. The original address will receive an automated notification
|
|
|
|
|
explaining that an administrator made the change.
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setEmailConfirmOpen(false);
|
|
|
|
|
void persist();
|
|
|
|
|
}}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
>
|
|
|
|
|
Confirm change
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
2026-04-08 15:47:11 -04:00
|
|
|
</SheetContent>
|
|
|
|
|
</Sheet>
|
|
|
|
|
);
|
|
|
|
|
}
|