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:
parent
071829a79e
commit
fde8672bb1
45
frontend/src/lib/api/auth.ts
Normal file
45
frontend/src/lib/api/auth.ts
Normal 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}`);
|
||||||
|
}
|
||||||
103
frontend/src/lib/api/client.ts
Normal file
103
frontend/src/lib/api/client.ts
Normal 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 }),
|
||||||
|
};
|
||||||
@ -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'];
|
||||||
|
|||||||
29
frontend/src/lib/stores/auth.ts
Normal file
29
frontend/src/lib/stores/auth.ts
Normal 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);
|
||||||
Loading…
x
Reference in New Issue
Block a user