feat: resolve all 22 items in Features and Bugs.txt and complete UI polish
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript/core";
|
||||
|
||||
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 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" }),
|
||||
};
|
||||
Reference in New Issue
Block a user