Replace the API-key SPA model with the session-cookie auth from PR1: - client.ts: stop sending X-Api-Key; attach X-Csrf on mutating verbs (POST/PUT/ PATCH/DELETE); add suppressUnauthorizedSignal to skip the global 401 signal on expected wrong-credentials 401s (login / change-password). - api/auth.ts: hand-written wire types (AuthConfig/AuthSession/MachineKey — the auth surface is IgnoreApi, deliberately not in generated types) + endpoint fns (getAuthConfig/getAuthSession/login/setup/logout/changePassword/getMachineKey); keep the notifyUnauthorized/subscribeUnauthorized 401 signal; add clearLegacyStoredApiKey. Legacy get/set/clearStoredApiKey retained ONLY so the still-shipping ApiKeyScreen (machine-key slice) compiles without a cross-slice conflict — the client no longer reads them. - AuthGate.tsx: boot gate wrapping <App/> outside the shell (mints no URL, deep links survive login). checking -> setup | login | ready | error, with a safe default AuthContext so App.test.tsx renders without a provider; config-fetch failure lands on an explicit error+Retry, never a blank screen. - LoginScreen / SetupScreen: shell-less centered cards; inline 401 / 409 handling; SSO button + local-form gating from AuthConfig. - UnauthorizedBanner: rewritten to prompt re-login (passive; consults the unsaved-changes guard before flipping the gate). - UserMenu: TopBar sign-out (guard -> logout -> signOut), mounted next to ConnectMenu. - main.tsx: wrap <AuthGate><App/></AuthGate> inside StrictMode. Tests: client/auth/AuthGate/LoginScreen/SetupScreen/UnauthorizedBanner/UserMenu (723 pass). Lint + build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
73 lines
2.1 KiB
TypeScript
73 lines
2.1 KiB
TypeScript
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { notifyUnauthorized } from './api';
|
|
import { AuthContext, type AuthContextValue } from './AuthGate';
|
|
import { registerNavigationGuard } from './navigationGuard';
|
|
import { UnauthorizedBanner } from './UnauthorizedBanner';
|
|
|
|
function renderBanner(overrides: Partial<AuthContextValue> = {}) {
|
|
const requireLogin = overrides.requireLogin ?? vi.fn();
|
|
const value: AuthContextValue = {
|
|
username: null,
|
|
method: null,
|
|
signOut: vi.fn(),
|
|
requireLogin,
|
|
...overrides
|
|
};
|
|
render(
|
|
<AuthContext.Provider value={value}>
|
|
<UnauthorizedBanner />
|
|
</AuthContext.Provider>
|
|
);
|
|
return { requireLogin };
|
|
}
|
|
|
|
describe('UnauthorizedBanner', () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
it('stays hidden until a 401 signal fires', () => {
|
|
renderBanner();
|
|
|
|
expect(screen.queryByRole('alert')).toBeNull();
|
|
|
|
act(() => notifyUnauthorized());
|
|
|
|
expect(screen.getByRole('alert')).toBeTruthy();
|
|
expect(screen.getByText(/session has expired or you are signed out/)).toBeTruthy();
|
|
});
|
|
|
|
it('flips the gate to login via requireLogin when navigation is allowed', () => {
|
|
const { requireLogin } = renderBanner();
|
|
act(() => notifyUnauthorized());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
|
|
|
expect(requireLogin).toHaveBeenCalledTimes(1);
|
|
expect(screen.queryByRole('alert')).toBeNull();
|
|
});
|
|
|
|
it('respects the unsaved-changes guard — a vetoed leave keeps the banner and skips requireLogin', () => {
|
|
const { requireLogin } = renderBanner();
|
|
const unregister = registerNavigationGuard(() => false);
|
|
act(() => notifyUnauthorized());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
|
|
|
expect(requireLogin).not.toHaveBeenCalled();
|
|
expect(screen.getByRole('alert')).toBeTruthy();
|
|
|
|
unregister();
|
|
});
|
|
|
|
it('can be dismissed', () => {
|
|
renderBanner();
|
|
act(() => notifyUnauthorized());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
|
|
|
|
expect(screen.queryByRole('alert')).toBeNull();
|
|
});
|
|
});
|