fix: show password and two-factor settings after a password and TOTP sign-in #1432

This commit is contained in:
Linus Rath
2026-10-29 15:50:56 +01:00
parent d42fc053dd
commit 5eb6deb26c
33 changed files with 184 additions and 7 deletions
@@ -0,0 +1,117 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AccountSecuritySettings } from '../account-security-settings';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { useAccountSecurityStore } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
const mocks = vi.hoisted(() => ({
toastSuccess: vi.fn(),
toastError: vi.fn(),
}));
vi.mock('next-intl', () => {
const t = Object.assign((key: string) => key, { raw: (key: string) => key });
return { useTranslations: () => t, useLocale: () => 'en' };
});
vi.mock('@/hooks/use-config', () => ({
useConfig: () => ({ oauthEnabled: false }),
}));
vi.mock('@/stores/toast-store', () => ({
toast: { success: mocks.toastSuccess, error: mocks.toastError, info: vi.fn() },
}));
function signIn(authMode: AccountEntry['authMode'], providerSession: boolean | undefined) {
const entry = {
id: 'me@example.com@mail.example.com',
label: 'me@example.com',
serverUrl: 'https://mail.example.com',
username: 'me@example.com',
authMode,
cookieSlot: 0,
rememberMe: false,
providerSession,
displayName: '',
email: 'me@example.com',
avatarColor: '#000000',
lastLoginAt: 0,
isConnected: true,
hasError: false,
isDefault: true,
} as AccountEntry;
useAccountStore.setState({ accounts: [entry], activeAccountId: entry.id });
// No client keeps the probe effect quiet; the verdict is set directly.
useAuthStore.setState({ isAuthenticated: false, authMode, client: null, activeAccountId: entry.id });
}
beforeEach(() => {
vi.clearAllMocks();
useAccountSecurityStore.setState({ isStalwart: true, isProbing: false, otpEnabled: false, isSaving: false });
});
describe('AccountSecuritySettings sign-in section', () => {
it('shows password and two-factor to a password sign-in', () => {
signIn('basic', false);
render(<AccountSecuritySettings />);
expect(screen.getByText('password.title')).toBeInTheDocument();
expect(screen.getByText('totp.section_title')).toBeInTheDocument();
});
it('shows them to a password plus TOTP sign-in, which runs on OAuth tokens (#1432)', () => {
signIn('oauth', false);
render(<AccountSecuritySettings />);
expect(screen.getByText('password.title')).toBeInTheDocument();
expect(screen.getByText('totp.section_title')).toBeInTheDocument();
});
it.each([
['an identity-provider sign-in', 'oauth', true],
['a session from before providerSession was recorded', 'oauth', undefined],
['an access-token sign-in', 'token', false],
] as const)('hides them for %s', (_name, authMode, providerSession) => {
signIn(authMode, providerSession);
render(<AccountSecuritySettings />);
expect(screen.queryByText('password.title')).toBeNull();
expect(screen.queryByText('totp.section_title')).toBeNull();
expect(screen.getByText('email_client.title')).toBeInTheDocument();
});
});
describe('AccountSecuritySettings password change', () => {
function changePassword(container: HTMLElement) {
const [current, ...fresh] = [
container.querySelector('input[autocomplete="current-password"]'),
...container.querySelectorAll('input[autocomplete="new-password"]'),
] as HTMLInputElement[];
fireEvent.change(current, { target: { value: 'old-password' } });
for (const input of fresh) fireEvent.change(input, { target: { value: 'new-password-1' } });
fireEvent.click(screen.getByRole('button', { name: 'password.submit' }));
}
it('signs a token session out, since Stalwart revokes its tokens with the old password', async () => {
signIn('oauth', false);
const change = vi.fn(async () => {});
const logout = vi.fn(async () => {});
useAccountSecurityStore.setState({ changePassword: change });
useAuthStore.setState({ logout });
const { container } = render(<AccountSecuritySettings />);
changePassword(container);
await waitFor(() => expect(logout).toHaveBeenCalledWith({ endProviderSession: false }));
expect(change).toHaveBeenCalledWith('old-password', 'new-password-1', undefined);
expect(mocks.toastSuccess).toHaveBeenCalledWith('password.success', 'password.sign_in_again');
});
it('keeps a password session signed in', async () => {
signIn('basic', false);
const change = vi.fn(async () => {});
const logout = vi.fn(async () => {});
useAccountSecurityStore.setState({ changePassword: change });
useAuthStore.setState({ logout });
const { container } = render(<AccountSecuritySettings />);
changePassword(container);
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('password.success'));
expect(logout).not.toHaveBeenCalled();
});
});
@@ -17,6 +17,7 @@ import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
import { sanitizeI18nHtml } from '@/lib/email-sanitization';
import { IS_LITE } from '@/lib/lite';
import { isPasswordSession } from '@/lib/auth/password-session';
import { useConfig } from '@/hooks/use-config';
import { totpIssuer } from '@/lib/totp-issuer';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -54,6 +55,13 @@ function PasswordChangeSection() {
setOtpCode('');
setNewPassword('');
setConfirmPassword('');
// Stalwart revokes the account's tokens with the old password, refresh
// token included, so a password plus TOTP session ends here (#1432).
if (useAuthStore.getState().authMode !== 'basic') {
toast.success(t('password.success'), t('password.sign_in_again'));
void useAuthStore.getState().logout({ endProviderSession: false });
return;
}
toast.success(t('password.success'));
} catch (err) {
const msg = err instanceof Error ? err.message : t('password.error_generic');
@@ -1475,9 +1483,10 @@ export function AccountSecuritySettings() {
const t = useTranslations('settings.security');
const { isStalwart, isProbing, probe, fetchAll, fetchAuthInfo, fetchPublicKeys, fetchCryptoInfo } = useAccountSecurityStore();
const { isAuthenticated, authMode, client } = useAuthStore();
// OAuth and access-token sign-ins hold no password to change, and mail
// apps need an app password instead.
const withoutPassword = authMode === 'oauth' || authMode === 'token';
const activeAccount = useAccountStore((s) => s.getActiveAccount());
// Identity-provider and access-token sign-ins hold no password to change
// here, and mail apps need an app password instead.
const withoutPassword = !isPasswordSession(authMode, activeAccount);
// Wait for `client` before probing. On reload the persisted `isAuthenticated`
// flips true before the async OAuth reconnect sets `client`; probing in that
@@ -1541,8 +1550,9 @@ export function AccountSecuritySettings() {
return (
<div className="space-y-8">
{/* Sign-in: password and two-factor. OAuth and token sessions have
neither here, so the page opens with the apps and devices section. */}
{/* Sign-in: password and two-factor. Identity-provider and token
sessions have neither here, so the page opens with the apps and
devices section. */}
{!withoutPassword && (
<SettingsSection id="signin" title={t('title')} description={t('description')}>
<div className="space-y-6">
+5 -2
View File
@@ -16,6 +16,7 @@ import { getMaxAccounts } from '@/lib/account-utils';
import { formatFileSize, cn } from '@/lib/utils';
import { toUnicodeDomain, toUnicodeEmail } from '@/lib/idn';
import { IS_LITE } from '@/lib/lite';
import { isPasswordSession } from '@/lib/auth/password-session';
import { usePolicyStore } from '@/stores/policy-store';
import { AccountArchiveSettings } from './account-archive-settings';
import { DisplayNameSection } from './account-security-settings';
@@ -48,9 +49,11 @@ export function AccountSettings() {
const archiveEnabled = usePolicyStore((s) => s.isFeatureEnabled('accountArchiveEnabled')) && !IS_LITE && !isDemoMode;
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
// The display name is editable on Stalwart (the principal's name) for
// password sign-ins; OAuth and token sessions cannot manage the principal.
// password sign-ins; identity-provider and token sessions cannot manage
// the principal.
const { isStalwart, probe, fetchPrincipal } = useAccountSecurityStore();
const canEditDisplayName = isStalwart === true && authMode !== 'oauth' && authMode !== 'token';
const activeAccount = useAccountStore((s) => (activeAccountId ? s.getAccountById(activeAccountId) : undefined));
const canEditDisplayName = isStalwart === true && isPasswordSession(authMode, activeAccount);
// Wait for `client` before probing, as the Security page does: on reload
// the persisted session flips authenticated before the client reconnects.
useEffect(() => {
+18
View File
@@ -0,0 +1,18 @@
import type { AccountEntry } from '@/stores/account-store';
/**
* The session was signed in with the account's own password, so the server
* holds a password (and maybe TOTP) the user can manage here. A password plus
* TOTP login runs on OAuth tokens (authMode 'oauth') but is still one of them
* (#1432). A sign-in through the identity provider may come from an outside
* IdP that holds the password instead, and a pasted access token says nothing
* about how the account signs in. Accounts signed in before `providerSession`
* was recorded count as provider sign-ins.
*/
export function isPasswordSession(
authMode: AccountEntry['authMode'] | null | undefined,
account: Pick<AccountEntry, 'providerSession'> | null | undefined,
): boolean {
if (authMode === 'basic') return true;
return authMode === 'oauth' && account?.providerSession === false;
}
+1
View File
@@ -1980,6 +1980,7 @@
"confirm": "تأكيد كلمة المرور الجديدة",
"submit": "تغيير كلمة المرور",
"success": "تم تغيير كلمة المرور بنجاح",
"sign_in_again": "سجّل الدخول مرة أخرى باستخدام كلمة المرور الجديدة.",
"error_title": "فشل تغيير كلمة المرور",
"error_mismatch": "كلمتا المرور الجديدتان غير متطابقتين",
"error_min_length": "يجب أن تتكون كلمة المرور من 8 أحرف على الأقل",
+1
View File
@@ -1980,6 +1980,7 @@
"confirm": "Confirma la contrasenya nova",
"submit": "Canvia la contrasenya",
"success": "Contrasenya canviada correctament",
"sign_in_again": "Torna a iniciar la sessió amb la contrasenya nova.",
"error_title": "No s'ha pogut canviar la contrasenya",
"error_mismatch": "Les contrasenyes noves no coincideixen",
"error_min_length": "La contrasenya ha de tenir com a mínim 8 caràcters",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Potvrdit nové heslo",
"submit": "Změnit heslo",
"success": "Heslo bylo změněno",
"sign_in_again": "Přihlaste se znovu pomocí nového hesla.",
"error_title": "Změna hesla selhala",
"error_mismatch": "Nová hesla se neshodují",
"error_min_length": "Heslo musí obsahovat alespoň 8 znaků",
+1
View File
@@ -1976,6 +1976,7 @@
"confirm": "Bekræft ny adgangskode",
"submit": "Skift adgangskode",
"success": "Adgangskode ændret succesfuldt",
"sign_in_again": "Log ind igen med din nye adgangskode.",
"error_title": "Adgangskodeændring mislykkedes",
"error_mismatch": "De nye adgangskoder er ikke ens",
"error_min_length": "Adgangskoden skal være mindst 8 tegn",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Neues Passwort bestätigen",
"submit": "Passwort ändern",
"success": "Passwort erfolgreich geändert",
"sign_in_again": "Melden Sie sich mit Ihrem neuen Passwort erneut an.",
"error_title": "Passwortänderung fehlgeschlagen",
"error_mismatch": "Neue Passwörter stimmen nicht überein",
"error_min_length": "Das Passwort muss mindestens 8 Zeichen lang sein",
+1
View File
@@ -1980,6 +1980,7 @@
"confirm": "Confirm new password",
"submit": "Change password",
"success": "Password changed successfully",
"sign_in_again": "Sign in again with your new password.",
"error_title": "Password change failed",
"error_mismatch": "New passwords do not match",
"error_min_length": "Password must be at least 8 characters",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Confirmar nueva contraseña",
"submit": "Cambiar contraseña",
"success": "Contraseña cambiada exitosamente",
"sign_in_again": "Vuelve a iniciar sesión con tu nueva contraseña.",
"error_title": "Error al cambiar la contraseña",
"error_mismatch": "Las nuevas contraseñas no coinciden",
"error_min_length": "La contraseña debe tener al menos 8 caracteres",
+1
View File
@@ -1980,6 +1980,7 @@
"confirm": "Kinnita uus parool",
"submit": "Muuda parooli",
"success": "Parool muudeti edukalt",
"sign_in_again": "Logi uuesti sisse oma uue parooliga.",
"error_title": "Parooli muutmine ebaõnnestus",
"error_mismatch": "Uued paroolid ei kattu",
"error_min_length": "Parool peab olema vähemalt 8 tähemärki pikk",
+1
View File
@@ -1980,6 +1980,7 @@
"confirm": "تأیید رمز جدید",
"submit": "تغییر رمز",
"success": "رمز تغییر کرد",
"sign_in_again": "با گذرواژه جدید دوباره وارد شوید.",
"error_title": "خطا",
"error_mismatch": "مطابقت ندارد",
"error_min_length": "حداقل ۸ کاراکتر",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Confirmer le nouveau mot de passe",
"submit": "Changer le mot de passe",
"success": "Mot de passe changé avec succès",
"sign_in_again": "Reconnectez-vous avec votre nouveau mot de passe.",
"error_title": "Échec du changement de mot de passe",
"error_mismatch": "Les nouveaux mots de passe ne correspondent pas",
"error_min_length": "Le mot de passe doit comporter au moins 8 caractères",
+1
View File
@@ -1938,6 +1938,7 @@
"confirm": "אשר סיסמה חדשה",
"submit": "שנה סיסמה",
"success": "הסיסמה שונתה בהצלחה",
"sign_in_again": "יש להתחבר שוב עם הסיסמה החדשה.",
"error_title": "שינוי הסיסמה נכשל",
"error_mismatch": "סיסמאות חדשות אינן תואמות",
"error_min_length": "הסיסמה חייבת להיות לפחות 8 תווים",
+1
View File
@@ -1976,6 +1976,7 @@
"confirm": "Új jelszó megerősítése",
"submit": "Jelszó módosítása",
"success": "Jelszó sikeresen módosítva",
"sign_in_again": "Jelentkezz be újra az új jelszavaddal.",
"error_title": "Jelszó módosítás sikertelen",
"error_mismatch": "Az új jelszavak nem egyeznek",
"error_min_length": "A jelszónak legalább 8 karakter hosszúnak kell lennie",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Conferma nuova password",
"submit": "Cambia password",
"success": "Password cambiata con successo",
"sign_in_again": "Accedi di nuovo con la nuova password.",
"error_title": "Cambio password fallito",
"error_mismatch": "Le nuove password non corrispondono",
"error_min_length": "La password deve contenere almeno 8 caratteri",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "新しいパスワードの確認",
"submit": "パスワードを変更",
"success": "パスワードが正常に変更されました",
"sign_in_again": "新しいパスワードでもう一度サインインしてください。",
"error_title": "パスワードの変更に失敗しました",
"error_mismatch": "新しいパスワードが一致しません",
"error_min_length": "パスワードは8文字以上である必要があります",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "새 비밀번호 확인",
"submit": "비밀번호 변경",
"success": "비밀번호가 성공적으로 변경되었어요",
"sign_in_again": "새 비밀번호로 다시 로그인하세요.",
"error_title": "비밀번호 변경 실패",
"error_mismatch": "새 비밀번호가 서로 일치하지 않아요",
"error_min_length": "비밀번호는 최소 8자 이상이어야 해요",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Apstipriniet jauno paroli",
"submit": "Mainīt paroli",
"success": "Parole veiksmīgi mainīta",
"sign_in_again": "Piesakieties vēlreiz ar savu jauno paroli.",
"error_title": "Neizdevās mainīt paroli",
"error_mismatch": "Jaunās paroles nesakrīt",
"error_min_length": "Parolei jābūt vismaz 8 rakstzīmes garai",
+1
View File
@@ -1980,6 +1980,7 @@
"confirm": "Шинэ нууц үгээ баталгаажуулах",
"submit": "Нууц үг солих",
"success": "Нууц үг амжилттай өөрчлөгдсөн",
"sign_in_again": "Шинэ нууц үгээрээ дахин нэвтэрнэ үү.",
"error_title": "Нууц үг солигдож чадсангүй",
"error_mismatch": "Шинэ нууц үг таарахгүй байна",
"error_min_length": "Нууц үг хамгийн багадаа 8 тэмдэгт байх ёстой",
+1
View File
@@ -1980,6 +1980,7 @@
"confirm": "Bekreft nytt passord",
"submit": "Endre passord",
"success": "Passordet ble endret",
"sign_in_again": "Logg inn på nytt med det nye passordet ditt.",
"error_title": "Passordendring mislyktes",
"error_mismatch": "De nye passordene er ikke like",
"error_min_length": "Passordet må inneholde minst 8 tegn",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Bevestig nieuw wachtwoord",
"submit": "Wachtwoord wijzigen",
"success": "Wachtwoord succesvol gewijzigd",
"sign_in_again": "Meld je opnieuw aan met je nieuwe wachtwoord.",
"error_title": "Wachtwoord wijzigen mislukt",
"error_mismatch": "Nieuwe wachtwoorden komen niet overeen",
"error_min_length": "Wachtwoord moet minimaal 8 tekens bevatten",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Potwierdź nowe hasło",
"submit": "Zmień hasło",
"success": "Hasło zostało zmienione",
"sign_in_again": "Zaloguj się ponownie, używając nowego hasła.",
"error_title": "Zmiana hasła nie powiodła się",
"error_mismatch": "Nowe hasła nie są zgodne",
"error_min_length": "Hasło musi mieć co najmniej 8 znaków",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Confirmar nova senha",
"submit": "Alterar senha",
"success": "Senha alterada com sucesso",
"sign_in_again": "Inicie sessão novamente com a sua nova palavra-passe.",
"error_title": "Falha ao alterar a senha",
"error_mismatch": "As novas senhas não coincidem",
"error_min_length": "A senha deve ter pelo menos 8 caracteres",
+1
View File
@@ -1980,6 +1980,7 @@
"confirm": "Confirmă noua parolă",
"submit": "Schimbă parola",
"success": "Parola a fost modificată cu succes",
"sign_in_again": "Conectează-te din nou cu parola nouă.",
"error_title": "Schimbarea parolei a eșuat",
"error_mismatch": "Noile parole nu se potrivesc",
"error_min_length": "Parola trebuie să conțină cel puțin 8 caractere",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Подтвердите новый пароль",
"submit": "Изменить пароль",
"success": "Пароль успешно изменён",
"sign_in_again": "Войдите снова с новым паролем.",
"error_title": "Не удалось изменить пароль",
"error_mismatch": "Новые пароли не совпадают",
"error_min_length": "Пароль должен содержать не менее 8 символов",
+1
View File
@@ -1980,6 +1980,7 @@
"confirm": "Potvrdiť nové heslo",
"submit": "Zmeniť heslo",
"success": "Heslo bolo zmenené",
"sign_in_again": "Prihláste sa znova pomocou nového hesla.",
"error_title": "Zmena hesla zlyhala",
"error_mismatch": "Nové heslá sa nezhodujú",
"error_min_length": "Heslo musí mať aspoň 8 znakov",
+1
View File
@@ -4506,6 +4506,7 @@
"confirm": "ยืนยันรหัสผ่านใหม่",
"submit": "เปลี่ยนรหัสผ่าน",
"success": "เปลี่ยนรหัสผ่านสำเร็จแล้ว",
"sign_in_again": "โปรดลงชื่อเข้าใช้อีกครั้งด้วยรหัสผ่านใหม่",
"error_title": "เปลี่ยนรหัสผ่านไม่สำเร็จ",
"error_mismatch": "รหัสผ่านใหม่ไม่ตรงกัน",
"error_min_length": "รหัสผ่านต้องมีความยาวอย่างน้อย 8 อักขระ",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Yeni Parolayı Onayla",
"submit": "Parolayı Değiştir",
"success": "Parola başarıyla değiştirildi",
"sign_in_again": "Yeni şifrenizle tekrar oturum açın.",
"error_title": "Parola değiştirme başarısız",
"error_mismatch": "Yeni parolalar eşleşmiyor",
"error_min_length": "Parola en az 8 karakter olmalıdır",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "Підтвердьте новий пароль",
"submit": "Змінити пароль",
"success": "Пароль успішно змінено",
"sign_in_again": "Увійдіть знову з новим паролем.",
"error_title": "Не вдалося змінити пароль",
"error_mismatch": "Нові паролі не збігаються",
"error_min_length": "Пароль має бути не менше 8 символів",
+1
View File
@@ -1980,6 +1980,7 @@
"confirm": "確認新密碼",
"submit": "變更密碼",
"success": "已成功變更密碼",
"sign_in_again": "請使用新密碼重新登入。",
"error_title": "變更密碼失敗",
"error_mismatch": "兩次輸入的新密碼不相符",
"error_min_length": "密碼必須至少為 8 個字元",
+1
View File
@@ -1973,6 +1973,7 @@
"confirm": "确认新密码",
"submit": "更改密码",
"success": "密码修改成功",
"sign_in_again": "请使用新密码重新登录。",
"error_title": "密码修改失败",
"error_mismatch": "新密码不匹配",
"error_min_length": "密码必须至少为 8 个字符",