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

+18 -4
View File
@@ -18,7 +18,7 @@ import {
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import PageContainer from '../components/PageContainer.jsx';
import {decideUser, deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
import {formatDate} from '../utils/format.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js';
@@ -29,12 +29,14 @@ const REFUSAL_KEY = {
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING',
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
};
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
/**
* PLATFORM_ADMIN only: list, block and delete users, accept or deny registrations that wait. Auth checks the role; a
* PLATFORM_ADMIN only: list, block and delete users, accept or deny registrations that wait, delete the ones whose address is not confirmed. Auth checks the role; a
* 403 shows "no access".
*/
export default function AdminUsersPage() {
@@ -45,7 +47,7 @@ export default function AdminUsersPage() {
const [pendingOnly, setPendingOnly] = useState(false);
const [result, setResult] = useState(null);
const [status, setStatus] = useState('loading');
// {kind: 'block' | 'delete' | 'accept' | 'deny', user}
// {kind: 'block' | 'delete' | 'deleteUnconfirmed' | 'accept' | 'deny', user}
const [pending, setPending] = useState(null);
const [typedEmail, setTypedEmail] = useState('');
const [busy, setBusy] = useState(false);
@@ -92,6 +94,11 @@ export default function AdminUsersPage() {
title: t('ADMIN.DELETE_TITLE', {email}), text: t('ADMIN.DELETE_TEXT'), action: 'ADMIN.DELETE',
done: 'ADMIN.DONE_DELETE', destructive: true, run: () => deleteUser(pending.user.id),
},
deleteUnconfirmed: {
title: t('ADMIN.DELETE_UNCONFIRMED_TITLE', {email}), text: t('ADMIN.DELETE_UNCONFIRMED_TEXT'),
action: 'ADMIN.DELETE', done: 'ADMIN.DONE_DELETE', destructive: true,
run: () => deleteUnconfirmedUser(pending.user.id),
},
block: {
...(pending.user.blocked
? {title: t('ADMIN.UNBLOCK'), text: email, action: 'ADMIN.UNBLOCK', done: 'ADMIN.DONE_UNBLOCK'}
@@ -181,6 +188,7 @@ export default function AdminUsersPage() {
<div className="flex flex-wrap items-center gap-1">
{user.role !== 'USER' && <Badge variant="outline">{t(`ROLES.${user.role}`)}</Badge>}
{user.steamLinked && <Badge variant="secondary">{t('ADMIN.STEAM')}</Badge>}
{user.status === 'UNCONFIRMED' && <Badge variant="outline">{t('ADMIN.UNCONFIRMED')}</Badge>}
{user.status === 'PENDING' && <Badge>{t('ADMIN.PENDING')}</Badge>}
{user.blocked && <Badge variant="destructive">{t('ADMIN.BLOCKED')}</Badge>}
{user.deletedAt && (
@@ -189,6 +197,12 @@ export default function AdminUsersPage() {
</Badge>
)}
</div>
{user.status === 'UNCONFIRMED' && (
<Button type="button" variant="outline" size="sm" className="border-destructive text-destructive"
onClick={() => setPending({kind: 'deleteUnconfirmed', user})}>
<Trash2/>{t('ADMIN.DELETE')}
</Button>
)}
{user.status === 'PENDING' && (
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm"
@@ -201,7 +215,7 @@ export default function AdminUsersPage() {
</Button>
</div>
)}
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && (
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && (
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm"
onClick={() => setPending({kind: 'block', user})}>