fix(audit): UI — L18 (decorative emoji -> Lucide icons), L19 (gated NotesList timer + create-from-url ref-in-effect)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 13:30:25 +02:00
parent e7fdf75a6c
commit 8c4c9b967e
40 changed files with 277 additions and 130 deletions

View File

@@ -1,3 +1,4 @@
import { notFound } from 'next/navigation';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
@@ -29,7 +30,10 @@ export default async function ExpensesLayout({ children, params }: ExpensesLayou
where: eq(portsTable.slug, portSlug),
columns: { id: true },
});
if (!port) return children;
// Fail closed: an unresolved slug means the port doesn't exist (or the
// user mistyped one) — 404 rather than silently rendering the gated
// subtree without a module check.
if (!port) notFound();
const enabled = await isExpensesModuleEnabled(port.id);
if (enabled) return children;
return (

View File

@@ -1,3 +1,4 @@
import { notFound } from 'next/navigation';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
@@ -28,7 +29,10 @@ export default async function InvoicesLayout({ children, params }: InvoicesLayou
where: eq(portsTable.slug, portSlug),
columns: { id: true },
});
if (!port) return children;
// Fail closed: an unresolved slug means the port doesn't exist (or the
// user mistyped one) — 404 rather than silently rendering the gated
// subtree without a module check.
if (!port) notFound();
const enabled = await isInvoicesModuleEnabled(port.id);
if (enabled) return children;
return (

View File

@@ -1,3 +1,4 @@
import { notFound } from 'next/navigation';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
@@ -30,7 +31,10 @@ export default async function ResidentialLayout({ children, params }: Residentia
where: eq(portsTable.slug, portSlug),
columns: { id: true },
});
if (!port) return children;
// Fail closed: an unresolved slug means the port doesn't exist (or the
// user mistyped one) — 404 rather than silently rendering the gated
// subtree without a module check.
if (!port) notFound();
const enabled = await isResidentialModuleEnabled(port.id);
if (enabled) return children;
return (

View File

@@ -30,7 +30,10 @@ function safeNextPath(raw: string | null): string {
export default function PortalLoginPage() {
const router = useRouter();
const search = useSearchParams();
const next = safeNextPath(search.get('next'));
// The middleware backstop (src/proxy.ts) redirects unauthenticated
// portal visitors with `?redirect=`; older links / manual callers may
// still use `?next=`. Accept either, preferring `redirect`.
const next = safeNextPath(search.get('redirect') ?? search.get('next'));
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');

View File

@@ -1,11 +1,12 @@
import type { Metadata, Viewport } from 'next';
import { redirect } from 'next/navigation';
import { notFound, redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
import { QueryProvider } from '@/providers/query-provider';
import { PortProvider } from '@/providers/port-provider';
@@ -60,7 +61,22 @@ export default async function ScannerLayout({
const port = await db.query.ports.findFirst({
where: eq(portsTable.slug, portSlug),
});
if (!port) redirect('/login');
if (!port) notFound();
// Membership gate (mirrors the dashboard layout): super admins reach
// every port; everyone else needs an explicit user_port_roles row for
// THIS port. Without this the scanner resolved the port by slug alone,
// so any authenticated user could scan receipts into a port they have
// no role on.
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
});
if (!profile?.isSuperAdmin) {
const membership = await db.query.userPortRoles.findFirst({
where: and(eq(userPortRoles.userId, session.user.id), eq(userPortRoles.portId, port.id)),
});
if (!membership) notFound();
}
return (
<QueryProvider>

View File

@@ -81,6 +81,12 @@ export async function POST(req: NextRequest) {
firstName: result.firstName,
});
// L34 carve-out note: this is a public website intake POST (external
// contract). Unlike the sibling intake routes it already uses the
// canonical `{ data }` envelope — the external marketing site is
// coded against THIS shape, so keep `{ data: { id, message } }` and do
// not "normalize" it toward the bespoke `{ success }`/bare shapes used
// by the other public intake endpoints.
return NextResponse.json(
{ data: { id: result.interestId, message: 'Interest registered successfully' } },
{ status: 201 },

View File

@@ -128,6 +128,11 @@ export async function POST(req: NextRequest) {
crmDeepLink: `${env.APP_URL}/${port.slug}/residential/clients/${result.clientId}`,
}).catch((err) => logger.error({ err }, 'Failed to send residential inquiry notifications'));
// L34 carve-out: deliberate bespoke `{ success: true, ... }` shape
// (NOT the `{ data }` envelope). This is the public website's intake
// contract — the external marketing site reads `success` and the
// returned ids off the JSON root, mirroring the public portal-auth
// endpoints. Changing the shape would be a breaking cross-repo change.
return NextResponse.json({ success: true, ...result }, { status: 201 });
} catch (error) {
return errorResponse(error);

View File

@@ -169,6 +169,11 @@ export async function POST(req: NextRequest) {
},
'website inquiry captured',
);
// L34 carve-out: deliberate bespoke `{ id, deduped }` shape (NOT the
// `{ data }` envelope). This is the public website's intake contract —
// the external marketing site reads `id`/`deduped` off the JSON root.
// Both return sites below share this shape on purpose. Changing it
// would be a breaking cross-repo change.
return NextResponse.json({ id: insertResult[0].id, deduped: false });
}

View File

@@ -61,7 +61,7 @@ export const PUT = withAuth(
},
ctx.userId,
);
return NextResponse.json({ ok: true });
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}

