Skip to content

Commit ea774de

Browse files
committed
frontend: add component to resend verification token. fix #212
1 parent 81259df commit ea774de

File tree

8 files changed

+236
-10
lines changed

8 files changed

+236
-10
lines changed

frontend/src/components/RecoveryDataComponent.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ export function RecoveryDataComponent(props: RecoveryDataProps) {
5656
<Modal.Footer>
5757
<Button variant={saved.value ? "primary" : "danger"} onClick={() => {
5858
props.show.value = false;
59-
window.location.replace("/");
6059
}}>{t("close")}</Button>
6160
</Modal.Footer>
6261
</Modal>

frontend/src/components/Register.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Trans, useTranslation } from "react-i18next";
77
import i18n from "../i18n";
88
import { PasswordComponent } from "./PasswordComponent";
99
import { RecoveryDataComponent } from "./RecoveryDataComponent";
10+
import { ResendVerification } from "./ResendVerification";
1011
import { Signal, signal } from "@preact/signals";
1112
import { privacy_notice, terms_of_use } from "links";
1213

@@ -37,6 +38,7 @@ interface RegisterState {
3738
recoverySafed: boolean,
3839
encryptedSecret: Uint8Array,
3940
secret: Uint8Array,
41+
registrationSuccess: boolean,
4042
}
4143

4244
export class Register extends Component<{}, RegisterState> {
@@ -59,6 +61,7 @@ export class Register extends Component<{}, RegisterState> {
5961
recoverySafed: false,
6062
encryptedSecret: new Uint8Array(),
6163
secret: new Uint8Array(),
64+
registrationSuccess: false,
6265
}
6366

6467
this.showModal = signal(false);
@@ -202,18 +205,18 @@ export class Register extends Component<{}, RegisterState> {
202205
showAlert(text, "danger");
203206
return;
204207
}
205-
206-
this.setState({encryptedSecret: new Uint8Array(encrypted_secret), secret: keypair.privateKey});
207-
this.showModal.value = true;
208+
this.setState({
209+
encryptedSecret: new Uint8Array(encrypted_secret),
210+
secret: keypair.privateKey,
211+
registrationSuccess: true,
212+
});
213+
this.showModal.value = true; // keep existing behavior of showing recovery modal
208214
}
209215

