Files
Clarium/src/api.ts
T
mroberts 7f5d034a1f feat: auth by default, theme toggle, CoderPad startup, review fixes
- Require a session on every route; public routes opt out with @allow_anonymous
- Split password hashing and pepper loading into passwords.py
- Add a system/light/dark theme toggle backed by light-dark() colors
- Ignore stale 401s from an earlier session, PATCH only changed book fields,
  and block overlapping journal-entry saves
- Add bin/start and CoderPad Vite server settings for the pad's start/restart
- Rewrite README as a mise onboarding guide; expand .gitignore
- Include review-round fixes and tests
2026-10-02 16:49:14 -05:00

84 lines
2.1 KiB
TypeScript

export type User = { id: number; username: string };
export type Genre = { id: number; name: string };
export type ReadingStatus = "not_started" | "reading" | "finished";
export type Book = {
id: number;
title: string;
author: string;
genre: Genre;
total_pages: number;
current_page: number;
status: ReadingStatus;
created_at: string;
updated_at: string;
};
export type Note = {
id: number;
book_id: number;
body: string;
created_at: string;
updated_at: string;
};
export class ApiError extends Error {
readonly status: number;
readonly field?: string;
constructor(status: number, message: string, field?: string) {
super(message);
this.status = status;
this.field = field;
}
}
let handleUnauthorized: () => void = () => {};
let authEpoch = 0;
// A 401 only means "signed out" for the session that sent the request; one started before
// a later login, logout or expiry must not end the session that replaced it.
export function startAuthEpoch() {
authEpoch += 1;
}
export function setUnauthorizedHandler(handler: () => void) {
handleUnauthorized = handler;
}
export async function api<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
body?: unknown,
): Promise<T> {
const epoch = authEpoch;
const response = await fetch(`/api${path}`, {
method,
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (response.status === 204) return undefined as T;
const data = await response.json().catch(() => null);
if (!response.ok) {
if (
response.status === 401 &&
!path.startsWith("/auth/") &&
epoch === authEpoch
) {
startAuthEpoch();
handleUnauthorized();
}
throw new ApiError(
response.status,
data?.error ?? `Request failed with status ${response.status}`,
data?.field,
);
}
return data as T;
}
export function errorMessage(error: unknown): string {
return error instanceof ApiError
? error.message
: "Could not reach the server; check your connection and try again";
}