Files
PubScript/apps/web/src/api.ts
T

609 lines
17 KiB
TypeScript

import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript/core";
import { useSettings } from "./store/settings.js";
const BASE = "/api";
const TOKEN_KEY = "pubscript.admin.token";
const LOCAL_USERS_KEY = "pubscript.local.admin.users.v1";
const LOCAL_AUDIT_KEY = "pubscript.local.admin.audit.v1";
export interface AdminStats {
users: number;
projects: number;
chapters: number;
worldItems: number;
auditEntries: number;
disabledUsers: number;
}
function getStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
function setStoredToken(token: string | null): void {
try {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
function loadLocalUsers(): User[] {
try {
const raw = localStorage.getItem(LOCAL_USERS_KEY);
if (raw) {
const parsed = JSON.parse(raw) as User[];
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch {
/* ignore */
}
const initial: User[] = [
{
id: createUlid(),
email: "admin@pubscript.local",
displayName: "Local Admin",
role: "admin",
disabled: false,
createdAt: new Date().toISOString(),
},
{
id: createUlid(),
email: "mara@pubscript.local",
displayName: "Mara Vell",
role: "author",
disabled: false,
createdAt: new Date().toISOString(),
},
];
try {
localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(initial));
} catch {
/* ignore */
}
return initial;
}
function saveLocalUsers(users: User[]): void {
try {
localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(users));
} catch {
/* ignore */
}
}
function loadLocalAudit(): AuditLogEntry[] {
try {
const raw = localStorage.getItem(LOCAL_AUDIT_KEY);
if (raw) {
const parsed = JSON.parse(raw) as AuditLogEntry[];
if (Array.isArray(parsed)) return parsed;
}
} catch {
/* ignore */
}
const initial: AuditLogEntry[] = [
{
id: createUlid(),
at: new Date().toISOString(),
userId: "admin@pubscript.local",
action: "admin.bootstrap",
detail: { email: "admin@pubscript.local" },
},
];
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(initial));
} catch {
/* ignore */
}
return initial;
}
function appendLocalAudit(action: string, detail: Record<string, unknown>): void {
const entries = loadLocalAudit();
entries.unshift({
id: createUlid(),
at: new Date().toISOString(),
userId: "admin@pubscript.local",
action,
detail,
});
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(entries.slice(0, 500)));
} catch {
/* ignore */
}
}
async function bootstrapAdminSession(): Promise<string | null> {
try {
const res = await fetch(`${BASE}/auth/bootstrap-admin`, {
method: "POST",
credentials: "include",
});
const ct = res.headers.get("content-type") ?? "";
if (!res.ok || !ct.includes("application/json")) return null;
const data = (await res.json()) as { token?: string };
if (data.token) {
setStoredToken(data.token);
return data.token;
}
} catch {
/* server offline */
}
return null;
}
async function request<T>(path: string, init?: RequestInit, retried = false): Promise<T> {
const { serverEnabled } = useSettings.getState();
if (!serverEnabled) {
throw new Error("Server disabled in settings (Local Storage Mode)");
}
const token = getStoredToken();
const headers: Record<string, string> = {
...(init?.body ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...((init?.headers as Record<string, string> | undefined) ?? {}),
};
const res = await fetch(`${BASE}${path}`, {
credentials: "include",
...init,
headers,
});
const contentType = res.headers.get("content-type") ?? "";
if (res.status === 401 && !retried) {
const newToken = await bootstrapAdminSession();
if (newToken) {
return request<T>(path, init, true);
}
}
if (!res.ok) {
let detail = res.statusText;
if (contentType.includes("application/json")) {
try {
const body = (await res.json()) as { error?: string };
if (body.error) detail = body.error;
} catch {
/* ignore */
}
}
throw new Error(`${res.status} ${res.statusText}: ${detail}`);
}
if (res.status === 204) return undefined as T;
if (!contentType.includes("application/json")) {
throw new Error("API returned non-JSON response (offline fallback)");
}
return (await res.json()) as T;
}
export const api = {
// Admin
adminStats: async (): Promise<AdminStats> => {
try {
return await request<AdminStats>("/admin/stats");
} catch {
const users = loadLocalUsers();
const audit = loadLocalAudit();
return {
users: users.length,
projects: 1,
chapters: 3,
worldItems: 2,
auditEntries: audit.length,
disabledUsers: users.filter((u) => u.disabled).length,
};
}
},
listUsers: async (): Promise<User[]> => {
try {
const users = await request<User[]>("/admin/users");
saveLocalUsers(users);
return users;
} catch {
return loadLocalUsers();
}
},
createUser: async (input: {
email: string;
displayName: string;
role: Role;
password?: string;
}): Promise<User> => {
try {
return await request<User>("/admin/users", {
method: "POST",
body: JSON.stringify(input),
});
} catch (e) {
if (e instanceof Error && e.message.includes("email_taken")) {
throw new Error("A user with that email already exists.");
}
const users = loadLocalUsers();
if (users.some((u) => u.email.toLowerCase() === input.email.toLowerCase())) {
throw new Error("A user with that email already exists.");
}
const created: User = {
id: createUlid(),
email: input.email,
displayName: input.displayName,
role: input.role,
disabled: false,
createdAt: new Date().toISOString(),
};
users.unshift(created);
saveLocalUsers(users);
appendLocalAudit("user_create", { targetId: created.id, email: created.email, role: created.role });
return created;
}
},
updateUser: async (
id: string,
patch: { displayName?: string; role?: Role; disabled?: boolean; password?: string },
): Promise<User> => {
try {
return await request<User>(`/admin/users/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
} catch {
const users = loadLocalUsers();
const idx = users.findIndex((u) => u.id === id);
if (idx === -1) throw new Error("User not found");
const updated: User = {
...users[idx]!,
...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}),
...(patch.role !== undefined ? { role: patch.role } : {}),
...(patch.disabled !== undefined ? { disabled: patch.disabled } : {}),
};
users[idx] = updated;
saveLocalUsers(users);
appendLocalAudit("user_update", { targetId: id, changes: Object.keys(patch) });
return updated;
}
},
setUsersDisabled: async (id: string, disabled: boolean): Promise<User> => {
return api.updateUser(id, { disabled });
},
deleteUser: async (id: string): Promise<{ ok: true }> => {
try {
return await request<{ ok: true }>(`/admin/users/${id}`, {
method: "DELETE",
});
} catch {
const users = loadLocalUsers();
const target = users.find((u) => u.id === id);
saveLocalUsers(users.filter((u) => u.id !== id));
appendLocalAudit("user_delete", { targetId: id, email: target?.email ?? "" });
return { ok: true };
}
},
listAudit: async (params?: {
userId?: string;
action?: string;
limit?: number;
}): Promise<{
items: AuditLogEntry[];
total: number;
limit: number;
offset: number;
}> => {
try {
const q = new URLSearchParams();
if (params?.userId) q.set("userId", params.userId);
if (params?.action) q.set("action", params.action);
if (params?.limit) q.set("limit", String(params.limit));
const suffix = q.toString() ? `?${q.toString()}` : "";
const res = await request<{
items: AuditLogEntry[];
total: number;
limit: number;
offset: number;
}>(`/admin/audit${suffix}`);
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(res.items));
} catch {
/* ignore */
}
return res;
} catch {
const items = loadLocalAudit();
return {
items,
total: items.length,
limit: params?.limit ?? 200,
offset: 0,
};
}
},
clearAudit: async (keepAll = false): Promise<{ ok: true; deleted: number }> => {
try {
return await request<{ ok: true; deleted: number }>("/admin/audit/clear", {
method: "POST",
body: JSON.stringify({ keepAll }),
});
} catch {
const prev = loadLocalAudit().length;
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify([]));
} catch {
/* ignore */
}
return { ok: true, deleted: prev };
}
},
// Auth
me: () => request<{ user: User | null }>("/auth/me"),
login: (email: string, password: string) =>
request<{ token: string; user: User }>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
register: (email: string, password: string, displayName: string) =>
request<{ token: string; user: User }>("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password, displayName }),
}),
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
getServerConfig: async (portHint?: number): Promise<{
online: boolean;
enabled: boolean;
host?: string;
activeHost?: string;
allowedHosts?: string;
port: number;
activePort?: number;
webPort?: number;
activeWebPort?: number;
startOnBoot: boolean;
projectDirectory?: string;
}> => {
const endpoints = [
"/api/system/server-config",
...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []),
];
for (const url of endpoints) {
try {
const res = await fetch(url, { credentials: "include" });
const ct = res.headers.get("content-type") ?? "";
if (res.ok && ct.includes("application/json")) {
const data = (await res.json()) as {
enabled: boolean;
host?: string;
activeHost?: string;
allowedHosts?: string;
port: number;
activePort?: number;
webPort?: number;
activeWebPort?: number;
startOnBoot: boolean;
projectDirectory?: string;
};
return {
online: true,
enabled: data.enabled,
...(data.host ? { host: data.host } : {}),
...(data.activeHost ? { activeHost: data.activeHost } : {}),
...(data.allowedHosts ? { allowedHosts: data.allowedHosts } : {}),
port: data.port,
...(data.activePort !== undefined ? { activePort: data.activePort } : {}),
...(data.webPort !== undefined ? { webPort: data.webPort } : {}),
...(data.activeWebPort !== undefined ? { activeWebPort: data.activeWebPort } : {}),
startOnBoot: data.startOnBoot,
...(data.projectDirectory ? { projectDirectory: data.projectDirectory } : {}),
};
}
} catch {
/* try next endpoint */
}
}
return {
online: false,
enabled: true,
host: "127.0.0.1",
allowedHosts: "localhost, 127.0.0.1",
port: portHint ?? 8081,
webPort: 5173,
startOnBoot: false,
};
},
updateServerConfig: async (
patch: {
enabled?: boolean;
host?: string;
allowedHosts?: string;
port?: number;
webPort?: number;
startOnBoot?: boolean;
projectDirectory?: string;
},
portHint?: number,
): Promise<{
online: boolean;
enabled: boolean;
host?: string;
activeHost?: string;
allowedHosts?: string;
port: number;
activePort?: number;
webPort?: number;
activeWebPort?: number;
startOnBoot: boolean;
projectDirectory?: string;
}> => {
const endpoints = [
"/api/system/server-config",
...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []),
];
for (const url of endpoints) {
try {
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
const ct = res.headers.get("content-type") ?? "";
if (res.ok && ct.includes("application/json")) {
const data = (await res.json()) as {
enabled: boolean;
host?: string;
activeHost?: string;
allowedHosts?: string;
port: number;
activePort?: number;
webPort?: number;
activeWebPort?: number;
startOnBoot: boolean;
projectDirectory?: string;
};
return {
online: true,
enabled: data.enabled,
...(data.host ? { host: data.host } : {}),
...(data.activeHost ? { activeHost: data.activeHost } : {}),
...(data.allowedHosts ? { allowedHosts: data.allowedHosts } : {}),
port: data.port,
...(data.activePort !== undefined ? { activePort: data.activePort } : {}),
...(data.webPort !== undefined ? { webPort: data.webPort } : {}),
...(data.activeWebPort !== undefined ? { activeWebPort: data.activeWebPort } : {}),
startOnBoot: data.startOnBoot,
...(data.projectDirectory ? { projectDirectory: data.projectDirectory } : {}),
};
}
} catch {
/* try next endpoint */
}
}
return {
online: false,
enabled: patch.enabled ?? true,
...(patch.host ? { host: patch.host } : {}),
...(patch.allowedHosts ? { allowedHosts: patch.allowedHosts } : {}),
port: patch.port ?? portHint ?? 8081,
...(patch.webPort !== undefined ? { webPort: patch.webPort } : {}),
startOnBoot: patch.startOnBoot ?? false,
...(patch.projectDirectory ? { projectDirectory: patch.projectDirectory } : {}),
};
},
listSystemDirectories: async (
dirPath?: string,
portHint?: number,
): Promise<{
online: boolean;
currentPath: string;
parentPath: string | null;
homePath: string;
directories: { name: string; path: string }[];
}> => {
const query = dirPath ? `?path=${encodeURIComponent(dirPath)}` : "";
const endpoints = [
`/api/system/list-dirs${query}`,
...(portHint ? [`http://localhost:${portHint}/api/system/list-dirs${query}`] : []),
];
for (const url of endpoints) {
try {
const res = await fetch(url, { credentials: "include" });
const ct = res.headers.get("content-type") ?? "";
if (res.ok && ct.includes("application/json")) {
const data = (await res.json()) as {
currentPath: string;
parentPath: string | null;
homePath: string;
directories: { name: string; path: string }[];
};
return {
online: true,
currentPath: data.currentPath,
parentPath: data.parentPath,
homePath: data.homePath,
directories: data.directories,
};
}
} catch {
/* try next */
}
}
return {
online: false,
currentPath: dirPath || "~/Documents/PubScript",
parentPath: null,
homePath: "~",
directories: [],
};
},
saveExportToDirectory: async (
params: {
directory: string;
fileName: string;
data: Uint8Array | string;
},
portHint?: number,
): Promise<{ savedOnServer: boolean; savedPath?: string }> => {
const bytes =
typeof params.data === "string"
? new TextEncoder().encode(params.data)
: params.data;
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
const base64Data = btoa(binary);
const endpoints = [
"/api/system/save-export",
...(portHint ? [`http://localhost:${portHint}/api/system/save-export`] : []),
];
for (const url of endpoints) {
try {
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
directory: params.directory,
fileName: params.fileName,
base64Data,
}),
});
const ct = res.headers.get("content-type") ?? "";
if (res.ok && ct.includes("application/json")) {
const out = (await res.json()) as { ok?: boolean; savedPath?: string };
if (out.ok && out.savedPath) {
return { savedOnServer: true, savedPath: out.savedPath };
}
}
} catch {
/* try next */
}
}
return { savedOnServer: false };
},
};