Files
pn-new-crm/src/components/admin/roles/role-form.tsx

307 lines
9.5 KiB
TypeScript
Raw Normal View History

'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';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { apiFetch } from '@/lib/api/client';
/** Default permissions structure matching RolePermissions type */
const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
interests: {
view: false,
create: false,
edit: false,
delete: false,
change_stage: false,
generate_eoi: false,
export: false,
},
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
documents: {
view: false,
create: false,
send_for_signing: false,
upload_signed: false,
delete: false,
},
expenses: {
view: false,
create: false,
edit: false,
delete: false,
export: false,
scan_receipt: false,
},
invoices: {
view: false,
create: false,
edit: false,
delete: false,
send: false,
record_payment: false,
export: false,
},
files: { view: false, upload: false, delete: false, manage_folders: false },
email: { view: false, send: false, configure_account: false },
reminders: {
view_own: false,
view_all: false,
create: false,
edit_own: false,
edit_all: false,
assign_others: false,
},
calendar: { connect: false, view_events: false },
reports: { view_dashboard: false, view_analytics: false, export: false },
document_templates: { view: false, generate: false, manage: false },
admin: {
manage_users: false,
view_audit_log: false,
manage_settings: false,
manage_webhooks: false,
manage_reports: false,
manage_custom_fields: false,
manage_forms: false,
manage_tags: false,
system_backup: false,
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
},
residential_clients: { view: false, create: false, edit: false, delete: false },
residential_interests: {
view: false,
create: false,
edit: false,
delete: false,
change_stage: false,
},
};
const GROUP_LABELS: Record<string, string> = {
clients: 'Clients',
interests: 'Interests / Pipeline',
berths: 'Berths',
documents: 'Documents',
expenses: 'Expenses',
invoices: 'Invoices',
files: 'Files',
email: 'Email',
reminders: 'Reminders',
calendar: 'Calendar',
reports: 'Reports',
document_templates: 'Document Templates',
admin: 'Administration',
};
function formatAction(action: string): string {
return action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
interface RoleFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
role?: {
id: string;
name: string;
description: string | null;
isSystem: boolean;
permissions: Record<string, Record<string, boolean>>;
} | null;
onSuccess: () => void;
}
export function RoleForm({ open, onOpenChange, role, onSuccess }: RoleFormProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [permissions, setPermissions] = useState<Record<string, Record<string, boolean>>>(
structuredClone(DEFAULT_PERMISSIONS),
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string, boolean>)) {
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);
next[group]![action] = !next[group]![action];
return next;
});
}
function toggleGroup(group: string, value: boolean) {
setPermissions((prev) => {
const next = structuredClone(prev);
for (const action of Object.keys(next[group]!)) {
next[group]![action] = value;
}
return next;
});
}
function isGroupAllChecked(group: string): boolean {
return Object.values(permissions[group]!).every(Boolean);
}
function isGroupPartial(group: string): boolean {
const vals = Object.values(permissions[group]!);
return vals.some(Boolean) && !vals.every(Boolean);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (isEdit) {
await apiFetch(`/api/v1/admin/roles/${role.id}`, {
method: 'PATCH',
body: { name, description: description || null, permissions },
});
} else {
await apiFetch('/api/v1/admin/roles', {
method: 'POST',
body: { name, description: description || undefined, permissions },
});
}
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);
setError(message);
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[500px] sm:max-w-[500px]">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Role' : 'New Role'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 flex flex-col h-[calc(100vh-140px)]">
<div className="space-y-4 mb-4">
<div className="space-y-2">
<Label htmlFor="role-name">Name</Label>
<Input
id="role-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Sales Manager"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role-description">Description</Label>
<Textarea
id="role-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this role is for..."
rows={2}
/>
</div>
</div>
<Label className="mb-2">Permissions</Label>
<ScrollArea className="flex-1 rounded-md border">
<Accordion type="multiple" className="px-3">
{Object.entries(permissions).map(([group, actions]) => (
<AccordionItem key={group} value={group}>
<AccordionTrigger className="text-sm">
<div className="flex items-center gap-3">
<Checkbox
checked={isGroupAllChecked(group)}
ref={(el) => {
if (el) {
(el as unknown as HTMLInputElement).indeterminate =
isGroupPartial(group);
}
}}
onCheckedChange={(checked) => toggleGroup(group, checked === true)}
onClick={(e) => e.stopPropagation()}
/>
<span>{GROUP_LABELS[group] ?? group}</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-8 pb-2">
{Object.entries(actions).map(([action, checked]) => (
<label
key={action}
className="flex items-center gap-2 text-sm cursor-pointer"
>
<Checkbox
checked={checked}
onCheckedChange={() => togglePermission(group, action)}
/>
{formatAction(action)}
</label>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</ScrollArea>
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="mt-2 whitespace-pre-line text-sm text-destructive">{error}</p>}
<SheetFooter className="mt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !name.trim()}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Role'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}