fix: surface server errors on password change and TOTP toggle

This commit is contained in:
Linus Rath
2026-06-24 18:02:18 +02:00
parent 1119d8ed73
commit 85fbab9eb4
2 changed files with 38 additions and 4 deletions
@@ -222,6 +222,20 @@ describe('account-security-store', () => {
expect(useAccountSecurityStore.getState().error).toBe('forbidden'); expect(useAccountSecurityStore.getState().error).toBe('forbidden');
expect(useAccountSecurityStore.getState().isSaving).toBe(false); 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', () => { describe('updateDisplayName', () => {
+24 -4
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { debug } from '@/lib/debug'; import { debug } from '@/lib/debug';
import { useAuthStore } from '@/stores/auth-store'; 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'; 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<string, unknown>;
notUpdated?: Record<string, { type?: string; description?: string }>;
}>(responses, 'x:AccountPassword/set');
const failed = result.notUpdated?.singleton;
if (failed) {
throw new Error(failed.description || failed.type || fallbackError);
}
}
function extractEncryptionType(raw: unknown): EncryptionType { function extractEncryptionType(raw: unknown): EncryptionType {
if (!raw || typeof raw !== 'object') return 'Disabled'; if (!raw || typeof raw !== 'object') return 'Disabled';
const type = (raw as { ['@type']?: string })['@type']; const type = (raw as { ['@type']?: string })['@type'];
@@ -344,7 +361,7 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
set({ isSaving: true, error: null }); set({ isSaving: true, error: null });
try { try {
const accountId = getPrimaryAccountId(); const accountId = getPrimaryAccountId();
await stalwartJmap([ const responses = await stalwartJmap([
[ [
'x:AccountPassword/set', 'x:AccountPassword/set',
{ {
@@ -354,6 +371,7 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
'0', '0',
], ],
]); ]);
requireAccountPasswordUpdate(responses, 'Failed to change password');
set({ isSaving: false }); set({ isSaving: false });
} catch (error) { } catch (error) {
set({ set({
@@ -389,7 +407,7 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
set({ isSaving: true, error: null }); set({ isSaving: true, error: null });
try { try {
const accountId = getPrimaryAccountId(); const accountId = getPrimaryAccountId();
await stalwartJmap([ const responses = await stalwartJmap([
[ [
'x:AccountPassword/set', 'x:AccountPassword/set',
{ {
@@ -404,6 +422,7 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
'0', '0',
], ],
]); ]);
requireAccountPasswordUpdate(responses, 'Failed to enable TOTP');
set({ otpEnabled: true, isSaving: false }); set({ otpEnabled: true, isSaving: false });
} catch (error) { } catch (error) {
set({ set({
@@ -418,7 +437,7 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
set({ isSaving: true, error: null }); set({ isSaving: true, error: null });
try { try {
const accountId = getPrimaryAccountId(); const accountId = getPrimaryAccountId();
await stalwartJmap([ const responses = await stalwartJmap([
[ [
'x:AccountPassword/set', 'x:AccountPassword/set',
{ {
@@ -433,6 +452,7 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
'0', '0',
], ],
]); ]);
requireAccountPasswordUpdate(responses, 'Failed to disable TOTP');
set({ otpEnabled: false, isSaving: false }); set({ otpEnabled: false, isSaving: false });
} catch (error) { } catch (error) {
set({ set({