diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts new file mode 100644 index 0000000..530a870 --- /dev/null +++ b/frontend/src/lib/api/auth.ts @@ -0,0 +1,45 @@ +import { get } from 'svelte/store'; +import { authStore } from '$lib/stores/auth'; +import { api } from './client'; +import type { TokenPair, SessionList } from './types'; + +export async function login(name: string, password: string): Promise { + const tokens = await api.post('/auth/login', { name, password }); + authStore.update((s) => ({ + ...s, + accessToken: tokens.access_token ?? null, + refreshToken: tokens.refresh_token ?? null, + })); +} + +export async function logout(): Promise { + try { + await api.post('/auth/logout'); + } finally { + authStore.set({ accessToken: null, refreshToken: null, user: null }); + } +} + +export async function refresh(): Promise { + const { refreshToken } = get(authStore); + if (!refreshToken) throw new Error('No refresh token'); + + const tokens = await api.post('/auth/refresh', { refresh_token: refreshToken }); + authStore.update((s) => ({ + ...s, + accessToken: tokens.access_token ?? null, + refreshToken: tokens.refresh_token ?? null, + })); +} + +export function listSessions(params?: { offset?: number; limit?: number }): Promise { + const entries = Object.entries(params ?? {}) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, String(v)]); + const qs = entries.length ? '?' + new URLSearchParams(entries).toString() : ''; + return api.get(`/auth/sessions${qs}`); +} + +export function terminateSession(sessionId: number): Promise { + return api.delete(`/auth/sessions/${sessionId}`); +} \ No newline at end of file diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..fe38fb7 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,103 @@ +import { get } from 'svelte/store'; +import { authStore } from '$lib/stores/auth'; + +const BASE = '/api/v1'; + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string, + public readonly details?: Array<{ field?: string; message?: string }>, + ) { + super(message); + this.name = 'ApiError'; + } +} + +// Deduplicates concurrent 401 refresh attempts into a single in-flight request. +let refreshPromise: Promise | null = null; + +async function refreshTokens(): Promise { + const { refreshToken } = get(authStore); + if (!refreshToken) { + authStore.set({ accessToken: null, refreshToken: null, user: null }); + throw new ApiError(401, 'unauthorized', 'Session expired'); + } + + const res = await fetch(`${BASE}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!res.ok) { + authStore.set({ accessToken: null, refreshToken: null, user: null }); + throw new ApiError(401, 'unauthorized', 'Session expired'); + } + + const data = await res.json(); + authStore.update((s) => ({ + ...s, + accessToken: data.access_token ?? null, + refreshToken: data.refresh_token ?? null, + })); +} + +function buildHeaders(init: RequestInit | undefined, accessToken: string | null): HeadersInit { + const isFormData = init?.body instanceof FormData; + const base: Record = isFormData ? {} : { 'Content-Type': 'application/json' }; + if (accessToken) base['Authorization'] = `Bearer ${accessToken}`; + return { ...base, ...(init?.headers as Record | undefined) }; +} + +async function request(path: string, init?: RequestInit): Promise { + let res = await fetch(BASE + path, { + ...init, + headers: buildHeaders(init, get(authStore).accessToken), + }); + + if (res.status === 401) { + if (!refreshPromise) { + refreshPromise = refreshTokens().finally(() => { + refreshPromise = null; + }); + } + try { + await refreshPromise; + } catch { + throw new ApiError(401, 'unauthorized', 'Session expired'); + } + + res = await fetch(BASE + path, { + ...init, + headers: buildHeaders(init, get(authStore).accessToken), + }); + } + + if (!res.ok) { + let body: { code?: string; message?: string; details?: Array<{ field?: string; message?: string }> } = {}; + try { + body = await res.json(); + } catch { + // ignore parse failure + } + throw new ApiError(res.status, body.code ?? 'error', body.message ?? res.statusText, body.details); + } + + if (res.status === 204) return undefined as T; + return res.json(); +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { method: 'POST', body: JSON.stringify(body) }), + patch: (path: string, body?: unknown) => + request(path, { method: 'PATCH', body: JSON.stringify(body) }), + put: (path: string, body?: unknown) => + request(path, { method: 'PUT', body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: 'DELETE' }), + upload: (path: string, formData: FormData) => + request(path, { method: 'POST', body: formData }), +}; \ No newline at end of file diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 49dc5de..6801888 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -7,6 +7,7 @@ export type Pool = components['schemas']['Pool']; export type PoolFile = components['schemas']['PoolFile']; export type User = components['schemas']['User']; export type Session = components['schemas']['Session']; +export type TokenPair = components['schemas']['TokenPair']; export type Permission = components['schemas']['Permission']; export type AuditEntry = components['schemas']['AuditLogEntry']; export type TagRule = components['schemas']['TagRule']; diff --git a/frontend/src/lib/stores/auth.ts b/frontend/src/lib/stores/auth.ts new file mode 100644 index 0000000..babd429 --- /dev/null +++ b/frontend/src/lib/stores/auth.ts @@ -0,0 +1,29 @@ +import { writable, derived } from 'svelte/store'; +import type { User } from '$lib/api/types'; + +export interface AuthState { + accessToken: string | null; + refreshToken: string | null; + user: User | null; +} + +const initial: AuthState = { accessToken: null, refreshToken: null, user: null }; + +function loadStored(): AuthState { + if (typeof localStorage === 'undefined') return initial; + try { + return JSON.parse(localStorage.getItem('auth') ?? 'null') ?? initial; + } catch { + return initial; + } +} + +export const authStore = writable(loadStored()); + +authStore.subscribe((state) => { + if (typeof localStorage !== 'undefined') { + localStorage.setItem('auth', JSON.stringify(state)); + } +}); + +export const isAuthenticated = derived(authStore, ($auth) => !!$auth.accessToken); \ No newline at end of file