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): 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 { 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(path: string, init?: RequestInit, retried = false): Promise { const { serverEnabled } = useSettings.getState(); if (!serverEnabled) { throw new Error("Server disabled in settings (Local Storage Mode)"); } const token = getStoredToken(); const headers: Record = { ...(init?.body ? { "Content-Type": "application/json" } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...((init?.headers as Record | 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(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 => { try { return await request("/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 => { try { const users = await request("/admin/users"); saveLocalUsers(users); return users; } catch { return loadLocalUsers(); } }, createUser: async (input: { email: string; displayName: string; role: Role; password?: string; }): Promise => { try { return await request("/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 => { try { return await request(`/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 => { 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; 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; port: number; activePort?: number; webPort?: number; activeWebPort?: number; startOnBoot: boolean; projectDirectory?: string; }; return { online: true, enabled: data.enabled, 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, port: portHint ?? 8081, webPort: 5173, startOnBoot: false, }; }, updateServerConfig: async ( patch: { enabled?: boolean; port?: number; webPort?: number; startOnBoot?: boolean; projectDirectory?: string; }, portHint?: number, ): Promise<{ online: boolean; enabled: boolean; 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; port: number; activePort?: number; webPort?: number; activeWebPort?: number; startOnBoot: boolean; projectDirectory?: string; }; return { online: true, enabled: data.enabled, 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, 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 }; }, };