From 0b721661e9fd78257619ced8ef7fb09450ce05eb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:01:27 +0100 Subject: [PATCH] Fix logout redirects and unauthenticated home rendering --- app/[locale]/page.tsx | 4 + lib/browser-navigation.ts | 7 ++ stores/__tests__/auth-store-logout.test.ts | 97 ++++++++++++++++++++++ stores/auth-store.ts | 79 ++++++++++++++++-- 4 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 lib/browser-navigation.ts create mode 100644 stores/__tests__/auth-store-logout.test.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 6b2cbd20..c0399cab 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1022,6 +1022,10 @@ export default function Home() { ); + if (!isAuthenticated) { + return null; + } + return (
diff --git a/lib/browser-navigation.ts b/lib/browser-navigation.ts new file mode 100644 index 00000000..d7b377f3 --- /dev/null +++ b/lib/browser-navigation.ts @@ -0,0 +1,7 @@ +export function replaceWindowLocation(url: string): void { + if (typeof window === 'undefined') { + return; + } + + window.location.replace(url); +} \ No newline at end of file diff --git a/stores/__tests__/auth-store-logout.test.ts b/stores/__tests__/auth-store-logout.test.ts new file mode 100644 index 00000000..813b6744 --- /dev/null +++ b/stores/__tests__/auth-store-logout.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import * as browserNavigation from '@/lib/browser-navigation'; +import { useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; + +type FetchInput = Parameters[0]; +type FetchInit = Parameters[1]; + +describe('auth-store logout redirects', () => { + beforeEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + window.history.pushState({}, '', '/en'); + + useAccountStore.setState({ + accounts: [], + activeAccountId: null, + defaultAccountId: null, + }); + + useAuthStore.setState({ + isAuthenticated: false, + isLoading: false, + error: null, + serverUrl: null, + username: null, + client: null, + identities: [], + primaryIdentity: null, + authMode: 'basic', + rememberMe: false, + accessToken: null, + tokenExpiresAt: null, + connectionLost: false, + activeAccountId: null, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('redirects full logout to the locale login page', () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); + vi.stubGlobal('fetch', fetchMock); + const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + + window.history.pushState({}, '', '/fr/calendar'); + useAuthStore.setState({ isAuthenticated: true, authMode: 'basic' }); + + useAuthStore.getState().logout(); + + expect(replaceSpy).toHaveBeenCalledWith('/fr/login'); + expect(fetchMock).toHaveBeenCalledWith('/api/auth/session?slot=0', { method: 'DELETE', keepalive: true }); + }); + + it('marks session expiry, preserves the current path, and redirects to login on refresh failure', async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + + if (url === '/api/auth/token?slot=0' && method === 'PUT') { + return { ok: false, json: async () => ({}) }; + } + + if (url === '/api/auth/token?slot=0' && method === 'DELETE') { + return { ok: true, json: async () => ({}) }; + } + + if (url === '/api/auth/session?slot=0' && method === 'DELETE') { + return { ok: true, json: async () => ({}) }; + } + + throw new Error(`Unexpected fetch call: ${method} ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + + window.history.pushState({}, '', '/en/calendar?view=day'); + useAuthStore.setState({ + isAuthenticated: true, + authMode: 'oauth', + activeAccountId: null, + }); + + await useAuthStore.getState().refreshAccessToken(); + await vi.runAllTimersAsync(); + + expect(sessionStorage.getItem('session_expired')).toBe('true'); + expect(sessionStorage.getItem('redirect_after_login')).toBe('/en/calendar?view=day'); + expect(replaceSpy).toHaveBeenCalledWith('/en/login'); + }); +}); \ No newline at end of file diff --git a/stores/auth-store.ts b/stores/auth-store.ts index d9ba5702..d028d2db 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -11,6 +11,7 @@ import { useAccountStore } from './account-store'; import { fetchConfig } from '@/hooks/use-config'; import { debug } from '@/lib/debug'; import { generateAccountId } from '@/lib/account-utils'; +import { replaceWindowLocation } from '@/lib/browser-navigation'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager'; import type { Identity } from '@/lib/jmap/types'; @@ -98,8 +99,45 @@ function loadIdentities(rawIdentities: Identity[], username: string): { identiti return { identities, primaryIdentity }; } +function getLocaleLoginPath(): string { + if (typeof window === 'undefined') return '/en/login'; + + const segments = window.location.pathname.split('/').filter(Boolean); + const locale = segments[0] || 'en'; + return `/${locale}/login`; +} + +function saveRedirectAfterLogin(): void { + if (typeof window === 'undefined') return; + + try { + const loginPath = getLocaleLoginPath(); + const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; + + if (currentPath !== loginPath) { + sessionStorage.setItem('redirect_after_login', currentPath); + } + } catch { + /* noop */ + } +} + +function redirectToLogin(): void { + if (typeof window === 'undefined') return; + + const loginPath = getLocaleLoginPath(); + if (window.location.pathname === loginPath) return; + replaceWindowLocation(loginPath); +} + function markSessionExpired(): void { - try { sessionStorage.setItem('session_expired', 'true'); } catch { /* noop */ } + try { + sessionStorage.setItem('session_expired', 'true'); + } catch { + /* noop */ + } + + saveRedirectAfterLogin(); } function initializeFeatureStores(client: JMAPClient): void { @@ -479,6 +517,7 @@ export const useAuthStore = create()( // Check if there are remaining accounts to switch to const remainingAccounts = accountStore.accounts; + const shouldRedirectToLogin = remainingAccounts.length === 0; if (remainingAccounts.length > 0) { // Switch to the next account const nextAccount = remainingAccounts[0]; @@ -540,28 +579,51 @@ export const useAuthStore = create()( } // Clean up cookies for the removed account - fetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE' }).catch((err) => { + fetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: shouldRedirectToLogin }).catch((err) => { debug.error('Failed to clear session cookie:', err); }); - if (wasOAuth) { - fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE' }) + if (wasOAuth && shouldRedirectToLogin) { + let redirectCommitted = false; + const commitLoginRedirect = () => { + if (redirectCommitted) return; + redirectCommitted = true; + redirectToLogin(); + }; + + window.setTimeout(commitLoginRedirect, 0); + + fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: true }) .then((res) => { if (!res.ok) throw new Error(`Revocation failed: ${res.status}`); return res.json(); }) .then((data) => { - if (data.end_session_url && remainingAccounts.length === 0) { + if (redirectCommitted) return; + + if (data.end_session_url) { + redirectCommitted = true; const locale = window.location.pathname.split('/')[1] || 'en'; const redirectUri = `${window.location.origin}/${locale}/login`; const url = new URL(data.end_session_url); url.searchParams.set('post_logout_redirect_uri', redirectUri); - window.location.href = url.toString(); + replaceWindowLocation(url.toString()); + return; } + + commitLoginRedirect(); }) .catch((err) => { debug.error('OAuth logout cleanup failed:', err); + commitLoginRedirect(); }); + } else if (wasOAuth) { + fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE', keepalive: false }) + .catch((err) => { + debug.error('OAuth logout cleanup failed:', err); + }); + } else if (shouldRedirectToLogin) { + redirectToLogin(); } }, @@ -604,8 +666,9 @@ export const useAuthStore = create()( } // Delete all cookies - fetch('/api/auth/session?all=true', { method: 'DELETE' }).catch(() => {}); - fetch('/api/auth/token?all=true', { method: 'DELETE' }).catch(() => {}); + fetch('/api/auth/session?all=true', { method: 'DELETE', keepalive: true }).catch(() => {}); + fetch('/api/auth/token?all=true', { method: 'DELETE', keepalive: true }).catch(() => {}); + redirectToLogin(); }, switchAccount: async (accountId: string) => {