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

+58
View File
@@ -0,0 +1,58 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import FeedbackAlert from './FeedbackAlert.jsx';
import FormField from './FormField.jsx';
import {clientFetch} from '../utils/api.js';
/**
* "Send the confirmation link again". Auth answers the same for any address, so does this form.
*/
export default function ResendConfirmation({defaultEmail = ''}) {
const {t} = useTranslation();
const [email, setEmail] = useState(defaultEmail);
const [isLoading, setIsLoading] = useState(false);
const [feedback, setFeedback] = useState({message: '', isError: false});
const resend = async () => {
setIsLoading(true);
setFeedback({message: '', isError: false});
try {
const response = await clientFetch(`${import.meta.env.VITE_API_URL}/api/registration/resend`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email}),
});
if (response.ok) {
setFeedback({message: t('REGISTRATION.RESEND_SENT'), isError: false});
} else if (response.status === 429) {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
setFeedback({message: t('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS', {minutes}), isError: true});
} else {
setFeedback({message: t('REGISTRATION.ERROR_GENERIC'), isError: true});
}
} catch (error) {
console.error('Resend API error:', error);
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
} finally {
setIsLoading(false);
}
};
return (
<div className="space-y-3">
<FormField id="resend-email" label={t('REGISTRATION.RESEND_EMAIL')} t={t}>
<Input id="resend-email" type="email" autoComplete="email" value={email}
onChange={(e) => setEmail(e.target.value)}/>
</FormField>
<Button type="button" variant="outline" disabled={isLoading} onClick={resend} className="w-full">
{isLoading && <Loader2 className="animate-spin"/>}
{t('REGISTRATION.RESEND')}
</Button>
<FeedbackAlert message={feedback.message} isError={feedback.isError}/>
</div>
);
}
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import ResendConfirmation from './ResendConfirmation.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key, options) => (options?.minutes === undefined ? key : `${key}:${options.minutes}`),
i18n: {language: 'de'},
}),
}));
describe('ResendConfirmation', () => {
afterEach(cleanup);
it('sends the prefilled address and shows the same answer for any address', async () => {
const mock = fetchRoutes({'POST /api/registration/resend': json(204, null)});
render(<ResendConfirmation defaultEmail="a@b.de"/>);
fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.RESEND'}));
expect(await screen.findByText('REGISTRATION.RESEND_SENT')).toBeTruthy();
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({email: 'a@b.de'});
});
it('shows the wait time when the IP is blocked', async () => {
fetchRoutes({
'POST /api/registration/resend': {...json(429, {}), headers: new Headers({'Retry-After': '900'})},
});
render(<ResendConfirmation/>);
fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.RESEND'}));
expect(await screen.findByText('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS:15')).toBeTruthy();
});
});