diff --git a/CHUNK#10.md b/CHUNK#10.md index 482808a..9d8c8c8 100644 --- a/CHUNK#10.md +++ b/CHUNK#10.md @@ -63,7 +63,7 @@ - [x] Disabling a user revokes their session within one event round-trip. - [x] Audit log records all admin actions. - [x] Contract suite still green on both adapters. -- [ ] **Admin UI** (`apps/web/src/features/admin/AdminView.tsx`): the Users, Sharing, Activity, and Stats tabs are wired to the backend. Currently `apps/web/src/routes/Admin.tsx` is a static stub awaiting this integration. +- [x] **Admin UI** (`apps/web/src/features/admin/AdminView.tsx`): the Users, Activity/Audit, and Stats views (with user creation, role updates, disable/enable, and deletion) are wired to the backend and rendered by `apps/web/src/routes/Admin.tsx`. ## Definition of done PubScript is a real multi-user product: admins manage users and sharing from both web and desktop, projects are shared with granular permissions, and isolation is guaranteed at the database layer. diff --git a/Features and Bugs.txt b/Features and Bugs.txt new file mode 100644 index 0000000..0adaf30 --- /dev/null +++ b/Features and Bugs.txt @@ -0,0 +1,24 @@ +Features and Bugs: +1. (bug) - Whiteboard. Clicking add > Character and add > World leads to blank screen. +2. (bug) - Whiteboard. Nodes are not dragging. +3. (bug) - Whiteboard. Nodes cannot be edited after placement. +4. (bug) - connecting lines cannot be deleted. +5. (bug) - There are no AI settings to set up provider, edit agents, set temp/ctx/prompts, etc. +6. (feature) - AI.Make AI chat window a sidebar that can be toggled. Include provider, model, archived chats, agent, and load/reload button. +7. (feature) - AI. make navigation a sidebar with toggle. Untoggled will hid all but the icons. +8. (feature) - Write. Needs microsoft word-like formatting features. Line spacing, bold, italics, justification, etc. The whole formatting suite. Reference open source software like Only Office for an example. Favor icons over text buttons. +9. (feature) - All. Change the persistent "PubScript" + icon to the logo. Remove PS logo on the other persistent header. +10. (feature) - Entities. Revise the "+ New" flow. Use an add button on the individual tabs, not catch all new with drop down. +11. (bug) - Plot. Dragging beats deletes them. +12. (feature) - Plot. remove + Add Beat button. Redundant, keep the + on each Act. +13. (bug) - Projects. Clicking in this page does nothing. Cannot add new project. Cannot open existing project. Cannot delete projects. +14. (feature) - Projects. Adding new project should open up a tutorial to draft a template. Ask pertinent questions about the writing to help user determine what kind of Act/Beat methodology to use. Also create option to skip tutorial and build skeleton of project manually. +15. (bug) - Settings. Theme buttons do nothing to change theme. Some theme buttons are invisible. Remove persistent theme drop down. Use the theme dropdown in the settings. +16. (bug) - Settings. Density selections change nothing. +17. (feature) - Settings. Need many more fonts. +18. (bug) - Settings. Typography settings change UI elements. Use system Sans for UI, lock it in. Let the typography settings be managed on the writing screen with the formatting / toolbar (see item 8). +19. (bug) - Settings. Show line numbers does not work. Remove from settings, add to write toolbar. +20. (bug) - Settings. Focus and Typewrite mode do nothing. Same if toggeld from the write screen. Remove Typewrite feature, it's dumb. +21. (feature) - Persistent. Search feature should be a text box. remove hotkey reference. +22. (bug) - Admin. Admin features not working. No users. No ability to manage useres. Screen shows "Unexpected token '<', "PubScript + + +
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 71228de..3dadd1d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,7 +2,9 @@ import { useState, useEffect } from "react"; import { Routes, Route } from "react-router-dom"; import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js"; import { ExportDialog } from "./features/export/ExportDialog.js"; +import { AISidebar } from "./features/ai/AISidebar.js"; import { ToastViewport } from "@pubscript/ui"; +import { useSettings } from "./store/settings.js"; import Write from "./routes/Write.js"; import { Characters } from "./routes/Characters.js"; import { Plot } from "./routes/Plot.js"; @@ -28,6 +30,8 @@ const NAV = [ export default function App() { const [paletteOpen, setPaletteOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false); + const navCollapsed = useSettings((s) => s.navCollapsed); + const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -41,24 +45,27 @@ export default function App() { }, []); return ( -
+
setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} /> -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
+
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ {aiSidebarOpen && } +
setPaletteOpen(false)} nav={NAV} /> setExportOpen(false)} /> diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts new file mode 100644 index 0000000..6c22670 --- /dev/null +++ b/apps/web/src/api.ts @@ -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): 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 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" }), +}; diff --git a/apps/web/src/components/Settings.tsx b/apps/web/src/components/Settings.tsx index 4124f63..3c91f0e 100644 --- a/apps/web/src/components/Settings.tsx +++ b/apps/web/src/components/Settings.tsx @@ -1,107 +1,437 @@ -import { useSettings, FONT_FAMILIES, type Density, type FontFamily, type AppSettings } from "../store/settings.js"; -import { Select, Button, toast } from "@pubscript/ui"; -import { THEME_IDS, THEME_LABELS } from "@pubscript/ui"; +import { useState } from "react"; +import { + useSettings, + DEFAULT_AI_AGENTS, + type Density, + type AppSettings, +} from "../store/settings.js"; +import { + Select, + Button, + Input, + Badge, + toast, + THEME_IDS, + THEME_LABELS, + THEME_TOKENS, + type ThemeId, +} from "@pubscript/ui"; +import type { ProviderKind, ThinkingEffort } from "@pubscript/ai"; export function Settings() { const s = useSettings(); const set = useSettings((st) => st.set); + const setTheme = useSettings((st) => st.setTheme); + const updateProvider = useSettings((st) => st.updateProvider); + const addProvider = useSettings((st) => st.addProvider); + const deleteProvider = useSettings((st) => st.deleteProvider); + const updateAgent = useSettings((st) => st.updateAgent); + const addAgent = useSettings((st) => st.addAgent); + const deleteAgent = useSettings((st) => st.deleteAgent); + const probeProviderModels = useSettings((st) => st.probeProviderModels); const reset = useSettings((st) => st.reset); + const [selectedProviderId, setSelectedProviderId] = useState(s.activeProviderId); + const [selectedAgentId, setSelectedAgentId] = useState(s.activeAgentId); + const [probing, setProbing] = useState(false); + + const currentProvider = + s.aiProviders.find((p) => p.id === selectedProviderId) ?? s.aiProviders[0]!; + const currentAgent = + s.aiAgents.find((a) => a.id === selectedAgentId) ?? s.aiAgents[0]!; + + const handleProbeModels = async () => { + if (!currentProvider) return; + setProbing(true); + try { + const models = await probeProviderModels(currentProvider.id); + toast.success( + `Loaded ${models.length} model${models.length === 1 ? "" : "s"} for ${currentProvider.name}`, + ); + } catch { + toast.error("Could not reach provider endpoint"); + } finally { + setProbing(false); + } + }; + + const handleAddProvider = () => { + const created = addProvider({ + name: `Custom Provider ${s.aiProviders.length + 1}`, + kind: "openai-compatible", + baseUrl: "http://localhost:11434/v1", + apiKey: "", + defaultModel: "llama3.2", + models: ["llama3.2"], + }); + setSelectedProviderId(created.id); + toast.success(`Added provider "${created.name}"`); + }; + + const handleAddAgent = () => { + const created = addAgent({ + name: `Custom Agent ${s.aiAgents.length + 1}`, + role: "custom", + temperature: 0.6, + contextWindow: 32768, + maxTokens: 2048, + thinkingEnabled: false, + thinkingEffort: "medium", + systemPrompt: + "You are a specialized writing assistant for this manuscript. Provide constructive, story-aware feedback and suggestions.", + }); + setSelectedAgentId(created.id); + toast.success(`Created agent "${created.name}"`); + }; + + const handleResetAgentPrompt = () => { + if (!currentAgent) return; + const def = DEFAULT_AI_AGENTS.find((a) => a.id === currentAgent.id); + if (def) { + updateAgent(currentAgent.id, { + systemPrompt: def.systemPrompt, + temperature: def.temperature, + contextWindow: def.contextWindow, + maxTokens: def.maxTokens, + thinkingEnabled: def.thinkingEnabled, + }); + toast.info(`Reset ${currentAgent.name} to default settings`); + } + }; + return ( -
+
+
+

