feat(setup): validate setup token at step 1 before advancing

Previously "Continue" on the token step only checked the field was
non-empty; a wrong token wasn't caught until the final submit, after the
user had filled in email + password. Add a non-burning verify:

- backend: POST /setup/verify-token constant-time compares the token
  without consuming it (createInitialAdmin still claims it atomically on
  submit), gated on no-admin-exists and rate-limited like /setup/admin.
- frontend: step-1 "Continue" calls verifyToken and only advances on a
  valid token; a wrong token shows the invalidToken error on the field,
  429 -> too-many-attempts, 409 -> redirect to login.

Adds integration tests for accept-without-burn / reject / closed-once-set.
This commit is contained in:
Luca
2026-07-02 15:18:22 +02:00
parent 3e69c5df3f
commit b0912c7427
6 changed files with 99 additions and 4 deletions
+24 -3
View File
@@ -41,6 +41,7 @@ export const SetupPage: React.FC = () => {
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isVerifyingToken, setIsVerifyingToken] = useState(false);
const [copied, setCopied] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -92,12 +93,32 @@ export const SetupPage: React.FC = () => {
return Object.keys(next).length === 0;
};
const handleTokenContinue = (e: React.FormEvent) => {
const handleTokenContinue = async (e: React.FormEvent) => {
e.preventDefault();
toast.dismiss();
if (!validateToken()) return;
// Verify the token server-side before advancing — a wrong token is caught
// here at "Continue" rather than after the user has filled in the account
// step. The token is checked, not consumed; createInitialAdmin still burns
// it atomically on final submit.
setIsVerifyingToken(true);
setErrors({});
setStep('account');
try {
await setupService.verifyToken(form.token.trim());
setStep('account');
} catch (error: any) {
const httpStatus = error.response?.status;
if (httpStatus === 429) {
toast.error(t('setup.tooManyAttempts'));
} else if (httpStatus === 409) {
// Someone else finished setup first — send to login.
navigate('/admin/login', { replace: true });
} else {
setErrors({ token: t('setup.invalidToken') });
}
} finally {
setIsVerifyingToken(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
@@ -246,7 +267,7 @@ export const SetupPage: React.FC = () => {
</div>
</div>
<Button type="submit" variant="primary" size="lg" className="w-full" rightIcon={<ArrowRight className="w-4 h-4" />}>
<Button type="submit" variant="primary" size="lg" isLoading={isVerifyingToken} className="w-full" rightIcon={<ArrowRight className="w-4 h-4" />}>
{t('setup.continue')}
</Button>
</form>
+8
View File
@@ -25,6 +25,14 @@ export const setupService = {
return response.data;
},
// Step-1 pre-flight: confirm the token is valid before advancing to the
// account step. Rejects (400, field: 'token') on a wrong token without
// burning it. Throws on non-2xx so the caller can branch on the status.
async verifyToken(token: string): Promise<{ valid: boolean }> {
const response = await api.post<{ valid: boolean }>('/setup/verify-token', { token });
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);