audit: Tier 1/3/6/7 batch — PII redaction, mobile safe-area, perf, build hardening

Tier 1.4: error_events.request_body_excerpt sanitizer now redacts
GDPR-relevant fields (email, phone, dob, address, fullName, firstName,
lastName, postcode, nationalId, etc.) on top of the existing
credential list. A 5xx in /api/v1/clients no longer lands full client
PII in the super-admin inspector.

Tier 3.10: ScanShell <main> now adds pb-[max(1.5rem, env(safe-area-
inset-bottom))]. Mobile-pwa audit caught the Save expense button sitting
flush against the iPhone 14/15 home indicator in standalone PWA mode.

Tier 6.2: dashboard widget-registry now dynamic-imports every
recharts-backed chart widget (berth status, lead source, occupancy
timeline, pipeline funnel, revenue breakdown, source conversion).
~80-150KB initial-bundle savings when reps have charts disabled.
ssr:false because recharts needs window.

Tier 6.3: DataTable wraps the assembled columns in useMemo keyed on
(columns, hasBulkActions). TanStack docs explicitly warn that
rebuilding columns every render resets the table's internal state.

Tier 7.1: Added .dockerignore (was missing — 7.6 GB context with
.env reachable via COPY . .). Excludes git, env files, node_modules,
build artefacts, IDE config, test artefacts, audit docs.

Tier 7.4: Dockerfile.dev now runs as the node user (uid 1000) — was
root. Working dir moves to /home/node/app.

Tier 7.5: docker-compose.prod.yml adds memory limits (2g postgres,
512m redis, 1g crm-app, 1g crm-worker) and json-file log rotation
(max-size, max-file) to every service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 17:18:35 +02:00
parent 50f48a8b6a
commit ad74e4a174
9 changed files with 435 additions and 45 deletions

View File

