fix: stop calling a reload without Remember me an expired session

This commit is contained in:
Linus Rath
2026-10-11 20:15:45 +02:00
parent 4b282e7064
commit 47f6378d3f
2 changed files with 80 additions and 1 deletions
@@ -0,0 +1,74 @@
import { beforeAll, beforeEach, expect, it, vi } from 'vitest';
import { createConfig } from '@/lib/__tests__/fixtures/config';
// Without "Remember me" nothing is stored to resume from, so a reload signs
// the user out on purpose. The login page then said "Your session has
// expired", which reads like an error.
vi.mock('@/lib/jmap/client', () => {
class JMAPClient {
constructor(public serverUrl: string, public username: string, public password: string) {}
static withBearer() { throw new Error('not used'); }
async connect() {}
getSessionUsername() { return this.username; }
getUsername() { return this.username; }
async getIdentities() { return [{ id: 'a', email: this.username, name: this.username, mayDelete: false }]; }
getAuthHeader() { return 'Basic eA=='; }
onConnectionChange() {}
onRateLimit() {}
getRateLimitRemainingMs() { return 0; }
supportsContacts() { return false; }
supportsPrincipals() { return false; }
supportsVacationResponse() { return false; }
supportsCalendars() { return false; }
supportsSieve() { return false; }
disconnect() {}
getAccountId() { return 'acct'; }
}
class RateLimitError extends Error {}
class RequestTimeoutError extends Error {}
return { JMAPClient, RateLimitError, RequestTimeoutError };
});
vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null }));
import * as nav from '@/lib/browser-navigation';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
const signedIn = {
id: 'bob@x.test@mail.x.test', label: 'Bob', serverUrl: 'https://mail.x.test', username: 'bob@x.test', authMode: 'basic' as const,
rememberMe: true, cookieSlot: 0, displayName: 'Bob', email: 'bob@x.test', isConnected: false, hasError: false,
lastLoginAt: 1, avatarColor: '#000000', isDefault: true,
};
const redirect = vi.fn();
beforeAll(() => {
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => {
const url = String(input);
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
return new Response('{}', { status: 401 });
}));
vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(redirect);
});
beforeEach(() => {
redirect.mockClear();
sessionStorage.clear();
});
it('does not call a reload of a session without Remember me an expired session', async () => {
useAccountStore.setState({ accounts: [{ ...signedIn, rememberMe: false }] as never, activeAccountId: signedIn.id, defaultAccountId: signedIn.id });
await useAuthStore.getState().checkAuth();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(sessionStorage.getItem('session_expired')).toBeNull();
});
it('still reports a remembered session that could not be resumed', async () => {
useAccountStore.setState({ accounts: [signedIn] as never, activeAccountId: signedIn.id, defaultAccountId: signedIn.id });
await useAuthStore.getState().checkAuth();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(sessionStorage.getItem('session_expired')).toBe('true');
});
+6 -1
View File
@@ -2577,6 +2577,9 @@ export const useAuthStore = create<AuthState>()(
// and sync the passthrough auth context. The context write never
// throws and doesn't depend on the connect result, so its round trip
// overlaps the connect instead of running after it.
// Accounts signed in without "Remember me": a reload ends them on
// purpose, so it is not reported as an expired session.
let forgottenAccounts = 0;
const restoreAccount = async (account: (typeof accounts)[number]) => {
if (clients.has(account.id)) return; // Already connected
// Restored from the account list backup: listed until signed in.
@@ -2589,6 +2592,7 @@ export const useAuthStore = create<AuthState>()(
// way; a missing one surfaces as a 401 from fetchSlotSession below
// and evicts too.)
if (account.authMode !== 'oauth' && !account.rememberMe && !IS_LITE) {
forgottenAccounts += 1;
evictAccount(account.id);
accountStore.removeAccount(account.id);
return;
@@ -2791,7 +2795,8 @@ export const useAuthStore = create<AuthState>()(
return;
}
markSessionExpired();
if (forgottenAccounts === accounts.length) saveRedirectAfterLogin();
else markSessionExpired();
set({
isAuthenticated: false,
isLoading: false,