diff --git a/locales/rn/en.json b/locales/rn/en.json index 727d039c..5bad7fe9 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1490,6 +1490,8 @@ "description": "Change the password for this account.", "error": "Failed to change password.", "mismatch": "Passwords do not match.", + "session_ended": "Changing the password ended this session. Sign in again with your new password.", + "session_ended_title": "Sign in again", "toggle_visibility": "Show or hide password", "too_short": "New password must be at least 8 characters." }, diff --git a/src/components/settings/AccountSecuritySettings.tsx b/src/components/settings/AccountSecuritySettings.tsx index 88ee9544..7c2d4185 100644 --- a/src/components/settings/AccountSecuritySettings.tsx +++ b/src/components/settings/AccountSecuritySettings.tsx @@ -18,6 +18,8 @@ import { pickClientCertAlias, } from '../../lib/client-cert'; import { useLocaleStore } from '../../stores/locale-store'; +import { useAuthStore } from '../../stores/auth-store'; +import { passwordChangeOutcome } from './password-change'; import { AccountSecurityForbiddenError, isLimitedStalwartSignIn, @@ -155,21 +157,45 @@ function PasswordChangeSection({ otpEnabled }: { otpEnabled: boolean }) { if (newPwd.length < 8) { setError(t('settings.security.password.too_short', "New password must be at least 8 characters.")); return; } if (newPwd !== confirmPwd) { setError(t('settings.security.password.mismatch', "Passwords do not match.")); return; } setSaving(true); + const tokenSignIn = jmapClient.usesBearerAuth; + let failure: unknown = null; try { - const tokenSignIn = jmapClient.usesBearerAuth; await changePassword(currentPwd, newPwd, otpEnabled ? otpCode : undefined); - setCurrentPwd(''); setOtpCode(''); setNewPwd(''); setConfirmPwd(''); - Alert.alert( - t('settings.security.password.changed_title', "Password changed"), - tokenSignIn - ? t('settings.security.password.changed_sign_in_again', "Your account password was updated. Sign in again with the new password.") - : t('settings.security.password.changed', "Your account password was updated."), - ); } catch (err) { - setError(err instanceof Error ? err.message : t('settings.security.password.error', "Failed to change password.")); + failure = err ?? new Error('Failed to change password'); } finally { setSaving(false); } + const outcome = passwordChangeOutcome(failure, tokenSignIn); + if (outcome.kind === 'error') { + setError(outcome.message || t('settings.security.password.error', "Failed to change password.")); + return; + } + setCurrentPwd(''); setOtpCode(''); setNewPwd(''); setConfirmPwd(''); + if (outcome.kind === 'changed') { + Alert.alert( + t('settings.security.password.changed_title', "Password changed"), + t('settings.security.password.changed', "Your account password was updated."), + ); + return; + } + // The change ended the session (see password-change): say so, then open + // the sign-in to this account, filled in, instead of letting the next + // request fail with a generic error. + const accountId = useAuthStore.getState().activeAccountId; + Alert.alert( + outcome.changed + ? t('settings.security.password.changed_title', "Password changed") + : t('settings.security.password.session_ended_title', "Sign in again"), + outcome.changed + ? t('settings.security.password.changed_sign_in_again', "Your account password was updated. Sign in again with the new password.") + : t('settings.security.password.session_ended', "Changing the password ended this session. Sign in again with your new password."), + [{ + text: t('login.mobile.continue', 'Continue'), + onPress: () => { if (accountId) void useAuthStore.getState().requestReauth(accountId); }, + }], + { cancelable: false }, + ); }; return ( diff --git a/src/components/settings/__tests__/password-change.test.ts b/src/components/settings/__tests__/password-change.test.ts new file mode 100644 index 00000000..73088cca --- /dev/null +++ b/src/components/settings/__tests__/password-change.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { AuthenticationError } from '../../../api/jmap-client'; +import { passwordChangeOutcome } from '../password-change'; + +// A password change on Stalwart revokes the session's tokens (0.16 with +// TOTP: the sign-in runs on OAuth tokens). The page says "sign in again" +// instead of showing the 401 as a failed change. + +describe('passwordChangeOutcome', () => { + it('asks a token session to sign in again after the change', () => { + expect(passwordChangeOutcome(null, true)).toEqual({ kind: 'sign-in-again', changed: true }); + expect(passwordChangeOutcome(null, false)).toEqual({ kind: 'changed' }); + }); + + it('takes a 401 on the change for the session it ended, not a failure', () => { + expect(passwordChangeOutcome(new AuthenticationError('Session expired'), true)) + .toEqual({ kind: 'sign-in-again', changed: false }); + }); + + it('shows what the server refused', () => { + expect(passwordChangeOutcome(new Error('Current secret is incorrect.'), true)) + .toEqual({ kind: 'error', message: 'Current secret is incorrect.' }); + }); +}); diff --git a/src/components/settings/password-change.ts b/src/components/settings/password-change.ts new file mode 100644 index 00000000..2580b0b1 --- /dev/null +++ b/src/components/settings/password-change.ts @@ -0,0 +1,24 @@ +/** + * What the Security page says after a password change. Stalwart revokes the + * account's tokens with the old password, refresh token included (webmail + * #1432, checked on 0.16.27), so a session on tokens (a password plus TOTP + * sign-in, OAuth) ends with the change. That is told as "sign in again with + * the new password", and the app opens the sign-in to the account. A 401 on + * the change itself is the same ending seen from the other side: the server + * dropped the session as it changed the password. Anything else is an error + * the form shows. + */ +export type PasswordChangeOutcome = + | { kind: 'changed' } + | { kind: 'sign-in-again'; changed: boolean } + | { kind: 'error'; message: string | null }; + +export function passwordChangeOutcome(error: unknown, tokenSignIn: boolean): PasswordChangeOutcome { + if (error === null || error === undefined) { + return tokenSignIn ? { kind: 'sign-in-again', changed: true } : { kind: 'changed' }; + } + if (error instanceof Error && error.name === 'AuthenticationError') { + return { kind: 'sign-in-again', changed: false }; + } + return { kind: 'error', message: error instanceof Error ? error.message : null }; +}