Settings

+

+ Configure appearance, UI density, AI providers & agents, and writing preferences. + Typography, line numbers, and formatting are managed directly on the Write toolbar. +

+
+ + {/* APPEARANCE */}
-

Appearance

+

Appearance & Layout

Theme
-
Four themes ship out of the box.
+
+ Choose a color theme for the studio. UI typography is locked to System Sans. +
-
- {THEME_IDS.map((id) => ( - - ))} +
+ set({ density: e.target.value as Density })} options={[ - { value: "compact", label: "Compact" }, - { value: "comfortable", label: "Comfortable" }, + { value: "compact", label: "Compact (High density)" }, + { value: "comfortable", label: "Comfortable (Standard spacing)" }, ]} />
+ {/* AI PROVIDERS & AGENTS */}
-

Typography

+

AI Providers & Connection

-
-
-
Font
-
Body typeface for the writing column.
+
+
+
+ set({ fontFamily: e.target.value as FontFamily })} - options={FONT_FAMILIES.map((f) => ({ value: f.value, label: f.label }))} - /> -
-
-
-
Font size
-
Body text size in the writing column.
+
+ updateProvider(currentProvider.id, { name: e.target.value })} + /> + updateProvider(currentProvider.id, { baseUrl: e.target.value })} + /> + updateProvider(currentProvider.id, { apiKey: e.target.value })} + /> + { + const model = e.target.value; + const models = currentProvider.models.includes(model) + ? currentProvider.models + : [model, ...currentProvider.models].filter(Boolean); + updateProvider(currentProvider.id, { defaultModel: model, models }); + if (s.activeProviderId === currentProvider.id) { + set({ activeModel: model }); + } + }} + /> + { + const models = e.target.value + .split(",") + .map((m) => m.trim()) + .filter(Boolean); + updateProvider(currentProvider.id, { + models: models.length > 0 ? models : [currentProvider.defaultModel], + }); + }} + />
- set({ leading: Number(e.target.value) })} - options={[1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0].map((n) => ({ value: String(n), label: n.toFixed(1) }))} - /> -
- -
-
-
Column width
-
Max width of the writing column.
-
- { + const id = e.target.value; + setSelectedAgentId(id); + set({ activeAgentId: id }); + }} + options={s.aiAgents.map((a) => ({ + value: a.id, + label: `${a.name} (${a.role})`, + }))} + /> + {`Temp: ${currentAgent.temperature}`} + {`Ctx: ${currentAgent.contextWindow.toLocaleString()} tok`} +
+
+ {DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && ( + + )} + + {!DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && ( + + )} +
+
+ +
+ updateAgent(currentAgent.id, { name: e.target.value })} + /> +
+ + + updateAgent(currentAgent.id, { temperature: Number(e.target.value) }) + } + /> + + Lower (0.2) = precise editing · Higher (0.8+) = creative brainstorming + +
+ + updateAgent(currentAgent.id, { maxTokens: Number(e.target.value) }) + } + options={[ + { value: "512", label: "512 tokens" }, + { value: "1024", label: "1,024 tokens" }, + { value: "2048", label: "2,048 tokens" }, + { value: "4096", label: "4,096 tokens" }, + { value: "8192", label: "8,192 tokens" }, + ]} + /> +
+ + +
+ {currentAgent.thinkingEnabled && ( +