Files
pn-new-crm/src/app/(dashboard)/[portSlug]/admin/page.tsx
Matt Ciaccio 83693dd993 feat(storage): pluggable s3-or-filesystem backend + migration CLI + admin UI
Phase 6a from docs/berth-recommender-and-pdf-plan.md §4.7a + §14.9a. Lays
the storage groundwork for Phase 6b/7 file-bearing schemas (per-berth PDFs,
brochures) without touching those domains yet.

New files:
- src/lib/storage/index.ts        StorageBackend interface + per-process
                                  factory keyed on system_settings.
- src/lib/storage/s3.ts           S3-compatible backend (MinIO/AWS/B2/R2/
                                  Wasabi/Tigris) wrapping the existing minio
                                  JS client. Includes a healthCheck() used
                                  by the admin "Test connection" button.
- src/lib/storage/filesystem.ts   Local filesystem backend with all §14.9a
                                  mitigations baked in.
- src/lib/storage/migrate.ts      Shared migration core — pg_advisory_lock,
                                  per-row resumable progress markers,
                                  sha256 round-trip verification, atomic
                                  storage_backend flip on success.
- scripts/migrate-storage.ts      Thin CLI shim around runMigration().
- src/app/api/storage/[token]/route.ts
                                  Filesystem proxy GET. Verifies HMAC,
                                  enforces single-use replay protection
                                  via Redis SET NX, streams via NextResponse
                                  ReadableStream with explicit Content-Type
                                  + Content-Disposition. Node runtime only.
- src/app/api/v1/admin/storage/route.ts
                                  GET status + POST connection test.
- src/app/api/v1/admin/storage/migrate/route.ts
                                  Super-admin-only POST that runs the
                                  exact same runMigration() as the CLI.
- src/app/(dashboard)/[portSlug]/admin/storage/page.tsx
                                  Super-admin admin UI (current backend,
                                  capacity stats, switch button with
                                  dry-run, test connection, backup hint).
- src/components/admin/storage-admin-panel.tsx
                                  Client component for the page above.

§14.9a critical mitigations implemented:
- Path-traversal: storage keys validated against ^[a-zA-Z0-9/_.-]+$;
  `..`, `.`, `//`, leading `/`, and overlength keys rejected.
- Realpath: storage root realpath'd at create time, every per-key
  resolution checked against the realpath'd prefix.
