diff --git a/src/components/TwoFactorCard.js b/src/components/TwoFactorCard.js
index 24540d2..366abcf 100644
--- a/src/components/TwoFactorCard.js
+++ b/src/components/TwoFactorCard.js
@@ -179,7 +179,8 @@ export default function TwoFactorCard({
setErrCode(null);
setSuccess(null);
try {
- await authService.disableTotp(code);
+ const oldAuthHash = await deriveStepUpAuthHash();
+ await authService.disableTotp({ code, oldAuthHash });
setSetup(null);
setCode('');
setCurrentPassword('');
@@ -290,21 +291,19 @@ export default function TwoFactorCard({
) : null}
- {!status.enabled ? (
-
-
- setCurrentPassword(e.target.value)}
- />
-
- ) : null}
+
+
+ setCurrentPassword(e.target.value)}
+ />
+
{setup || status.enabled ? (
diff --git a/src/components/TwoFactorCard.test.js b/src/components/TwoFactorCard.test.js
index cb27863..474359f 100644
--- a/src/components/TwoFactorCard.test.js
+++ b/src/components/TwoFactorCard.test.js
@@ -156,6 +156,81 @@ test('does not finalize TOTP enable UI without recovery codes', async () => {
).not.toBeInTheDocument();
});
+test('requires current password step-up when disabling TOTP', async () => {
+ const authService = service({
+ getTotpStatus: jest.fn().mockResolvedValue({
+ enabled: true,
+ pending: false,
+ recoveryCodesRemaining: 9,
+ }),
+ disableTotp: jest.fn().mockResolvedValue({ status: 'disabled' }),
+ });
+ renderCard(authService);
+
+ await waitFor(() => expect(authService.me).toHaveBeenCalled());
+ await waitFor(() => expect(authService.getTotpStatus).toHaveBeenCalled());
+ expect(await screen.findByText('Enabled')).toBeInTheDocument();
+ await userEvent.type(screen.getByLabelText(/current password/i), 'Correct1!');
+ await userEvent.type(screen.getByLabelText(/authenticator code/i), '123456');
+ await userEvent.click(
+ screen.getByRole('button', { name: /disable two-factor authentication/i })
+ );
+
+ await waitFor(() =>
+ expect(authService.disableTotp).toHaveBeenCalledWith({
+ code: '123456',
+ oldAuthHash: 'a'.repeat(64),
+ })
+ );
+ expect(authService.deriveStepUpAuthHash).toHaveBeenCalledWith(
+ 'Correct1!',
+ 'user@example.com'
+ );
+});
+
+test('keeps current password editable during active TOTP setup', async () => {
+ const authService = renderCard();
+
+ await waitFor(() => expect(authService.me).toHaveBeenCalled());
+ await waitFor(() => expect(authService.getTotpStatus).toHaveBeenCalled());
+ await userEvent.type(screen.getByLabelText(/current password/i), 'OldPass1!');
+ await userEvent.click(
+ screen.getByRole('button', { name: /set up two-factor authentication/i })
+ );
+ await screen.findByLabelText(/manual setup secret fallback/i);
+
+ const password = screen.getByLabelText(/current password/i);
+ expect(password).toBeInTheDocument();
+ await userEvent.clear(password);
+ await userEvent.type(password, 'NewPass1!');
+ expect(password).toHaveValue('NewPass1!');
+});
+
+test('keeps current password editable while recovery codes are displayed', async () => {
+ const authService = service({
+ enableTotp: jest.fn().mockResolvedValue({
+ recoveryCodes: ['one', 'two'],
+ }),
+ });
+ renderCard(authService);
+
+ await waitFor(() => expect(authService.me).toHaveBeenCalled());
+ await waitFor(() => expect(authService.getTotpStatus).toHaveBeenCalled());
+ await userEvent.type(screen.getByLabelText(/current password/i), 'Correct1!');
+ await userEvent.click(
+ screen.getByRole('button', { name: /set up two-factor authentication/i })
+ );
+ await screen.findByLabelText(/manual setup secret fallback/i);
+ await userEvent.type(screen.getByLabelText(/authenticator code/i), '123456');
+ await userEvent.click(screen.getByRole('button', { name: /verify and enable/i }));
+
+ expect(await screen.findByText(/save these recovery codes/i)).toBeInTheDocument();
+ const password = screen.getByLabelText(/current password/i);
+ expect(password).toBeInTheDocument();
+ await userEvent.type(password, 'DisablePass1!');
+ expect(password).toHaveValue('DisablePass1!');
+});
+
test('clears stale TOTP errors after a successful status refresh', async () => {
const authService = service({
getTotpStatus: jest
diff --git a/src/lib/authService.js b/src/lib/authService.js
index 80a4942..1b19b42 100644
--- a/src/lib/authService.js
+++ b/src/lib/authService.js
@@ -206,8 +206,11 @@ export function createAuthService(client = defaultClient) {
return res.data;
}
- async function disableTotp(code) {
- const res = await client.post('/auth/totp/disable', { code });
+ async function disableTotp({ code, oldAuthHash }) {
+ if (typeof oldAuthHash !== 'string' || oldAuthHash.length === 0) {
+ throw new Error('disableTotp: oldAuthHash required');
+ }
+ const res = await client.post('/auth/totp/disable', { code, oldAuthHash });
return res.data;
}
diff --git a/src/lib/authService.test.js b/src/lib/authService.test.js
index cfbdebb..6eb9185 100644
--- a/src/lib/authService.test.js
+++ b/src/lib/authService.test.js
@@ -375,6 +375,32 @@ describe('authService.updatePrefs', () => {
});
});
+describe('authService.disableTotp', () => {
+ const AUTH =
+ 'a4f8b3c1d9e7f2a5b1c6d8e4f7a9b2c5d1e8f4a7b3c9d5e1f6a2b8c4d7e3f5a9';
+
+ test('posts authenticator code and current-password proof', async () => {
+ const { service, adapter } = makeService();
+ let captured;
+ adapter.onPost('/auth/totp/disable').reply((config) => {
+ captured = JSON.parse(config.data);
+ return [200, { status: 'disabled' }];
+ });
+
+ await expect(
+ service.disableTotp({ code: '123456', oldAuthHash: AUTH })
+ ).resolves.toEqual({ status: 'disabled' });
+ expect(captured).toEqual({ code: '123456', oldAuthHash: AUTH });
+ });
+
+ test('rejects client-side when oldAuthHash is missing', async () => {
+ const { service } = makeService();
+ await expect(service.disableTotp({ code: '123456' })).rejects.toThrow(
+ /oldAuthHash required/
+ );
+ });
+});
+
// ---------------------------------------------------------------------------
// PR 7 — deleteAccount (GDPR right to erasure)
// ---------------------------------------------------------------------------