From fde8672bb16a219333060cac86ed7ab2b5b049b0 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sun, 5 Apr 2026 03:02:35 +0300 Subject: [PATCH] feat(frontend): implement API client and auth module Adds the base fetch wrapper (client.ts) with JWT auth headers, automatic token refresh on 401 with request deduplication, and typed ApiError. Adds auth.ts with login/logout/refresh/listSessions/ terminateSession. Adds authStore (stores/auth.ts) persisted to localStorage. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/api/auth.ts | 45 ++++++++++++++ frontend/src/lib/api/client.ts | 103 ++++++++++++++++++++++++++++++++ frontend/src/lib/api/types.ts | 1 + frontend/src/lib/stores/auth.ts | 29 +++++++++ 4 files changed, 178 insertions(+) create mode 100644 frontend/src/lib/api/auth.ts create mode 100644 frontend/src/lib/api/client.ts create mode 100644 frontend/src/lib/stores/auth.ts 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