View File

@@ -14,6 +14,7 @@ import { and, eq } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { ALLOWED_RESOURCE_ACTIONS } from '@/lib/auth/permissions';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import {
@@ -28,61 +29,12 @@ import { createAuditLog } from '@/lib/audit';
import { errorResponse, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import { z } from 'zod';
/**
* Mirrors `RolePermissions` in src/lib/db/schema/users.ts. Used as the
* allow-list for the PUT body so a client can't write arbitrary keys
* that the resolver would happily merge into the effective permission
* map. Keep this in sync when RolePermissions gains a leaf.
*/
const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
clients: new Set(['view', 'create', 'edit', 'delete', 'merge', 'export']),
interests: new Set([
'view',
'create',
'edit',
'delete',
'change_stage',
'override_stage',
'generate_eoi',
'export',
]),
berths: new Set(['view', 'edit', 'import', 'manage_waiting_list', 'update_prices']),
documents: new Set([
'view',
'create',
'edit',
'send_for_signing',
'upload_signed',
'delete',
'manage_folders',
]),
expenses: new Set(['view', 'create', 'edit', 'delete', 'export', 'scan_receipt']),
invoices: new Set(['view', 'create', 'edit', 'delete', 'send', 'record_payment', 'export']),
files: new Set(['view', 'upload', 'edit', 'delete', 'manage_folders']),
email: new Set(['view', 'send', 'configure_account']),
reminders: new Set(['view_own', 'view_all', 'create', 'edit_own', 'edit_all', 'assign_others']),
calendar: new Set(['connect', 'view_events']),
reports: new Set(['view_dashboard', 'view_analytics', 'export']),
document_templates: new Set(['view', 'generate', 'manage']),
yachts: new Set(['view', 'create', 'edit', 'delete', 'transfer']),
companies: new Set(['view', 'create', 'edit', 'delete']),
memberships: new Set(['view', 'manage']),
tenancies: new Set(['view', 'manage', 'cancel']),
admin: new Set([
'manage_users',
'view_audit_log',
'manage_settings',
'manage_webhooks',
'manage_reports',
'manage_custom_fields',
'manage_forms',
'manage_tags',
'system_backup',
'permanently_delete_clients',
]),
residential_clients: new Set(['view', 'create', 'edit', 'delete']),
residential_interests: new Set(['view', 'create', 'edit', 'delete', 'change_stage']),
};
// The per-user override allow-list is the canonical PERMISSION_CATALOG
// (src/lib/auth/permissions.ts), imported as `ALLOWED_RESOURCE_ACTIONS` above.
// Sharing it with the role validator means the two can't diverge (L23): a
// client can't write arbitrary keys that the resolver would merge into the
// effective permission map, and the catalog stays in lockstep with
// `RolePermissions`.
const updateOverridesSchema = z.object({
/** Partial<RolePermissions> - passthrough JSON. Validated structurally

View File

@@ -9,7 +9,7 @@ export const POST = withAuth(async (_req, ctx, params) => {
const id = params.id;
if (!id) throw new ValidationError('id is required');
await acknowledgeAlert(id, ctx.portId, ctx.userId);
return NextResponse.json({ ok: true });
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}

View File

@@ -9,7 +9,7 @@ export const POST = withAuth(async (_req, ctx, params) => {
const id = params.id;
if (!id) throw new ValidationError('id is required');
await dismissAlert(id, ctx.portId, ctx.userId);
return NextResponse.json({ ok: true });
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}

View File

@@ -30,7 +30,7 @@ export const DELETE = withAuth(
withPermission('interests', 'edit', async (_req, ctx, params) => {
try {
await remove(params.id!, ctx.portId);
return NextResponse.json({ ok: true });
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { AppError, errorResponse } from '@/lib/errors';
import { convert } from '@/lib/services/currency';
const convertSchema = z.object({
@@ -19,9 +19,13 @@ export const POST = withAuth(async (req, _ctx) => {
const result = await convert(amount, from, to);
if (!result) {
return NextResponse.json(
{ error: `Exchange rate not available for ${from}${to}` },
{ status: 422 },
// 422 (not 400): the input is well-formed, but no rate is
// available for the requested pair. Routed through errorResponse
// so the body carries code + requestId like every other error.
throw new AppError(
422,
`Exchange rate not available for ${from}${to}`,
'CURRENCY_RATE_UNAVAILABLE',
);
}

View File

@@ -6,6 +6,9 @@ import { getRecentActivity } from '@/lib/services/dashboard.service';
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
const result = await getRecentActivity(ctx.portId);
// L34 carve-out: deliberate bare-shape response (NOT the `{ data }`
// envelope). The dashboard widgets read these fields off the root of
// the JSON; wrapping them would break the consumers. Left as-is.
return NextResponse.json(result);
}),
);

View File

@@ -19,6 +19,9 @@ export const GET = withAuth(
const range = rangeSlug ? parseRangeSlug(rangeSlug) : null;
const bounds = range ? rangeToBounds(range) : null;
const result = await getRevenueForecast(ctx.portId, bounds);
// L34 carve-out: deliberate bare-shape response (NOT the `{ data }`
// envelope). The dashboard widgets read these fields off the root of
// the JSON; wrapping them would break the consumers. Left as-is.
return NextResponse.json(result);
}),
);

View File

@@ -20,6 +20,9 @@ export const GET = withAuth(
const range = rangeSlug ? parseRangeSlug(rangeSlug) : null;
const bounds = range ? rangeToBounds(range) : null;
const result = await getKpis(ctx.portId, bounds);
// L34 carve-out: deliberate bare-shape response (NOT the `{ data }`
// envelope). The dashboard widgets read these fields off the root of
// the JSON; wrapping them would break the consumers. Left as-is.
return NextResponse.json(result);
}),
);

View File

@@ -6,6 +6,9 @@ import { getPipelineCounts } from '@/lib/services/dashboard.service';
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
const result = await getPipelineCounts(ctx.portId);
// L34 carve-out: deliberate bare-shape response (NOT the `{ data }`
// envelope). The dashboard widgets read these fields off the root of
// the JSON; wrapping them would break the consumers. Left as-is.
return NextResponse.json(result);
}),
);

View File

@@ -10,7 +10,7 @@ export const POST = withAuth(
const id = params.id;
if (!id) throw new ValidationError('id is required');
await clearDuplicate(id, ctx.portId, ctx.userId);
return NextResponse.json({ ok: true });
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}

View File

@@ -18,7 +18,7 @@ export const POST = withAuth(
if (!sourceId) throw new ValidationError('id is required');
const body = await parseBody(req, mergeSchema);
await mergeDuplicate(sourceId, body.targetId, ctx.portId, ctx.userId);
return NextResponse.json({ ok: true });
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import { createPaymentSchema } from '@/lib/validators/payments';
import {
createPayment,
@@ -32,10 +32,7 @@ export const POST = withAuth(
// a client that sends one ID in the URL but another in the body.
const body = await parseBody(req, createPaymentSchema);
if (body.interestId !== params.id) {
return NextResponse.json(
{ error: 'interestId in body must match URL parameter' },
{ status: 400 },
);
throw new ValidationError('interestId in body must match URL parameter');
}
const payment = await createPayment(ctx.portId, body, {
userId: ctx.userId,

View File

@@ -22,6 +22,9 @@ export const GET = withAuth(
try {
const filters = parseQuery(req, boardFiltersSchema);
const result = await listInterestsForBoard(ctx.portId, filters);
// L34 carve-out: deliberate bare-shape response (NOT the `{ data }`
// envelope). The pipeline board reads the column/lane fields off the
// JSON root; wrapping them would break the consumer. Left as-is.
return NextResponse.json(result);
} catch (error) {
return errorResponse(error);

View File

@@ -35,7 +35,7 @@ export const PATCH = withAuth(async (req, ctx) => {
try {
const { email } = await parseBody(req, updateEmailSchema);
if (email === ctx.user.email) {
return NextResponse.json({ ok: true, unchanged: true });
return NextResponse.json({ data: { unchanged: true } });
}
// Reject if another account already owns this address.

View File

@@ -25,7 +25,7 @@ export const POST = withAuth(async (_req, ctx) => {
redirectTo: '/set-password',
},
});
return NextResponse.json({ ok: true });
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}

View File

@@ -10,6 +10,9 @@ export const GET = withAuth(async (req, ctx) => {
try {
const query = parseQuery(req, listNotificationsSchema);
const result = await notificationsService.listNotifications(ctx.userId, ctx.portId, query);
// L34 carve-out: deliberate bare-shape response (NOT the `{ data }`
// envelope). The notifications bell/list consumers read the fields
// off the JSON root; wrapping them would break them. Left as-is.
return NextResponse.json(result);
} catch (error) {
return errorResponse(error);

View File

@@ -7,6 +7,9 @@ import * as notificationsService from '@/lib/services/notifications.service';
export const GET = withAuth(async (_req, ctx) => {
try {
const result = await notificationsService.getUnreadCount(ctx.userId, ctx.portId);
// L34 carve-out: deliberate bare-shape response (NOT the `{ data }`
// envelope). The bell badge reads `count` off the JSON root; wrapping
// it would break the consumer. Left as-is.
return NextResponse.json(result);
} catch (error) {
return errorResponse(error);

View File

@@ -11,6 +11,10 @@ export const GET = withAuth(
try {
const query = parseQuery(req, reminderListQuerySchema);
const result = await listReminders(ctx.portId, query);
// L34 carve-out: deliberate bare-shape response (NOT the `{ data }`
// envelope). The reminders consumers read the fields off the JSON
// root; wrapping them would break them. Left as-is. (The POST below
// already uses the canonical `{ data }` envelope.)
return NextResponse.json(result);
} catch (error) {
return errorResponse(error);

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import { ENTITY_KEYS, ENTITY_REGISTRY, type EntityKey } from '@/lib/reports/custom/registry';
/**
@@ -38,10 +38,7 @@ export const POST = withAuth(
const allowedKeys = new Set(def.columns.map((c) => c.key));
const requested = body.columns.filter((k) => allowedKeys.has(k));
if (requested.length === 0) {
return NextResponse.json(
{ error: `No valid columns selected for entity "${body.entity}"` },
{ status: 400 },
);
throw new ValidationError(`No valid columns selected for entity "${body.entity}"`);
}
const filter = {

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import { createReportTemplate, listReportTemplates } from '@/lib/services/report-templates.service';
const createBodySchema = z.object({
@@ -66,10 +66,7 @@ export const POST = withAuth(
// path at use time.
const configKind = (body.config as { kind?: unknown }).kind;
if (configKind !== body.kind) {
return NextResponse.json(
{ error: `config.kind must equal "${body.kind}"` },
{ status: 400 },
);
throw new ValidationError(`config.kind must equal "${body.kind}"`);
}
const row = await createReportTemplate({
portId: ctx.portId,

View File

@@ -31,7 +31,7 @@ export const DELETE = withAuth(
const noteId = params.noteId;
if (!id || !noteId) throw new NotFoundError('Residential client note');
await notesService.deleteNote(ctx.portId, 'residential_clients', id, noteId);
return NextResponse.json({ ok: true });
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}

View File

@@ -31,7 +31,7 @@ export const DELETE = withAuth(
const noteId = params.noteId;
if (!id || !noteId) throw new NotFoundError('Residential interest note');
await notesService.deleteNote(ctx.portId, 'residential_interests', id, noteId);
return NextResponse.json({ ok: true });
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}

View File

@@ -63,7 +63,7 @@ export const PUT = withAuth(
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ ok: true });
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}

View File

@@ -73,6 +73,9 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
saveRecentSearch(ctx.userId, ctx.portId, parsed.q);
}
// L34 carve-out: deliberate bare-shape response (NOT the `{ data }`
// envelope). The global-search palette reads the grouped result fields
// off the JSON root; wrapping them would break the consumer. Left as-is.
return NextResponse.json(results);
} catch (error) {
return errorResponse(error);