210216
render() {
211217
const {t} = useTranslation("", {useSuspense: false, keyPrefix: "register"})
212218

213-
return (<>
214-
<RecoveryDataComponent email={this.state.email} secret={this.state.secret} show={this.showModal} />
215-
216-
<Form onSubmit={(e: SubmitEvent) => this.onSubmit(e)} noValidate>
219+
const form = <Form onSubmit={(e: SubmitEvent) => this.onSubmit(e)} noValidate>
217220
<Form.Group className="mb-3" controlId="registerName">
218221
<Form.Label>{t("name")}</Form.Label>
219222
<Form.Control name="Name" type="text" placeholder="John Doe" value={this.state.name} isInvalid={!this.state.nameValid} onChange={(e) => {
@@ -263,7 +266,14 @@ export class Register extends Component<{}, RegisterState> {
263266
<Button variant="primary" type="submit">
264267
{t("register")}
265268
</Button>
266-
</Form>
269+
</Form>;
270+
271+
return (<>
272+
<RecoveryDataComponent email={this.state.email} secret={this.state.secret} show={this.showModal} />
273+
274+
{ !this.state.registrationSuccess && form}
275+
276+
{ this.state.registrationSuccess && this.state.email && <ResendVerification email={this.state.email} /> }
267277
</>)
268278
}
269279
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useState } from 'preact/hooks';
2+
import { Button, Alert, Spinner } from 'react-bootstrap';
3+
import { fetchClient } from '../utils';
4+
import { useTranslation } from 'react-i18next';
5+
6+
interface Props { email: string; }
7+
8+
export function ResendVerification(props: Props) {
9+
const { t, i18n } = useTranslation('', { useSuspense: false });
10+
const [sending, setSending] = useState(false);
11+
const [done, setDone] = useState(false);
12+
const [error, setError] = useState<string | null>(null);
13+
14+
async function resend() {
15+
if (sending) return;
16+
setSending(true);
17+
setError(null);
18+
const { response } = await fetchClient.POST('/auth/resend_verification', { body: { email: props.email }, headers: { 'X-Lang': i18n.language }});
19+
if (response.status === 200) {
20+
setDone(true);
21+
} else {
22+
setError(t('register.resend_error'));
23+
}
24+
setSending(false);
25+
}
26+
27+
if (!props.email) return null;
28+
29+
return (
30+
<div className="mt-3" data-testid="resend-verification">
31+
{done && <Alert variant="success" data-testid="resend-success">{t('register.resend_success')}</Alert>}
32+
{error && <Alert variant="danger" data-testid="resend-error">{error}</Alert>}
33+
{!done && <Button variant="secondary" size="sm" onClick={resend} disabled={sending} data-testid="resend-button">
34+
{sending && <Spinner as="span" animation="border" size="sm" className="me-1" />}
35+
{t('register.resend_verification')}
36+
</Button>}
37+
</div>
38+
);
39+
}

frontend/src/components/__tests__/register.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,31 @@ describe('Register Component', () => {
334334
});
335335
});
336336

337+
it('renders ResendVerification component after successful registration', async () => {
338+
render(<Register />);
339+
340+
const nameInput = screen.getByRole('textbox', { name: 'name' });
341+
const emailInput = screen.getByRole('textbox', { name: 'email' });
342+
const passwordInput = screen.getByRole('textbox', { name: 'password' });
343+
const confirmPasswordInput = screen.getByRole('textbox', { name: 'confirm_password' });
344+
const checkboxes = screen.getAllByRole('checkbox');
345+
346+
fireEvent.change(nameInput, { target: { value: 'Jane Doe' } });
347+
fireEvent.change(emailInput, { target: { value: '[email protected]' } });
348+
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } });
349+
fireEvent.change(confirmPasswordInput, { target: { value: 'ValidPass123!' } });
350+
fireEvent.click(checkboxes[0]);
351+
fireEvent.click(checkboxes[1]);
352+
353+
const submitButton = screen.getByTestId('submit-button');
354+
fireEvent.click(submitButton);
355+
356+
await waitFor(() => {
357+
// ResendVerification wrapper div
358+
expect(screen.getByTestId('resend-verification')).toBeTruthy();
359+
});
360+
});
361+
337362
it('handles registration error correctly', async () => {
338363
mockUtils.fetchClient.POST.mockResolvedValue({
339364
response: { status: 400 },
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { render, fireEvent, waitFor, screen } from '@testing-library/preact';
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import { ResendVerification } from '../ResendVerification';
4+
5+
// Mock utils (fetchClient) – path resolution to actual file used by component
6+
vi.mock('../../utils', () => ({
7+
fetchClient: {
8+
POST: vi.fn(),
9+
},
10+
}));
11+
12+
// Mock i18n
13+
vi.mock('react-i18next', () => ({
14+
useTranslation: () => ({
15+
t: (key: string) => key,
16+
i18n: { language: 'en' },
17+
}),
18+
}));
19+
20+
describe('ResendVerification', () => {
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
let mockUtils: any;
23+
24+
beforeEach(async () => {
25+
vi.clearAllMocks();
26+
mockUtils = await import('../../utils');
27+
});
28+
29+
it('returns null when email prop is empty', () => {
30+
const { container } = render(<ResendVerification email="" />);
31+
expect(container.firstChild).toBeNull();
32+
expect(screen.queryByTestId('resend-verification')).toBeNull();
33+
});
34+
35+
it('renders resend button when email provided', () => {
36+
render(<ResendVerification email="[email protected]" />);
37+
expect(screen.getByTestId('submit-button')).toBeTruthy();
38+
});
39+
40+
it('calls API and shows success alert on 200', async () => {
41+
mockUtils.fetchClient.POST.mockResolvedValue({ response: { status: 200 } });
42+
43+
render(<ResendVerification email="[email protected]" />);
44+
const btn = screen.getByTestId('submit-button');
45+
fireEvent.click(btn);
46+
47+
await waitFor(() => {
48+
expect(mockUtils.fetchClient.POST).toHaveBeenCalledWith('/auth/resend_verification', {
49+
body: { email: '[email protected]' },
50+
headers: { 'X-Lang': 'en' },
51+
});
52+
expect(screen.getByTestId('resend-success')).toBeTruthy();
53+
// Button should disappear after success
54+
expect(screen.queryByTestId('submit-button')).toBeNull();
55+
});
56+
});
57+
58+
it('calls API and shows error alert on non-200', async () => {
59+
mockUtils.fetchClient.POST.mockResolvedValue({ response: { status: 500 } });
60+
61+
render(<ResendVerification email="[email protected]" />);
62+
const btn = screen.getByTestId('submit-button');
63+
fireEvent.click(btn);
64+
65+
await waitFor(() => {
66+
expect(mockUtils.fetchClient.POST).toHaveBeenCalledTimes(1);
67+
expect(screen.getByTestId('resend-error')).toBeTruthy();
68+
// Button still present (still can retry) since done=false
69+
expect(screen.getByTestId('submit-button')).toBeTruthy();
70+
});
71+
});
72+
73+
it('shows spinner and prevents double submission while sending', async () => {
74+
let resolvePromise: (value: unknown) => void;
75+
const pending = new Promise(resolve => { resolvePromise = resolve; });
76+
mockUtils.fetchClient.POST.mockReturnValue(pending);
77+
78+
render(<ResendVerification email="[email protected]" />);
79+
const btn = screen.getByTestId('submit-button');
80+
fireEvent.click(btn);
81+
// Immediate second click attempt
82+
fireEvent.click(btn);
83+
84+
expect(mockUtils.fetchClient.POST).toHaveBeenCalledTimes(1);
85+
86+
// Spinner should be visible while pending (mocked Spinner renders 'Loading...')
87+
expect(screen.getByText('Loading...')).toBeTruthy();
88+
89+
// Finish request with non-success to keep button
90+
// @ts-expect-error resolvePromise defined above
91+
resolvePromise({ response: { status: 500 } });
92+
93+
await waitFor(() => {
94+
expect(screen.getByTestId('resend-error')).toBeTruthy();
95+
});
96+
});
97+
});

frontend/src/locales/de.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ export const de ={
114114
"save_recovery_data_text": "Da die Zugangsdaten für die Geräte nur mithilfe des korrekten Passworts entschlüsselt werden können brauchst du, falls du dein Passwort vergessen solltest, diese Datei um den Zugang zu deinen Geräten wiederherzustellen. Bewahre diese Datei sicher und für niemanden sonst zugänglich auf, da sie mit deinem Passwort gleichzustellen ist.",
115115
"close": "Schließen",
116116
"registration_successful": "Die Registrierung war erfolgreich. Du solltest innerhalb der nächsten paar Minuten eine Email mit einem Bestätigungslink erhalten"
117+
,"resend_verification": "Bestätigungs-E-Mail erneut senden"
118+
,"resend_success": "Bestätigungs-E-Mail gesendet (falls noch nicht bestätigt)."
119+
,"resend_error": "Bestätigungs-E-Mail konnte nicht gesendet werden. Bitte versuche es später erneut."
117120
},
118121
"login": {
119122
"password_recovery": "Passwort zurücksetzen",

frontend/src/locales/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ export const en = {
114114
"save_recovery_data_text": "Since the access data for the devices can only be decrypted with the correct password, you need this file to restore access to your devices if you forget your password. Keep this file safe and inaccessible to others, as it is equivalent to your password.",
115115
"close": "Close",
116116
"registration_successful": "Registration was successful. You should receive an email with a confirmation link within the next few minutes."
117+
,"resend_verification": "Resend verification email"
118+
,"resend_success": "Verification email sent (if the address is not yet verified)."
119+
,"resend_error": "Could not resend verification email. Please try again later."
117120
},
118121
"login": {
119122
"password_recovery": "Password reset",

frontend/src/schema.d.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,23 @@ export interface paths {
139139
patch?: never;
140140
trace?: never;
141141
};
142+
"/auth/resend_verification": {
143+
parameters: {
144+
query?: never;
145+
header?: never;
146+
path?: never;
147+
cookie?: never;
148+
};
149+
get?: never;
150+
put?: never;
151+
/** Resend a verification email if user exists and not verified yet. */
152+
post: operations["resend_verification"];
153+
delete?: never;
154+
options?: never;
155+
head?: never;
156+
patch?: never;
157+
trace?: never;
158+
};
142159
"/auth/start_recovery": {
143160
parameters: {
144161
query?: never;
@@ -663,6 +680,9 @@ export interface components {
663680
secret_nonce: number[];
664681
secret_salt: number[];
665682
};
683+
ResendSchema: {
684+
email: string;
685+
};
666686
ResponseAuthorizationToken: {
667687
/** Format: int64 */
668688
created_at: number;
@@ -682,8 +702,9 @@ export interface components {
682702
SendChargelogSchema: {
683703
chargelog: number[];
684704
charger_uuid: string;
705+
filename: string;
685706
password: string;
686-
user_email: string;
707+
user_uuid: string;
687708
};
688709
/** @enum {string} */
689710
TokenType: "Recovery" | "Verification";
@@ -949,6 +970,35 @@ export interface operations {
949970
};
950971
};
951972
};
973+
resend_verification: {
974+
parameters: {
975+
query?: never;
976+
header?: never;
977+
path?: never;
978+
cookie?: never;
979+
};
980+
requestBody: {
981+
content: {
982+
"application/json": components["schemas"]["ResendSchema"];
983+
};
984+
};
985+
responses: {
986+
/** @description Verification email resent (or already verified but hidden). */
987+
200: {
988+
headers: {
989+
[name: string]: unknown;
990+
};
991+
content?: never;
992+
};
993+
/** @description User not found */
994+
404: {
995+
headers: {
996+
[name: string]: unknown;
997+
};
998+
content?: never;
999+
};
1000+
};
1001+
};
9521002
start_recovery: {
9531003
parameters: {
9541004
query: {

0 commit comments

Comments
 (0)