@@ -187,10 +187,7 @@ export const PUT = withAuth(
// (e.g. `permanently_delete_clients`, `system_backup`). Require
// every `true` write to be a leaf the caller already has.
// Super-admins bypass (they hold all leaves by definition).
const callerPerms = ctx.permissions as Record<
string,
Record<string, boolean>
> | null;
const callerPerms = ctx.permissions as Record<string, Record<string, boolean>> | null;
const sanitized: Record<string, Record<string, boolean>> = {};
for (const [resource, actions] of Object.entries(overrides)) {
const allowed = ALLOWED_RESOURCE_ACTIONS[resource];

View File

@@ -11,22 +11,52 @@
*/
import type { ReactNode } from 'react';
import dynamic from 'next/dynamic';
import { ActiveDealsTile } from './active-deals-tile';
import { ActivityFeed } from './activity-feed';
import { BerthStatusChart } from './berth-status-chart';
import { HotDealsCard } from './hot-deals-card';
import { LeadSourceChart } from './lead-source-chart';
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
import { PipelineFunnelChart } from './pipeline-funnel-chart';
import { PipelineValueTile } from './pipeline-value-tile';
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
import { SourceConversionChart } from './source-conversion-chart';
import { WebsiteGlanceTile } from './website-glance-tile';
import { MyRemindersRail } from './my-reminders-rail';
import { AlertRail } from '@/components/alerts/alert-rail';
import type { DateRange } from '@/lib/analytics/range';
// Recharts-backed widgets are dynamic-imported so the recharts bundle
// (~80-150KB) doesn't ship on every dashboard load when the rep has
// disabled charts. perf-test-auditor HIGH H3 caught the static import.
// Each one gets a placeholder loading state matching its grid slot.
const ChartFallback = () => (
<div className="rounded-lg border bg-muted/30 p-8 text-center text-sm text-muted-foreground">
Loading chart
</div>
);
const BerthStatusChart = dynamic(
() => import('./berth-status-chart').then((m) => ({ default: m.BerthStatusChart })),
{ loading: ChartFallback, ssr: false },
);
const LeadSourceChart = dynamic(
() => import('./lead-source-chart').then((m) => ({ default: m.LeadSourceChart })),
{ loading: ChartFallback, ssr: false },
);
const OccupancyTimelineChart = dynamic(
() =>
import('./occupancy-timeline-chart').then((m) => ({ default: m.OccupancyTimelineChart })),
{ loading: ChartFallback, ssr: false },
);
const PipelineFunnelChart = dynamic(
() => import('./pipeline-funnel-chart').then((m) => ({ default: m.PipelineFunnelChart })),
{ loading: ChartFallback, ssr: false },
);
const RevenueBreakdownChart = dynamic(
() => import('./revenue-breakdown-chart').then((m) => ({ default: m.RevenueBreakdownChart })),
{ loading: ChartFallback, ssr: false },
);
const SourceConversionChart = dynamic(
() => import('./source-conversion-chart').then((m) => ({ default: m.SourceConversionChart })),
{ loading: ChartFallback, ssr: false },
);
/**
* Where a widget lives on the dashboard. The shell renders three
* separate auto-fit regions so charts and rails don't compete for the

View File

@@ -446,7 +446,14 @@ export function ScanShell() {
}
return (
<main className="mx-auto flex min-h-[100dvh] w-full max-w-xl flex-col gap-4 px-4 py-6 sm:py-10">
<main
// pb-[max(1.5rem,env(safe-area-inset-bottom))] — mobile-pwa-auditor
// caught that the "Save expense" button was sitting flush against
// the home indicator on iPhone 14/15 in standalone PWA mode
// (viewportFit:cover + statusBarStyle:default exposes the safe-
// area inset, but the original `py-6` ignored it).
className="mx-auto flex min-h-[100dvh] w-full max-w-xl flex-col gap-4 px-4 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] sm:py-10"
>
{/* Brand header - logo centered, page title underneath. Establishes
the standalone identity (this is the PWA home for the scanner). */}
<header className="flex flex-col items-center gap-3">

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import {
flexRender,
getCoreRowModel,
@@ -106,35 +106,44 @@ export function DataTable<TData>({
const rowSelectionState = externalSelection ?? internalSelection;
const setRowSelection = onRowSelectionChange ?? setInternalSelection;
const allColumns: ColumnDef<TData, unknown>[] = [];
if (bulkActions && bulkActions.length > 0) {
allColumns.push({
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false,
size: 40,
});
}
allColumns.push(...columns);
// Memoize the assembled columns array. perf-test-auditor HIGH H2:
// TanStack docs explicitly warn that rebuilding `columns` on every
// render resets the table's internal state (sort, filter, sizing).
// Re-derive only when the source columns or bulkActions presence
// actually change.
const hasBulkActions = Boolean(bulkActions && bulkActions.length > 0);
const allColumns = useMemo<ColumnDef<TData, unknown>[]>(() => {
const cols: ColumnDef<TData, unknown>[] = [];
if (hasBulkActions) {
cols.push({
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false,
size: 40,
});
}
cols.push(...columns);
return cols;
}, [columns, hasBulkActions]);
const table = useReactTable({
data,

View File

@@ -21,7 +21,13 @@ const STACK_MAX_BYTES = 4 * 1024;
const BODY_MAX_BYTES = 1 * 1024;
/** Keys whose values are never persisted to the body excerpt. */
// gdpr-auditor HIGH H2: the previous list only covered credentials.
// A 5xx in /api/v1/clients (create / update) was landing full client
// PII (full name, DOB, address, phone, nationality, email) in
// error_events.request_body_excerpt for the super-admin inspector.
// Extend to cover GDPR-relevant fields too.
const SENSITIVE_KEYS = new Set([
// Credentials
'password',
'newPassword',
'oldPassword',
@@ -35,6 +41,26 @@ const SENSITIVE_KEYS = new Set([
'cvv',
'ssn',
'authorization',
// PII
'email',
'emails',
'phone',
'phoneNumber',
'mobile',
'whatsapp',
'dob',
'dateOfBirth',
'birthdate',
'address',
'street',
'postcode',
'zip',
'nationalId',
'passport',
'taxId',
'fullName',
'firstName',
'lastName',
]);
/** Drop sensitive keys + cap the JSON length. */