diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index d04415d..6fb6df7 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -10,17 +10,16 @@ export function AuthProvider({ children }) { const [loading, setLoading] = useState(true) useEffect(() => { - const token = localStorage.getItem('token') - if (token) { - api.get('/auth/me') - .then(res => setUser(res.data)) - .catch(() => { if (localStorage.getItem('token') === token) setToken(null) }) - .finally(() => setLoading(false)) - } else { - // Nobody signed in here — but they may be signed in at the provider for - // the other app, in which case they should not be shown a door. One - // silent attempt, then the page as normal. `loading` stays true while - // the browser is on its way out, so nothing paints and swaps. + // Nobody signed in here — but they may be signed in at the provider for + // the other app, in which case they should not be shown a door. One + // silent attempt, then the page as normal. `loading` stays true while the + // browser is on its way out, so nothing paints and swaps. + // + // "Nobody signed in" includes a token the server will not accept: an + // expired session is exactly the case where somebody would otherwise be + // shown a sign-in page for no reason, having done nothing but leave the + // tab open overnight. + const askProviderQuietly = () => { api.get('/auth/sso/config') .then(res => { if (shouldTrySilently({ hasToken: false, ssoEnabled: res.data?.sso_enabled === true })) { @@ -31,6 +30,18 @@ export function AuthProvider({ children }) { }) .catch(() => setLoading(false)) } + + const token = localStorage.getItem('token') + if (token) { + api.get('/auth/me') + .then(res => { setUser(res.data); setLoading(false) }) + .catch(() => { + if (localStorage.getItem('token') === token) setToken(null) + askProviderQuietly() + }) + } else { + askProviderQuietly() + } }, []) const login = async (email, password) => { diff --git a/frontend/src/context/AuthContext.test.jsx b/frontend/src/context/AuthContext.test.jsx index d05a150..510ea50 100644 --- a/frontend/src/context/AuthContext.test.jsx +++ b/frontend/src/context/AuthContext.test.jsx @@ -1,38 +1,62 @@ -import { beforeEach, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { AuthProvider, useAuth } from './AuthContext' import api from '../api/client' +import { AuthProvider, useAuth } from './AuthContext' -vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } })) -beforeEach(() => { - localStorage.clear() - vi.restoreAllMocks() - api.get.mockResolvedValue({ data: { id: 1, name: 'Synthetic' } }) - api.post.mockResolvedValue({ data: { access_token: 'password-token' } }) -}) -function Controls() { - const { login, loginWithToken, logout, user } = useAuth() - return <>{user?.name}> +vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) + +const replace = vi.fn() + +function Show() { + const { user, loading } = useAuth() + return
{loading ? 'loading' : user ? `signed in as ${user.name}` : 'signed out'}
} -it.each(['Password', 'SSO'])('mirrors %s login and synchronously clears logout', async method => { - const cookie = vi.spyOn(document, 'cookie', 'set') - render(