UI side of the smart-archive backend that shipped in d07f1ed.
- SmartArchiveDialog renders the dossier as a sectioned modal:
Pipeline interests, Berths (with next-in-line listed), Yachts,
Active reservations, Outstanding invoices, In-flight Documenso
envelopes, Auto-handled summary. Each section has a per-row decision
dropdown with sensible defaults (release for available/under-offer
berths, retain for sold berths and yachts, cancel for active
reservations, leave for invoices and documents).
- High-stakes deals show an amber warning panel + require a reason in
the textarea before the Archive button enables. Signed-document
acknowledgment checkbox blocks submission until checked.
- Wires into client-detail-header in place of the previous dumb
ArchiveConfirmDialog (the simple confirm dialog is kept for the
restore case until the smart-restore wizard ships).
- Pre-flight blocker banner surfaces dossier.blockers (e.g. active
reservation on a sold berth) and disables the Archive button entirely.
Two side fixes from CSP rollout:
- next.config CSP allows unpkg.com in dev so the react-grab devtool
loads. Stripped in prod via the existing isProd flag.
- middleware whitelist now passes /manifest.json + icon-*.png +
apple-touch-icon through unauthenticated, so PWA installability
isn't blocked by the auth redirect.
Bulk variant + restore wizard + hard-delete-with-email-code land in
follow-on commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
124 lines
4.1 KiB
TypeScript
124 lines
4.1 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import type { NextRequest } from 'next/server';
|
|
|
|
/**
|
|
* Paths that do not require an authenticated session.
|
|
* Checked with startsWith, so /auth/ covers /auth/callback etc.
|
|
*/
|
|
const PUBLIC_PATHS: string[] = [
|
|
'/login',
|
|
'/reset-password',
|
|
'/set-password',
|
|
'/auth/',
|
|
'/api/auth/',
|
|
'/api/public/',
|
|
'/api/health',
|
|
'/api/webhooks/',
|
|
'/scan',
|
|
'/portal/',
|
|
'/api/portal/',
|
|
];
|
|
|
|
function isPublicPath(pathname: string): boolean {
|
|
// Per-port PWA manifests sit under `/<portSlug>/scan/manifest.webmanifest`
|
|
// and need to be fetchable without a session - browsers fetch them eagerly
|
|
// during install / first paint. The manifest only contains port name +
|
|
// icon paths, no sensitive data, so making it public is safe.
|
|
if (pathname.endsWith('/scan/manifest.webmanifest')) return true;
|
|
return PUBLIC_PATHS.some((prefix) => pathname === prefix || pathname.startsWith(prefix));
|
|
}
|
|
|
|
function isApiRoute(pathname: string): boolean {
|
|
return pathname.startsWith('/api/');
|
|
}
|
|
|
|
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
|
|
/**
|
|
* SameSite=Lax cookies block top-level cross-site POSTs in modern browsers,
|
|
* but defense-in-depth: every state-changing request to a session-authed
|
|
* `/api/v1/**` endpoint must originate from the same origin as the app.
|
|
* Webhooks (`/api/webhooks/**`) and public posts (`/api/public/**`) are
|
|
* exempt because they're called by external systems with no session
|
|
* cookie. Auth flows (`/api/auth/**`) and portal (`/api/portal/**`) handle
|
|
* their own origin/CSRF checks via better-auth.
|
|
*/
|
|
function isOriginCheckedPath(pathname: string): boolean {
|
|
if (!pathname.startsWith('/api/v1/')) return false;
|
|
return true;
|
|
}
|
|
|
|
function originAllowed(request: NextRequest): boolean {
|
|
const origin = request.headers.get('origin');
|
|
const referer = request.headers.get('referer');
|
|
// Same-origin fetch from the app sends both Origin AND a matching host.
|
|
// Use request.nextUrl.origin (the deployed origin) as the source of truth.
|
|
const expectedOrigin = request.nextUrl.origin;
|
|
if (origin) return origin === expectedOrigin;
|
|
if (referer) {
|
|
try {
|
|
return new URL(referer).origin === expectedOrigin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
// Neither header present: most browser fetches always send Origin on
|
|
// POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side
|
|
// call (e.g. Next.js internal fetch). Allow.
|
|
return true;
|
|
}
|
|
|
|
export function middleware(request: NextRequest): NextResponse {
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// CSRF defense-in-depth: state-changing requests to authed /api/v1
|
|
// endpoints must come from the app's own origin.
|
|
if (
|
|
STATE_CHANGING_METHODS.has(request.method) &&
|
|
isOriginCheckedPath(pathname) &&
|
|
!originAllowed(request)
|
|
) {
|
|
return NextResponse.json(
|
|
{ error: 'Cross-origin state-changing request rejected' },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
|
|
// Always allow public paths through
|
|
if (isPublicPath(pathname)) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
const sessionToken = request.cookies.get('pn-crm.session_token');
|
|
|
|
if (!sessionToken?.value) {
|
|
if (isApiRoute(pathname)) {
|
|
// API routes return 401 JSON - never redirect
|
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
|
}
|
|
|
|
// Page routes redirect to /login, preserving the intended destination
|
|
const loginUrl = new URL('/login', request.url);
|
|
loginUrl.searchParams.set('redirect', pathname + request.nextUrl.search);
|
|
return NextResponse.redirect(loginUrl);
|
|
}
|
|
|
|
return NextResponse.next();
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except:
|
|
* - _next/static (static files)
|
|
* - _next/image (Next.js image optimisation)
|
|
* - favicon.ico (browser tab icon)
|
|
* - /images/ (public image assets)
|
|
* - manifest.json (PWA manifest — must be unauthed for installability)
|
|
* - icon-*.png (PWA + apple-touch icons referenced by manifest)
|
|
* - apple-touch-icon (iOS home-screen icon)
|
|
*/
|
|
'/((?!_next/static|_next/image|favicon\\.ico|images/|manifest\\.json|icon-|apple-touch-icon).*)',
|
|
],
|
|
};
|