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:
@@ -79,6 +79,8 @@ import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
||||
import { ConfirmDialogProvider } from './components/common';
|
||||
import { usePublicSettings } from './hooks/usePublicSettings';
|
||||
import { SetupPage } from './pages/SetupPage';
|
||||
import { AdminAuthProvider } from './contexts';
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
@@ -214,6 +216,13 @@ function App() {
|
||||
</GalleryAuthProvider>
|
||||
} />
|
||||
|
||||
{/* First-run setup — public, self-closes once an admin exists */}
|
||||
<Route path="/setup" element={
|
||||
<AdminAuthProvider>
|
||||
<SetupPage />
|
||||
</AdminAuthProvider>
|
||||
} />
|
||||
|
||||
{/* Admin routes - wrap with AdminAuthProvider */}
|
||||
<Route path="/admin" element={<AdminAuthWrapper />}>
|
||||
<Route path="login" element={<AdminLoginPage />} />
|
||||
|
||||
@@ -3462,6 +3462,30 @@
|
||||
"regenerateToken": "Link neu generieren",
|
||||
"tokenRegenerated": "Kundenzugangs-Link neu generiert"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Willkommen bei PicPeak",
|
||||
"subtitle": "Erstellen Sie Ihr Administrator-Konto, um loszulegen",
|
||||
"tokenLabel": "Setup-Token",
|
||||
"tokenPlaceholder": "Einmaligen Setup-Token einfügen",
|
||||
"tokenHint": "Wird beim ersten Start in den Server-Logs ausgegeben (auch in data/SETUP_TOKEN gespeichert).",
|
||||
"tokenRequired": "Der Setup-Token ist erforderlich",
|
||||
"tokenLocationHint": "Nicht gefunden? Führen Sie aus: docker compose logs backend | grep -i \"setup token\"",
|
||||
"emailLabel": "E-Mail-Adresse",
|
||||
"emailPlaceholder": "[email protected]",
|
||||
"emailRequired": "E-Mail ist erforderlich",
|
||||
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"passwordLabel": "Passwort",
|
||||
"passwordPlaceholder": "Wählen Sie ein sicheres Passwort",
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
"passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
|
||||
"confirmLabel": "Passwort bestätigen",
|
||||
"confirmPlaceholder": "Passwort erneut eingeben",
|
||||
"passwordMismatch": "Die Passwörter stimmen nicht überein",
|
||||
"submit": "Admin-Konto erstellen",
|
||||
"success": "Admin-Konto erstellt. Willkommen!",
|
||||
"genericError": "Einrichtung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"tooManyAttempts": "Zu viele Versuche. Bitte warten Sie einen Moment und versuchen Sie es erneut."
|
||||
},
|
||||
"adminLogin": {
|
||||
"title": "Admin-Anmeldung",
|
||||
"subtitle": "Melden Sie sich an, um Ihre Fotogalerien zu verwalten",
|
||||
|
||||
@@ -3358,6 +3358,30 @@
|
||||
"fourStarsPlus": "4+ Stars",
|
||||
"fiveStarsOnly": "5 Stars Only"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Welcome to PicPeak",
|
||||
"subtitle": "Create your administrator account to get started",
|
||||
"tokenLabel": "Setup token",
|
||||
"tokenPlaceholder": "Paste the one-time setup token",
|
||||
"tokenHint": "Printed to the server logs on first start (also saved to data/SETUP_TOKEN).",
|
||||
"tokenRequired": "The setup token is required",
|
||||
"tokenLocationHint": "Can't find it? Run: docker compose logs backend | grep -i \"setup token\"",
|
||||
"emailLabel": "Email address",
|
||||
"emailPlaceholder": "[email protected]",
|
||||
"emailRequired": "Email is required",
|
||||
"invalidEmail": "Please enter a valid email address",
|
||||
"passwordLabel": "Password",
|
||||
"passwordPlaceholder": "Choose a strong password",
|
||||
"passwordRequired": "Password is required",
|
||||
"passwordMinLength": "Password must be at least 8 characters",
|
||||
"confirmLabel": "Confirm password",
|
||||
"confirmPlaceholder": "Re-enter your password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"submit": "Create admin account",
|
||||
"success": "Admin account created. Welcome!",
|
||||
"genericError": "Setup failed. Please try again.",
|
||||
"tooManyAttempts": "Too many attempts. Please wait a moment and try again."
|
||||
},
|
||||
"adminLogin": {
|
||||
"title": "Admin Login",
|
||||
"subtitle": "Sign in to manage your photo galleries",
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface SetupStatus {
|
||||
needsAdmin: boolean;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface SetupAdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: { name: string; displayName?: string };
|
||||
}
|
||||
|
||||
export interface CreateInitialAdminInput {
|
||||
token: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// First-run bootstrap. Public endpoints that self-close once an admin exists.
|
||||
export const setupService = {
|
||||
async getSetupStatus(): Promise<SetupStatus> {
|
||||
const response = await api.get<SetupStatus>('/setup/status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createInitialAdmin(input: CreateInitialAdminInput): Promise<{ user: SetupAdminUser }> {
|
||||
// Admin JWT is returned as an HttpOnly cookie (mirrors login); body carries the user.
|
||||
const response = await api.post<{ user: SetupAdminUser }>('/setup/admin', input);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user