feat(setup): brand first-run screen and split into two-step wizard

Address post-merge UI feedback on the first-run setup screen — the first
screen any new admin sees:

- Use the bundled PicPeak logo (same asset the login page falls back to)
  on the cream brand plate instead of the generic lucide Sparkles icon.
- Split the flow into two steps: step 1 takes only the one-time setup
  token, with the `docker compose logs backend | grep -i "setup token"`
  recovery command shown prominently (with a copy button) directly under
  the field, plus a docs link for when the logs have rotated away; step 2
  collects email + password. A rejected token bounces back to step 1.

en/de strings added; other locales fall back to en.
This commit is contained in:
Luca
2026-07-02 14:56:14 +02:00
parent 286975dc52
commit d9b0eb7232
3 changed files with 210 additions and 88 deletions
+8
View File
@@ -3465,10 +3465,18 @@
"setup": {
"title": "Willkommen bei PicPeak",
"subtitle": "Erstellen Sie Ihr Administrator-Konto, um loszulegen",
"tokenStepSubtitle": "Geben Sie zunächst Ihren einmaligen Setup-Token ein",
"accountStepSubtitle": "Erstellen Sie nun Ihr Administrator-Konto",
"stepOf": "Schritt {{current}} von {{total}}",
"continue": "Weiter",
"back": "Zurück",
"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",
"tokenCommandLabel": "Token nicht gefunden? Führen Sie dies im Projektverzeichnis aus:",
"copyCommand": "Befehl kopieren",
"tokenRotatedLink": "Logs bereits rotiert? Zur Einrichtungsanleitung",
"tokenLocationHint": "Nicht gefunden? Führen Sie aus: docker compose logs backend | grep -i \"setup token\"",
"invalidToken": "Dieser Setup-Token ist ungültig.",
"passwordRequirements": "Verwenden Sie mindestens 8 Zeichen mit einem Groß- und einem Kleinbuchstaben sowie einer Ziffer.",
+8
View File
@@ -3361,10 +3361,18 @@
"setup": {
"title": "Welcome to PicPeak",
"subtitle": "Create your administrator account to get started",
"tokenStepSubtitle": "First, enter your one-time setup token",
"accountStepSubtitle": "Now create your administrator account",
"stepOf": "Step {{current}} of {{total}}",
"continue": "Continue",
"back": "Back",
"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",
"tokenCommandLabel": "Can't find your token? Run this in the project directory:",
"copyCommand": "Copy command",
"tokenRotatedLink": "Logs already rotated away? Read the setup guide",
"tokenLocationHint": "Can't find it? Run: docker compose logs backend | grep -i \"setup token\"",
"invalidToken": "That setup token is not valid.",
"passwordRequirements": "Use at least 8 characters with an upper-case letter, a lower-case letter and a number.",
+119 -13
View File
@@ -1,7 +1,7 @@
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 { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -10,10 +10,20 @@ import { useAdminAuth } from '../contexts';
import { setupService } from '../services/setup.service';
import type { AdminUser } from '../types';
// Where the first-run setup is documented, for the case where the server logs
// have already rotated away and the admin can no longer grep the token out.
const SETUP_DOCS_URL =
'https://github.com/PicPeak/picpeak/blob/main/README.md#first-run--create-your-admin-account';
// 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.
//
// Split into two steps so the token-recovery guidance gets the space it needs:
// 1. paste the one-time setup token (with the `docker compose logs` recovery
// command shown prominently right under the field)
// 2. choose the admin email + password
export const SetupPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -26,9 +36,11 @@ export const SetupPage: React.FC = () => {
staleTime: Infinity,
});
const [step, setStep] = useState<'token' | 'account'>('token');
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [copied, setCopied] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
if (statusLoading) {
@@ -46,9 +58,28 @@ export const SetupPage: React.FC = () => {
if (errors[field]) setErrors((prev) => ({ ...prev, [field]: '' }));
};
const validate = (): boolean => {
const recoveryCommand = 'docker compose logs backend | grep -i "setup token"';
const copyRecoveryCommand = async () => {
try {
await navigator.clipboard.writeText(recoveryCommand);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard unavailable (e.g. non-secure context) — the command is still
// visible for the user to copy by hand, so fail quietly.
}
};
const validateToken = (): boolean => {
const next: Record<string, string> = {};
if (!form.token.trim()) next.token = t('setup.tokenRequired');
setErrors(next);
return Object.keys(next).length === 0;
};
const validateAccount = (): boolean => {
const next: Record<string, string> = {};
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');
@@ -60,10 +91,18 @@ export const SetupPage: React.FC = () => {
return Object.keys(next).length === 0;
};
const handleTokenContinue = (e: React.FormEvent) => {
e.preventDefault();
toast.dismiss();
if (!validateToken()) return;
setErrors({});
setStep('account');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
toast.dismiss();
if (!validate()) return;
if (!validateAccount()) return;
setIsSubmitting(true);
setErrors({});
@@ -94,6 +133,11 @@ export const SetupPage: React.FC = () => {
email: 'setup.invalidEmail',
password: 'setup.passwordRequirements',
};
// A rejected token belongs to step 1 — send the user back there to fix it
// rather than showing the error on a field the account step doesn't render.
const bounceToTokenStep = (field: string) => {
if (field === 'token') setStep('token');
};
if (httpStatus === 429) {
toast.error(t('setup.tooManyAttempts'));
} else if (httpStatus === 409) {
@@ -101,9 +145,15 @@ export const SetupPage: React.FC = () => {
navigate('/admin/login', { replace: true });
} else if (data?.field && fieldKey[data.field]) {
setErrors({ [data.field]: t(fieldKey[data.field]) });
bounceToTokenStep(data.field);
} else if (Array.isArray(data?.errors) && data.errors.length) {
const p = data.errors[0]?.path || data.errors[0]?.param;
setErrors(p && fieldKey[p] ? { [p]: t(fieldKey[p]) } : { form: t('setup.genericError') });
if (p && fieldKey[p]) {
setErrors({ [p]: t(fieldKey[p]) });
bounceToTokenStep(p);
} else {
setErrors({ form: t('setup.genericError') });
}
} else {
setErrors({ form: t('setup.genericError') });
}
@@ -112,26 +162,37 @@ export const SetupPage: React.FC = () => {
}
};
const stepNumber = step === 'token' ? 1 : 2;
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">
{/* On a fresh instance there are no branding settings yet, so use the
bundled PicPeak logo the same default the login page falls back
to on the cream brand plate. First impressions should be on-brand. */}
<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)' }} />
<img src="/picpeak-logo-transparent.png" alt="PicPeak" className="w-11 h-11 object-contain" />
</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>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
{step === 'token' ? t('setup.tokenStepSubtitle') : t('setup.accountStepSubtitle')}
</p>
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
{t('setup.stepOf', { current: stepNumber, total: 2 })}
</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">
<div className="mb-6 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>
)}
{step === 'token' ? (
<form onSubmit={handleTokenContinue} className="space-y-6">
<div>
<label htmlFor="setup-token" className="block text-sm font-medium text-neutral-700 mb-1">
{t('setup.tokenLabel')}
@@ -147,8 +208,43 @@ export const SetupPage: React.FC = () => {
autoFocus
/>
<p className="mt-1 text-xs text-neutral-500">{t('setup.tokenHint')}</p>
{/* Recovery guidance sits directly under the field it explains. */}
<div className="mt-4 rounded-lg border border-neutral-200 bg-neutral-50 p-3">
<p className="text-xs font-medium text-neutral-600">{t('setup.tokenCommandLabel')}</p>
<div className="mt-2 flex items-center gap-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded bg-neutral-900 px-3 py-2 font-mono text-xs text-neutral-100">
{recoveryCommand}
</code>
<button
type="button"
onClick={copyRecoveryCommand}
className="flex-shrink-0 rounded-md border border-neutral-200 bg-white p-2 text-neutral-500 hover:text-neutral-700 transition-colors"
aria-label={t('setup.copyCommand')}
title={t('setup.copyCommand')}
>
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<a
href={SETUP_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{t('setup.tokenRotatedLink')}
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
<Button type="submit" variant="primary" size="lg" className="w-full" rightIcon={<ArrowRight className="w-4 h-4" />}>
{t('setup.continue')}
</Button>
</form>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="setup-email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('setup.emailLabel')}
@@ -162,6 +258,7 @@ export const SetupPage: React.FC = () => {
placeholder={t('setup.emailPlaceholder')}
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
autoComplete="email"
autoFocus
/>
</div>
@@ -207,15 +304,24 @@ export const SetupPage: React.FC = () => {
/>
</div>
<Button type="submit" variant="primary" size="lg" isLoading={isSubmitting} className="w-full">
<div className="flex gap-3">
<Button
type="button"
variant="outline"
size="lg"
onClick={() => { toast.dismiss(); setErrors({}); setStep('token'); }}
disabled={isSubmitting}
leftIcon={<ArrowLeft className="w-4 h-4" />}
>
{t('setup.back')}
</Button>
<Button type="submit" variant="primary" size="lg" isLoading={isSubmitting} className="flex-1">
{t('setup.submit')}
</Button>
</div>
</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>
);