task-d.7.11
This commit is contained in:
23
src/app/administration/setup/page.tsx
Normal file
23
src/app/administration/setup/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { BootstrapSetup } from '@/features/setup/components/bootstrap-setup';
|
||||
import { getBootstrapSetupStatus } from '@/features/setup/server/setup-bootstrap.service';
|
||||
|
||||
export const metadata = {
|
||||
title: 'ALLA OS Setup'
|
||||
};
|
||||
|
||||
export default async function BootstrapSetupPage() {
|
||||
const status = await getBootstrapSetupStatus();
|
||||
|
||||
if (status.initialized) {
|
||||
redirect('/auth/sign-in');
|
||||
}
|
||||
|
||||
return (
|
||||
<BootstrapSetup
|
||||
tokenRequired={status.tokenRequired}
|
||||
requiredFiles={status.requiredFiles}
|
||||
optionalFiles={status.optionalFiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
77
src/app/api/setup/bootstrap/commit/route.ts
Normal file
77
src/app/api/setup/bootstrap/commit/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { commitSetupCsvImport } from '@/features/setup/server/csv-commit.service';
|
||||
import { createCsvImportSeedManifest } from '@/features/setup/server/seed-manifest.service';
|
||||
import {
|
||||
assertBootstrapRequiredFiles,
|
||||
assertBootstrapSetupAvailable,
|
||||
assertBootstrapToken,
|
||||
BOOTSTRAP_ACTOR_ID,
|
||||
BootstrapSetupError,
|
||||
setupStepStatusFromImportReport,
|
||||
toSerializableRecord
|
||||
} from '@/features/setup/server/setup-bootstrap.service';
|
||||
import { saveSetupStep, startSetupRun } from '@/features/setup/server/setup-state.service';
|
||||
|
||||
function isFile(value: FormDataEntryValue): value is File {
|
||||
return typeof value === 'object' && 'arrayBuffer' in value && 'name' in value;
|
||||
}
|
||||
|
||||
function parseBoolean(value: FormDataEntryValue | null): boolean {
|
||||
if (typeof value !== 'string') return false;
|
||||
return value.trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await assertBootstrapSetupAvailable();
|
||||
|
||||
const formData = await request.formData();
|
||||
assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? ''));
|
||||
|
||||
const previewHash = String(formData.get('previewHash') ?? '');
|
||||
const dryRun = parseBoolean(formData.get('dryRun'));
|
||||
const uploadedFiles = Array.from(formData.values()).filter(isFile);
|
||||
|
||||
if (uploadedFiles.length === 0) {
|
||||
return NextResponse.json({ message: 'Upload at least one setup CSV file using multipart/form-data.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const files = await Promise.all(
|
||||
uploadedFiles.map(async (file) => ({
|
||||
fileName: file.name,
|
||||
bytes: new Uint8Array(await file.arrayBuffer())
|
||||
}))
|
||||
);
|
||||
|
||||
assertBootstrapRequiredFiles(files);
|
||||
await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null });
|
||||
|
||||
const report = await commitSetupCsvImport({
|
||||
files,
|
||||
previewHash,
|
||||
dryRun,
|
||||
actorId: BOOTSTRAP_ACTOR_ID
|
||||
});
|
||||
|
||||
if (!dryRun && report.status !== 'FAIL') {
|
||||
await createCsvImportSeedManifest({ files, report, actorId: BOOTSTRAP_ACTOR_ID });
|
||||
}
|
||||
|
||||
await saveSetupStep({
|
||||
stepKey: 'csv_commit',
|
||||
status: setupStepStatusFromImportReport(report),
|
||||
inputHash: report.committedHash,
|
||||
output: toSerializableRecord(report),
|
||||
errors: report.errors,
|
||||
warnings: report.warnings
|
||||
});
|
||||
|
||||
return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof BootstrapSetupError) {
|
||||
return NextResponse.json({ message: error.message, details: error.details }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable commit bootstrap setup CSV import' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
64
src/app/api/setup/bootstrap/complete/route.ts
Normal file
64
src/app/api/setup/bootstrap/complete/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
assertBootstrapSetupAvailable,
|
||||
assertBootstrapToken,
|
||||
BOOTSTRAP_ACTOR_ID,
|
||||
BootstrapSetupError,
|
||||
getBootstrapSetupStatus
|
||||
} from '@/features/setup/server/setup-bootstrap.service';
|
||||
import { completeSetupRun, getCurrentSetupRun } from '@/features/setup/server/setup-state.service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await assertBootstrapSetupAvailable();
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { bootstrapToken?: string; report?: Record<string, unknown> };
|
||||
assertBootstrapToken(body.bootstrapToken ?? request.headers.get('x-bootstrap-setup-token') ?? '');
|
||||
|
||||
const currentRun = await getCurrentSetupRun();
|
||||
const commitStep = currentRun.steps.find((step) => step.stepKey === 'csv_commit');
|
||||
const hasSuccessfulCommit =
|
||||
currentRun.run?.status === 'csv_imported' ||
|
||||
currentRun.run?.status === 'verified' ||
|
||||
commitStep?.status === 'passed' ||
|
||||
commitStep?.status === 'warning';
|
||||
|
||||
if (!hasSuccessfulCommit) {
|
||||
return NextResponse.json({ message: 'Commit bootstrap setup data before completing setup.' }, { status: 409 });
|
||||
}
|
||||
|
||||
const status = await getBootstrapSetupStatus();
|
||||
const missingChecks = Object.entries(status.checks)
|
||||
.filter(([key, passed]) => key !== 'completedSetupRun' && !passed)
|
||||
.map(([key]) => key);
|
||||
|
||||
if (missingChecks.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Bootstrap setup cannot complete until required setup data exists.',
|
||||
missingChecks
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await completeSetupRun({
|
||||
actorId: BOOTSTRAP_ACTOR_ID,
|
||||
report: Object.assign(
|
||||
{
|
||||
source: 'bootstrap',
|
||||
checks: status.checks
|
||||
},
|
||||
body.report
|
||||
)
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof BootstrapSetupError) {
|
||||
return NextResponse.json({ message: error.message, details: error.details }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable complete bootstrap setup' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
60
src/app/api/setup/bootstrap/preview/route.ts
Normal file
60
src/app/api/setup/bootstrap/preview/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { previewSetupCsvImport } from '@/features/setup/server/csv-preview.service';
|
||||
import {
|
||||
assertBootstrapRequiredFiles,
|
||||
assertBootstrapSetupAvailable,
|
||||
assertBootstrapToken,
|
||||
BOOTSTRAP_ACTOR_ID,
|
||||
BootstrapSetupError,
|
||||
setupStepStatusFromPreview,
|
||||
toSerializableRecord
|
||||
} from '@/features/setup/server/setup-bootstrap.service';
|
||||
import { saveSetupStep, startSetupRun } from '@/features/setup/server/setup-state.service';
|
||||
|
||||
function isFile(value: FormDataEntryValue): value is File {
|
||||
return typeof value === 'object' && 'arrayBuffer' in value && 'name' in value;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await assertBootstrapSetupAvailable();
|
||||
|
||||
const formData = await request.formData();
|
||||
assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? ''));
|
||||
|
||||
const uploadedFiles = Array.from(formData.values()).filter(isFile);
|
||||
|
||||
if (uploadedFiles.length === 0) {
|
||||
return NextResponse.json({ message: 'Upload at least one setup CSV file using multipart/form-data.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const files = await Promise.all(
|
||||
uploadedFiles.map(async (file) => ({
|
||||
fileName: file.name,
|
||||
bytes: new Uint8Array(await file.arrayBuffer())
|
||||
}))
|
||||
);
|
||||
|
||||
assertBootstrapRequiredFiles(files);
|
||||
|
||||
await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null });
|
||||
const preview = await previewSetupCsvImport(files);
|
||||
|
||||
await saveSetupStep({
|
||||
stepKey: 'csv_preview',
|
||||
status: setupStepStatusFromPreview(preview),
|
||||
inputHash: preview.previewHash,
|
||||
output: toSerializableRecord(preview),
|
||||
errors: preview.files.flatMap((file) => file.errors),
|
||||
warnings: preview.files.flatMap((file) => file.warnings)
|
||||
});
|
||||
|
||||
return NextResponse.json(preview, { status: preview.summary.error > 0 ? 400 : 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof BootstrapSetupError) {
|
||||
return NextResponse.json({ message: error.message, details: error.details }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable preview bootstrap setup CSV import' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
6
src/app/api/setup/bootstrap/status/route.ts
Normal file
6
src/app/api/setup/bootstrap/status/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getBootstrapSetupStatus } from '@/features/setup/server/setup-bootstrap.service';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await getBootstrapSetupStatus());
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getBootstrapSetupStatus } from '@/features/setup/server/setup-bootstrap.service';
|
||||
import { getInstallationProfileBundle } from '@/features/setup/plugins/plugin-registry';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
@@ -19,7 +20,12 @@ interface SetupTemplateMetadata {
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await requireSystemRole('super_admin');
|
||||
const bootstrapStatus = await getBootstrapSetupStatus();
|
||||
|
||||
if (bootstrapStatus.initialized) {
|
||||
await requireSystemRole('super_admin');
|
||||
}
|
||||
|
||||
const profileId = request.nextUrl.searchParams.get('profile') ?? 'crm_demo';
|
||||
const metadataPath = path.join(process.cwd(), 'setup', 'templates', 'templates.json');
|
||||
const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as SetupTemplateMetadata;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { auth } from "@/auth";
|
||||
import { getBootstrapSetupStatus } from "@/features/setup/server/setup-bootstrap.service";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Page() {
|
||||
const setupStatus = await getBootstrapSetupStatus();
|
||||
|
||||
if (!setupStatus.initialized) {
|
||||
return redirect("/administration/setup");
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
|
||||
349
src/features/setup/components/bootstrap-setup.tsx
Normal file
349
src/features/setup/components/bootstrap-setup.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { CsvPreviewResult, ImportReport } from '@/features/setup/api/types';
|
||||
|
||||
interface BootstrapSetupProps {
|
||||
tokenRequired: boolean;
|
||||
requiredFiles: string[];
|
||||
optionalFiles: string[];
|
||||
}
|
||||
|
||||
interface ApiErrorPayload {
|
||||
message?: string;
|
||||
details?: {
|
||||
missingFiles?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
|
||||
return Boolean(value && typeof value === 'object');
|
||||
}
|
||||
|
||||
function getFileMap(files: File[]) {
|
||||
return new Map(files.map((file) => [file.name.toLowerCase(), file]));
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return 'Request failed.';
|
||||
}
|
||||
|
||||
async function readJsonResponse<T>(response: Response): Promise<T> {
|
||||
const payload = (await response.json().catch(() => ({}))) as unknown;
|
||||
|
||||
if (!response.ok) {
|
||||
const message = isApiErrorPayload(payload) && payload.message ? payload.message : 'Request failed.';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function appendFiles(formData: FormData, files: File[]) {
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file, file.name);
|
||||
});
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'PASS' | 'WARNING' | 'FAIL' }) {
|
||||
const variant = status === 'FAIL' ? 'destructive' : status === 'WARNING' ? 'secondary' : 'default';
|
||||
return <Badge variant={variant}>{status}</Badge>;
|
||||
}
|
||||
|
||||
export function BootstrapSetup({ tokenRequired, requiredFiles, optionalFiles }: BootstrapSetupProps) {
|
||||
const router = useRouter();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [bootstrapToken, setBootstrapToken] = useState('');
|
||||
const [preview, setPreview] = useState<CsvPreviewResult | null>(null);
|
||||
const [commitReport, setCommitReport] = useState<ImportReport | null>(null);
|
||||
const [isPreviewing, setIsPreviewing] = useState(false);
|
||||
const [isCommitting, setIsCommitting] = useState(false);
|
||||
const [isCompleting, setIsCompleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fileMap = useMemo(() => getFileMap(files), [files]);
|
||||
const missingRequiredFiles = requiredFiles.filter((fileName) => !fileMap.has(fileName.toLowerCase()));
|
||||
const selectedOptionalCount = optionalFiles.filter((fileName) => fileMap.has(fileName.toLowerCase())).length;
|
||||
const canPreview = missingRequiredFiles.length === 0 && files.length > 0 && (!tokenRequired || bootstrapToken.length > 0);
|
||||
const canCommit = Boolean(preview?.previewHash) && preview?.summary.error === 0 && canPreview;
|
||||
const canComplete = Boolean(commitReport && commitReport.status !== 'FAIL');
|
||||
|
||||
function handleFiles(fileList: FileList | null) {
|
||||
setFiles(Array.from(fileList ?? []));
|
||||
setPreview(null);
|
||||
setCommitReport(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
setIsPreviewing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set('bootstrapToken', bootstrapToken);
|
||||
appendFiles(formData, files);
|
||||
|
||||
const response = await fetch('/api/setup/bootstrap/preview', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const result = await readJsonResponse<CsvPreviewResult>(response);
|
||||
setPreview(result);
|
||||
} catch (requestError) {
|
||||
setError(getErrorMessage(requestError));
|
||||
} finally {
|
||||
setIsPreviewing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommit() {
|
||||
if (!preview?.previewHash) return;
|
||||
|
||||
setIsCommitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set('bootstrapToken', bootstrapToken);
|
||||
formData.set('previewHash', preview.previewHash);
|
||||
appendFiles(formData, files);
|
||||
|
||||
const response = await fetch('/api/setup/bootstrap/commit', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const result = await readJsonResponse<ImportReport>(response);
|
||||
setCommitReport(result);
|
||||
} catch (requestError) {
|
||||
setError(getErrorMessage(requestError));
|
||||
} finally {
|
||||
setIsCommitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function completeSetup() {
|
||||
setIsCompleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/setup/bootstrap/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ bootstrapToken })
|
||||
});
|
||||
await readJsonResponse(response);
|
||||
router.replace('/auth/sign-in');
|
||||
} catch (requestError) {
|
||||
setError(getErrorMessage(requestError));
|
||||
} finally {
|
||||
setIsCompleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='min-h-screen bg-background px-4 py-8 md:px-8'>
|
||||
<div className='mx-auto flex w-full max-w-6xl flex-col gap-6'>
|
||||
<div className='space-y-2'>
|
||||
<Badge variant='secondary'>First-run bootstrap</Badge>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>ALLA OS Setup</h1>
|
||||
<p className='max-w-3xl text-sm text-muted-foreground'>
|
||||
Upload the official setup CSV files, validate them, commit the initial organization data,
|
||||
then sign in with the administrator account from users.csv.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Icons.lock className='h-4 w-4' />
|
||||
<AlertTitle>Public only before initialization</AlertTitle>
|
||||
<AlertDescription>
|
||||
This bootstrap page is available only until setup is completed. After completion, setup
|
||||
maintenance moves to the authenticated dashboard administration page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{error ? (
|
||||
<Alert variant='destructive'>
|
||||
<Icons.alertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Setup request failed</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-[1fr_360px]'>
|
||||
<section className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>CSV Upload</CardTitle>
|
||||
<CardDescription>Required foundation files must be selected before preview.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{tokenRequired ? (
|
||||
<Input
|
||||
type='password'
|
||||
value={bootstrapToken}
|
||||
onChange={(event) => setBootstrapToken(event.target.value)}
|
||||
placeholder='Bootstrap setup token'
|
||||
aria-label='Bootstrap setup token'
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Input type='file' multiple accept='.csv,text/csv' onChange={(event) => handleFiles(event.target.files)} />
|
||||
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
{files.map((file) => (
|
||||
<div key={`${file.name}-${file.size}-${file.lastModified}`} className='rounded-md border p-3 text-sm'>
|
||||
<div className='font-medium'>{file.name}</div>
|
||||
<div className='text-muted-foreground'>{file.size} bytes</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{missingRequiredFiles.length > 0 ? (
|
||||
<Alert>
|
||||
<Icons.info className='h-4 w-4' />
|
||||
<AlertTitle>Required files missing</AlertTitle>
|
||||
<AlertDescription>{missingRequiredFiles.join(', ')}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button type='button' onClick={runPreview} disabled={!canPreview || isPreviewing}>
|
||||
{isPreviewing ? (
|
||||
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Icons.upload className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
Preview CSV
|
||||
</Button>
|
||||
<Button type='button' variant='outline' onClick={runCommit} disabled={!canCommit || isCommitting}>
|
||||
{isCommitting ? (
|
||||
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
Setup System
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview Result</CardTitle>
|
||||
<CardDescription>Commit is enabled only when preview has no blocking errors.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{preview ? (
|
||||
<>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<div className='font-medium'>Preview hash</div>
|
||||
<div className='break-all text-sm text-muted-foreground'>{preview.previewHash}</div>
|
||||
</div>
|
||||
<StatusBadge status={preview.summary.error > 0 ? 'FAIL' : preview.summary.warning > 0 ? 'WARNING' : 'PASS'} />
|
||||
</div>
|
||||
<div className='grid gap-2 sm:grid-cols-5'>
|
||||
<Metric label='Files' value={preview.summary.files} />
|
||||
<Metric label='Rows' value={preview.summary.rows} />
|
||||
<Metric label='Create' value={preview.summary.create} />
|
||||
<Metric label='Warning' value={preview.summary.warning} />
|
||||
<Metric label='Error' value={preview.summary.error} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyPanel message='Preview has not run yet.' />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Completion</CardTitle>
|
||||
<CardDescription>Finish setup after data commit succeeds.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{commitReport ? (
|
||||
<div className='grid gap-2 sm:grid-cols-5'>
|
||||
<Metric label='Imported' value={commitReport.summary.imported} />
|
||||
<Metric label='Updated' value={commitReport.summary.updated} />
|
||||
<Metric label='Skipped' value={commitReport.summary.skipped} />
|
||||
<Metric label='Warnings' value={commitReport.summary.warnings} />
|
||||
<Metric label='Errors' value={commitReport.summary.errors} />
|
||||
</div>
|
||||
) : (
|
||||
<EmptyPanel message='Commit has not run yet.' />
|
||||
)}
|
||||
|
||||
<Button type='button' onClick={completeSetup} disabled={!canComplete || isCompleting}>
|
||||
{isCompleting ? (
|
||||
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Icons.arrowRight className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
Complete and go to login
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<aside className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Required Template Checklist</CardTitle>
|
||||
<CardDescription>Foundation, document sequence, and approval setup.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2'>
|
||||
{requiredFiles.map((fileName) => (
|
||||
<TemplateChecklistItem key={fileName} fileName={fileName} selected={fileMap.has(fileName.toLowerCase())} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Business CSV</CardTitle>
|
||||
<CardDescription>Optional files selected: {selectedOptionalCount}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2'>
|
||||
{optionalFiles.map((fileName) => (
|
||||
<TemplateChecklistItem key={fileName} fileName={fileName} selected={fileMap.has(fileName.toLowerCase())} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateChecklistItem({ fileName, selected }: { fileName: string; selected: boolean }) {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm'>
|
||||
<span className='min-w-0 truncate'>{fileName}</span>
|
||||
<Badge variant={selected ? 'default' : 'secondary'}>{selected ? 'Selected' : 'Missing'}</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className='rounded-md border p-3'>
|
||||
<div className='text-xs font-medium uppercase text-muted-foreground'>{label}</div>
|
||||
<div className='text-lg font-semibold'>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPanel({ message }: { message: string }) {
|
||||
return <div className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>{message}</div>;
|
||||
}
|
||||
163
src/features/setup/server/setup-bootstrap.service.ts
Normal file
163
src/features/setup/server/setup-bootstrap.service.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'server-only';
|
||||
|
||||
import { count, eq } from 'drizzle-orm';
|
||||
import { memberships, organizations, setupRuns, users } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import type { ImportReport } from './csv/import-report';
|
||||
import type { CsvPreviewFileInput, CsvPreviewResult } from './csv/types';
|
||||
import type { SetupStepStatus } from './setup-state.service';
|
||||
|
||||
export const BOOTSTRAP_ACTOR_ID = 'bootstrap-setup';
|
||||
|
||||
export const BOOTSTRAP_REQUIRED_TEMPLATE_FILES = [
|
||||
'users.csv',
|
||||
'organizations.csv',
|
||||
'memberships.csv',
|
||||
'master-options.csv',
|
||||
'branches.csv',
|
||||
'product-types.csv',
|
||||
'document-sequences.csv',
|
||||
'approval-workflows.csv',
|
||||
'approval-steps.csv',
|
||||
'approval-matrix.csv'
|
||||
] as const;
|
||||
|
||||
export const BOOTSTRAP_OPTIONAL_TEMPLATE_FILES = [
|
||||
'customers.csv',
|
||||
'contacts.csv',
|
||||
'leads.csv',
|
||||
'opportunities.csv',
|
||||
'quotations.csv',
|
||||
'quotation-items.csv',
|
||||
'quotation-parties.csv',
|
||||
'quotation-topics.csv',
|
||||
'quotation-topic-items.csv'
|
||||
] as const;
|
||||
|
||||
export class BootstrapSetupError extends Error {
|
||||
status: number;
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
constructor(message: string, status = 400, details?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = 'BootstrapSetupError';
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BootstrapSetupStatus {
|
||||
initialized: boolean;
|
||||
tokenRequired: boolean;
|
||||
checks: {
|
||||
completedSetupRun: boolean;
|
||||
superAdminUser: boolean;
|
||||
organization: boolean;
|
||||
adminMembership: boolean;
|
||||
};
|
||||
requiredFiles: string[];
|
||||
optionalFiles: string[];
|
||||
}
|
||||
|
||||
async function hasRows<T>(query: Promise<Array<T & { value: number }>>) {
|
||||
const [row] = await query;
|
||||
return Number(row?.value ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatus> {
|
||||
const [completedSetupRun, superAdminUser, organization, adminMembership] = await Promise.all([
|
||||
hasRows(
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(setupRuns)
|
||||
.where(eq(setupRuns.status, 'completed'))
|
||||
),
|
||||
hasRows(
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(users)
|
||||
.where(eq(users.systemRole, 'super_admin'))
|
||||
),
|
||||
hasRows(db.select({ value: count() }).from(organizations)),
|
||||
hasRows(
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(memberships)
|
||||
.where(eq(memberships.role, 'admin'))
|
||||
)
|
||||
]);
|
||||
|
||||
const checks = {
|
||||
completedSetupRun,
|
||||
superAdminUser,
|
||||
organization,
|
||||
adminMembership
|
||||
};
|
||||
|
||||
return {
|
||||
initialized: Object.values(checks).every(Boolean),
|
||||
tokenRequired: Boolean(process.env.BOOTSTRAP_SETUP_TOKEN?.trim()),
|
||||
checks,
|
||||
requiredFiles: [...BOOTSTRAP_REQUIRED_TEMPLATE_FILES],
|
||||
optionalFiles: [...BOOTSTRAP_OPTIONAL_TEMPLATE_FILES]
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertBootstrapSetupAvailable() {
|
||||
const status = await getBootstrapSetupStatus();
|
||||
|
||||
if (status.initialized) {
|
||||
throw new BootstrapSetupError('Bootstrap setup is no longer available after setup completion.', 403, {
|
||||
checks: status.checks
|
||||
});
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
export function assertBootstrapToken(token: string | null | undefined) {
|
||||
const expectedToken = process.env.BOOTSTRAP_SETUP_TOKEN?.trim();
|
||||
|
||||
if (!expectedToken) return;
|
||||
|
||||
if (!token || token !== expectedToken) {
|
||||
throw new BootstrapSetupError('Bootstrap setup token is invalid.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
export function getMissingBootstrapRequiredFiles(files: CsvPreviewFileInput[]) {
|
||||
const uploaded = new Set(files.map((file) => file.fileName.trim().toLowerCase()));
|
||||
return BOOTSTRAP_REQUIRED_TEMPLATE_FILES.filter((fileName) => !uploaded.has(fileName));
|
||||
}
|
||||
|
||||
export function assertBootstrapRequiredFiles(files: CsvPreviewFileInput[]) {
|
||||
const missingFiles = getMissingBootstrapRequiredFiles(files);
|
||||
|
||||
if (missingFiles.length > 0) {
|
||||
throw new BootstrapSetupError('Upload all required setup CSV files before running bootstrap setup.', 400, {
|
||||
missingFiles
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function setupStepStatusFromReportStatus(status: 'PASS' | 'WARNING' | 'FAIL'): SetupStepStatus {
|
||||
if (status === 'PASS') return 'passed';
|
||||
if (status === 'WARNING') return 'warning';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
export function setupStepStatusFromImportReport(report: ImportReport): SetupStepStatus {
|
||||
if (report.status === 'FAIL') return 'failed';
|
||||
if (report.status === 'WARNING') return 'warning';
|
||||
return 'passed';
|
||||
}
|
||||
|
||||
export function setupStepStatusFromPreview(preview: CsvPreviewResult): SetupStepStatus {
|
||||
if (preview.summary.error > 0 || preview.files.some((file) => file.status === 'FAIL')) return 'failed';
|
||||
if (preview.summary.warning > 0 || preview.files.some((file) => file.status === 'WARNING')) return 'warning';
|
||||
return 'passed';
|
||||
}
|
||||
|
||||
export function toSerializableRecord(value: unknown): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
||||
}
|
||||
112
src/proxy.ts
112
src/proxy.ts
@@ -1,56 +1,89 @@
|
||||
import { auth } from "@/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from '@/auth';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const protectedApiPrefixes = [
|
||||
"/api/products",
|
||||
"/api/organizations",
|
||||
"/api/users",
|
||||
];
|
||||
const superAdminOnlyRoutes = ["/dashboard/workspaces"];
|
||||
const organizationAdminRoutes = [
|
||||
"/dashboard/users",
|
||||
"/dashboard/workspaces/team",
|
||||
];
|
||||
const protectedApiPrefixes = ['/api/products', '/api/organizations', '/api/users'];
|
||||
const superAdminOnlyRoutes = ['/dashboard/workspaces', '/dashboard/administration/setup'];
|
||||
const organizationAdminRoutes = ['/dashboard/users', '/dashboard/workspaces/team'];
|
||||
|
||||
export default auth((req) => {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
const isDashboardRoute = pathname.startsWith("/dashboard/crm");
|
||||
const isProtectedApiRoute = protectedApiPrefixes.some((prefix) =>
|
||||
pathname.startsWith(prefix),
|
||||
interface BootstrapStatusResponse {
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
function isBootstrapAllowedPath(pathname: string) {
|
||||
return (
|
||||
pathname === '/administration/setup' ||
|
||||
pathname.startsWith('/api/setup/bootstrap/') ||
|
||||
pathname === '/api/setup/templates'
|
||||
);
|
||||
}
|
||||
|
||||
async function getBootstrapInitialized(origin: string): Promise<boolean | null> {
|
||||
try {
|
||||
const response = await fetch(new URL('/api/setup/bootstrap/status', origin), {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'x-setup-proxy-check': '1'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const payload = (await response.json()) as BootstrapStatusResponse;
|
||||
return payload.initialized;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default auth(async (req) => {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
const bootstrapAllowedPath = isBootstrapAllowedPath(pathname);
|
||||
|
||||
if (bootstrapAllowedPath) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const isInitialized = await getBootstrapInitialized(req.nextUrl.origin);
|
||||
|
||||
if (isInitialized === false) {
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json(
|
||||
{ message: 'System setup is required before this API is available.', redirectTo: '/administration/setup' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL('/administration/setup', req.nextUrl.origin));
|
||||
}
|
||||
|
||||
const isDashboardRoute = pathname.startsWith('/dashboard');
|
||||
const isProtectedApiRoute = protectedApiPrefixes.some((prefix) => pathname.startsWith(prefix));
|
||||
const user = req.auth?.user;
|
||||
const isSignedIn = !!user;
|
||||
const isSuperAdmin = user?.systemRole === "super_admin";
|
||||
const isSignedIn = Boolean(user);
|
||||
const isSuperAdmin = user?.systemRole === 'super_admin';
|
||||
const isOrganizationAdmin =
|
||||
isSuperAdmin ||
|
||||
(user?.activeOrganizationId &&
|
||||
(user.activeMembershipRole === "admin" ||
|
||||
user.activePermissions?.includes("users:manage")));
|
||||
Boolean(
|
||||
user?.activeOrganizationId &&
|
||||
(user.activeMembershipRole === 'admin' || user.activePermissions?.includes('users:manage'))
|
||||
);
|
||||
|
||||
if (!isSignedIn && (isDashboardRoute || isProtectedApiRoute)) {
|
||||
if (isProtectedApiRoute) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const signInUrl = new URL("/auth/sign-in", req.nextUrl.origin);
|
||||
signInUrl.searchParams.set("callbackUrl", pathname);
|
||||
const signInUrl = new URL('/auth/sign-in', req.nextUrl.origin);
|
||||
signInUrl.searchParams.set('callbackUrl', `${pathname}${req.nextUrl.search}`);
|
||||
return NextResponse.redirect(signInUrl);
|
||||
}
|
||||
|
||||
if (
|
||||
isSignedIn &&
|
||||
superAdminOnlyRoutes.some((prefix) => pathname.startsWith(prefix)) &&
|
||||
!isSuperAdmin
|
||||
) {
|
||||
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
|
||||
if (isSignedIn && superAdminOnlyRoutes.some((prefix) => pathname.startsWith(prefix)) && !isSuperAdmin) {
|
||||
return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin));
|
||||
}
|
||||
|
||||
if (
|
||||
isSignedIn &&
|
||||
organizationAdminRoutes.some((prefix) => pathname.startsWith(prefix)) &&
|
||||
!isOrganizationAdmin
|
||||
) {
|
||||
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
|
||||
if (isSignedIn && organizationAdminRoutes.some((prefix) => pathname.startsWith(prefix)) && !isOrganizationAdmin) {
|
||||
return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
@@ -58,7 +91,6 @@ export default auth((req) => {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
||||
"/(api|trpc)(.*)",
|
||||
],
|
||||
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)'
|
||||
]
|
||||
};
|
||||
|
||||
56
src/tests/setup/setup-bootstrap.test.ts
Normal file
56
src/tests/setup/setup-bootstrap.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { assertFileExists, readText } from './helpers.ts';
|
||||
|
||||
test('first-run bootstrap setup public surfaces exist', () => {
|
||||
for (const route of ['status', 'preview', 'commit', 'complete']) {
|
||||
assertFileExists(`src/app/api/setup/bootstrap/${route}/route.ts`);
|
||||
}
|
||||
|
||||
assertFileExists('src/app/administration/setup/page.tsx');
|
||||
assertFileExists('src/features/setup/components/bootstrap-setup.tsx');
|
||||
assertFileExists('src/features/setup/server/setup-bootstrap.service.ts');
|
||||
});
|
||||
|
||||
test('bootstrap setup guards public access after initialization', () => {
|
||||
const service = readText('src/features/setup/server/setup-bootstrap.service.ts');
|
||||
const templatesRoute = readText('src/app/api/setup/templates/route.ts');
|
||||
const proxy = readText('src/proxy.ts');
|
||||
|
||||
assert.match(service, /completedSetupRun/);
|
||||
assert.match(service, /superAdminUser/);
|
||||
assert.match(service, /adminMembership/);
|
||||
assert.match(service, /BOOTSTRAP_SETUP_TOKEN/);
|
||||
assert.match(service, /Bootstrap setup is no longer available/);
|
||||
assert.match(templatesRoute, /getBootstrapSetupStatus/);
|
||||
assert.match(templatesRoute, /requireSystemRole\('super_admin'\)/);
|
||||
assert.match(proxy, /\/administration\/setup/);
|
||||
assert.match(proxy, /\/api\/setup\/bootstrap\//);
|
||||
assert.match(proxy, /\/api\/setup\/templates/);
|
||||
});
|
||||
|
||||
test('bootstrap setup enforces required CSV minimum and preview hash commit', () => {
|
||||
const service = readText('src/features/setup/server/setup-bootstrap.service.ts');
|
||||
const commitRoute = readText('src/app/api/setup/bootstrap/commit/route.ts');
|
||||
const previewRoute = readText('src/app/api/setup/bootstrap/preview/route.ts');
|
||||
|
||||
for (const fileName of [
|
||||
'users.csv',
|
||||
'organizations.csv',
|
||||
'memberships.csv',
|
||||
'master-options.csv',
|
||||
'branches.csv',
|
||||
'product-types.csv',
|
||||
'document-sequences.csv',
|
||||
'approval-workflows.csv',
|
||||
'approval-steps.csv',
|
||||
'approval-matrix.csv'
|
||||
]) {
|
||||
assert.match(service, new RegExp(fileName.replace('.', '\\.')));
|
||||
}
|
||||
|
||||
assert.match(previewRoute, /assertBootstrapRequiredFiles/);
|
||||
assert.match(commitRoute, /previewHash/);
|
||||
assert.match(commitRoute, /commitSetupCsvImport/);
|
||||
assert.match(commitRoute, /createCsvImportSeedManifest/);
|
||||
});
|
||||
Reference in New Issue
Block a user