diff --git a/AGENTS.md b/AGENTS.md index d4ed9d3..8a7c03f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,10 @@ Performance Review Manager — a local, single-user ("solo EM") pnpm monorepo. S - This repo requires **pnpm 10.33.3**. The VM's default corepack resolves pnpm 11, which (a) rejects this committed lockfile via a `minimumReleaseAge` supply-chain policy and (b) ignores the `pnpm.onlyBuiltDependencies` field in `package.json` that is needed to build the `better-sqlite3` native module. The update script pins 10.33.3 via `corepack prepare pnpm@10.33.3 --activate`; keep using pnpm 10.x. If native `better-sqlite3` is missing, run `pnpm rebuild better-sqlite3`. ### Data / running -- Local data lives in `/workspace/data` (gitignored): SQLite DB, encrypted vault, uploaded files. Delete it to reset to a clean state (stop the API first, since it holds the DB handle). +- Local data lives in `/workspace/data` (gitignored): SQLite DB, encrypted vault, uploaded files. Delete it to reset to a clean state (stop the API first, since it holds the DB handle), or use **Delete workspace** in Settings / **Erase workspace** on Unlock (password + type `DELETE`). - First app load offers **Empty workspace** or **Demo team** (seeds an 8-person org). After init it shows "Unlock". Default demo password: `workbench`. - Locking the workspace ("Lock & encrypt") seals the SQLite DB + files to `.enc` at rest; unlocking decrypts them. +- After erase, the create flow returns so you can start a new empty or demo workspace. - `.session` stores the auth token only (not the AES key). After an API process restart you must unlock again even if the UI still has a token. ### Known pre-existing issues (not environment problems) diff --git a/README.md b/README.md index e156a18..03a7acb 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ pnpm dev | API | http://127.0.0.1:8787 | | Seed password | `workbench` | -First load: choose **Empty workspace** or **Demo team** (8-person org, password default `workbench`), then unlock later sessions with your password. Between sessions use **Lock & encrypt**. +First load: choose **Empty workspace** or **Demo team** (8-person org, password default `workbench`), then unlock later sessions with your password. Between sessions use **Lock & encrypt**. To wipe and start over, use **Delete workspace** in Settings (or **Erase workspace** on the unlock screen) — password + type `DELETE`. Requires **pnpm 10.33.3** (see [`AGENTS.md`](./AGENTS.md) if Corepack hands you pnpm 11). diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 31fad9c..58aad38 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -71,6 +71,7 @@ import { nowIso, requireSession, bootstrapVault, + destroyWorkspace, unlockVault, writeEncryptedDocument, writeFrameworkFile, @@ -196,7 +197,8 @@ app.use("/api/*", async (c, next) => { c.req.path === "/api/health" || c.req.path === "/api/workspace/status" || c.req.path === "/api/workspace/init" || - c.req.path === "/api/workspace/unlock" + c.req.path === "/api/workspace/unlock" || + c.req.path === "/api/workspace/destroy" ) { return next(); } @@ -369,6 +371,25 @@ app.post("/api/workspace/lock", (c) => { return c.json({ ok: true, encryptedAtRest: true }); }); +/** Wipe local vault + data so a new empty/demo workspace can be created. Public (password-gated). */ +app.post("/api/workspace/destroy", async (c) => { + if (!isInitialized()) return c.json({ error: "No workspace to delete" }, 400); + const body = await c.req.json<{ password?: string; confirm?: string }>(); + if (body.confirm !== "DELETE") { + return c.json({ error: 'Type confirm: "DELETE" to erase the workspace' }, 400); + } + if (!body.password) return c.json({ error: "password required" }, 400); + try { + destroyWorkspace(body.password); + return c.json({ ok: true, initialized: false }); + } catch (e) { + const err = e as Error & { code?: string }; + const status = + err.message === "Invalid password" ? 401 : err.code === "legal_hold" ? 403 : 400; + return c.json({ error: err.message || "Destroy failed", code: err.code }, status); + } +}); + app.get("/api/workspace/backups", (c) => { return c.json({ backups: listEncryptedBackups() }); }); diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index 505cc5c..93e69fc 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -356,6 +356,91 @@ export function lockVault() { clearSession(); } +/** + * Permanently delete the local workspace (DB, vault, files, backups). + * Requires the workspace password. Blocks when legal hold is enabled. + * After success, `/api/workspace/init` can create a fresh empty or demo workspace. + */ +export function destroyWorkspace(password: string): void { + if (!isInitialized()) { + throw new Error("No workspace to delete"); + } + + const wasUnlocked = Boolean(sessionToken && workspaceSecret && existsSync(DB_PATH) && dbHandle); + let unlockedForDestroy = false; + + try { + if (!wasUnlocked) { + unlockVault(password); + unlockedForDestroy = true; + } else { + const vault = readVault(); + if (vault) { + if (!verifyPassword(password, vault.passwordHash, vault.salt)) { + throw new Error("Invalid password"); + } + } else { + const row = getDb().select().from(workspace).limit(1).all()[0]; + if (!row || !verifyPassword(password, row.passwordHash, row.passwordSalt)) { + throw new Error("Invalid password"); + } + } + } + + const row = getDb().select().from(workspace).limit(1).all()[0]; + if (row) { + let parsed: { legalHold?: boolean } = {}; + try { + parsed = JSON.parse(row.settingsJson || "{}") as { legalHold?: boolean }; + } catch { + parsed = {}; + } + if (parsed.legalHold) { + const err = new Error( + "Legal hold is on — disable it in Settings → Retention before deleting the workspace.", + ); + (err as Error & { code?: string }).code = "legal_hold"; + throw err; + } + } + } catch (e) { + if (unlockedForDestroy) { + try { + lockVault(); + } catch { + /* ignore re-seal failure */ + } + } + throw e; + } + + try { + if (sqliteHandle) { + sqliteHandle.pragma("wal_checkpoint(TRUNCATE)"); + } + } catch { + /* ignore */ + } + closeDbHandles(); + removeSidecars(); + clearSession(); + + for (const f of [DB_PATH, `${DB_PATH}-wal`, `${DB_PATH}-shm`, DB_ENC_PATH, VAULT_PATH, SESSION_PATH]) { + try { + if (existsSync(f)) unlinkSync(f); + } catch { + /* ignore */ + } + } + + rmSync(paths.files, { recursive: true, force: true }); + rmSync(paths.backups, { recursive: true, force: true }); + mkdirSync(paths.documents, { recursive: true }); + mkdirSync(paths.frameworks, { recursive: true }); + mkdirSync(paths.exports, { recursive: true }); + mkdirSync(paths.backups, { recursive: true }); +} + export function encryptionStatus() { const vault = readVault(); return { diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 7d4cf0e..9329705 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -151,10 +151,22 @@ export function App() { const unlocked = Boolean(getToken()) && status.unlocked; + async function handleWorkspaceDestroyed() { + setToken(null); + await refresh(); + } + if (!status.initialized || !unlocked) { return ( <> - { setToken(token); await refresh(); }} /> + { + setToken(token); + await refresh(); + }} + onDestroyed={handleWorkspaceDestroyed} + />
{themeToggle}
); @@ -255,7 +267,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/apps/ui/src/components/PageSectionNav.tsx b/apps/ui/src/components/PageSectionNav.tsx new file mode 100644 index 0000000..b09c69a --- /dev/null +++ b/apps/ui/src/components/PageSectionNav.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from "react"; + +export type SectionNavItem = { + id: string; + label: string; +}; + +export function PageSectionNav({ + items, + ariaLabel = "On this page", +}: { + items: SectionNavItem[]; + ariaLabel?: string; +}) { + const [activeId, setActiveId] = useState(items[0]?.id ?? ""); + const sectionKey = items.map((item) => item.id).join("|"); + + useEffect(() => { + const nodes = items + .map((item) => document.getElementById(item.id)) + .filter((el): el is HTMLElement => Boolean(el)); + if (nodes.length === 0) return; + + const ratios = new Map(); + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + ratios.set(entry.target.id, entry.isIntersecting ? entry.intersectionRatio : 0); + } + const visible = items + .map((item) => document.getElementById(item.id)) + .filter((el): el is HTMLElement => Boolean(el)) + .filter((el) => (ratios.get(el.id) ?? 0) > 0.02) + .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); + if (visible[0]) { + setActiveId(visible[0].id); + return; + } + const above = nodes + .filter((n) => n.getBoundingClientRect().top <= 140) + .sort((a, b) => b.getBoundingClientRect().top - a.getBoundingClientRect().top)[0]; + if (above) setActiveId(above.id); + }, + { + root: null, + rootMargin: "-12% 0px -60% 0px", + threshold: [0, 0.1, 0.25, 0.5, 1], + }, + ); + + for (const node of nodes) observer.observe(node); + return () => observer.disconnect(); + }, [sectionKey, items]); + + function goTo(id: string) { + const el = document.getElementById(id); + if (!el) return; + setActiveId(id); + el.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + return ( + + ); +} diff --git a/apps/ui/src/pages/SettingsPage.tsx b/apps/ui/src/pages/SettingsPage.tsx index e0be0f1..7536a3d 100644 --- a/apps/ui/src/pages/SettingsPage.tsx +++ b/apps/ui/src/pages/SettingsPage.tsx @@ -4,9 +4,21 @@ import { api } from "../lib/api"; import { useTheme } from "../hooks/useTheme"; import { ACCENTS, type Accent, type ColorMode } from "../lib/theme"; import { NextStep, PageHeader } from "../components/PageChrome"; +import { PageSectionNav, type SectionNavItem } from "../components/PageSectionNav"; import { IntegrationsPanel } from "../components/IntegrationsPanel"; import { RetentionPanel } from "../components/RetentionPanel"; +const SETTINGS_SECTIONS: SectionNavItem[] = [ + { id: "settings-appearance", label: "Appearance" }, + { id: "settings-retention", label: "Retention" }, + { id: "settings-integrations", label: "Integrations" }, + { id: "settings-audit", label: "Audit" }, + { id: "settings-backup", label: "Backup" }, + { id: "settings-ai", label: "AI" }, + { id: "settings-danger", label: "Danger zone" }, + { id: "settings-desktop", label: "Desktop" }, +]; + type AiSettings = { enabled: boolean; provider: string; @@ -22,7 +34,11 @@ type AiSettings = { apiKeyFromEnv?: boolean; }; -export function SettingsPage() { +export function SettingsPage({ + onWorkspaceDestroyed, +}: { + onWorkspaceDestroyed: () => Promise; +}) { const [settings, setSettings] = useState(null); const [apiKey, setApiKey] = useState(""); const [msg, setMsg] = useState(null); @@ -33,6 +49,11 @@ export function SettingsPage() { Array<{ filename: string; byteSize: number; modifiedAt: string }> >([]); const [backupBusy, setBackupBusy] = useState(false); + const [destroyOpen, setDestroyOpen] = useState(false); + const [destroyPassword, setDestroyPassword] = useState(""); + const [destroyConfirm, setDestroyConfirm] = useState(""); + const [destroyBusy, setDestroyBusy] = useState(false); + const [destroyError, setDestroyError] = useState(null); const { colorMode, setColorMode, accent, setAccent, resolved } = useTheme(); async function load() { @@ -106,7 +127,9 @@ export function SettingsPage() { } /> -
+
+
+

Appearance

@@ -157,11 +180,15 @@ export function SettingsPage() {
- +
+ +
- +
+ +
-
+

Activity audit

Hash-chained local activity log with append-only SQLite triggers. Verify recomputes the chain; @@ -228,7 +255,7 @@ export function SettingsPage() { )}

-
+

Backup & restore

Encrypted snapshots of the unlocked SQLite DB under data/backups. Restore replaces the live @@ -339,6 +366,7 @@ export function SettingsPage() { /> )} +

{msg &&

{msg}

}
+
+ +
+

Danger zone

+

+ Permanently erase this local workspace (database, vault, uploads, and backups), then create a + new empty or demo workspace. Legal hold must be off. This cannot be undone. +

+ {!destroyOpen ? ( +
+ +
+ ) : ( +
{ + e.preventDefault(); + setDestroyBusy(true); + setDestroyError(null); + try { + if (destroyConfirm !== "DELETE") { + setDestroyError('Type DELETE to confirm.'); + setDestroyBusy(false); + return; + } + await api("/api/workspace/destroy", { + method: "POST", + body: JSON.stringify({ password: destroyPassword, confirm: "DELETE" }), + }); + await onWorkspaceDestroyed(); + } catch (err) { + setDestroyError(err instanceof Error ? err.message : "Delete failed"); + } finally { + setDestroyBusy(false); + } + }} + > +
+ + setDestroyPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+
+ + setDestroyConfirm(e.target.value)} + autoComplete="off" + placeholder="DELETE" + required + /> +
+ {destroyError &&

{destroyError}

} +
+ + +
+
+ )} +
-
+

Desktop updates

Packaged builds serve the UI from the local API (same-origin) to avoid WebView “Failed to fetch”. Check GitHub @@ -550,6 +668,10 @@ export function SettingsPage() { Check for updates

+
+ + +
); } diff --git a/apps/ui/src/pages/UnlockPage.tsx b/apps/ui/src/pages/UnlockPage.tsx index b1815b3..9361e49 100644 --- a/apps/ui/src/pages/UnlockPage.tsx +++ b/apps/ui/src/pages/UnlockPage.tsx @@ -7,15 +7,20 @@ type SetupMode = "empty" | "demo"; export function UnlockPage({ status, onUnlocked, + onDestroyed, }: { status: WorkspaceStatus; onUnlocked: (token: string) => Promise; + onDestroyed: () => Promise; }) { const [setupMode, setSetupMode] = useState("empty"); const [password, setPassword] = useState(""); const [name, setName] = useState("My team"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [eraseOpen, setEraseOpen] = useState(false); + const [erasePassword, setErasePassword] = useState(""); + const [eraseConfirm, setEraseConfirm] = useState(""); function selectMode(mode: SetupMode) { setSetupMode(mode); @@ -58,6 +63,32 @@ export function UnlockPage({ } } + async function eraseWorkspace(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + try { + if (eraseConfirm !== "DELETE") { + setError('Type DELETE to confirm erasing this workspace.'); + setBusy(false); + return; + } + await api("/api/workspace/destroy", { + method: "POST", + body: JSON.stringify({ password: erasePassword, confirm: "DELETE" }), + }); + setEraseOpen(false); + setErasePassword(""); + setEraseConfirm(""); + setPassword(""); + await onDestroyed(); + } catch (err) { + setError(err instanceof Error ? err.message : "Erase failed"); + } finally { + setBusy(false); + } + } + const primaryLabel = status.initialized ? "Unlock workbench" : setupMode === "demo" @@ -104,7 +135,7 @@ export function UnlockPage({ ) : !status.initialized ? (

{setupMode === "demo" - ? "Demo data is for exploring the product. You can lock and re-create later by deleting the local data folder." + ? "Demo data is for exploring the product. You can erase the workspace later from Unlock or Settings and create a new one." : "No sample people or reviews. You will add the roster from Team."}

) : null} @@ -136,11 +167,78 @@ export function UnlockPage({ placeholder={setupMode === "demo" && !status.initialized ? "workbench" : "Choose a password"} />
- {error &&

{error}

} + {error && !eraseOpen &&

{error}

} + + {status.initialized && ( +
+ {!eraseOpen ? ( + + ) : ( +
+

Erase workspace

+

+ Permanently deletes the local vault, database, uploads, and backups on this machine. + You can create a new empty or demo workspace afterward. This cannot be undone. +

+
+ + setErasePassword(e.target.value)} + autoComplete="current-password" + required + /> +
+
+ + setEraseConfirm(e.target.value)} + autoComplete="off" + placeholder="DELETE" + required + /> +
+ {error && eraseOpen &&

{error}

} +
+ + +
+
+ )} +
+ )}
); } diff --git a/apps/ui/src/styles.css b/apps/ui/src/styles.css index f21d012..fcbeea5 100644 --- a/apps/ui/src/styles.css +++ b/apps/ui/src/styles.css @@ -378,6 +378,106 @@ a:hover { color: var(--brand-strong); } min-width: 0; } +/* ---------- Sticky page section nav (Settings) ---------- */ +.settings-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 10.5rem; + gap: var(--space-6); + align-items: start; +} +.settings-section { + scroll-margin-top: 1.25rem; +} +.page-section-nav { + position: sticky; + top: var(--space-6); + align-self: start; + padding: var(--space-3) 0 var(--space-3) var(--space-3); + border-left: 1px solid var(--line); +} +.page-section-nav-label { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ink-soft); + margin-bottom: var(--space-2); + padding-left: var(--space-2); +} +.page-section-nav-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} +.page-section-nav-link { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + color: var(--ink-soft); + font: inherit; + font-size: 0.86rem; + font-weight: 600; + padding: 0.4rem 0.55rem; + border-radius: var(--radius-sm); + cursor: pointer; + border-left: 2px solid transparent; + margin-left: -1px; +} +.page-section-nav-link:hover { + color: var(--ink); + background: var(--bg-soft); +} +.page-section-nav-link.on { + color: var(--brand-strong); + background: var(--brand-tint); + border-left-color: var(--brand); +} + +@media (max-width: 960px) { + .settings-layout { + grid-template-columns: 1fr; + } + .page-section-nav { + order: -1; + position: sticky; + top: calc(var(--topbar-height) + env(safe-area-inset-top, 0px)); + z-index: 8; + margin: 0 calc(var(--space-4) * -1); + padding: var(--space-2) var(--space-4); + border-left: none; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(10px); + } + .page-section-nav-label { display: none; } + .page-section-nav-list { + flex-direction: row; + flex-wrap: nowrap; + gap: 0.35rem; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + padding-bottom: 0.15rem; + } + .page-section-nav-link { + flex: 0 0 auto; + white-space: nowrap; + border-left: none; + border-bottom: 2px solid transparent; + margin-left: 0; + padding: 0.45rem 0.7rem; + } + .page-section-nav-link.on { + border-left-color: transparent; + border-bottom-color: var(--brand); + } +} + /* ---------- Surfaces ---------- */ .panel { background: var(--panel); @@ -441,6 +541,16 @@ strong { color: var(--ink-soft); font-weight: 600; } } .btn.ghost:hover { background: var(--bg-soft); color: var(--ink); } +.btn.danger { + background: var(--danger-tint); + border-color: var(--danger-border); + color: var(--danger); + box-shadow: none; +} +.btn.danger:hover { + filter: brightness(0.97); +} + .btn:disabled { opacity: 0.45; cursor: not-allowed; } .btn:disabled:hover { background: var(--brand); border-color: var(--brand); } @@ -626,8 +736,11 @@ li { margin-bottom: 0.4rem; line-height: 1.55; color: var(--ink-soft); } /* ---------- Unlock / auth screen ---------- */ .unlock-screen { min-height: 100vh; - display: grid; - place-items: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); padding: 2rem; } .unlock-card { @@ -638,6 +751,13 @@ li { margin-bottom: 0.4rem; line-height: 1.55; color: var(--ink-soft); } } .unlock-card h1 { font-size: var(--text-xl); margin-bottom: var(--space-3); } .unlock-card .btn { width: 100%; margin-top: var(--space-2); padding: 0.75rem; min-height: var(--touch-min); } +.unlock-erase { + margin-top: 0; +} +.unlock-erase .cta-primary-row .btn { + width: auto; + flex: 1 1 auto; +} .unlock-actions { display: flex; flex-direction: column;