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:
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle2, Download, Loader2, XCircle } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, Download, Loader2, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -325,10 +325,14 @@ export function TemplateSyncButton() {
|
||||
</div>
|
||||
) : report.fields.length === 0 ? (
|
||||
<div className="rounded bg-amber-100 px-2 py-1 text-xs text-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
||||
⚠️ This PDF has no AcroForm fields. The CRM's <code>formValues</code>{' '}
|
||||
path will fill nothing. Re-export your PDF with form fields enabled, or
|
||||
place overlays inside Documenso's editor and use{' '}
|
||||
<code>prefillFields</code> instead.
|
||||
<AlertTriangle
|
||||
className="mr-1 inline h-3.5 w-3.5 align-text-bottom"
|
||||
aria-hidden
|
||||
/>
|
||||
This PDF has no AcroForm fields. The CRM's <code>formValues</code> path
|
||||
will fill nothing. Re-export your PDF with form fields enabled, or place
|
||||
overlays inside Documenso's editor and use <code>prefillFields</code>{' '}
|
||||
instead.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -262,7 +262,7 @@ export function OnboardingChecklist() {
|
||||
if (loading) return;
|
||||
const prev = prevCompletedRef.current;
|
||||
if (prev !== null && prev < STEPS.length && completed === STEPS.length) {
|
||||
toast.success('🎉 Setup complete — every onboarding step is checked off.', {
|
||||
toast.success('Setup complete — every onboarding step is checked off.', {
|
||||
duration: 6000,
|
||||
});
|
||||
// Invalidate the shared status query so the banner + tile collapse
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { FileText, ClipboardSignature } from 'lucide-react';
|
||||
import { FileText, ClipboardSignature, Folder } from 'lucide-react';
|
||||
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
@@ -153,7 +153,7 @@ export function HubRootView({ portSlug }: Props) {
|
||||
href={`/${portSlug}/documents?folderId=${f.folderId}` as any}
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
<span aria-hidden>📁</span>
|
||||
<Folder className="h-3 w-3" aria-hidden />
|
||||
{f.folderName}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
@@ -196,11 +196,11 @@ export function NotesList({
|
||||
// countdown decrements on screen. Reading `Date.now()` directly inside
|
||||
// render is impure (different value every call); pinning to a state
|
||||
// value means React Compiler can memoize cleanly.
|
||||
// The interval is scheduled below ONLY while at least one note is still
|
||||
// inside its 15-min edit window — see `anyNoteWithinEditWindow`. An idle
|
||||
// NotesList (every note past its window, or none editable by this user)
|
||||
// burns no timer and triggers no re-renders.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const aggregateOn = !!aggregate && AGGREGATABLE.has(entityType);
|
||||
const baseEndpoint = `/api/v1/${NOTES_API_PATH[entityType]}/${entityId}/notes`;
|
||||
@@ -243,14 +243,15 @@ export function NotesList({
|
||||
onSuccess: () => invalidateAll(),
|
||||
});
|
||||
|
||||
// Aggregated view: only notes from THIS entity itself are editable
|
||||
// in-place. Notes pulled in from related entities (e.g. interests
|
||||
// surfaced under a client) must be edited on the source page so the
|
||||
// owning entity's timeline records the change.
|
||||
const selfSource = SELF_SOURCE[entityType];
|
||||
|
||||
function canEdit(note: Note): boolean {
|
||||
if (note.authorId !== currentUserId) return false;
|
||||
if (note.isLocked) return false;
|
||||
// Aggregated view: only notes from THIS entity itself are editable
|
||||
// in-place. Notes pulled in from related entities (e.g. interests
|
||||
// surfaced under a client) must be edited on the source page so the
|
||||
// owning entity's timeline records the change.
|
||||
const selfSource = SELF_SOURCE[entityType];
|
||||
if (aggregateOn && note.source && note.source !== selfSource) return false;
|
||||
const elapsed = now - new Date(note.createdAt).getTime();
|
||||
return elapsed < NOTE_EDIT_WINDOW_MS;
|
||||
@@ -264,6 +265,27 @@ export function NotesList({
|
||||
return `${mins}m left to edit`;
|
||||
}
|
||||
|
||||
// Whether THIS user has any note still inside its 15-min edit window.
|
||||
// Mirrors `canEdit`'s non-time gates (author, not locked, self-source)
|
||||
// and adds the time check against the current `now`. Drives the countdown
|
||||
// interval below: it only runs while this is true, so a NotesList with
|
||||
// nothing editable doesn't re-render every 30s. Recomputed each tick, so
|
||||
// when the last editable note crosses the threshold this flips false and
|
||||
// the effect tears the interval down.
|
||||
const anyNoteWithinEditWindow = notes.some((note) => {
|
||||
if (note.authorId !== currentUserId) return false;
|
||||
if (note.isLocked) return false;
|
||||
if (aggregateOn && note.source && note.source !== selfSource) return false;
|
||||
const elapsed = now - new Date(note.createdAt).getTime();
|
||||
return elapsed < NOTE_EDIT_WINDOW_MS;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!anyNoteWithinEditWindow) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [anyNoteWithinEditWindow]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Create note form */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
/**
|
||||
@@ -14,14 +14,24 @@ export function useCreateFromUrl(onOpen: () => void): void {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// Keep the latest `onOpen` in a ref so the effect can call it without
|
||||
// depending on it. Callers commonly pass an inline arrow (a fresh
|
||||
// identity every render); listing it as a dep would re-run the effect
|
||||
// and re-pop the sheet on every parent re-render. The ref lets us drop
|
||||
// the eslint-disable while still always invoking the current callback.
|
||||
// (Assigned in an effect, not during render, to satisfy react-hooks/refs.)
|
||||
const onOpenRef = useRef(onOpen);
|
||||
useEffect(() => {
|
||||
onOpenRef.current = onOpen;
|
||||
}, [onOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') !== '1') return;
|
||||
onOpen();
|
||||
onOpenRef.current();
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('create');
|
||||
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
|
||||
// typedRoutes can't statically validate a same-route replace; cast is safe.
|
||||
router.replace(newUrl as never);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
}, [searchParams, router]);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,84 @@ export type { RolePermissions };
|
||||
export type PermissionResource = keyof RolePermissions;
|
||||
export type PermissionAction<R extends PermissionResource> = keyof RolePermissions[R];
|
||||
|
||||
/**
|
||||
* Canonical permission catalog — the SINGLE source of truth for which
|
||||
* `resource → action` leaves are valid across the app.
|
||||
*
|
||||
* Derived structurally from `RolePermissions` (the `satisfies` clause below
|
||||
* forces this literal to enumerate every resource and every action that the
|
||||
* type declares; adding a leaf to `RolePermissions` without adding it here is
|
||||
* a compile error). Both the role-creation validator
|
||||
* (`src/lib/validators/roles.ts`) and the per-user override allow-list
|
||||
* (`src/app/api/v1/admin/users/[id]/permission-overrides/route.ts`) build their
|
||||
* accepted-key sets from this object, so the two can never diverge again
|
||||
* (audit finding L23).
|
||||
*/
|
||||
export const PERMISSION_CATALOG = {
|
||||
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export'],
|
||||
interests: [
|
||||
'view',
|
||||
'create',
|
||||
'edit',
|
||||
'delete',
|
||||
'change_stage',
|
||||
'override_stage',
|
||||
'generate_eoi',
|
||||
'export',
|
||||
],
|
||||
berths: ['view', 'edit', 'import', 'manage_waiting_list', 'update_prices'],
|
||||
documents: [
|
||||
'view',
|
||||
'create',
|
||||
'edit',
|
||||
'send_for_signing',
|
||||
'upload_signed',
|
||||
'delete',
|
||||
'manage_folders',
|
||||
],
|
||||
expenses: ['view', 'create', 'edit', 'delete', 'export', 'scan_receipt'],
|
||||
invoices: ['view', 'create', 'edit', 'delete', 'send', 'record_payment', 'export'],
|
||||
payments: ['view', 'record', 'delete'],
|
||||
files: ['view', 'upload', 'edit', 'delete', 'manage_folders'],
|
||||
email: ['view', 'send', 'configure_account'],
|
||||
reminders: ['view_own', 'view_all', 'create', 'edit_own', 'edit_all', 'assign_others'],
|
||||
calendar: ['connect', 'view_events'],
|
||||
reports: ['view_dashboard', 'view_analytics', 'export'],
|
||||
document_templates: ['view', 'generate', 'manage'],
|
||||
yachts: ['view', 'create', 'edit', 'delete', 'transfer'],
|
||||
companies: ['view', 'create', 'edit', 'delete'],
|
||||
memberships: ['view', 'manage'],
|
||||
tenancies: ['view', 'manage', 'cancel'],
|
||||
admin: [
|
||||
'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: ['view', 'create', 'edit', 'delete'],
|
||||
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
|
||||
} as const satisfies {
|
||||
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
|
||||
};
|
||||
|
||||
/** Every valid resource key, in catalog order. */
|
||||
export const PERMISSION_RESOURCES = Object.keys(PERMISSION_CATALOG) as PermissionResource[];
|
||||
|
||||
/**
|
||||
* Same catalog as `PERMISSION_CATALOG` but with each action list materialised
|
||||
* as a `Set` for O(1) membership tests. Consumed by the per-user override
|
||||
* allow-list to drop unknown resources/actions before persisting.
|
||||
*/
|
||||
export const ALLOWED_RESOURCE_ACTIONS: Record<string, ReadonlySet<string>> = Object.fromEntries(
|
||||
Object.entries(PERMISSION_CATALOG).map(([resource, actions]) => [resource, new Set(actions)]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks whether a permissions map grants a specific resource/action pair.
|
||||
*
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const permissionGroupSchema = z.record(z.string(), z.boolean());
|
||||
import { PERMISSION_CATALOG } from '@/lib/auth/permissions';
|
||||
|
||||
const rolePermissionsSchema = z.object({
|
||||
clients: permissionGroupSchema,
|
||||
interests: permissionGroupSchema,
|
||||
berths: permissionGroupSchema,
|
||||
documents: permissionGroupSchema,
|
||||
expenses: permissionGroupSchema,
|
||||
invoices: permissionGroupSchema,
|
||||
payments: permissionGroupSchema,
|
||||
files: permissionGroupSchema,
|
||||
email: permissionGroupSchema,
|
||||
reminders: permissionGroupSchema,
|
||||
calendar: permissionGroupSchema,
|
||||
reports: permissionGroupSchema,
|
||||
document_templates: permissionGroupSchema,
|
||||
admin: permissionGroupSchema,
|
||||
});
|
||||
/**
|
||||
* Role-permission validation schema, built from the canonical
|
||||
* `PERMISSION_CATALOG` (`src/lib/auth/permissions.ts`) so it can never drift
|
||||
* from the per-user override allow-list again (audit finding L23).
|
||||
*
|
||||
* Previously this was `z.record(z.string(), z.boolean())` per resource, which
|
||||
* accepted ARBITRARY action keys and was missing several resources entirely
|
||||
* (`yachts`, `companies`, `memberships`, `tenancies`, `payments`,
|
||||
* `residential_*`, `document_templates`). It now:
|
||||
* - rejects unknown resources (top-level `.strict()`),
|
||||
* - rejects unknown actions within a resource (per-resource `.strict()`),
|
||||
* - accepts full OR partial action maps (each action `.optional()`), so the
|
||||
* seed defaults and the admin role form (which send full maps) and any
|
||||
* future partial-patch caller all validate.
|
||||
*/
|
||||
const rolePermissionsSchema = z
|
||||
.object(
|
||||
Object.fromEntries(
|
||||
Object.entries(PERMISSION_CATALOG).map(([resource, actions]) => [
|
||||
resource,
|
||||
z
|
||||
.object(Object.fromEntries(actions.map((action) => [action, z.boolean().optional()])))
|
||||
.strict(),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.strict();
|
||||
|
||||
export const createRoleSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
|
||||
Reference in New Issue
Block a user