mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
fix: ask to sign in again when a password change ends the session
This commit is contained in:
@@ -1490,6 +1490,8 @@
|
|||||||
"description": "Change the password for this account.",
|
"description": "Change the password for this account.",
|
||||||
"error": "Failed to change password.",
|
"error": "Failed to change password.",
|
||||||
"mismatch": "Passwords do not match.",
|
"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",
|
"toggle_visibility": "Show or hide password",
|
||||||
"too_short": "New password must be at least 8 characters."
|
"too_short": "New password must be at least 8 characters."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
pickClientCertAlias,
|
pickClientCertAlias,
|
||||||
} from '../../lib/client-cert';
|
} from '../../lib/client-cert';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { useAuthStore } from '../../stores/auth-store';
|
||||||
|
import { passwordChangeOutcome } from './password-change';
|
||||||
import {
|
import {
|
||||||
AccountSecurityForbiddenError,
|
AccountSecurityForbiddenError,
|
||||||
isLimitedStalwartSignIn,
|
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.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; }
|
if (newPwd !== confirmPwd) { setError(t('settings.security.password.mismatch', "Passwords do not match.")); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
|
||||||
const tokenSignIn = jmapClient.usesBearerAuth;
|
const tokenSignIn = jmapClient.usesBearerAuth;
|
||||||
|
let failure: unknown = null;
|
||||||
|
try {
|
||||||
await changePassword(currentPwd, newPwd, otpEnabled ? otpCode : undefined);
|
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) {
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
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 (
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user