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:
@@ -3465,10 +3465,18 @@
|
|||||||
"setup": {
|
"setup": {
|
||||||
"title": "Willkommen bei PicPeak",
|
"title": "Willkommen bei PicPeak",
|
||||||
"subtitle": "Erstellen Sie Ihr Administrator-Konto, um loszulegen",
|
"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",
|
"tokenLabel": "Setup-Token",
|
||||||
"tokenPlaceholder": "Einmaligen Setup-Token einfügen",
|
"tokenPlaceholder": "Einmaligen Setup-Token einfügen",
|
||||||
"tokenHint": "Wird beim ersten Start in den Server-Logs ausgegeben (auch in data/SETUP_TOKEN gespeichert).",
|
"tokenHint": "Wird beim ersten Start in den Server-Logs ausgegeben (auch in data/SETUP_TOKEN gespeichert).",
|
||||||
"tokenRequired": "Der Setup-Token ist erforderlich",
|
"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\"",
|
"tokenLocationHint": "Nicht gefunden? Führen Sie aus: docker compose logs backend | grep -i \"setup token\"",
|
||||||
"invalidToken": "Dieser Setup-Token ist ungültig.",
|
"invalidToken": "Dieser Setup-Token ist ungültig.",
|
||||||
"passwordRequirements": "Verwenden Sie mindestens 8 Zeichen mit einem Groß- und einem Kleinbuchstaben sowie einer Ziffer.",
|
"passwordRequirements": "Verwenden Sie mindestens 8 Zeichen mit einem Groß- und einem Kleinbuchstaben sowie einer Ziffer.",
|
||||||
|
|||||||
@@ -3361,10 +3361,18 @@
|
|||||||
"setup": {
|
"setup": {
|
||||||
"title": "Welcome to PicPeak",
|
"title": "Welcome to PicPeak",
|
||||||
"subtitle": "Create your administrator account to get started",
|
"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",
|
"tokenLabel": "Setup token",
|
||||||
"tokenPlaceholder": "Paste the one-time setup token",
|
"tokenPlaceholder": "Paste the one-time setup token",
|
||||||
"tokenHint": "Printed to the server logs on first start (also saved to data/SETUP_TOKEN).",
|
"tokenHint": "Printed to the server logs on first start (also saved to data/SETUP_TOKEN).",
|
||||||
"tokenRequired": "The setup token is required",
|
"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\"",
|
"tokenLocationHint": "Can't find it? Run: docker compose logs backend | grep -i \"setup token\"",
|
||||||
"invalidToken": "That setup token is not valid.",
|
"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.",
|
"passwordRequirements": "Use at least 8 characters with an upper-case letter, a lower-case letter and a number.",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
import { Navigate, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { toast } from 'react-toastify';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -10,10 +10,20 @@ import { useAdminAuth } from '../contexts';
|
|||||||
import { setupService } from '../services/setup.service';
|
import { setupService } from '../services/setup.service';
|
||||||
import type { AdminUser } from '../types';
|
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
|
// 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
|
// 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
|
// one-time setup token printed to the server logs. Once an admin exists the
|
||||||
// endpoints self-close and this page redirects to the login.
|
// 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 = () => {
|
export const SetupPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -26,9 +36,11 @@ export const SetupPage: React.FC = () => {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [step, setStep] = useState<'token' | 'account'>('token');
|
||||||
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
|
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
if (statusLoading) {
|
if (statusLoading) {
|
||||||
@@ -46,9 +58,28 @@ export const SetupPage: React.FC = () => {
|
|||||||
if (errors[field]) setErrors((prev) => ({ ...prev, [field]: '' }));
|
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> = {};
|
const next: Record<string, string> = {};
|
||||||
if (!form.token.trim()) next.token = t('setup.tokenRequired');
|
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');
|
if (!form.email) next.email = t('setup.emailRequired');
|
||||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) next.email = t('setup.invalidEmail');
|
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) next.email = t('setup.invalidEmail');
|
||||||
if (!form.password) next.password = t('setup.passwordRequired');
|
if (!form.password) next.password = t('setup.passwordRequired');
|
||||||
@@ -60,10 +91,18 @@ export const SetupPage: React.FC = () => {
|
|||||||
return Object.keys(next).length === 0;
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
if (!validate()) return;
|
if (!validateAccount()) return;
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
@@ -94,6 +133,11 @@ export const SetupPage: React.FC = () => {
|
|||||||
email: 'setup.invalidEmail',
|
email: 'setup.invalidEmail',
|
||||||
password: 'setup.passwordRequirements',
|
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) {
|
if (httpStatus === 429) {
|
||||||
toast.error(t('setup.tooManyAttempts'));
|
toast.error(t('setup.tooManyAttempts'));
|
||||||
} else if (httpStatus === 409) {
|
} else if (httpStatus === 409) {
|
||||||
@@ -101,9 +145,15 @@ export const SetupPage: React.FC = () => {
|
|||||||
navigate('/admin/login', { replace: true });
|
navigate('/admin/login', { replace: true });
|
||||||
} else if (data?.field && fieldKey[data.field]) {
|
} else if (data?.field && fieldKey[data.field]) {
|
||||||
setErrors({ [data.field]: t(fieldKey[data.field]) });
|
setErrors({ [data.field]: t(fieldKey[data.field]) });
|
||||||
|
bounceToTokenStep(data.field);
|
||||||
} else if (Array.isArray(data?.errors) && data.errors.length) {
|
} else if (Array.isArray(data?.errors) && data.errors.length) {
|
||||||
const p = data.errors[0]?.path || data.errors[0]?.param;
|
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 {
|
} else {
|
||||||
setErrors({ form: t('setup.genericError') });
|
setErrors({ form: t('setup.genericError') });
|
||||||
}
|
}
|
||||||
@@ -112,110 +162,166 @@ export const SetupPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stepNumber = step === 'token' ? 1 : 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
<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="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
<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' }}>
|
<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>
|
</div>
|
||||||
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>{t('setup.title')}</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
{errors.form && (
|
||||||
{errors.form && (
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
|
||||||
<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" />
|
||||||
<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>
|
||||||
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
{step === 'token' ? (
|
||||||
<label htmlFor="setup-email" className="block text-sm font-medium text-neutral-700 mb-1">
|
<form onSubmit={handleTokenContinue} className="space-y-6">
|
||||||
{t('setup.emailLabel')}
|
<div>
|
||||||
</label>
|
<label htmlFor="setup-token" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
<Input
|
{t('setup.tokenLabel')}
|
||||||
id="setup-email"
|
</label>
|
||||||
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
|
<Input
|
||||||
id="setup-password"
|
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>
|
||||||
|
|
||||||
|
{/* 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')}
|
||||||
|
</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"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</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'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={form.password}
|
value={form.confirm}
|
||||||
onChange={setField('password')}
|
onChange={setField('confirm')}
|
||||||
error={errors.password}
|
error={errors.confirm}
|
||||||
placeholder={t('setup.passwordPlaceholder')}
|
placeholder={t('setup.confirmPlaceholder')}
|
||||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
autoComplete="new-password"
|
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>
|
|
||||||
|
|
||||||
<div>
|
<div className="flex gap-3">
|
||||||
<label htmlFor="setup-confirm" className="block text-sm font-medium text-neutral-700 mb-1">
|
<Button
|
||||||
{t('setup.confirmLabel')}
|
type="button"
|
||||||
</label>
|
variant="outline"
|
||||||
<Input
|
size="lg"
|
||||||
id="setup-confirm"
|
onClick={() => { toast.dismiss(); setErrors({}); setStep('token'); }}
|
||||||
type={showPassword ? 'text' : 'password'}
|
disabled={isSubmitting}
|
||||||
value={form.confirm}
|
leftIcon={<ArrowLeft className="w-4 h-4" />}
|
||||||
onChange={setField('confirm')}
|
>
|
||||||
error={errors.confirm}
|
{t('setup.back')}
|
||||||
placeholder={t('setup.confirmPlaceholder')}
|
</Button>
|
||||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
<Button type="submit" variant="primary" size="lg" isLoading={isSubmitting} className="flex-1">
|
||||||
autoComplete="new-password"
|
{t('setup.submit')}
|
||||||
/>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
<Button type="submit" variant="primary" size="lg" isLoading={isSubmitting} className="w-full">
|
)}
|
||||||
{t('setup.submit')}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p className="text-center text-xs mt-6" style={{ color: 'var(--color-text, #171717)', opacity: 0.6 }}>
|
|
||||||
{t('setup.tokenLocationHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user