WP-01: authentication, user management and application shell
Argon2id password hashing, JWT access tokens with rotating HttpOnly refresh cookies and reuse detection, login rate limiting, auth audit log, bootstrap admin, admin-only user CRUD and password reset, OpenAPI spec. Vue login page, auth store with automatic token refresh, route guards, sidebar shell with toasts and placeholder pages, user management page. All tests green: 40 backend, 12 Vitest, 4 Playwright. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
62
frontend/src/api/client.ts
Normal file
62
frontend/src/api/client.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { TokenResponse } from './types'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
code: string
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
async function parse(res: Response): Promise<unknown> {
|
||||
if (res.status === 204) return undefined
|
||||
const text = await res.text()
|
||||
return text ? JSON.parse(text) : undefined
|
||||
}
|
||||
|
||||
async function toError(res: Response): Promise<ApiError> {
|
||||
const body = (await parse(res).catch(() => undefined)) as
|
||||
{ error?: string; message?: string } | undefined
|
||||
return new ApiError(res.status, body?.error ?? 'unknown', body?.message ?? res.statusText)
|
||||
}
|
||||
|
||||
/** Refresh the access token via the HttpOnly cookie. Returns false if it failed. */
|
||||
export async function refreshAccessToken(): Promise<boolean> {
|
||||
const auth = useAuthStore()
|
||||
const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin' })
|
||||
if (!res.ok) {
|
||||
auth.clear()
|
||||
return false
|
||||
}
|
||||
const data = (await parse(res)) as TokenResponse
|
||||
auth.setSession(data)
|
||||
return true
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown, retry = true): Promise<T> {
|
||||
const auth = useAuthStore()
|
||||
const headers: Record<string, string> = {}
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (auth.accessToken) headers['Authorization'] = `Bearer ${auth.accessToken}`
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
if (res.status === 401 && retry && !path.startsWith('/api/auth/')) {
|
||||
if (await refreshAccessToken()) return request<T>(method, path, body, false)
|
||||
throw new ApiError(401, 'unauthorized', 'session expired')
|
||||
}
|
||||
if (!res.ok) throw await toError(res)
|
||||
return (await parse(res)) as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
}
|
||||
Reference in New Issue
Block a user