609 lines
17 KiB
TypeScript
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 };
|
|
},
|
|
};
|