From eb540c2cd8e272778be7a756e3322e057d2e188d Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Wed, 20 May 2026 23:02:47 +0200 Subject: [PATCH] feat(frontend): API client CSRF support + auth and admin-users API modules --- packages/frontend/src/api/admin-users.ts | 20 ++++++++++++++ packages/frontend/src/api/auth.ts | 21 +++++++++++++++ packages/frontend/src/api/client.ts | 34 +++++++++++++++++++++--- 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 packages/frontend/src/api/admin-users.ts create mode 100644 packages/frontend/src/api/auth.ts diff --git a/packages/frontend/src/api/admin-users.ts b/packages/frontend/src/api/admin-users.ts new file mode 100644 index 0000000..b0c27d8 --- /dev/null +++ b/packages/frontend/src/api/admin-users.ts @@ -0,0 +1,20 @@ +import type { User, AdminUserUpdateInput, InviteUserInput, Role } from '@flashcard/shared'; +import { api } from './client.js'; + +export interface ListUsersResponse { rows: User[]; total: number; } + +export const adminUsersApi = { + list: (params: { q?: string; role?: Role; active?: boolean; limit?: number; offset?: number } = {}) => { + const qs = new URLSearchParams(); + if (params.q) qs.set('q', params.q); + if (params.role) qs.set('role', params.role); + if (params.active !== undefined) qs.set('active', String(params.active)); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + const s = qs.toString(); + return api.get(`/admin/users${s ? '?' + s : ''}`); + }, + invite: (input: InviteUserInput) => api.post<{ id: number; email: string; role: Role }>('/admin/users/invite', input), + update: (id: number, input: AdminUserUpdateInput) => api.patch(`/admin/users/${id}`, input), + sendReset: (id: number) => api.post(`/admin/users/${id}/send-reset`), +}; diff --git a/packages/frontend/src/api/auth.ts b/packages/frontend/src/api/auth.ts new file mode 100644 index 0000000..b94afb2 --- /dev/null +++ b/packages/frontend/src/api/auth.ts @@ -0,0 +1,21 @@ +import type { + PublicUser, User, + LoginInput, RegisterInput, VerifyEmailInput, ResendVerificationInput, + ForgotPasswordInput, ResetPasswordInput, AcceptInviteInput, + ProfileUpdateInput, ChangePasswordInput, +} from '@flashcard/shared'; +import { api } from './client.js'; + +export const authApi = { + me: () => api.get('/auth/me'), + register: (input: RegisterInput) => api.post('/auth/register', input), + verifyEmail: (input: VerifyEmailInput) => api.post<{ ok: true }>('/auth/verify-email', input), + resendVerification: (input: ResendVerificationInput) => api.post<{ ok: true }>('/auth/resend-verification', input), + login: (input: LoginInput) => api.post('/auth/login', input), + logout: () => api.post('/auth/logout'), + forgotPassword: (input: ForgotPasswordInput) => api.post<{ ok: true }>('/auth/forgot-password', input), + resetPassword: (input: ResetPasswordInput) => api.post<{ ok: true }>('/auth/reset-password', input), + acceptInvite: (input: AcceptInviteInput) => api.post('/auth/accept-invite', input), + updateProfile: (input: ProfileUpdateInput) => api.patch('/auth/profile', input), + changePassword: (input: ChangePasswordInput) => api.post('/auth/change-password', input), +}; diff --git a/packages/frontend/src/api/client.ts b/packages/frontend/src/api/client.ts index 4d044c0..f9108c2 100644 --- a/packages/frontend/src/api/client.ts +++ b/packages/frontend/src/api/client.ts @@ -1,10 +1,28 @@ +const CSRF_COOKIE = 'flashcard_csrf'; + +function readCookie(name: string): string | null { + const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]+)`)); + return m ? decodeURIComponent(m[1]!) : null; +} + export class ApiClientError extends Error { constructor(public status: number, public code: string, message: string, public details?: unknown) { super(message); } } -async function request(method: string, path: string, body?: unknown, opts?: { isFormData?: boolean }): Promise { +const listeners = new Set<() => void>(); +export function onUnauthorized(cb: () => void): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} + +async function request( + method: string, + path: string, + body?: unknown, + opts?: { isFormData?: boolean } +): Promise { const headers: Record = {}; let payload: BodyInit | undefined; if (opts?.isFormData) { @@ -13,12 +31,22 @@ async function request(method: string, path: string, body?: unknown, opts?: { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); } - const res = await fetch(`/api${path}`, { method, headers, body: payload }); + if (method !== 'GET' && method !== 'HEAD') { + const csrf = readCookie(CSRF_COOKIE); + if (csrf) headers['X-CSRF-Token'] = csrf; + } + const res = await fetch(`/api${path}`, { + method, + headers, + body: payload, + credentials: 'same-origin', + }); if (res.status === 204) return undefined as T; const isJson = res.headers.get('content-type')?.includes('application/json'); const data = isJson ? await res.json() : await res.blob(); if (!res.ok) { const e = (data as { error?: { code: string; message: string; details?: unknown } }).error; + if (res.status === 401) listeners.forEach((l) => l()); throw new ApiClientError(res.status, e?.code ?? 'UNKNOWN', e?.message ?? 'Request failed', e?.details); } return data as T; @@ -31,7 +59,7 @@ export const api = { patch: (path: string, body: unknown) => request('PATCH', path, body), delete: (path: string) => request('DELETE', path), getBlob: async (path: string): Promise => { - const res = await fetch(`/api${path}`); + const res = await fetch(`/api${path}`, { credentials: 'same-origin' }); if (!res.ok) throw new ApiClientError(res.status, 'UNKNOWN', 'Request failed'); return res.blob(); },