From d9b0eb723295c20e80c0e633ebcdb6e191528607 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:56:14 +0200 Subject: [PATCH] feat(setup): brand first-run screen and split into two-step wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/i18n/locales/de.json | 8 + frontend/src/i18n/locales/en.json | 8 + frontend/src/pages/SetupPage.tsx | 282 ++++++++++++++++++++---------- 3 files changed, 210 insertions(+), 88 deletions(-) diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index d2bdaedf..c231bd27 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8f6ed7ab..e5c89510 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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.", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 3164c4a3..4e7f8acd 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -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>({}); 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 = {}; if (!form.token.trim()) next.token = t('setup.tokenRequired'); + setErrors(next); + return Object.keys(next).length === 0; + }; + + const validateAccount = (): boolean => { + const next: Record = {}; 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,110 +162,166 @@ export const SetupPage: React.FC = () => { } }; + const stepNumber = step === 'token' ? 1 : 2; + return (
+ {/* 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. */}
- + PicPeak

{t('setup.title')}

-

{t('setup.subtitle')}

+

+ {step === 'token' ? t('setup.tokenStepSubtitle') : t('setup.accountStepSubtitle')} +

+

+ {t('setup.stepOf', { current: stepNumber, total: 2 })} +

-
- {errors.form && ( -
- -

{errors.form}

-
- )} - -
- - } - autoFocus - /> -

{t('setup.tokenHint')}

+ {errors.form && ( +
+ +

{errors.form}

+ )} -
- - } - autoComplete="email" - /> -
- -
- -
+ {step === 'token' ? ( + +
+ } + autoFocus + /> +

{t('setup.tokenHint')}

+ + {/* Recovery guidance sits directly under the field it explains. */} +
+

{t('setup.tokenCommandLabel')}

+
+ + {recoveryCommand} + + +
+ + {t('setup.tokenRotatedLink')} + + +
+
+ + + + ) : ( +
+
+ + } + autoComplete="email" + autoFocus + /> +
+ +
+ +
+ } + autoComplete="new-password" + /> + +
+
+ +
+ + } autoComplete="new-password" /> -
-
-
- - } - autoComplete="new-password" - /> -
- - - +
+ + +
+ + )} - -

- {t('setup.tokenLocationHint')} -

);