From 85fbab9eb426f45bcf4dbc8f06199343f0d9dcc7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:02:18 +0200 Subject: [PATCH] fix: surface server errors on password change and TOTP toggle --- .../__tests__/account-security-store.test.ts | 14 ++++++++++ stores/account-security-store.ts | 28 ++++++++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/stores/__tests__/account-security-store.test.ts b/stores/__tests__/account-security-store.test.ts index 470f4b46..3a443df2 100644 --- a/stores/__tests__/account-security-store.test.ts +++ b/stores/__tests__/account-security-store.test.ts @@ -222,6 +222,20 @@ describe('account-security-store', () => { expect(useAccountSecurityStore.getState().error).toBe('forbidden'); expect(useAccountSecurityStore.getState().isSaving).toBe(false); }); + + it('throws the server message when the set comes back as notUpdated (HTTP 200)', async () => { + mockedJmap.mockResolvedValueOnce([ + ['x:AccountPassword/set', { + notUpdated: { singleton: { type: 'invalidProperties', description: 'Current password is incorrect' } }, + }, '0'], + ]); + + await expect( + useAccountSecurityStore.getState().changePassword('wrong', 'new'), + ).rejects.toThrow('Current password is incorrect'); + expect(useAccountSecurityStore.getState().error).toBe('Current password is incorrect'); + expect(useAccountSecurityStore.getState().isSaving).toBe(false); + }); }); describe('updateDisplayName', () => { diff --git a/stores/account-security-store.ts b/stores/account-security-store.ts index 248a42f8..66db7b28 100644 --- a/stores/account-security-store.ts +++ b/stores/account-security-store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { debug } from '@/lib/debug'; import { useAuthStore } from '@/stores/auth-store'; -import { stalwartJmap, requireResult } from '@/lib/stalwart/jmap-passthrough'; +import { stalwartJmap, requireResult, type JmapMethodResponse } from '@/lib/stalwart/jmap-passthrough'; export type EncryptionType = 'Disabled' | 'Aes128' | 'Aes256'; @@ -173,6 +173,23 @@ async function removeCredential( } } +/** + * A JMAP `/set` reports per-object failures (wrong current password, weak + * password, …) inside `notUpdated` with an HTTP 200 — `stalwartJmap` does not + * throw for these. Inspect the response and surface the server's message so the + * UI doesn't report a failed change as successful. + */ +function requireAccountPasswordUpdate(responses: JmapMethodResponse[], fallbackError: string): void { + const result = requireResult<{ + updated?: Record; + notUpdated?: Record; + }>(responses, 'x:AccountPassword/set'); + const failed = result.notUpdated?.singleton; + if (failed) { + throw new Error(failed.description || failed.type || fallbackError); + } +} + function extractEncryptionType(raw: unknown): EncryptionType { if (!raw || typeof raw !== 'object') return 'Disabled'; const type = (raw as { ['@type']?: string })['@type']; @@ -344,7 +361,7 @@ export const useAccountSecurityStore = create()((set, get) set({ isSaving: true, error: null }); try { const accountId = getPrimaryAccountId(); - await stalwartJmap([ + const responses = await stalwartJmap([ [ 'x:AccountPassword/set', { @@ -354,6 +371,7 @@ export const useAccountSecurityStore = create()((set, get) '0', ], ]); + requireAccountPasswordUpdate(responses, 'Failed to change password'); set({ isSaving: false }); } catch (error) { set({ @@ -389,7 +407,7 @@ export const useAccountSecurityStore = create()((set, get) set({ isSaving: true, error: null }); try { const accountId = getPrimaryAccountId(); - await stalwartJmap([ + const responses = await stalwartJmap([ [ 'x:AccountPassword/set', { @@ -404,6 +422,7 @@ export const useAccountSecurityStore = create()((set, get) '0', ], ]); + requireAccountPasswordUpdate(responses, 'Failed to enable TOTP'); set({ otpEnabled: true, isSaving: false }); } catch (error) { set({ @@ -418,7 +437,7 @@ export const useAccountSecurityStore = create()((set, get) set({ isSaving: true, error: null }); try { const accountId = getPrimaryAccountId(); - await stalwartJmap([ + const responses = await stalwartJmap([ [ 'x:AccountPassword/set', { @@ -433,6 +452,7 @@ export const useAccountSecurityStore = create()((set, get) '0', ], ]); + requireAccountPasswordUpdate(responses, 'Failed to disable TOTP'); set({ otpEnabled: false, isSaving: false }); } catch (error) { set({