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>
63 lines
2.1 KiB
TypeScript
63 lines
2.1 KiB
TypeScript
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),
|
|
}
|