feat(frontend): API client CSRF support + auth and admin-users API modules

This commit is contained in:
2026-05-20 23:02:47 +02:00
parent 00e69a8d90
commit eb540c2cd8
3 changed files with 72 additions and 3 deletions

View File

@@ -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<ListUsersResponse>(`/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<User>(`/admin/users/${id}`, input),
sendReset: (id: number) => api.post<void>(`/admin/users/${id}/send-reset`),
};

View File

@@ -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<User>('/auth/me'),
register: (input: RegisterInput) => api.post<PublicUser>('/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<PublicUser>('/auth/login', input),
logout: () => api.post<void>('/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<PublicUser>('/auth/accept-invite', input),
updateProfile: (input: ProfileUpdateInput) => api.patch<PublicUser>('/auth/profile', input),
changePassword: (input: ChangePasswordInput) => api.post<void>('/auth/change-password', input),
};

View File

@@ -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<T>(method: string, path: string, body?: unknown, opts?: { isFormData?: boolean }): Promise<T> {
const listeners = new Set<() => void>();
export function onUnauthorized(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
async function request<T>(
method: string,
path: string,
body?: unknown,
opts?: { isFormData?: boolean }
): Promise<T> {
const headers: Record<string, string> = {};
let payload: BodyInit | undefined;
if (opts?.isFormData) {
@@ -13,12 +31,22 @@ async function request<T>(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: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
getBlob: async (path: string): Promise<Blob> => {
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();
},