feat: zero-config first run — in-browser admin bootstrap + auto-generated secrets

Fresh installs need nothing in .env. See PR description for the full feature.
This commit is contained in:
Luca
2026-07-01 14:49:18 +02:00
parent b8b33ae6d6
commit 415bffa04c
17 changed files with 869 additions and 22 deletions
+211
View File
@@ -0,0 +1,211 @@
import React, { useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, Sparkles } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card, Loading } from '../components/common';
import { useAdminAuth } from '../contexts';
import { setupService } from '../services/setup.service';
import type { AdminUser } from '../types';
// First-run screen. Reached on a fresh instance where no admin account exists
// yet — creates the first (super_admin) account from the browser using the
// one-time setup token printed to the server logs. Once an admin exists the
// endpoints self-close and this page redirects to the login.
export const SetupPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { login } = useAdminAuth();
const { data: status, isLoading: statusLoading } = useQuery({
queryKey: ['setup-status'],
queryFn: setupService.getSetupStatus,
retry: false,
staleTime: Infinity,
});
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
if (statusLoading) {
return <Loading fullScreen />;
}
// Setup already done → nothing to bootstrap here.
if (status && !status.needsAdmin) {
return <Navigate to="/admin/login" replace />;
}
const setField = (field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }));
if (errors[field]) setErrors((prev) => ({ ...prev, [field]: '' }));
};
const validate = (): boolean => {
const next: Record<string, string> = {};
if (!form.token.trim()) next.token = t('setup.tokenRequired');
if (!form.email) next.email = t('setup.emailRequired');
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) next.email = t('setup.invalidEmail');
if (!form.password) next.password = t('setup.passwordRequired');
else if (form.password.length < 8) next.password = t('setup.passwordMinLength');
if (form.confirm !== form.password) next.confirm = t('setup.passwordMismatch');
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
toast.dismiss();
if (!validate()) return;
setIsSubmitting(true);
setErrors({});
try {
const { user } = await setupService.createInitialAdmin({
token: form.token.trim(),
email: form.email.trim(),
password: form.password,
});
// Cookie is set by the backend; register the session and enter the app.
const adminUser: AdminUser = {
id: user.id,
username: user.username,
email: user.email,
mustChangePassword: false,
role: { name: user.role.name, displayName: user.role.displayName ?? user.role.name },
};
login('', adminUser);
toast.success(t('setup.success'));
navigate('/admin/dashboard', { replace: true });
} catch (error: any) {
const apiErrors = error.response?.data?.errors;
if (error.response?.status === 429) {
toast.error(t('setup.tooManyAttempts'));
} else if (Array.isArray(apiErrors) && apiErrors.length) {
setErrors({ form: apiErrors[0]?.msg || t('setup.genericError') });
} else if (error.response?.status === 409) {
// Someone else finished setup first — send to login.
navigate('/admin/login', { replace: true });
} else if (error.response?.data?.error) {
setErrors({ form: error.response.data.error });
} else {
toast.error(t('setup.genericError'));
}
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: '#eee6d2' }}>
<Sparkles className="w-8 h-8" style={{ color: 'var(--color-primary, #5C8762)' }} />
</div>
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>{t('setup.title')}</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>{t('setup.subtitle')}</p>
</div>
<Card padding="lg">
<form onSubmit={handleSubmit} className="space-y-6">
{errors.form && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{errors.form}</p>
</div>
)}
<div>
<label htmlFor="setup-token" className="block text-sm font-medium text-neutral-700 mb-1">
{t('setup.tokenLabel')}
</label>
<Input
id="setup-token"
type="text"
value={form.token}
onChange={setField('token')}
error={errors.token}
placeholder={t('setup.tokenPlaceholder')}
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
autoFocus
/>
<p className="mt-1 text-xs text-neutral-500">{t('setup.tokenHint')}</p>
</div>
<div>
<label htmlFor="setup-email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('setup.emailLabel')}
</label>
<Input
id="setup-email"
type="email"
value={form.email}
onChange={setField('email')}
error={errors.email}
placeholder={t('setup.emailPlaceholder')}
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
autoComplete="email"
/>
</div>
<div>
<label htmlFor="setup-password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('setup.passwordLabel')}
</label>
<div className="relative">
<Input
id="setup-password"
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={setField('password')}
error={errors.password}
placeholder={t('setup.passwordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600 transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label htmlFor="setup-confirm" className="block text-sm font-medium text-neutral-700 mb-1">
{t('setup.confirmLabel')}
</label>
<Input
id="setup-confirm"
type={showPassword ? 'text' : 'password'}
value={form.confirm}
onChange={setField('confirm')}
error={errors.confirm}
placeholder={t('setup.confirmPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="new-password"
/>
</div>
<Button type="submit" variant="primary" size="lg" isLoading={isSubmitting} className="w-full">
{t('setup.submit')}
</Button>
</form>
</Card>
<p className="text-center text-xs mt-6" style={{ color: 'var(--color-text, #171717)', opacity: 0.6 }}>
{t('setup.tokenLocationHint')}
</p>
</div>
</div>
);
};
SetupPage.displayName = 'SetupPage';
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Navigate, useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -7,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service';
import { setupService } from '../../services/setup.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
@@ -50,6 +52,17 @@ export const AdminLoginPage: React.FC = () => {
}
}, [searchParams, t]);
// Fresh instance with no admin yet → send to first-run setup.
const { data: setupStatus } = useQuery({
queryKey: ['setup-status'],
queryFn: setupService.getSetupStatus,
retry: false,
staleTime: Infinity,
});
if (setupStatus?.needsAdmin) {
return <Navigate to="/setup" replace />;
}
// Redirect if already authenticated or login successful
if (isAuthenticated || loginSuccess) {
return <Navigate to="/admin/dashboard" replace />;