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
|
|
|
|
2026-05-13 11:50:07 +02:00
|
|
|
import { useState } from 'react';
|
|
|
|
|
import { useQuery } from '@tanstack/react-query';
|
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:52:35 +02:00
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
|
|
|
import { UserPermissionMatrix } from './user-permission-matrix';
|
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';
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 11:50:07 +02:00
|
|
|
export function UserForm(props: UserFormProps) {
|
|
|
|
|
return (
|
|
|
|
|
<UserFormBody key={props.open ? `open:${props.user?.userId ?? 'new'}` : 'closed'} {...props} />
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|
|
|
|
// Derive initial first/last names from the user payload.
|
|
|
|
|
const initialNames = (() => {
|
|
|
|
|
if (!user) return { first: '', last: '' };
|
|
|
|
|
if (user.firstName || user.lastName) {
|
|
|
|
|
return { first: user.firstName ?? '', last: user.lastName ?? '' };
|
|
|
|
|
}
|
|
|
|
|
const source = user.fullName ?? user.displayName;
|
|
|
|
|
const parts = source.split(/\s+/);
|
|
|
|
|
return { first: parts[0] ?? '', last: parts.slice(1).join(' ') };
|
|
|
|
|
})();
|
|
|
|
|
// useQuery replaces the prior useEffect(fetch+setRoles) pattern.
|
|
|
|
|
const rolesQuery = useQuery<{ data: Role[] }>({
|
|
|
|
|
queryKey: ['admin', 'roles'],
|
|
|
|
|
queryFn: () => apiFetch('/api/v1/admin/roles'),
|
|
|
|
|
enabled: open,
|
|
|
|
|
});
|
|
|
|
|
const roles = rolesQuery.data?.data ?? [];
|
|
|
|
|
const [firstName, setFirstName] = useState(initialNames.first);
|
|
|
|
|
const [lastName, setLastName] = useState(initialNames.last);
|
|
|
|
|
const [email, setEmail] = useState(user?.email ?? '');
|
|
|
|
|
const [originalEmail] = useState(user?.email ?? '');
|
2026-05-12 16:14:12 +02:00
|
|
|
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
|
2026-04-08 15:47:11 -04:00
|
|
|
const [password, setPassword] = useState('');
|
2026-05-13 11:50:07 +02:00
|
|
|
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
|
|
|
|
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
|
|
|
|
|
user?.phone ? { e164: user.phone, country: 'US' } : null,
|
|
|
|
|
);
|
|
|
|
|
const [roleId, setRoleId] = useState(user?.role.id ?? '');
|
|
|
|
|
const [isActive, setIsActive] = useState(user?.isActive ?? true);
|
|
|
|
|
const [residentialAccess, setResidentialAccess] = useState(user?.residentialAccess ?? false);
|
2026-04-08 15:47:11 -04:00
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const isEdit = !!user;
|
2026-05-12 16:14:12 +02:00
|
|
|
const fullName = `${firstName} ${lastName}`.trim();
|
2026-04-08 15:47:11 -04:00
|
|
|
|
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>
|
|
|
|
|
|
2026-05-12 16:52:35 +02:00
|
|
|
<Tabs defaultValue="profile" className="mt-6">
|
|
|
|
|
<TabsList className="w-full">
|
|
|
|
|
<TabsTrigger value="profile" className="flex-1">
|
|
|
|
|
Profile & role
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger value="permissions" className="flex-1" disabled={!isEdit}>
|
|
|
|
|
Permissions
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
|
|
|
<TabsContent value="permissions" className="mt-4">
|
|
|
|
|
{isEdit ? (
|
|
|
|
|
<UserPermissionMatrix userId={user.userId} />
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Save the new user first, then return here to fine-tune their permissions.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
<TabsContent value="profile" className="mt-4">
|
2026-05-12 17:02:10 +02:00
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
|
|
|
<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
|
|
|
|
2026-05-12 17:02:10 +02:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="user-display-name">Display name</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="user-display-name"
|
|
|
|
|
value={displayName}
|
|
|
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
|
|
|
placeholder={fullName || 'Jane Doe'}
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
2026-05-21 20:02:58 +02:00
|
|
|
How this user appears across the app - usually their full name, but they can pick
|
2026-05-12 17:02:10 +02:00
|
|
|
a nickname.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-05-12 16:14:12 +02:00
|
|
|
|
2026-05-12 17:02:10 +02:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="user-email">Email</Label>
|
|
|
|
|
<Input
|
|
|
|
|
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">
|
2026-05-21 20:02:58 +02:00
|
|
|
You'll be asked to confirm - the original address will receive an automated
|
2026-05-12 17:02:10 +02:00
|
|
|
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>
|
2026-05-12 16:14:12 +02:00
|
|
|
|
2026-05-12 17:02:10 +02:00
|
|
|
{!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>
|
|
|
|
|
)}
|
2026-04-08 15:47:11 -04:00
|
|
|
|
2026-05-12 17:02:10 +02:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="user-phone">Phone</Label>
|
|
|
|
|
<PhoneInput
|
|
|
|
|
id="user-phone"
|
|
|
|
|
value={phoneValue}
|
|
|
|
|
onChange={setPhoneValue}
|
|
|
|
|
placeholder="Phone number"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-04-08 15:47:11 -04:00
|
|
|
|
2026-05-12 17:02:10 +02:00
|
|
|
<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}>
|
|
|
|
|
{formatRole(r.name)}
|
|
|
|
|
</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
|
|
|
|
2026-05-12 17:02:10 +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}
|
|
|
|
|
/>
|
2026-04-08 15:47:11 -04:00
|
|
|
</div>
|
|
|
|
|
|
2026-05-12 17:02:10 +02:00
|
|
|
{isEdit && (
|
|
|
|
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="user-active">Account active</Label>
|
|
|
|
|
<p className="text-xs text-muted-foreground">Disabled users cannot sign in.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
|
2026-04-08 15:47:11 -04:00
|
|
|
|
2026-05-12 17:02:10 +02:00
|
|
|
<SheetFooter>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
|
fix(audit-wave-9): copy/terminology sweep (copy-auditor)
Address the highest-impact items from the copy-auditor's CRITICAL +
HIGH + MEDIUM bands:
**C2 portal raw-status leak**
- Drop the staff-only `leadCategory` chip from the portal interests
page entirely. Privacy + optics: clients should never see "hot lead"
in their own portal. `eoiStatus` was already wrapped in
`portalSigningLabel`; only the categorical chip remained.
**C3 signing-status label drift**
- Add `src/lib/labels/document-status.ts` as the single source of
truth for the {draft, sent, partially_signed, completed, expired,
cancelled} lifecycle: labels (CRM + portal variants), StatusPill
variant, and the "active / in-flight" set.
- Wire it into interest-eoi-tab, interest-contract-tab,
interest-reservation-tab — they previously redefined identical
STATUS_LABELS / ACTIVE_STATUSES blocks per-file.
**H1 + M3 verbiage codemod**
- `Save Changes` → `Save changes` (sentence case, matches the
surrounding admin/CRM pattern).
- `Saving...` (ASCII three dots) → `Saving…` (Unicode ellipsis).
Matches the project's UTF-8-elsewhere convention and reads
correctly via screen-readers.
**M1 envelope jargon → signing request**
- smart-archive-dialog: "Leave envelope pending" → "Leave signing
request pending"; "Void the signing envelope" → "Cancel the signing
request"; section header updated to match.
- document-detail: "voids the signing envelope" → "cancels the signing
request".
- bulk-archive-wizard: "leave invoices/signing envelopes alone" →
"leave invoices/signing requests alone".
- Documenso admin page intentionally keeps `envelope` (dev/integration
vocabulary).
**M5 Hot Lead casing**
- Normalize `Hot Lead` / `General Interest` / `Specific Qualified` to
sentence case in `constants.ts` LABEL_OVERRIDES and all per-file
lead-category maps so the CRM trend (sentence case) is consistent.
**C1 surface-level rename**
- "Linked prospect (optional)" → "Linked interest (optional)" on the
berth status-change dialog.
- "Deal Documents" tab → "Interest Documents" (URL/route kept as
`/deal-documents` to avoid breaking deep links; rename deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:12:40 +02:00
|
|
|
{loading ? 'Saving…' : isEdit ? 'Save changes' : 'Create user'}
|
2026-05-12 17:02:10 +02:00
|
|
|
</Button>
|
|
|
|
|
</SheetFooter>
|
|
|
|
|
</form>
|
2026-05-12 16:52:35 +02:00
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|