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 <noreply@anthropic.com>
This commit is contained in:
Masahiko AMANO 2026-04-05 03:02:35 +03:00
parent 071829a79e
commit fde8672bb1
4 changed files with 178 additions and 0 deletions

View File

@ -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<void> {
const tokens = await api.post<TokenPair>('/auth/login', { name, password });
authStore.update((s) => ({
...s,
accessToken: tokens.access_token ?? null,
refreshToken: tokens.refresh_token ?? null,
}));
}
export async function logout(): Promise<void> {
try {
await api.post('/auth/logout');
} finally {
authStore.set({ accessToken: null, refreshToken: null, user: null });
}
}
export async function refresh(): Promise<void> {
const { refreshToken } = get(authStore);
if (!refreshToken) throw new Error('No refresh token');
const tokens = await api.post<TokenPair>('/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<SessionList> {
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<SessionList>(`/auth/sessions${qs}`);
}
export function terminateSession(sessionId: number): Promise<void> {
return api.delete<void>(`/auth/sessions/${sessionId}`);
}

View File

@ -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<void> | null = null;
async function refreshTokens(): Promise<void> {
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<string, string> = isFormData ? {} : { 'Content-Type': 'application/json' };
if (accessToken) base['Authorization'] = `Bearer ${accessToken}`;
return { ...base, ...(init?.headers as Record<string, string> | undefined) };
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
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: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
upload: <T>(path: string, formData: FormData) =>
request<T>(path, { method: 'POST', body: formData }),
};

View File

@ -7,6 +7,7 @@ export type Pool = components['schemas']['Pool'];
export type PoolFile = components['schemas']['PoolFile']; export type PoolFile = components['schemas']['PoolFile'];
export type User = components['schemas']['User']; export type User = components['schemas']['User'];
export type Session = components['schemas']['Session']; export type Session = components['schemas']['Session'];
export type TokenPair = components['schemas']['TokenPair'];
export type Permission = components['schemas']['Permission']; export type Permission = components['schemas']['Permission'];
export type AuditEntry = components['schemas']['AuditLogEntry']; export type AuditEntry = components['schemas']['AuditLogEntry'];
export type TagRule = components['schemas']['TagRule']; export type TagRule = components['schemas']['TagRule'];

View File

@ -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<AuthState>(loadStored());
authStore.subscribe((state) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('auth', JSON.stringify(state));
}
});
export const isAuthenticated = derived(authStore, ($auth) => !!$auth.accessToken);