Fix logout redirects and unauthenticated home rendering
This commit is contained in:
@@ -1022,6 +1022,10 @@ export default function Home() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropProvider>
|
<DragDropProvider>
|
||||||
<div className="flex flex-col h-dvh bg-background overflow-hidden">
|
<div className="flex flex-col h-dvh bg-background overflow-hidden">
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function replaceWindowLocation(url: string): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.replace(url);
|
||||||
|
}
|
||||||
@@ -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<typeof fetch>[0];
|
||||||
|
type FetchInit = Parameters<typeof fetch>[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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+71
-8
@@ -11,6 +11,7 @@ import { useAccountStore } from './account-store';
|
|||||||
import { fetchConfig } from '@/hooks/use-config';
|
import { fetchConfig } from '@/hooks/use-config';
|
||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
import { generateAccountId } from '@/lib/account-utils';
|
import { generateAccountId } from '@/lib/account-utils';
|
||||||
|
import { replaceWindowLocation } from '@/lib/browser-navigation';
|
||||||
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager';
|
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager';
|
||||||
import type { Identity } from '@/lib/jmap/types';
|
import type { Identity } from '@/lib/jmap/types';
|
||||||
|
|
||||||
@@ -98,8 +99,45 @@ function loadIdentities(rawIdentities: Identity[], username: string): { identiti
|
|||||||
return { identities, primaryIdentity };
|
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 {
|
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 {
|
function initializeFeatureStores(client: JMAPClient): void {
|
||||||
@@ -479,6 +517,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
// Check if there are remaining accounts to switch to
|
// Check if there are remaining accounts to switch to
|
||||||
const remainingAccounts = accountStore.accounts;
|
const remainingAccounts = accountStore.accounts;
|
||||||
|
const shouldRedirectToLogin = remainingAccounts.length === 0;
|
||||||
if (remainingAccounts.length > 0) {
|
if (remainingAccounts.length > 0) {
|
||||||
// Switch to the next account
|
// Switch to the next account
|
||||||
const nextAccount = remainingAccounts[0];
|
const nextAccount = remainingAccounts[0];
|
||||||
@@ -540,28 +579,51 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clean up cookies for the removed account
|
// 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);
|
debug.error('Failed to clear session cookie:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (wasOAuth) {
|
if (wasOAuth && shouldRedirectToLogin) {
|
||||||
fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE' })
|
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) => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error(`Revocation failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Revocation failed: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.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 locale = window.location.pathname.split('/')[1] || 'en';
|
||||||
const redirectUri = `${window.location.origin}/${locale}/login`;
|
const redirectUri = `${window.location.origin}/${locale}/login`;
|
||||||
const url = new URL(data.end_session_url);
|
const url = new URL(data.end_session_url);
|
||||||
url.searchParams.set('post_logout_redirect_uri', redirectUri);
|
url.searchParams.set('post_logout_redirect_uri', redirectUri);
|
||||||
window.location.href = url.toString();
|
replaceWindowLocation(url.toString());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commitLoginRedirect();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
debug.error('OAuth logout cleanup failed:', 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<AuthState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete all cookies
|
// Delete all cookies
|
||||||
fetch('/api/auth/session?all=true', { method: 'DELETE' }).catch(() => {});
|
fetch('/api/auth/session?all=true', { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||||
fetch('/api/auth/token?all=true', { method: 'DELETE' }).catch(() => {});
|
fetch('/api/auth/token?all=true', { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||||
|
redirectToLogin();
|
||||||
},
|
},
|
||||||
|
|
||||||
switchAccount: async (accountId: string) => {
|
switchAccount: async (accountId: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user