- Storage root created (or chmod'd) to 0o700.
- Multi-node refusal: FilesystemBackend.create() throws when
  MULTI_NODE_DEPLOYMENT=true.
- HMAC token: sha256-HMAC over the (key, expiry, nonce, filename,
  content-type) payload. Verified with timingSafeEqual; bad sig,
  expired, or invalid-key payloads all return 403.
- Single-use replay: token body cached in Redis SET NX EX 1800s.
- sha256 round-trip: copyAndVerify() re-fetches from the target after
  put() and aborts the migration on any mismatch.
- Free-disk pre-flight: when migrating to filesystem, sums byte counts
  via source.head() and aborts if free space < total * 1.2.
- pg_advisory_lock(0xc7000a01) prevents concurrent migrations.
- Resumable: per-row progress markers in _storage_migration_progress.

system_settings keys read by the factory (jsonb, no schema change):
storage_backend, storage_s3_endpoint, storage_s3_region,
storage_s3_bucket, storage_s3_access_key,
storage_s3_secret_key_encrypted, storage_s3_force_path_style,
storage_filesystem_root, storage_proxy_hmac_secret_encrypted.

Defaults: storage_backend=`s3`, storage_filesystem_root=`./storage`
(./storage added to .gitignore).

Tests added (34 tests, all green):
- tests/unit/storage/filesystem-backend.test.ts — key validation
  allow/reject matrix, realpath escape, 0o700 perms, multi-node
  refusal, HMAC token sign/verify/tamper/expire/invalid-key.
- tests/unit/storage/copy-and-verify.test.ts — sha256 mismatch on
  round-trip aborts the migration.
- tests/integration/storage/proxy-route.test.ts — happy path, wrong
  HMAC secret, expired token, replay rejection.

Phase 6a ships zero file-bearing tables — TABLES_WITH_STORAGE_KEYS is
intentionally empty. berth_pdf_versions and brochure_versions land in
Phase 6b and join the list there. Existing s3_key columns: only
gdpr_export_jobs.storage_key, already named correctly — no rename needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:15:59 +02:00

280 lines
7.6 KiB
TypeScript

import Link from 'next/link';
import {
Bell,
Briefcase,
Database,
FileText,
HardDrive,
Key,
LayoutDashboard,
Mail,
Palette,
ScrollText,
Settings,
Shield,
Sliders,
Tag,
Upload,
Users,
UsersRound,
Webhook,
Globe,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/shared/page-header';
interface AdminSection {
href: string;
label: string;
description: string;
icon: typeof Settings;
}
interface AdminGroup {
title: string;
description: string;
sections: AdminSection[];
}
const GROUPS: AdminGroup[] = [
{
title: 'Access',
description: 'Who can sign in and what they can do once they do.',
sections: [
{
href: 'users',
label: 'Users',
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
icon: Users,
},
{
href: 'invitations',
label: 'Invitations',
description: 'Send invitations, track pending invites, and resend or revoke them.',
icon: Mail,
},
{
href: 'roles',
label: 'Roles & Permissions',
description: 'Default permission sets and per-port role overrides.',
icon: Shield,
},
],
},
{
title: 'Configuration',
description: 'Branding, integrations, and per-port settings.',
sections: [
{
href: 'email',
label: 'Email Settings',
description: 'From address, signatures, and per-port SMTP overrides.',
icon: Mail,
},
{
href: 'documenso',
label: 'Documenso & EOI',
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
icon: FileText,
},
{
href: 'reminders',
label: 'Reminders',
description: 'Default reminder behaviour and the daily-digest delivery window.',
icon: Bell,
},
{
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Palette,
},
{
href: 'settings',
label: 'System Settings',
description: 'Generic key/value configuration store for advanced flags.',
icon: Settings,
},
{
href: 'webhooks',
label: 'Webhooks',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook,
},
],
},
{
title: 'Content',
description: 'Forms, templates, and labels that users see.',
sections: [
{
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: Sliders,
},
{
href: 'templates',
label: 'Document Templates',
description: 'PDF + email templates with merge-field placeholders.',
icon: FileText,
},
{
href: 'tags',
label: 'Tags',
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
icon: Tag,
},
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: Key,
},
],
},
{
title: 'Data Quality',
description: 'Cleanup, imports, and the audit trail.',
sections: [
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: UsersRound,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
},
{
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
},
],
},
{
title: 'Operations',
description: 'Health checks and disaster recovery.',
sections: [
{
href: 'reports',
label: 'Reports',
description: 'Saved analytics views and ad-hoc query results.',
icon: LayoutDashboard,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Database,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Database snapshots and on-demand exports.',
icon: HardDrive,
},
{
href: 'storage',
label: 'Storage Backend',
description:
'Choose between S3-compatible object store or local filesystem; migrate between them.',
icon: HardDrive,
},
],
},
{
title: 'Tenancy',
description: 'Multi-port and multi-install scaffolding.',
sections: [
{
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Briefcase,
},
{
href: 'onboarding',
label: 'Onboarding',
description: 'Initial-setup wizard for fresh ports.',
icon: LayoutDashboard,
},
],
},
{
title: 'Integrations',
description: 'Third-party providers wired into the app.',
sections: [
{
href: 'ocr',
label: 'Receipt OCR',
description: 'Configure the AI provider used by the mobile receipt scanner.',
icon: ScrollText,
},
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: Globe,
},
],
},
];
export default async function AdminLandingPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return (
<div className="space-y-8">
<PageHeader
title="Administration"
description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
/>
{GROUPS.map((group) => (
<section key={group.title} className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.title}
</h2>
<p className="text-xs text-muted-foreground/80">{group.description}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{group.sections.map((s) => {
const Icon = s.icon;
return (
<Link
key={s.href}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/admin/${s.href}` as any}
className="block group"
>
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
<div className="flex-1">
<CardTitle className="text-base">{s.label}</CardTitle>
</div>
</CardHeader>
<CardContent>
<CardDescription>{s.description}</CardDescription>
</CardContent>
</Card>
</Link>
);
})}
</div>
</section>
))}
</div>
);
}