feat(registration): add email confirmation UI
Build, Test & Push Frontend / quality-check (push) Successful in 26s
Build, Test & Push Frontend / docker (push) Successful in 31s

New ConfirmEmailPage for users to verify their email and set password.
Resend form on registration and error pages. Registration form no
longer asks for a password. Confirmation requires a button click. The
admin list marks unconfirmed requests and offers only delete for them.
This commit is contained in:
lobo committed 2026-10-01 16:20:51 +02:00
1 parent 249973dd69
commit 6dda186bdf
15 files changed
+416 -50

No files matched your search

+7 -1
View File
@@ -7,7 +7,7 @@ export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
/**
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
*
* @param status PENDING, ACTIVE or DENIED; empty for all
* @param status UNCONFIRMED, PENDING, ACTIVE or DENIED; empty for all
*/
export const fetchUsers = (query, status, page, size, init) =>
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, ...(status && {status}), page, size})}`, init);
@@ -34,6 +34,12 @@ export const setUserBlocked = (userId, blocked) =>
body: JSON.stringify({blocked}),
});
/**
* Hard delete of a request whose address is not confirmed: 204, or 409 NOT_UNCONFIRMED for any other status.
*/
export const deleteUnconfirmedUser = (userId) =>
apiFetch(`${AUTH()}/api/admin/users/${userId}/unconfirmed`, {method: 'DELETE'});
export const deleteUser = (userId) => apiFetch(`${AUTH()}/api/admin/users/${userId}`, {method: 'DELETE'});
/**
+7 -3
View File
@@ -24,12 +24,16 @@ const displayNameField = z
message: 'VALIDATION.DISPLAY_NAME_INVALID',
});
export const registrationSchema = z
// No password: the address owner chooses it on the confirm page.
export const registrationSchema = z.object({
email: emailField,
displayName: displayNameField,
});
export const confirmEmailSchema = z
.object({
email: emailField,
password: passwordField,
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
displayName: displayNameField,
})
.refine((d) => d.password === d.passwordConfirmation, {
path: ['passwordConfirmation'],
+19 -6
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest';
import {registrationSchema, settingsSchema} from './validation.js';
import {confirmEmailSchema, registrationSchema, settingsSchema} from './validation.js';
const settings = () => ({useDarkTheme: true, language: 'de'});
@@ -7,7 +7,7 @@ describe('Steam ID', () => {
it('is no form field any more: it comes only from "Sign in through Steam"', () => {
expect(settingsSchema.parse({...settings(), steamId: '76561198005679788'})).not.toHaveProperty('steamId');
expect(registrationSchema.parse({
email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1',
email: 'a@b.de',
steamId: '76561198005679788',
})).not.toHaveProperty('steamId');
});
@@ -30,11 +30,24 @@ describe('displayName validation', () => {
});
describe('registration password', () => {
describe('confirm page password', () => {
it('rejects a password over 72 UTF-8 bytes, as Auth does', () => {
const password = 'é'.repeat(37);
expect(registrationSchema.safeParse({
email: 'a@b.de', password, passwordConfirmation: password,
}).success).toBe(false);
expect(confirmEmailSchema.safeParse({password, passwordConfirmation: password}).success).toBe(false);
});
it('rejects a short password and one that differs from its repeat', () => {
expect(confirmEmailSchema.safeParse({password: 'short', passwordConfirmation: 'short'}).success).toBe(false);
expect(confirmEmailSchema.safeParse({password: 'password1', passwordConfirmation: 'password2'}).success).toBe(false);
});
it('takes a good password', () => {
expect(confirmEmailSchema.safeParse({password: 'password1', passwordConfirmation: 'password1'}).success).toBe(true);
});
});
describe('registration', () => {
it('has no password field', () => {
expect(registrationSchema.parse({email: 'a@b.de', password: 'password1'})).not.toHaveProperty('password');
});
});