411 lines
17 KiB
TypeScript
411 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { Icons } from '@/components/icons';
|
|
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 { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
type WizardState = {
|
|
appUrl: string;
|
|
authSecret: string;
|
|
dbHost: string;
|
|
dbPort: string;
|
|
dbName: string;
|
|
dbUser: string;
|
|
dbPassword: string;
|
|
superAdminEmail: string;
|
|
superAdminPassword: string;
|
|
superAdminName: string;
|
|
resetPassword: boolean;
|
|
buildStandalone: boolean;
|
|
};
|
|
|
|
const steps = [
|
|
'Project URL',
|
|
'Database',
|
|
'Auth + seed',
|
|
'Preview .env.local'
|
|
] as const;
|
|
|
|
function generateSecret() {
|
|
const bytes = new Uint8Array(32);
|
|
crypto.getRandomValues(bytes);
|
|
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
function download(filename: string, content: string) {
|
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
anchor.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export function SetupWizard() {
|
|
const [copied, setCopied] = useState(false);
|
|
const [state, setState] = useState<WizardState>({
|
|
appUrl: 'http://localhost:3000',
|
|
authSecret: '',
|
|
dbHost: 'localhost',
|
|
dbPort: '5432',
|
|
dbName: 'training_system',
|
|
dbUser: 'postgres',
|
|
dbPassword: 'postgres',
|
|
superAdminEmail: 'admin@example.com',
|
|
superAdminPassword: 'change-this-password',
|
|
superAdminName: 'Super Admin',
|
|
resetPassword: false,
|
|
buildStandalone: false
|
|
});
|
|
|
|
useEffect(() => {
|
|
setState((current) =>
|
|
current.authSecret ? current : { ...current, authSecret: generateSecret() }
|
|
);
|
|
}, []);
|
|
|
|
const databaseUrl = useMemo(() => {
|
|
const user = encodeURIComponent(state.dbUser);
|
|
const password = encodeURIComponent(state.dbPassword);
|
|
return `postgres://${user}:${password}@${state.dbHost}:${state.dbPort}/${state.dbName}`;
|
|
}, [state.dbHost, state.dbName, state.dbPassword, state.dbPort, state.dbUser]);
|
|
|
|
const envContent = useMemo(
|
|
() =>
|
|
[
|
|
'# Generated by /setup',
|
|
'',
|
|
'# Authentication (Auth.js)',
|
|
`AUTH_SECRET=${state.authSecret}`,
|
|
'',
|
|
'# Optional local app URL reference',
|
|
`APP_URL=${state.appUrl}`,
|
|
'',
|
|
'# Database (PostgreSQL)',
|
|
`DATABASE_URL=${databaseUrl}`,
|
|
'',
|
|
'# Initial Super Admin Seed',
|
|
`SUPER_ADMIN_EMAIL=${state.superAdminEmail}`,
|
|
`SUPER_ADMIN_PASSWORD=${state.superAdminPassword}`,
|
|
`SUPER_ADMIN_NAME=${state.superAdminName}`,
|
|
`SUPER_ADMIN_RESET_PASSWORD=${state.resetPassword ? 'true' : 'false'}`,
|
|
'',
|
|
'# Build Configuration',
|
|
`BUILD_STANDALONE=${state.buildStandalone ? 'true' : ''}`,
|
|
'',
|
|
'# Sentry (optional)',
|
|
'NEXT_PUBLIC_SENTRY_DSN=',
|
|
'NEXT_PUBLIC_SENTRY_ORG=',
|
|
'NEXT_PUBLIC_SENTRY_PROJECT=',
|
|
'SENTRY_AUTH_TOKEN=',
|
|
'NEXT_PUBLIC_SENTRY_DISABLED="false"'
|
|
].join('\n'),
|
|
[databaseUrl, state]
|
|
);
|
|
|
|
async function copyToClipboard() {
|
|
await navigator.clipboard.writeText(envContent);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
|
|
const completedSteps = [
|
|
!!state.appUrl.trim(),
|
|
!!state.dbHost.trim() && !!state.dbPort.trim() && !!state.dbName.trim(),
|
|
!!state.authSecret.trim() && !!state.superAdminEmail.trim() && !!state.superAdminPassword.trim(),
|
|
true
|
|
].filter(Boolean).length;
|
|
|
|
return (
|
|
<div className='min-h-screen bg-[linear-gradient(135deg,_hsl(var(--background))_20%,_hsl(var(--muted)/0.35)),radial-gradient(circle_at_top_right,_hsl(var(--primary)/0.16),_transparent_32%)]'>
|
|
<div className='mx-auto max-w-7xl px-4 py-10 md:px-6'>
|
|
<div className='grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
|
|
<div className='space-y-6'>
|
|
<div className='space-y-4'>
|
|
<Badge variant='outline' className='rounded-full px-3 py-1'>
|
|
Template onboarding
|
|
</Badge>
|
|
<div className='space-y-3'>
|
|
<h1 className='max-w-3xl text-4xl font-semibold tracking-tight md:text-6xl'>
|
|
Configure the template, then jump straight into the example dashboard.
|
|
</h1>
|
|
<p className='max-w-2xl text-base leading-7 text-muted-foreground md:text-lg'>
|
|
This setup wizard collects your local database and Auth.js seed values, generates
|
|
a ready-to-use <code>.env.local</code>, and points you to the public example
|
|
routes under <code>/example/dashboard/*</code>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='grid gap-4 md:grid-cols-2'>
|
|
<Card className='rounded-3xl border-border/60 bg-background/75 backdrop-blur-sm'>
|
|
<CardHeader>
|
|
<CardTitle className='flex items-center gap-2'>
|
|
<Icons.settings className='h-5 w-5' />
|
|
Local app
|
|
</CardTitle>
|
|
<CardDescription>Reference URL used during local development.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className='space-y-2'>
|
|
<Label htmlFor='appUrl'>App URL</Label>
|
|
<Input
|
|
id='appUrl'
|
|
value={state.appUrl}
|
|
onChange={(event) => setState({ ...state, appUrl: event.target.value })}
|
|
placeholder='http://localhost:3000'
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className='rounded-3xl border-border/60 bg-background/75 backdrop-blur-sm'>
|
|
<CardHeader>
|
|
<CardTitle className='flex items-center gap-2'>
|
|
<Icons.lock className='h-5 w-5' />
|
|
Auth secret
|
|
</CardTitle>
|
|
<CardDescription>Generated automatically for Auth.js credentials flow.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className='space-y-3'>
|
|
<Input
|
|
value={state.authSecret}
|
|
onChange={(event) => setState({ ...state, authSecret: event.target.value })}
|
|
/>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
className='rounded-2xl'
|
|
onClick={() => setState({ ...state, authSecret: generateSecret() })}
|
|
>
|
|
<Icons.sparkles className='mr-2 h-4 w-4' />
|
|
Regenerate secret
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card className='rounded-[28px] border-border/60 bg-background/80 shadow-xl backdrop-blur-sm'>
|
|
<CardHeader>
|
|
<CardTitle>Database configuration</CardTitle>
|
|
<CardDescription>
|
|
Fill in the PostgreSQL connection details. The wizard will build
|
|
<code> DATABASE_URL</code> for you.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
|
<div className='space-y-2'>
|
|
<Label htmlFor='dbHost'>Host</Label>
|
|
<Input
|
|
id='dbHost'
|
|
value={state.dbHost}
|
|
onChange={(event) => setState({ ...state, dbHost: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label htmlFor='dbPort'>Port</Label>
|
|
<Input
|
|
id='dbPort'
|
|
value={state.dbPort}
|
|
onChange={(event) => setState({ ...state, dbPort: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label htmlFor='dbName'>Database name</Label>
|
|
<Input
|
|
id='dbName'
|
|
value={state.dbName}
|
|
onChange={(event) => setState({ ...state, dbName: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label htmlFor='dbUser'>Database user</Label>
|
|
<Input
|
|
id='dbUser'
|
|
value={state.dbUser}
|
|
onChange={(event) => setState({ ...state, dbUser: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label htmlFor='dbPassword'>Database password</Label>
|
|
<Input
|
|
id='dbPassword'
|
|
type='password'
|
|
value={state.dbPassword}
|
|
onChange={(event) => setState({ ...state, dbPassword: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className='space-y-2 md:col-span-2 xl:col-span-1'>
|
|
<Label htmlFor='databaseUrlPreview'>DATABASE_URL preview</Label>
|
|
<Input id='databaseUrlPreview' value={databaseUrl} readOnly />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className='rounded-[28px] border-border/60 bg-background/80 shadow-xl backdrop-blur-sm'>
|
|
<CardHeader>
|
|
<CardTitle>Initial super admin seed</CardTitle>
|
|
<CardDescription>
|
|
These values feed the seed script used by <code>npm run setup:db</code>.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className='grid gap-4 md:grid-cols-2'>
|
|
<div className='space-y-2'>
|
|
<Label htmlFor='superAdminEmail'>Email</Label>
|
|
<Input
|
|
id='superAdminEmail'
|
|
value={state.superAdminEmail}
|
|
onChange={(event) =>
|
|
setState({ ...state, superAdminEmail: event.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label htmlFor='superAdminPassword'>Password</Label>
|
|
<Input
|
|
id='superAdminPassword'
|
|
type='password'
|
|
value={state.superAdminPassword}
|
|
onChange={(event) =>
|
|
setState({ ...state, superAdminPassword: event.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label htmlFor='superAdminName'>Display name</Label>
|
|
<Input
|
|
id='superAdminName'
|
|
value={state.superAdminName}
|
|
onChange={(event) =>
|
|
setState({ ...state, superAdminName: event.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label htmlFor='resetPassword'>Seed behavior</Label>
|
|
<div className='flex h-10 items-center rounded-xl border px-3 text-sm text-muted-foreground'>
|
|
<input
|
|
id='resetPassword'
|
|
type='checkbox'
|
|
aria-label='Reset the seeded super admin password on reruns'
|
|
checked={state.resetPassword}
|
|
onChange={(event) =>
|
|
setState({ ...state, resetPassword: event.target.checked })
|
|
}
|
|
className='mr-3'
|
|
/>
|
|
Reset the seeded super admin password on reruns
|
|
</div>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label htmlFor='buildStandalone'>Build mode</Label>
|
|
<div className='flex h-10 items-center rounded-xl border px-3 text-sm text-muted-foreground'>
|
|
<input
|
|
id='buildStandalone'
|
|
type='checkbox'
|
|
aria-label='Enable standalone build output'
|
|
checked={state.buildStandalone}
|
|
onChange={(event) =>
|
|
setState({ ...state, buildStandalone: event.target.checked })
|
|
}
|
|
className='mr-3'
|
|
/>
|
|
Enable standalone output for Docker-oriented builds
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className='space-y-6'>
|
|
<Card className='rounded-[28px] border-border/60 bg-background/85 shadow-xl backdrop-blur-sm'>
|
|
<CardHeader>
|
|
<CardTitle className='flex items-center justify-between gap-4'>
|
|
<span>.env.local preview</span>
|
|
<Badge variant='secondary'>{completedSteps}/{steps.length} steps ready</Badge>
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Copy or download this file, then run <code>npm run setup:db</code>.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className='space-y-4'>
|
|
<Textarea
|
|
value={envContent}
|
|
readOnly
|
|
className='min-h-[420px] rounded-2xl font-mono text-xs'
|
|
/>
|
|
<div className='flex flex-wrap gap-3'>
|
|
<Button type='button' className='rounded-2xl' onClick={copyToClipboard}>
|
|
<Icons.check className='mr-2 h-4 w-4' />
|
|
{copied ? 'Copied' : 'Copy .env.local'}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
className='rounded-2xl'
|
|
onClick={() => download('.env.local', envContent)}
|
|
>
|
|
<Icons.upload className='mr-2 h-4 w-4' />
|
|
Download file
|
|
</Button>
|
|
<Button asChild variant='secondary' className='rounded-2xl'>
|
|
<Link href='/example/dashboard/overview'>
|
|
<Icons.arrowRight className='mr-2 h-4 w-4' />
|
|
Open example dashboard
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className='rounded-[28px] border-border/60 bg-background/75 backdrop-blur-sm'>
|
|
<CardHeader>
|
|
<CardTitle>Next steps</CardTitle>
|
|
<CardDescription>Suggested flow after generating the env file.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className='space-y-4'>
|
|
{steps.map((step, index) => (
|
|
<div key={step} className='flex items-start gap-3'>
|
|
<div
|
|
className={cn(
|
|
'mt-0.5 flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold',
|
|
index < completedSteps
|
|
? 'bg-primary text-primary-foreground border-primary'
|
|
: 'text-muted-foreground'
|
|
)}
|
|
>
|
|
{index + 1}
|
|
</div>
|
|
<div>
|
|
<p className='font-medium'>{step}</p>
|
|
<p className='text-sm text-muted-foreground'>
|
|
{index === 0 && 'Confirm the URL you will use locally while iterating.'}
|
|
{index === 1 && 'Provide PostgreSQL connection details for local development.'}
|
|
{index === 2 &&
|
|
'Set credentials and seed values for the first super admin account.'}
|
|
{index === 3 &&
|
|
'Copy the generated file, run the seed command, then inspect /example/dashboard/*.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className='rounded-2xl border border-dashed p-4 text-sm text-muted-foreground'>
|
|
After saving <code>.env.local</code>, run <code>npm run setup:db</code>, then
|
|
<code> npm run dev</code>. The app will continue to open on <code>/setup</code>,
|
|
while all public showcase pages live under <code>/example/dashboard/*</code>.
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|