feat(auth): tell the user when the session expired

Every 401 from Auth or Core now goes through useSessionExpired: the login page
says the session expired and returns to the last page after login. App no
longer removes a token that only the browser clock calls expired, so a backend
401 is the one signal. A 403 on the profile now shows an error instead.
This commit is contained in:
lobo committed 2026-09-24 13:29:09 +02:00
1 parent 03259d0515
commit d4c0f8d8f3
19 files changed
+351 -35

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import LoginPage from './LoginPage.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const b64 = (o) => btoa(JSON.stringify(o)).replace(/=+$/, '');
const TOKEN = `${b64({alg: 'EdDSA'})}.${b64({exp: Math.floor(Date.now() / 1000) + 3600})}.sig`;
function CurrentPath() {
const location = useLocation();
return <div data-testid="path">{location.pathname + location.search}</div>;
}
function renderLogin(state) {
render(
<MemoryRouter initialEntries={[{pathname: '/login', state}]}>
<Routes>
<Route path="/login" element={<LoginPage/>}/>
<Route path="*" element={<CurrentPath/>}/>
</Routes>
</MemoryRouter>,
);
}
function submit() {
fireEvent.change(screen.getByLabelText('LOGIN.EMAIL'), {target: {value: 'a@b.de'}});
fireEvent.change(screen.getByLabelText('LOGIN.PASSWORD'), {target: {value: 'secret'}});
fireEvent.click(screen.getByRole('button', {name: 'LOGIN.SUBMIT'}));
}
describe('LoginPage', () => {
beforeEach(() => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({token: TOKEN})}));
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('says that the session expired', () => {
renderLogin({sessionExpired: true, from: '/library'});
expect(screen.getByText('LOGIN.SESSION_EXPIRED')).toBeTruthy();
});
it('shows no message on a normal visit', () => {
renderLogin(undefined);
expect(screen.queryByText('LOGIN.SESSION_EXPIRED')).toBeNull();
});
it('goes back to the page of the expired session after login', async () => {
renderLogin({sessionExpired: true, from: '/library?status=PLAYING'});
submit();
await waitFor(() => expect(screen.getByTestId('path').textContent).toBe('/library?status=PLAYING'));
expect(localStorage.getItem('tbp_jwt')).toBe(TOKEN);
});
it('ignores a return path outside the app', async () => {
renderLogin({sessionExpired: true, from: '//evil.example'});
submit();
await waitFor(() => expect(screen.getByTestId('path').textContent).toBe('/dashboard'));
});
it('hides the expired message once a login attempt fails', async () => {
fetch.mockResolvedValueOnce({ok: false, status: 401, headers: new Headers(), json: async () => ({code: 'INVALID_CREDENTIALS'})});
renderLogin({sessionExpired: true, from: '/library'});
submit();
await waitFor(() => expect(screen.getByText('LOGIN.ERROR_INVALID_CREDENTIALS')).toBeTruthy());
expect(screen.queryByText('LOGIN.SESSION_EXPIRED')).toBeNull();
});
});