Skip to content

Commit e489c7a

Browse files
committed
frontend/Recovery: Warn user about losing devices
Add a warning when the user tries to reset their password without providing a recovery file, informing them that they will lose access to all their devices and will need to re-add them manually. part of #255
1 parent 89e6d69 commit e489c7a

File tree

4 files changed

+160
-16
lines changed

4 files changed

+160
-16
lines changed

frontend/src/locales/de.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,17 @@ export const de ={
4747
"recovery": {
4848
"recovery": "Passwort zurücksetzen",
4949
"new_password": "Neues Passwort",
50+
"confirm_password": "Passwort bestätigen",
51+
"confirm_password_error_message": "Passwörter stimmen nicht überein",
5052
"recovery_file": "Wiederhestellungsdatei",
5153
"submit": "Abschicken",
5254
"invalid_file": "Datei ist beschädigt oder falsch",
53-
"token_expired": "Das Token ist abgelaufen. Bitte starte den Vorgang erneut."
55+
"token_expired": "Das Token ist abgelaufen. Bitte starte den Vorgang erneut.",
56+
"no_file_warning_heading": "Bist du sicher?",
57+
"no_file_warning_body": "Du hast keine Wiederherstellungsdatei angegeben. Wenn du fortfährst, wird ein neues Geräte-Secret erzeugt. Dadurch verlierst du den Zugriff auf alle deine Geräte und musst sie manuell erneut hinzufügen. Wenn du noch Zugriff auf deine Wiederherstellungsdatei hast, empfehlen wir dringend, diese zu verwenden.",
58+
"no_file_warning_cancel": "Abbrechen",
59+
"no_file_warning_proceed": "Ohne Datei fortfahren",
60+
"no_file_warning_ack": "Ich verstehe, dass das Fortfahren ohne Wiederherstellungsdatei meine Geräte trennt und ich sie erneut hinzufügen muss."
5461
},
5562
"chargers": {
5663
"charger_name": "Name",

frontend/src/locales/en.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,17 @@ export const en = {
4747
"recovery": {
4848
"recovery": "Password recovery",
4949
"new_password": "New password",
50+
"confirm_password": "Confirm password",
51+
"confirm_password_error_message": "Passwords do not match",
5052
"recovery_file": "Recovery File",
5153
"submit": "Submit",
5254
"invalid_file": "File is invalid",
53-
"token_expired": "The token has expired. Please start the process again."
55+
"token_expired": "The token has expired. Please start the process again.",
56+
"no_file_warning_heading": "Are you sure?",
57+
"no_file_warning_body": "You did not provide a recovery file. If you continue, we will generate a new device secret. This will cause you to lose access to all devices that are currently linked to your account and you will have to re-add them manually. If you still have access to your recovery file, we strongly recommend using it instead.",
58+
"no_file_warning_cancel": "Cancel",
59+
"no_file_warning_proceed": "Continue without file",
60+
"no_file_warning_ack": "I understand that continuing without a recovery file will disconnect my devices and require re-adding them."
5461
},
5562
"chargers": {
5663
"charger_name": "Name",

frontend/src/pages/Recovery.tsx

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Base64 } from "js-base64";
2-
import { Button, Card, Form } from "react-bootstrap";
2+
import { Button, Card, Form, Modal } from "react-bootstrap";
33
import { AppState, PASSWORD_PATTERN, concat_salts, fetchClient, generate_hash, generate_random_bytes, get_salt, loggedIn } from "../utils";
44
import { crypto_box_keypair, crypto_secretbox_KEYBYTES, crypto_secretbox_NONCEBYTES, crypto_secretbox_easy } from "libsodium-wrappers";
55
import { showAlert } from "../components/Alert";
@@ -36,42 +36,47 @@ export function Recovery() {
3636
}, [])
3737

3838
const [state, setState] = useState({
39-
recovery_key: query.token,
40-
email: query.email,
39+
recovery_key: query.token as string,
40+
email: query.email as string,
4141
new_password: "",
42+
confirm_password: "",
4243
passwordValid: true,
44+
confirmPasswordValid: true,
4345
fileValid: true,
4446
validated: false,
4547
});
48+
4649
const secret = useSignal<Uint8Array>(new Uint8Array());
4750
const showModal = useSignal(false);
51+
const showNoFileWarning = useSignal(false);
52+
const acknowledgeNoFile = useSignal(false);
4853

4954
const validateForm = () => {
5055
let ret = true;
5156
let passworValid = true;
57+
let confirmValid = true;
5258

5359
if (!PASSWORD_PATTERN.test(state.new_password)) {
5460
passworValid = false;
5561
ret = false;
5662
}
5763

64+
if (state.confirm_password !== state.new_password) {
65+
confirmValid = false;
66+
ret = false;
67+
}
68+
69+
// If user touched file input and it became invalid, block submit.
5870
if (!state.fileValid) {
5971
ret = false;
6072
}
6173

62-
setState({...state, validated: true, passwordValid: passworValid});
74+
setState({...state, validated: true, passwordValid: passworValid, confirmPasswordValid: confirmValid});
6375

6476
return ret;
6577
}
6678

67-
const onSubmit = async (e: SubmitEvent) => {
68-
e.preventDefault();
69-
70-
if (!validateForm()) {
71-
e.stopPropagation();
72-
return;
73-
}
74-
79+
const executeRecovery = async () => {
7580
const salt1 = await get_salt();
7681
const secret_salt = concat_salts(salt1);
7782
const secret_key = await generate_hash(state.new_password, secret_salt, crypto_secretbox_KEYBYTES);
@@ -84,7 +89,7 @@ export function Recovery() {
8489

8590
let secret_reuse: boolean;
8691
let encrypted_secret: Uint8Array;
87-
if (secret.value.length == 0) {
92+
if (secret.value.length === 0) {
8893
const key_pair = crypto_box_keypair();
8994
const new_secret = key_pair.privateKey;
9095
secret.value = new Uint8Array(new_secret);
@@ -114,8 +119,52 @@ export function Recovery() {
114119
}
115120
}
116121

122+
const onSubmit = async (e: SubmitEvent) => {
123+
e.preventDefault();
124+
125+
if (!validateForm()) {
126+
e.stopPropagation();
127+
return;
128+
}
129+
130+
// If no recovery file was provided, ask for explicit confirmation first.
131+
if (secret.value.length === 0) {
132+
showNoFileWarning.value = true;
133+
return;
134+
}
135+
136+
await executeRecovery();
137+
}
138+
117139
return <>
118-
<RecoveryDataComponent email={state.email} secret={secret.value as Uint8Array} show={showModal} />
140+
{/* Strong confirmation if no recovery file is provided */}
141+
<Modal show={showNoFileWarning.value} onHide={() => { showNoFileWarning.value = false; }} centered>
142+
<Modal.Header closeButton>
143+
<Modal.Title>
144+
{t("recovery.no_file_warning_heading")}
145+
</Modal.Title>
146+
</Modal.Header>
147+
<Modal.Body>
148+
<p>{t("recovery.no_file_warning_body")}</p>
149+
<Form.Check
150+
type="checkbox"
151+
id="no-file-ack"
152+
label={t("recovery.no_file_warning_ack")}
153+
checked={acknowledgeNoFile.value}
154+
onChange={(e: any) => { acknowledgeNoFile.value = e.target.checked; }}
155+
/>
156+
</Modal.Body>
157+
<Modal.Footer>
158+
<Button variant="outline-secondary" onClick={() => { showNoFileWarning.value = false; }}>
159+
{t("recovery.no_file_warning_cancel")}
160+
</Button>
161+
<Button variant="danger" disabled={!acknowledgeNoFile.value} onClick={async () => { showNoFileWarning.value = false; acknowledgeNoFile.value = false; await executeRecovery(); }}>
162+
{t("recovery.no_file_warning_proceed")}
163+
</Button>
164+
</Modal.Footer>
165+
</Modal>
166+
167+
<RecoveryDataComponent email={state.email} secret={secret.value as Uint8Array} show={showModal} />
119168

120169
<Card className="p-0 col-10 col-lg-5 col-xl-3">
121170
<Form onSubmit={(e: SubmitEvent) => onSubmit(e)} noValidate>
@@ -133,6 +182,14 @@ export function Recovery() {
133182
setState({...state, new_password: e});
134183
}} />
135184
</Form.Group>
185+
<Form.Group className="mb-3" controlId="confirmPassword">
186+
<Form.Label>
187+
{t("recovery.confirm_password")}
188+
</Form.Label>
189+
<PasswordComponent isInvalid={!state.confirmPasswordValid} invalidMessage={t("recovery.confirm_password_error_message")} onChange={(e) => {
190+
setState({...state, confirm_password: e});
191+
}} />
192+
</Form.Group>
136193
<Form.Group className="mb-3" controlId="recoveryFile">
137194
<Form.Label>
138195
{t("recovery.recovery_file")}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { render, screen, fireEvent, waitFor } from '@testing-library/preact';
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
4+
vi.mock('../../components/Alert', async () => {
5+
return {
6+
showAlert: vi.fn(),
7+
};
8+
});
9+
10+
vi.mock('../../components/RecoveryDataComponent', async () => {
11+
return {
12+
RecoveryDataComponent: () => null,
13+
};
14+
});
15+
16+
vi.mock('preact-iso', async () => ({
17+
useLocation: () => ({ route: vi.fn(), query: { token: 'tok', email: '[email protected]' } }),
18+
}));
19+
20+
// Mock utils to avoid crypto and network
21+
vi.mock('../../utils', async () => {
22+
return {
23+
AppState: { LoggedOut: 2 },
24+
PASSWORD_PATTERN: /.+/,
25+
concat_salts: vi.fn((a: Uint8Array) => a),
26+
fetchClient: { POST: vi.fn().mockResolvedValue({ response: { status: 200 } }) },
27+
generate_hash: vi.fn(async () => new Uint8Array([1,2,3])),
28+
generate_random_bytes: vi.fn(() => new Uint8Array([4,5,6])),
29+
get_salt: vi.fn(async () => new Uint8Array([7,8,9])),
30+
loggedIn: { value: 0 },
31+
};
32+
});
33+
34+
vi.mock('libsodium-wrappers', async () => ({
35+
crypto_box_keypair: () => ({ publicKey: new Uint8Array([1]), privateKey: new Uint8Array([2,3,4]) }),
36+
crypto_secretbox_KEYBYTES: 32,
37+
crypto_secretbox_NONCEBYTES: 24,
38+
crypto_secretbox_easy: vi.fn(() => new Uint8Array([9,9,9]))
39+
}));
40+
41+
import { Recovery } from '../Recovery';
42+
43+
describe('Recovery page', () => {
44+
beforeEach(() => {
45+
vi.clearAllMocks();
46+
});
47+
48+
it('shows warning modal when submitting without recovery file', async () => {
49+
render(<Recovery />);
50+
51+
// Fill password and confirm password to satisfy validation
52+
const passInputs = screen.getAllByTestId('password-input') as HTMLInputElement[];
53+
fireEvent.change(passInputs[0], { target: { value: 'ValidPass123!' } });
54+
fireEvent.change(passInputs[1], { target: { value: 'ValidPass123!' } });
55+
56+
const form = screen.getByTestId('form');
57+
fireEvent.submit(form);
58+
59+
// Modal should appear with heading and proceed button
60+
await waitFor(() => {
61+
expect(screen.getByText('recovery.no_file_warning_heading')).toBeTruthy();
62+
expect(screen.getByText('recovery.no_file_warning_proceed')).toBeTruthy();
63+
});
64+
65+
const proceed = screen.getByText('recovery.no_file_warning_proceed') as HTMLButtonElement;
66+
expect(proceed.disabled).toBe(true);
67+
68+
// Check the acknowledgment checkbox to enable proceed
69+
const ack = screen.getByLabelText('recovery.no_file_warning_ack');
70+
fireEvent.click(ack);
71+
expect(proceed.disabled).toBe(false);
72+
});
73+
});

0 commit comments

Comments
 (0)