fix: ask to sign in again when a password change ends the session

This commit is contained in:
Linus Rath
2026-10-31 13:42:37 +01:00
parent d00605492a
commit 149e5bcee5
4 changed files with 85 additions and 9 deletions
+2
View File
@@ -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."
},
@@ -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 (
@@ -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.' });
});
});
@@ -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 };
}