feat(web): add rune-based auth store with bootstrap/login/logout

Central synchronous source of truth for 'who is signed in'. bootstrap()
runs once in +layout.ts load(); login/logout mutate _user and the
TanStack query cache. The silent option on logout is used by the 401
interceptor (next commit) to avoid POSTing /logout against an already-
invalid session.
This commit is contained in:
2026-04-22 17:28:10 -04:00
parent 12bf873f39
commit 07b5912fae
2 changed files with 113 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { api, type User, type LoginResponse } from '$lib/api/client';
import { queryClient } from '$lib/query/client';
let _user = $state<User | null>(null);
export const user = {
get value(): User | null {
return _user;
}
};
export async function bootstrap(): Promise<void> {
try {
_user = await api.get<User>('/api/me');
} catch {
_user = null;
}
}
export async function login(username: string, password: string): Promise<void> {
const res = await api.post<LoginResponse>('/api/auth/login', { username, password });
_user = res.user;
}
export async function logout(opts: { silent?: boolean } = {}): Promise<void> {
if (!opts.silent) {
try {
await api.post('/api/auth/logout', {});
} catch {
// best effort — server-side session may already be gone
}
}
_user = null;
queryClient.clear();
}
+78
View File
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
vi.mock('$lib/api/client', () => ({
api: {
get: vi.fn(),
post: vi.fn(),
del: vi.fn()
}
}));
vi.mock('$lib/query/client', () => ({
queryClient: { clear: vi.fn() }
}));
import { api } from '$lib/api/client';
import { queryClient } from '$lib/query/client';
import { bootstrap, login, logout, user } from './store.svelte';
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
// Force-clear the store between tests by stubbing api.get to reject,
// then calling bootstrap.
});
describe('auth store', () => {
test('bootstrap populates user on 200', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: '1', username: 'alice', is_admin: false
});
await bootstrap();
expect(user.value).toEqual({ id: '1', username: 'alice', is_admin: false });
});
test('bootstrap leaves user null on failure', async () => {
(api.get as ReturnType<typeof vi.fn>).mockRejectedValue(
{ code: 'unauthorized', message: 'no session', status: 401 }
);
await bootstrap();
expect(user.value).toBeNull();
});
test('login sets user from LoginResponse.user', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue({
token: 't', user: { id: '2', username: 'bob', is_admin: true }
});
await login('bob', 'pw');
expect(user.value).toEqual({ id: '2', username: 'bob', is_admin: true });
expect(api.post).toHaveBeenCalledWith('/api/auth/login', {
username: 'bob', password: 'pw'
});
});
test('logout clears user, calls /api/auth/logout, and clears query cache', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue(null);
await logout();
expect(user.value).toBeNull();
expect(api.post).toHaveBeenCalledWith('/api/auth/logout', {});
expect(queryClient.clear).toHaveBeenCalledTimes(1);
});
test('logout with silent:true skips the POST but still clears state', async () => {
await logout({ silent: true });
expect(user.value).toBeNull();
expect(api.post).not.toHaveBeenCalled();
expect(queryClient.clear).toHaveBeenCalledTimes(1);
});
test('logout swallows POST errors (best-effort)', async () => {
(api.post as ReturnType<typeof vi.fn>).mockRejectedValue(
{ code: 'server_error', message: 'boom', status: 500 }
);
await expect(logout()).resolves.toBeUndefined();
expect(user.value).toBeNull();
});
});