public
anord
read
Ledger
Why work hard when you can work easier?
Languages
Repository composition by tracked source files.
TypeScript
86%
CSS
10%
SQL
3%
Shell
1%
HTML
0%
Commit
Continuing UI fixes
ea3e37e
apps/api/src/http/routes/admin.ts | 20 +
apps/api/src/http/routes/integrations.ts | 4 +-
apps/api/src/http/routes/pages.ts | 6 +
apps/api/src/http/routes/webhooks.ts | 45 +-
apps/api/src/services/pages.ts | 31 ++
apps/web/src/AppShell.tsx | 46 +-
apps/web/src/components/AdminConsole.tsx | 739 +++++++++++++++++++++++++++++++
apps/web/src/components/AskAiPage.tsx | 94 ++++
apps/web/src/components/DocsSidebar.tsx | 54 ++-
apps/web/src/components/DraftsPage.tsx | 82 ++++
apps/web/src/components/ImportsPage.tsx | 376 ++++++++++++++++
apps/web/src/components/PageHeader.tsx | 24 +
apps/web/src/components/SearchPage.tsx | 89 ++++
apps/web/src/components/SpacesPage.tsx | 51 +++
apps/web/src/docs-theme.css | 87 +++-
15 files changed, 1710 insertions(+), 38 deletions(-)
create mode 100644 apps/web/src/components/AdminConsole.tsx
create mode 100644 apps/web/src/components/AskAiPage.tsx
create mode 100644 apps/web/src/components/DraftsPage.tsx
create mode 100644 apps/web/src/components/ImportsPage.tsx
create mode 100644 apps/web/src/components/PageHeader.tsx
create mode 100644 apps/web/src/components/SearchPage.tsx
create mode 100644 apps/web/src/components/SpacesPage.tsx
Diff
diff --git a/apps/api/src/http/routes/admin.ts b/apps/api/src/http/routes/admin.ts
index 67db366..0806d0d 100644
--- a/apps/api/src/http/routes/admin.ts
+++ b/apps/api/src/http/routes/admin.ts
@@ -63,3 +63,23 @@ adminRouter.get("/search-analytics", async (_req, res) => {
});
});
+adminRouter.get("/activity", async (_req, res) => {
+ const result = await pool.query(
+ `
+ SELECT
+ al.id,
+ al.action,
+ al.resource_type,
+ al.resource_id,
+ al.metadata,
+ al.created_at,
+ COALESCE(u.display_name, 'System') AS actor_name
+ FROM audit_logs al
+ LEFT JOIN users u ON u.id = al.actor_user_id
+ ORDER BY al.created_at DESC
+ LIMIT 100
+ `
+ );
+
+ return res.json({ activity: result.rows });
+});
diff --git a/apps/api/src/http/routes/integrations.ts b/apps/api/src/http/routes/integrations.ts
index 914348c..2c56fe8 100644
--- a/apps/api/src/http/routes/integrations.ts
+++ b/apps/api/src/http/routes/integrations.ts
@@ -49,7 +49,7 @@ const googleDocsSchema = z.object({
export const integrationsRouter = Router();
-integrationsRouter.get("/", requireAdmin, async (_req, res) => {
+integrationsRouter.get("/", requireEditor, async (_req, res) => {
return res.json({ integrations: await listIntegrations() });
});
@@ -60,7 +60,7 @@ integrationsRouter.put("/:provider", requireAdmin, async (req, res) => {
return res.json({ integration });
});
-integrationsRouter.get("/import-jobs", requireAdmin, async (_req, res) => {
+integrationsRouter.get("/import-jobs", requireEditor, async (_req, res) => {
return res.json({ jobs: await listImportJobs() });
});
diff --git a/apps/api/src/http/routes/pages.ts b/apps/api/src/http/routes/pages.ts
index 31fcb1d..48291b5 100644
--- a/apps/api/src/http/routes/pages.ts
+++ b/apps/api/src/http/routes/pages.ts
@@ -5,6 +5,7 @@ import {
createOrUpdatePage,
getPageBySlug,
listPageRevisions,
+ listDraftPages,
listPagesForSpace,
rollbackPageRevision
} from "../../services/pages.js";
@@ -46,6 +47,11 @@ pagesRouter.get("/space/:spaceKey", async (req, res) => {
return res.json({ pages });
});
+pagesRouter.get("/drafts", requireEditor, async (req, res) => {
+ const pages = await listDraftPages(req.user ?? null);
+ return res.json({ pages });
+});
+
pagesRouter.get("/slug/:slug", async (req, res) => {
const page = await getPageBySlug(req.params.slug, req.user ?? null);
if (!page) {
diff --git a/apps/api/src/http/routes/webhooks.ts b/apps/api/src/http/routes/webhooks.ts
index 0374c5e..b972036 100644
--- a/apps/api/src/http/routes/webhooks.ts
+++ b/apps/api/src/http/routes/webhooks.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import { requireAdmin } from "../middleware/auth.js";
import { pool } from "../../db/pool.js";
import { logAudit } from "../../services/audit.js";
+import { enqueueWebhookEvent } from "../../services/webhooks.js";
const webhookSchema = z.object({
name: z.string().min(2),
@@ -20,6 +21,10 @@ const webhookSchema = z.object({
isActive: z.boolean().default(true)
});
+const webhookUpdateSchema = webhookSchema.extend({
+ signingSecret: z.string().optional()
+});
+
export const webhooksRouter = Router();
webhooksRouter.use(requireAdmin);
@@ -58,7 +63,13 @@ webhooksRouter.post("/", async (req, res) => {
});
webhooksRouter.put("/:webhookId", async (req, res) => {
- const input = webhookSchema.parse(req.body);
+ const input = webhookUpdateSchema.parse(req.body);
+ const existing = await pool.query(`SELECT signing_secret FROM webhooks WHERE id = $1`, [req.params.webhookId]);
+ if (!existing.rowCount) {
+ return res.status(404).json({ error: "Webhook not found" });
+ }
+
+ const signingSecret = input.signingSecret?.trim() ? input.signingSecret : existing.rows[0].signing_secret;
const updated = await pool.query(
`
UPDATE webhooks
@@ -70,16 +81,12 @@ webhooksRouter.put("/:webhookId", async (req, res) => {
req.params.webhookId,
input.name,
input.targetUrl,
- input.signingSecret,
+ signingSecret,
JSON.stringify(input.events),
input.isActive
]
);
- if (!updated.rowCount) {
- return res.status(404).json({ error: "Webhook not found" });
- }
-
await logAudit(req.user!.id, "webhook.update", "webhook", req.params.webhookId, {
events: input.events
});
@@ -111,3 +118,29 @@ webhooksRouter.delete("/:webhookId", async (req, res) => {
await logAudit(req.user!.id, "webhook.delete", "webhook", req.params.webhookId);
return res.status(204).send();
});
+
+webhooksRouter.post("/:webhookId/test", async (req, res) => {
+ const webhook = await pool.query(`SELECT id FROM webhooks WHERE id = $1`, [req.params.webhookId]);
+ if (!webhook.rowCount) {
+ return res.status(404).json({ error: "Webhook not found" });
+ }
+
+ await enqueueWebhookEvent(
+ "page.updated",
+ {
+ pageId: "test-page",
+ slug: "webhook-test",
+ test: true
+ },
+ {
+ actor: {
+ id: req.user!.id,
+ name: req.user!.displayName,
+ email: req.user!.email
+ },
+ workspaceId: "test-workspace"
+ }
+ );
+
+ return res.status(202).json({ queued: true });
+});
diff --git a/apps/api/src/services/pages.ts b/apps/api/src/services/pages.ts
index ccc63cf..df33105 100644
--- a/apps/api/src/services/pages.ts
+++ b/apps/api/src/services/pages.ts
@@ -193,6 +193,37 @@ export async function listPagesForSpace(spaceKey: string, user: SessionUser | nu
return pages;
}
+export async function listDraftPages(user: SessionUser | null) {
+ if (!user) {
+ return [];
+ }
+
+ const result = await pool.query(
+ `
+ SELECT
+ p.id, p.space_id, p.title, p.slug, p.excerpt, p.visibility, p.state, p.is_public,
+ p.parent_page_id, p.updated_at, pr.id AS revision_id, pr.body_markdown,
+ COALESCE(u.display_name, 'Unknown') AS author_name
+ FROM pages p
+ JOIN page_revisions pr ON pr.id = p.current_revision_id
+ LEFT JOIN users u ON u.id = pr.edited_by_user_id
+ WHERE p.state = 'draft'
+ ORDER BY p.updated_at DESC
+ `
+ );
+
+ const drafts: PageSummary[] = [];
+ for (const row of result.rows as PageRecord[]) {
+ const permissions = await getPagePermissions(row.id);
+ if (!canReadVisibility(user, row.visibility, permissions.roleKeys, permissions.groupIds)) {
+ continue;
+ }
+ drafts.push(toSummary(row, await getPageTags(row.id)));
+ }
+
+ return drafts;
+}
+
export async function getPageBySlug(slug: string, user: SessionUser | null): Promise<PageDetail | null> {
const result = await pool.query(
`
diff --git a/apps/web/src/AppShell.tsx b/apps/web/src/AppShell.tsx
index dd2d7b2..b3da8d1 100644
--- a/apps/web/src/AppShell.tsx
+++ b/apps/web/src/AppShell.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
-import { Link, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
+import { Link, Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import type {
AiCitation,
ImportJobSummary,
@@ -11,14 +11,19 @@ import type {
WebhookSummary
} from "@ledger/shared";
import { CommandSearch } from "./components/CommandSearch";
-import { DashboardView } from "./components/DashboardView";
+import { AdminConsole } from "./components/AdminConsole";
+import { AskAiPage } from "./components/AskAiPage";
+import { DraftsPage } from "./components/DraftsPage";
import { EmptyState } from "./components/EmptyState";
import { FeedbackForm } from "./components/FeedbackForm";
import { Icon } from "./components/Icon";
+import { ImportsPage } from "./components/ImportsPage";
import { PageEditor } from "./components/PageEditor";
import { DocsSidebar } from "./components/DocsSidebar";
+import { SearchPage } from "./components/SearchPage";
import { SearchBar } from "./components/SearchBar";
import { ContentSkeleton, SidebarSkeleton } from "./components/Skeleton";
+import { SpacesPage } from "./components/SpacesPage";
import { api } from "./lib/api";
type BrandingResponse = {
@@ -300,7 +305,7 @@ function DocsShell({
<div className="app-header__right">
{user ? (
<>
- <Link to="/dashboard" className="button-ghost">Manage</Link>
+ <Link to="/admin/general" className="button-ghost">Admin</Link>
<button className="button-ghost" onClick={onLogout}>Sign out</button>
</>
) : (
@@ -642,9 +647,9 @@ function PageView({
Copy link
</button>
{user && (user.role === "editor" || user.role === "admin" || user.role === "owner") ? (
- <Link to="/dashboard" className="button-secondary">
+ <Link to="/admin/general" className="button-secondary">
<Icon name="external" className="icon icon-sm" />
- Edit in manage
+ Open admin
</Link>
) : null}
</div>
@@ -743,7 +748,7 @@ function LoginPage({ onLogin }: { onLogin: (user: SessionUser) => void }) {
try {
const response = await api.post<{ user: SessionUser }>("/api/auth/login", { email, password });
onLogin(response.user);
- navigate("/dashboard");
+ navigate("/spaces");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Could not sign in");
}
@@ -815,7 +820,7 @@ function SetupPage({
publicKnowledgeBaseEnabled: form.publicKnowledgeBaseEnabled
});
- navigate("/dashboard");
+ navigate("/spaces");
} catch (error) {
setStatus(error instanceof Error ? error.message : "Could not initialize Ledger");
}
@@ -917,6 +922,21 @@ function SetupPage({
);
}
+function AccessDeniedPage() {
+ return (
+ <div className="stack-page">
+ <section className="panel">
+ <EmptyState
+ eyebrow="Permissions"
+ title="You do not have access to this area"
+ description="This section is limited to administrators and owners. Use the main knowledge base navigation to continue browsing documentation."
+ action={<Link to="/spaces" className="button-secondary">Back to spaces</Link>}
+ />
+ </section>
+ </div>
+ );
+}
+
function Dashboard({ user, spaces }: { user: SessionUser; spaces: Space[] }) {
const [analytics, setAnalytics] = useState<{
topSearches: Array<{ query: string; count: number }>;
@@ -1119,6 +1139,7 @@ export function App() {
const { spaces, pagesBySpace, loading: loadingNavigation, error: navigationError } = useKnowledgeBaseData(
Boolean(setupStatus?.isInitialized)
);
+ const canAdmin = user?.role === "admin" || user?.role === "owner";
async function logout() {
await api.post("/api/auth/logout");
@@ -1181,11 +1202,18 @@ export function App() {
onLogout={logout}
>
<Routes>
- <Route path="/" element={<HomePage spaces={spaces} pagesBySpace={pagesBySpace} onSearch={handleSearch} />} />
+ <Route path="/" element={<Navigate to="/spaces" replace />} />
<Route path="/login" element={<LoginPage onLogin={setUser} />} />
+ <Route path="/search" element={<SearchPage spaces={spaces} results={searchResults} isLoading={searchLoading} onSearch={handleSearch} />} />
+ <Route path="/spaces" element={<SpacesPage spaces={spaces} pagesBySpace={pagesBySpace} />} />
+ <Route path="/drafts" element={<DraftsPage user={user} spaces={spaces} />} />
+ <Route path="/imports" element={<ImportsPage user={user} spaces={spaces} />} />
+ <Route path="/ask-ai" element={<AskAiPage />} />
<Route path="/space/:spaceKey" element={<SpacePage spaces={spaces} pagesBySpace={pagesBySpace} />} />
<Route path="/page/:slug" element={<PageView spaces={spaces} pagesBySpace={pagesBySpace} user={user} />} />
- <Route path="/dashboard" element={user ? <DashboardView user={user} spaces={spaces} /> : <LoginPage onLogin={setUser} />} />
+ <Route path="/dashboard" element={<Navigate to="/admin/general" replace />} />
+ <Route path="/admin" element={user ? (canAdmin ? <Navigate to="/admin/general" replace /> : <AccessDeniedPage />) : <LoginPage onLogin={setUser} />} />
+ <Route path="/admin/:section" element={user ? (canAdmin ? <AdminConsole user={user} spaces={spaces} /> : <AccessDeniedPage />) : <LoginPage onLogin={setUser} />} />
</Routes>
</DocsShell>
);
diff --git a/apps/web/src/components/AdminConsole.tsx b/apps/web/src/components/AdminConsole.tsx
new file mode 100644
index 0000000..b32d98c
--- /dev/null
+++ b/apps/web/src/components/AdminConsole.tsx
@@ -0,0 +1,739 @@
+import { useEffect, useMemo, useState } from "react";
+import { Link, useParams } from "react-router-dom";
+import type {
+ ImportJobSummary,
+ IntegrationSummary,
+ SessionUser,
+ WebhookDeliverySummary,
+ WebhookSummary
+} from "@ledger/shared";
+import { api } from "../lib/api";
+import { EmptyState } from "./EmptyState";
+import { PageEditor } from "./PageEditor";
+import { PageHeader } from "./PageHeader";
+
+type Space = {
+ id: string;
+ name: string;
+ key: string;
+ visibility: string;
+};
+
+type AdminUser = { id: string; email: string; display_name: string; role_key: string };
+type AdminGroup = { id: string; name: string; description: string | null };
+type ActivityItem = {
+ id: string;
+ action: string;
+ resource_type: string;
+ resource_id: string;
+ actor_name: string;
+ created_at: string;
+};
+
+const adminNav = [
+ ["general", "General"],
+ ["members", "Members"],
+ ["permissions", "Permissions"],
+ ["integrations", "Integrations"],
+ ["webhooks", "Webhooks"],
+ ["ai", "AI Settings"],
+ ["mcp", "MCP"],
+ ["import-history", "Import History"],
+ ["activity", "Activity"]
+] as const;
+
+const webhookEvents = [
+ "page.created",
+ "page.updated",
+ "page.deleted",
+ "page.published",
+ "feedback.created",
+ "user.invited",
+ "search.no_results"
+] as const;
+
+export function AdminConsole({ user, spaces }: { user: SessionUser; spaces: Space[] }) {
+ const { section = "general" } = useParams();
+ const currentSection = adminNav.some(([key]) => key === section) ? section : "general";
+ const [brandingForm, setBrandingForm] = useState({
+ siteName: "Ledger",
+ logoUrl: "",
+ brandColor: "#245cff",
+ footerText: "",
+ publicKnowledgeBaseEnabled: true
+ });
+ const [users, setUsers] = useState<AdminUser[]>([]);
+ const [groups, setGroups] = useState<AdminGroup[]>([]);
+ const [roles, setRoles] = useState<Array<{ id: string; key: string; name: string }>>([]);
+ const [integrations, setIntegrations] = useState<IntegrationSummary[]>([]);
+ const [importJobs, setImportJobs] = useState<ImportJobSummary[]>([]);
+ const [webhooks, setWebhooks] = useState<WebhookSummary[]>([]);
+ const [deliveries, setDeliveries] = useState<Record<string, WebhookDeliverySummary[]>>({});
+ const [activity, setActivity] = useState<ActivityItem[]>([]);
+ const [aiSettings, setAiSettings] = useState({
+ provider: "none",
+ model: "",
+ isEnabled: false,
+ hasApiKey: false,
+ apiKey: ""
+ });
+ const [integrationForms, setIntegrationForms] = useState<Record<string, { name: string; isEnabled: boolean; token: string; accessToken: string }>>({
+ github: { name: "GitHub", isEnabled: false, token: "", accessToken: "" },
+ google_docs: { name: "Google Docs", isEnabled: false, token: "", accessToken: "" },
+ markdown_import: { name: "Markdown Import", isEnabled: true, token: "", accessToken: "" }
+ });
+ const [editingWebhookId, setEditingWebhookId] = useState<string | null>(null);
+ const [webhookForm, setWebhookForm] = useState({
+ name: "",
+ targetUrl: "",
+ signingSecret: "",
+ isActive: true,
+ events: ["page.created", "page.updated"] as string[]
+ });
+ const [status, setStatus] = useState<string | null>(null);
+
+ const currentWebhook = useMemo(
+ () => webhooks.find((webhook) => webhook.id === editingWebhookId) ?? null,
+ [editingWebhookId, webhooks]
+ );
+
+ useEffect(() => {
+ async function load() {
+ const [usersResponse, groupsResponse, rolesResponse, settingsResponse, integrationsResponse, jobsResponse, aiResponse, webhooksResponse, activityResponse] =
+ await Promise.all([
+ api.get<{ users: AdminUser[] }>("/api/admin/users"),
+ api.get<{ groups: AdminGroup[] }>("/api/admin/groups"),
+ api.get<{ roles: Array<{ id: string; key: string; name: string }> }>("/api/roles"),
+ api.get<{
+ branding: { site_name: string; logo_url: string | null; brand_color: string; footer_text: string | null; public_knowledge_base_enabled: boolean };
+ mcp: { endpoint: string; authMode: string };
+ }>("/api/settings/admin"),
+ api.get<{ integrations: IntegrationSummary[] }>("/api/integrations"),
+ api.get<{ jobs: ImportJobSummary[] }>("/api/integrations/import-jobs"),
+ api.get<{ settings: { provider: string; model: string; isEnabled: boolean; hasApiKey: boolean } }>("/api/ai/settings"),
+ api.get<{ webhooks: WebhookSummary[] }>("/api/webhooks"),
+ api.get<{ activity: ActivityItem[] }>("/api/admin/activity")
+ ]);
+
+ setUsers(usersResponse.users);
+ setGroups(groupsResponse.groups);
+ setRoles(rolesResponse.roles);
+ setBrandingForm({
+ siteName: settingsResponse.branding.site_name,
+ logoUrl: settingsResponse.branding.logo_url ?? "",
+ brandColor: settingsResponse.branding.brand_color,
+ footerText: settingsResponse.branding.footer_text ?? "",
+ publicKnowledgeBaseEnabled: settingsResponse.branding.public_knowledge_base_enabled
+ });
+ setIntegrations(integrationsResponse.integrations);
+ setImportJobs(jobsResponse.jobs);
+ setAiSettings((current) => ({ ...current, ...aiResponse.settings, apiKey: "" }));
+ setWebhooks(webhooksResponse.webhooks);
+ setActivity(activityResponse.activity);
+ setIntegrationForms((current) => {
+ const next = { ...current };
+ for (const integration of integrationsResponse.integrations) {
+ next[integration.provider] = {
+ name: integration.name,
+ isEnabled: integration.isEnabled,
+ token: "",
+ accessToken: ""
+ };
+ }
+ return next;
+ });
+ }
+
+ void load();
+ }, []);
+
+ useEffect(() => {
+ if (!currentWebhook) {
+ setWebhookForm({
+ name: "",
+ targetUrl: "",
+ signingSecret: "",
+ isActive: true,
+ events: ["page.created", "page.updated"]
+ });
+ return;
+ }
+
+ setWebhookForm({
+ name: currentWebhook.name,
+ targetUrl: currentWebhook.targetUrl,
+ signingSecret: "",
+ isActive: currentWebhook.isActive,
+ events: currentWebhook.events
+ });
+ }, [currentWebhook]);
+
+ async function refreshWebhooks() {
+ const response = await api.get<{ webhooks: WebhookSummary[] }>("/api/webhooks");
+ setWebhooks(response.webhooks);
+ }
+
+ async function refreshIntegrations() {
+ const [integrationsResponse, jobsResponse] = await Promise.all([
+ api.get<{ integrations: IntegrationSummary[] }>("/api/integrations"),
+ api.get<{ jobs: ImportJobSummary[] }>("/api/integrations/import-jobs")
+ ]);
+ setIntegrations(integrationsResponse.integrations);
+ setImportJobs(jobsResponse.jobs);
+ }
+
+ async function saveBranding() {
+ try {
+ await api.put("/api/settings/branding", {
+ siteName: brandingForm.siteName,
+ logoUrl: brandingForm.logoUrl || null,
+ brandColor: brandingForm.brandColor,
+ footerText: brandingForm.footerText || null,
+ publicKnowledgeBaseEnabled: brandingForm.publicKnowledgeBaseEnabled
+ });
+ setStatus("General settings saved.");
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not save general settings.");
+ }
+ }
+
+ async function saveIntegration(provider: "github" | "google_docs" | "markdown_import") {
+ try {
+ const form = integrationForms[provider];
+ await api.put(`/api/integrations/${provider}`, {
+ name: form.name,
+ isEnabled: form.isEnabled,
+ config:
+ provider === "github"
+ ? { token: form.token }
+ : provider === "google_docs"
+ ? { accessToken: form.accessToken }
+ : {}
+ });
+ await refreshIntegrations();
+ setStatus(`${provider.replace("_", " ")} settings saved.`);
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not save integration settings.");
+ }
+ }
+
+ async function saveWebhook() {
+ try {
+ const payload = {
+ ...webhookForm,
+ events: webhookForm.events
+ };
+
+ if (editingWebhookId) {
+ await api.put(`/api/webhooks/${editingWebhookId}`, payload);
+ setStatus("Webhook updated.");
+ } else {
+ await api.post("/api/webhooks", payload);
+ setStatus("Webhook created.");
+ }
+
+ setEditingWebhookId(null);
+ await refreshWebhooks();
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not save webhook.");
+ }
+ }
+
+ async function deleteWebhook(webhookId: string) {
+ if (!window.confirm("Delete this webhook?")) return;
+ try {
+ await api.delete(`/api/webhooks/${webhookId}`);
+ await refreshWebhooks();
+ setStatus("Webhook deleted.");
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not delete webhook.");
+ }
+ }
+
+ async function loadDeliveries(webhookId: string) {
+ try {
+ const response = await api.get<{ deliveries: WebhookDeliverySummary[] }>(`/api/webhooks/${webhookId}/deliveries`);
+ setDeliveries((current) => ({ ...current, [webhookId]: response.deliveries }));
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not load deliveries.");
+ }
+ }
+
+ async function testWebhook(webhookId: string) {
+ try {
+ await api.post(`/api/webhooks/${webhookId}/test`);
+ setStatus("Test webhook queued.");
+ await loadDeliveries(webhookId);
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not queue test webhook.");
+ }
+ }
+
+ async function saveAi() {
+ try {
+ await api.put("/api/ai/settings", {
+ provider: aiSettings.provider,
+ model: aiSettings.model,
+ apiKey: aiSettings.apiKey || null,
+ isEnabled: aiSettings.isEnabled
+ });
+ setStatus("AI settings saved.");
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not save AI settings.");
+ }
+ }
+
+ function sectionMeta() {
+ switch (currentSection) {
+ case "general":
+ return ["Admin", "General settings", "Manage workspace branding, public visibility, and core document publishing."];
+ case "members":
+ return ["Admin", "Members", "Review who has access to Ledger and how groups are organized."];
+ case "permissions":
+ return ["Admin", "Permissions", "Review role capabilities and how restricted pages are enforced."];
+ case "integrations":
+ return ["Admin", "Integrations", "Configure source systems and send people into the import workflow when the connector is ready."];
+ case "webhooks":
+ return ["Admin", "Webhooks", "Manage outbound event delivery, signing secrets, and recent delivery attempts."];
+ case "ai":
+ return ["Admin", "AI Settings", "Configure AI providers and review how retrieval and citations behave."];
+ case "mcp":
+ return ["Admin", "MCP", "Inspect Ledger’s MCP surface and understand how auth and permissions apply."];
+ case "import-history":
+ return ["Admin", "Import history", "Review import jobs, status, and outcomes."];
+ case "activity":
+ return ["Admin", "Activity", "Review recent audit activity across the workspace."];
+ default:
+ return ["Admin", "Admin", ""];
+ }
+ }
+
+ const [eyebrow, title, description] = sectionMeta();
+
+ return (
+ <div className="admin-shell">
+ <aside className="admin-sidebar panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Admin</p>
+ <h3>Workspace controls</h3>
+ </div>
+ </div>
+ <nav className="admin-nav">
+ {adminNav.map(([key, label]) => (
+ <Link key={key} to={`/admin/${key}`} className={`admin-nav__item${currentSection === key ? " is-current" : ""}`}>
+ {label}
+ </Link>
+ ))}
+ </nav>
+ </aside>
+
+ <div className="admin-content stack-page">
+ <PageHeader eyebrow={eyebrow} title={title} description={description} />
+ {status ? <p className="muted">{status}</p> : null}
+
+ {currentSection === "general" ? (
+ <>
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Branding</p>
+ <h3>Workspace identity</h3>
+ </div>
+ </div>
+ <label className="field">
+ Site name
+ <input value={brandingForm.siteName} onChange={(event) => setBrandingForm((current) => ({ ...current, siteName: event.target.value }))} />
+ </label>
+ <div className="field-grid">
+ <label className="field">
+ Brand color
+ <input value={brandingForm.brandColor} onChange={(event) => setBrandingForm((current) => ({ ...current, brandColor: event.target.value }))} />
+ </label>
+ <label className="field">
+ Logo URL
+ <input value={brandingForm.logoUrl} onChange={(event) => setBrandingForm((current) => ({ ...current, logoUrl: event.target.value }))} />
+ </label>
+ </div>
+ <label className="field">
+ Footer text
+ <input value={brandingForm.footerText} onChange={(event) => setBrandingForm((current) => ({ ...current, footerText: event.target.value }))} />
+ </label>
+ <label className="checkbox-row checkbox-card">
+ <input type="checkbox" checked={brandingForm.publicKnowledgeBaseEnabled} onChange={(event) => setBrandingForm((current) => ({ ...current, publicKnowledgeBaseEnabled: event.target.checked }))} />
+ <span>Public knowledge base enabled</span>
+ </label>
+ <div className="panel__footer">
+ <button onClick={saveBranding}>Save general settings</button>
+ </div>
+ </section>
+ <PageEditor spaces={spaces} />
+ </>
+ ) : null}
+
+ {currentSection === "members" ? (
+ <div className="admin-grid">
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Users</p>
+ <h3>Members</h3>
+ </div>
+ </div>
+ <div className="list-grid">
+ {users.map((member) => (
+ <div key={member.id} className="list-item">
+ <strong>{member.display_name}</strong>
+ <span>{member.email}</span>
+ <span className="badge badge-internal">{member.role_key}</span>
+ </div>
+ ))}
+ </div>
+ </section>
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Groups</p>
+ <h3>Permission groups</h3>
+ </div>
+ </div>
+ {groups.length === 0 ? (
+ <EmptyState title="No groups yet" description="Create groups in the backend or seed data to organize restricted pages by team." />
+ ) : (
+ <div className="list-grid">
+ {groups.map((group) => (
+ <div key={group.id} className="list-item">
+ <strong>{group.name}</strong>
+ <span>{group.description ?? "No description"}</span>
+ </div>
+ ))}
+ </div>
+ )}
+ </section>
+ </div>
+ ) : null}
+
+ {currentSection === "permissions" ? (
+ <div className="admin-grid">
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Roles</p>
+ <h3>Access levels</h3>
+ </div>
+ </div>
+ <div className="list-grid">
+ {roles.map((role) => (
+ <div key={role.id} className="list-item">
+ <strong>{role.name}</strong>
+ <span>{role.key}</span>
+ </div>
+ ))}
+ </div>
+ </section>
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Behavior</p>
+ <h3>Permission model</h3>
+ </div>
+ </div>
+ <div className="list-grid">
+ <div className="list-item">
+ <strong>Public pages</strong>
+ <span>Readable without authentication when public KB is enabled.</span>
+ </div>
+ <div className="list-item">
+ <strong>Internal pages</strong>
+ <span>Readable only by authenticated users with at least viewer access.</span>
+ </div>
+ <div className="list-item">
+ <strong>Restricted pages</strong>
+ <span>Require explicit role or group permissions. Search, AI, and MCP follow the same backend checks.</span>
+ </div>
+ </div>
+ </section>
+ </div>
+ ) : null}
+
+ {currentSection === "integrations" ? (
+ <section className="integration-list">
+ {(["google_docs", "github", "markdown_import"] as const).map((provider) => {
+ const integration = integrations.find((item) => item.provider === provider);
+ const lastJob = importJobs.find((job) => job.provider === provider);
+ const form = integrationForms[provider];
+ const canRunImport = provider === "markdown_import" || integration?.status === "configured";
+ return (
+ <section key={provider} className="integration-card panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Integration</p>
+ <h3>{provider === "google_docs" ? "Google Docs" : provider === "github" ? "GitHub" : "Markdown Import"}</h3>
+ </div>
+ <span className={`badge badge-${integration?.status === "configured" ? "public" : "restricted"}`}>
+ {integration?.statusMessage ?? "Not configured"}
+ </span>
+ </div>
+ <p className="muted">
+ {provider === "google_docs"
+ ? "Import Google Docs content into Ledger pages."
+ : provider === "github"
+ ? "Import Markdown files from a repository path."
+ : "Upload Markdown files directly from the browser."}
+ </p>
+ <p className="muted">
+ {lastJob ? `Last import: ${new Date(lastJob.updatedAt).toLocaleString()}` : "Last import: none yet"}
+ </p>
+ <label className="field">
+ Name
+ <input value={form.name} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], name: event.target.value } }))} />
+ </label>
+ {provider === "github" ? (
+ <label className="field">
+ GitHub token
+ <input type="password" value={form.token} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], token: event.target.value } }))} />
+ </label>
+ ) : null}
+ {provider === "google_docs" ? (
+ <label className="field">
+ Google access token
+ <input type="password" value={form.accessToken} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], accessToken: event.target.value } }))} />
+ </label>
+ ) : null}
+ <label className="checkbox-row checkbox-card">
+ <input type="checkbox" checked={form.isEnabled} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], isEnabled: event.target.checked } }))} />
+ <span>Enabled</span>
+ </label>
+ <div className="panel__footer">
+ <button onClick={() => saveIntegration(provider)}>Save configuration</button>
+ {canRunImport ? <Link to="/imports" className="button-secondary">Open imports</Link> : <button type="button" className="button-secondary" disabled title="Configure this integration before importing.">Import unavailable</button>}
+ </div>
+ </section>
+ );
+ })}
+ </section>
+ ) : null}
+
+ {currentSection === "webhooks" ? (
+ <>
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Webhook editor</p>
+ <h3>{editingWebhookId ? "Edit webhook" : "Create webhook"}</h3>
+ </div>
+ </div>
+ <div className="field-grid">
+ <label className="field">
+ Name
+ <input value={webhookForm.name} onChange={(event) => setWebhookForm((current) => ({ ...current, name: event.target.value }))} />
+ </label>
+ <label className="field">
+ Target URL
+ <input value={webhookForm.targetUrl} onChange={(event) => setWebhookForm((current) => ({ ...current, targetUrl: event.target.value }))} />
+ </label>
+ </div>
+ <label className="field">
+ Signing secret
+ <input value={webhookForm.signingSecret} onChange={(event) => setWebhookForm((current) => ({ ...current, signingSecret: event.target.value }))} placeholder={editingWebhookId ? "Enter a new secret to rotate it." : "Required"} />
+ </label>
+ <label className="field">
+ Event checklist
+ <div className="checkbox-list">
+ {webhookEvents.map((eventName) => (
+ <label key={eventName} className="checkbox-row checkbox-card">
+ <input
+ type="checkbox"
+ checked={webhookForm.events.includes(eventName)}
+ onChange={(event) =>
+ setWebhookForm((current) => ({
+ ...current,
+ events: event.target.checked
+ ? [...current.events, eventName]
+ : current.events.filter((value) => value !== eventName)
+ }))
+ }
+ />
+ <span>{eventName}</span>
+ </label>
+ ))}
+ </div>
+ </label>
+ <label className="checkbox-row checkbox-card">
+ <input type="checkbox" checked={webhookForm.isActive} onChange={(event) => setWebhookForm((current) => ({ ...current, isActive: event.target.checked }))} />
+ <span>Active</span>
+ </label>
+ <p className="muted">Ledger sends `X-Ledger-Event`, `X-Ledger-Timestamp`, and `X-Ledger-Signature` headers. The signature is HMAC SHA-256 over `timestamp.body`.</p>
+ <div className="panel__footer">
+ <button onClick={saveWebhook}>{editingWebhookId ? "Save webhook" : "Create webhook"}</button>
+ {editingWebhookId ? <button type="button" className="button-secondary" onClick={() => setEditingWebhookId(null)}>Cancel edit</button> : null}
+ </div>
+ </section>
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Configured endpoints</p>
+ <h3>Webhook endpoints</h3>
+ </div>
+ </div>
+ {webhooks.length === 0 ? (
+ <EmptyState title="No webhooks configured" description="Create a webhook to deliver Ledger events to another system." />
+ ) : (
+ <div className="feedback-list">
+ {webhooks.map((webhook) => (
+ <div key={webhook.id} className="feedback-item">
+ <div className="feedback-item__header">
+ <strong>{webhook.name}</strong>
+ <span className={`badge badge-${webhook.isActive ? "public" : "restricted"}`}>{webhook.isActive ? "Active" : "Disabled"}</span>
+ </div>
+ <p>{webhook.targetUrl}</p>
+ <p className="muted">{webhook.events.join(", ")}</p>
+ <div className="panel__footer">
+ <button type="button" className="button-secondary" onClick={() => setEditingWebhookId(webhook.id)}>Edit</button>
+ <button type="button" className="button-secondary" onClick={() => loadDeliveries(webhook.id)}>View deliveries</button>
+ <button type="button" className="button-secondary" onClick={() => testWebhook(webhook.id)}>Test webhook</button>
+ <button type="button" className="button-secondary" onClick={() => deleteWebhook(webhook.id)}>Delete</button>
+ </div>
+ {deliveries[webhook.id]?.length ? (
+ <div className="delivery-list">
+ {deliveries[webhook.id].map((delivery) => (
+ <div key={delivery.id} className="delivery-item">
+ <strong>{delivery.eventName}</strong>
+ <span>{delivery.responseStatus ?? "pending"}</span>
+ <span>{delivery.success ? "Success" : delivery.errorMessage ?? "Failed"}</span>
+ </div>
+ ))}
+ </div>
+ ) : null}
+ </div>
+ ))}
+ </div>
+ )}
+ </section>
+ </>
+ ) : null}
+
+ {currentSection === "ai" ? (
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Provider configuration</p>
+ <h3>AI settings</h3>
+ </div>
+ </div>
+ <div className="field-grid">
+ <label className="field">
+ Provider
+ <select value={aiSettings.provider} onChange={(event) => setAiSettings((current) => ({ ...current, provider: event.target.value }))}>
+ <option value="none">Disabled</option>
+ <option value="openai_compatible">OpenAI-compatible</option>
+ <option value="anthropic_compatible">Anthropic-compatible</option>
+ </select>
+ </label>
+ <label className="field">
+ Model
+ <input value={aiSettings.model} onChange={(event) => setAiSettings((current) => ({ ...current, model: event.target.value }))} />
+ </label>
+ </div>
+ <label className="field">
+ API key
+ <input type="password" value={aiSettings.apiKey} onChange={(event) => setAiSettings((current) => ({ ...current, apiKey: event.target.value }))} placeholder={aiSettings.hasApiKey ? "Configured. Enter a new key to rotate it." : "Paste provider key"} />
+ </label>
+ <label className="checkbox-row checkbox-card">
+ <input type="checkbox" checked={aiSettings.isEnabled} onChange={(event) => setAiSettings((current) => ({ ...current, isEnabled: event.target.checked }))} />
+ <span>Enable AI answers</span>
+ </label>
+ <div className="admin-grid">
+ <div className="list-item">
+ <strong>Retrieval settings</strong>
+ <span>Ledger uses permission-filtered keyword retrieval over permitted pages. This is active whenever AI is enabled.</span>
+ </div>
+ <div className="list-item">
+ <strong>Answer behavior</strong>
+ <span>Answers should refuse when the KB lacks enough information instead of fabricating a response.</span>
+ </div>
+ <div className="list-item">
+ <strong>Citation behavior</strong>
+ <span>Every answer links back to source pages the user can already access.</span>
+ </div>
+ </div>
+ {aiSettings.provider === "none" || !aiSettings.isEnabled ? (
+ <EmptyState title="AI is disabled" description="Ask AI will remain available as a page, but the primary action stays disabled until a provider is configured." />
+ ) : null}
+ <div className="panel__footer">
+ <button onClick={saveAi}>Save AI settings</button>
+ </div>
+ </section>
+ ) : null}
+
+ {currentSection === "mcp" ? (
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Server status</p>
+ <h3>MCP availability</h3>
+ </div>
+ </div>
+ <div className="list-grid">
+ <div className="list-item">
+ <strong>Status</strong>
+ <span>MCP is exposed at `/api/mcp` through the same backend service as the application.</span>
+ </div>
+ <div className="list-item">
+ <strong>Tools</strong>
+ <span>`search_knowledge_base`, `read_page`, `list_spaces`, `get_page_metadata`, `create_draft_page`</span>
+ </div>
+ <div className="list-item">
+ <strong>Auth</strong>
+ <span>MCP currently uses the active Ledger session. Dedicated API tokens are not implemented, so the UI does not claim otherwise.</span>
+ </div>
+ <div className="list-item">
+ <strong>Permission behavior</strong>
+ <span>MCP calls inherit the same backend visibility checks as page reads, search, and AI retrieval.</span>
+ </div>
+ </div>
+ </section>
+ ) : null}
+
+ {currentSection === "import-history" ? (
+ <section className="panel">
+ {importJobs.length === 0 ? (
+ <EmptyState title="No imports yet" description="Run an import from the Imports area to populate this history." />
+ ) : (
+ <div className="feedback-list">
+ {importJobs.map((job) => (
+ <div key={job.id} className="feedback-item">
+ <div className="feedback-item__header">
+ <strong>{job.sourceLabel}</strong>
+ <span className={`badge badge-${job.status === "completed" ? "public" : "restricted"}`}>{job.status}</span>
+ </div>
+ <p>{job.provider} • {job.importedCount} pages</p>
+ {job.errorMessage ? <p className="muted">{job.errorMessage}</p> : null}
+ </div>
+ ))}
+ </div>
+ )}
+ </section>
+ ) : null}
+
+ {currentSection === "activity" ? (
+ <section className="panel">
+ {activity.length === 0 ? (
+ <EmptyState title="No activity logged" description="Recent audit activity will appear here as users change content and settings." />
+ ) : (
+ <div className="list-grid">
+ {activity.map((item) => (
+ <div key={item.id} className="list-item">
+ <strong>{item.action}</strong>
+ <span>{item.actor_name} • {new Date(item.created_at).toLocaleString()}</span>
+ <span>{item.resource_type}:{item.resource_id}</span>
+ </div>
+ ))}
+ </div>
+ )}
+ </section>
+ ) : null}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/AskAiPage.tsx b/apps/web/src/components/AskAiPage.tsx
new file mode 100644
index 0000000..e3ffe71
--- /dev/null
+++ b/apps/web/src/components/AskAiPage.tsx
@@ -0,0 +1,94 @@
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import type { AiCitation } from "@ledger/shared";
+import { api } from "../lib/api";
+import { EmptyState } from "./EmptyState";
+import { PageHeader } from "./PageHeader";
+
+export function AskAiPage() {
+ const [question, setQuestion] = useState("");
+ const [answer, setAnswer] = useState<{ answer: string; citations: AiCitation[]; disabled?: boolean } | null>(null);
+ const [loading, setLoading] = useState(false);
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault();
+ if (!question.trim()) return;
+
+ setLoading(true);
+ try {
+ const response = await api.post<{ answer: string; citations: AiCitation[]; disabled?: boolean }>("/api/ai/answers", {
+ question
+ });
+ setAnswer(response);
+ } catch (error) {
+ setAnswer({
+ answer: error instanceof Error ? error.message : "Could not answer this question.",
+ citations: []
+ });
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+ <div className="stack-page">
+ <PageHeader
+ eyebrow="Ask AI"
+ title="Ask Ledger"
+ description="Ledger answers using only pages you are allowed to access, and every answer includes citations back to source documents."
+ />
+
+ <section className="panel">
+ <form className="stack" onSubmit={submit}>
+ <label className="field">
+ Question
+ <textarea
+ value={question}
+ onChange={(event) => setQuestion(event.target.value)}
+ placeholder="Ask a product, process, or onboarding question"
+ />
+ </label>
+ <div className="panel__footer">
+ <button type="submit" disabled={loading || !question.trim()}>
+ {loading ? "Answering..." : "Ask AI"}
+ </button>
+ <p className="muted">If no permitted content is relevant, Ledger will say so instead of guessing.</p>
+ </div>
+ </form>
+ </section>
+
+ {answer ? (
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Answer</p>
+ <h3>Response</h3>
+ </div>
+ </div>
+ {answer.disabled ? (
+ <EmptyState
+ title="AI provider is not configured"
+ description="An admin can enable AI in Admin > AI Settings. Until then, Ask AI is intentionally unavailable."
+ action={<Link to="/admin/ai" className="button-secondary">Open AI Settings</Link>}
+ />
+ ) : (
+ <>
+ <p>{answer.answer}</p>
+ <div className="citation-list">
+ {answer.citations.length === 0 ? (
+ <p className="muted">No citations were returned for this answer.</p>
+ ) : (
+ answer.citations.map((citation) => (
+ <Link key={citation.slug} to={`/page/${citation.slug}`} className="citation-chip">
+ {citation.title}
+ </Link>
+ ))
+ )}
+ </div>
+ </>
+ )}
+ </section>
+ ) : null}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/DocsSidebar.tsx b/apps/web/src/components/DocsSidebar.tsx
index f534f75..e1054de 100644
--- a/apps/web/src/components/DocsSidebar.tsx
+++ b/apps/web/src/components/DocsSidebar.tsx
@@ -135,36 +135,52 @@ export function DocsSidebar({
<nav className="sidebar-nav" aria-label="Primary">
<Link
- className={`sidebar-nav__item${location.pathname === "/" ? " is-current" : ""}`}
- to="/"
+ className={`sidebar-nav__item${location.pathname === "/search" ? " is-current" : ""}`}
+ to="/search"
onClick={() => closeSidebarOnMobile(onClose)}
>
- <Icon name="home" className="icon icon-sm" />
- <span>Overview</span>
+ <Icon name="search" className="icon icon-sm" />
+ <span>Search</span>
</Link>
<Link
- className={`sidebar-nav__item${location.pathname === "/dashboard" ? " is-current" : ""}`}
- to="/dashboard"
+ className={`sidebar-nav__item${location.pathname === "/spaces" || location.pathname.startsWith("/space/") ? " is-current" : ""}`}
+ to="/spaces"
onClick={() => closeSidebarOnMobile(onClose)}
>
- <Icon name="settings" className="icon icon-sm" />
- <span>Manage</span>
+ <Icon name="collection" className="icon icon-sm" />
+ <span>Spaces</span>
+ </Link>
+ <Link
+ className={`sidebar-nav__item${location.pathname === "/drafts" ? " is-current" : ""}`}
+ to="/drafts"
+ onClick={() => closeSidebarOnMobile(onClose)}
+ >
+ <Icon name="document" className="icon icon-sm" />
+ <span>Drafts</span>
</Link>
- <Link className="sidebar-nav__item" to="/dashboard#imports" onClick={() => closeSidebarOnMobile(onClose)}>
+ <Link
+ className={`sidebar-nav__item${location.pathname === "/imports" ? " is-current" : ""}`}
+ to="/imports"
+ onClick={() => closeSidebarOnMobile(onClose)}
+ >
<Icon name="plus" className="icon icon-sm" />
<span>Imports</span>
</Link>
- <Link className="sidebar-nav__item" to="/dashboard#integrations" onClick={() => closeSidebarOnMobile(onClose)}>
- <Icon name="collection" className="icon icon-sm" />
- <span>Integrations</span>
- </Link>
- <Link className="sidebar-nav__item" to="/dashboard#webhooks" onClick={() => closeSidebarOnMobile(onClose)}>
- <Icon name="external" className="icon icon-sm" />
- <span>Webhooks</span>
- </Link>
- <Link className="sidebar-nav__item" to="/dashboard#ai" onClick={() => closeSidebarOnMobile(onClose)}>
+ <Link
+ className={`sidebar-nav__item${location.pathname === "/ask-ai" ? " is-current" : ""}`}
+ to="/ask-ai"
+ onClick={() => closeSidebarOnMobile(onClose)}
+ >
<Icon name="spark" className="icon icon-sm" />
- <span>AI</span>
+ <span>Ask AI</span>
+ </Link>
+ <Link
+ className={`sidebar-nav__item${location.pathname.startsWith("/admin") ? " is-current" : ""}`}
+ to="/admin/general"
+ onClick={() => closeSidebarOnMobile(onClose)}
+ >
+ <Icon name="settings" className="icon icon-sm" />
+ <span>Admin</span>
</Link>
</nav>
diff --git a/apps/web/src/components/DraftsPage.tsx b/apps/web/src/components/DraftsPage.tsx
new file mode 100644
index 0000000..75a0787
--- /dev/null
+++ b/apps/web/src/components/DraftsPage.tsx
@@ -0,0 +1,82 @@
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import type { PageSummary, SessionUser } from "@ledger/shared";
+import { api } from "../lib/api";
+import { EmptyState } from "./EmptyState";
+import { PageHeader } from "./PageHeader";
+
+type Space = {
+ id: string;
+ name: string;
+ key: string;
+ visibility: string;
+};
+
+export function DraftsPage({
+ user,
+ spaces
+}: {
+ user: SessionUser | null;
+ spaces: Space[];
+}) {
+ const [drafts, setDrafts] = useState<PageSummary[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (!user || user.role === "viewer" || user.role === "public") {
+ setLoading(false);
+ setDrafts([]);
+ return;
+ }
+
+ api
+ .get<{ pages: PageSummary[] }>("/api/pages/drafts")
+ .then((response) => setDrafts(response.pages))
+ .catch((reason) => setError(reason instanceof Error ? reason.message : "Could not load drafts."))
+ .finally(() => setLoading(false));
+ }, [user]);
+
+ return (
+ <div className="stack-page">
+ <PageHeader
+ eyebrow="Drafts"
+ title="Work in progress"
+ description="Draft pages are only visible to people with the right access. Use this space to review and publish content safely."
+ actions={user && user.role !== "viewer" && user.role !== "public" ? <Link to="/admin/general" className="button-secondary">Create draft</Link> : null}
+ />
+
+ <section className="panel">
+ {loading ? <p className="muted">Loading drafts...</p> : null}
+ {!loading && (!user || user.role === "viewer" || user.role === "public") ? (
+ <EmptyState
+ title="Drafts require editor access"
+ description="Sign in with an editor, admin, or owner account to review draft content."
+ />
+ ) : null}
+ {!loading && error ? (
+ <EmptyState title="Could not load drafts" description={error} />
+ ) : null}
+ {!loading && !error && drafts.length === 0 && user && user.role !== "viewer" && user.role !== "public" ? (
+ <EmptyState
+ title="No drafts yet"
+ description="Create a new draft page from the admin area to start building documentation before publishing."
+ />
+ ) : null}
+ {!loading && drafts.length > 0 ? (
+ <div className="article-list">
+ {drafts.map((page) => (
+ <Link key={page.id} to={`/page/${page.slug}`} className="article-list__item">
+ <div className="article-list__meta">
+ <strong>{page.title}</strong>
+ <span>{spaces.find((space) => space.id === page.spaceId)?.name ?? "Collection"}</span>
+ </div>
+ <p>{page.excerpt ?? "Draft page without an excerpt yet."}</p>
+ </Link>
+ ))}
+ </div>
+ ) : null}
+ </section>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/ImportsPage.tsx b/apps/web/src/components/ImportsPage.tsx
new file mode 100644
index 0000000..16b8896
--- /dev/null
+++ b/apps/web/src/components/ImportsPage.tsx
@@ -0,0 +1,376 @@
+import { useEffect, useMemo, useState } from "react";
+import type { ImportJobSummary, IntegrationSummary, SessionUser } from "@ledger/shared";
+import { api } from "../lib/api";
+import { EmptyState } from "./EmptyState";
+import { PageHeader } from "./PageHeader";
+
+type Space = {
+ id: string;
+ name: string;
+ key: string;
+ visibility: string;
+};
+
+type ImportSource = "markdown" | "github" | "google_docs";
+
+export function ImportsPage({
+ user,
+ spaces
+}: {
+ user: SessionUser | null;
+ spaces: Space[];
+}) {
+ const [source, setSource] = useState<ImportSource>("markdown");
+ const [step, setStep] = useState<"configure" | "preview" | "imported">("configure");
+ const [integrations, setIntegrations] = useState<IntegrationSummary[]>([]);
+ const [jobs, setJobs] = useState<ImportJobSummary[]>([]);
+ const [status, setStatus] = useState<string | null>(null);
+ const [markdownFiles, setMarkdownFiles] = useState<Array<{ fileName: string; content: string }>>([]);
+ const [markdownPreview, setMarkdownPreview] = useState<Array<{ title: string; excerpt?: string }> | null>(null);
+ const [github, setGithub] = useState({ repo: "", branch: "main", path: "" });
+ const [githubPreview, setGithubPreview] = useState<{ title: string; excerpt?: string } | null>(null);
+ const [googleDocId, setGoogleDocId] = useState("");
+ const [googlePreview, setGooglePreview] = useState<{ title: string; excerpt?: string } | null>(null);
+ const [target, setTarget] = useState({
+ spaceId: spaces[0]?.id ?? "",
+ visibility: "internal",
+ state: "draft"
+ });
+
+ const canImport = Boolean(user && user.role !== "viewer" && user.role !== "public");
+
+ const githubIntegration = integrations.find((item) => item.provider === "github");
+ const googleIntegration = integrations.find((item) => item.provider === "google_docs");
+
+ useEffect(() => {
+ if (!canImport) return;
+
+ api.get<{ integrations: IntegrationSummary[] }>("/api/integrations").then((response) => setIntegrations(response.integrations));
+ api.get<{ jobs: ImportJobSummary[] }>("/api/integrations/import-jobs").then((response) => setJobs(response.jobs));
+ }, [canImport]);
+
+ const sourceCards = useMemo(
+ () => [
+ {
+ key: "markdown" as const,
+ title: "Markdown Import",
+ description: "Import one or more Markdown files directly from your browser.",
+ status: "Ready"
+ },
+ {
+ key: "github" as const,
+ title: "GitHub",
+ description: "Import Markdown content from a repository path and branch.",
+ status: githubIntegration?.statusMessage ?? "Not configured"
+ },
+ {
+ key: "google_docs" as const,
+ title: "Google Docs",
+ description: "Import a Google Doc into Markdown while preserving basic formatting.",
+ status: googleIntegration?.statusMessage ?? "Not configured"
+ }
+ ],
+ [githubIntegration?.statusMessage, googleIntegration?.statusMessage]
+ );
+
+ function sourceBadgeTone(key: ImportSource) {
+ if (key === "markdown") return "public";
+ if (key === "github") return githubIntegration?.status === "configured" ? "public" : "restricted";
+ return googleIntegration?.status === "configured" ? "public" : "restricted";
+ }
+
+ async function handleMarkdownFiles(fileList: FileList | null) {
+ if (!fileList) return;
+
+ try {
+ const files = await Promise.all(
+ Array.from(fileList).map(
+ (file) =>
+ new Promise<{ fileName: string; content: string }>((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve({ fileName: file.name, content: String(reader.result ?? "") });
+ reader.onerror = () => reject(reader.error);
+ reader.readAsText(file);
+ })
+ )
+ );
+
+ setMarkdownFiles(files);
+ const response = await api.post<{ documents: Array<{ title: string; excerpt?: string }> }>("/api/integrations/markdown/preview", {
+ files
+ });
+ setMarkdownPreview(response.documents);
+ setStep("preview");
+ setStatus(null);
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not preview Markdown files.");
+ }
+ }
+
+ async function previewGithub() {
+ try {
+ const response = await api.post<{ document: { title: string; excerpt?: string } }>("/api/integrations/github/preview", github);
+ setGithubPreview(response.document);
+ setStep("preview");
+ setStatus(null);
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not preview GitHub content.");
+ }
+ }
+
+ async function previewGoogle() {
+ try {
+ const response = await api.post<{ document: { title: string; excerpt?: string } }>("/api/integrations/google-docs/preview", {
+ documentId: googleDocId
+ });
+ setGooglePreview(response.document);
+ setStep("preview");
+ setStatus(null);
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not preview Google Docs content.");
+ }
+ }
+
+ async function runImport() {
+ try {
+ if (source === "markdown") {
+ await api.post("/api/integrations/markdown/import", { files: markdownFiles, target });
+ }
+
+ if (source === "github") {
+ await api.post("/api/integrations/github/import", { ...github, target });
+ }
+
+ if (source === "google_docs") {
+ await api.post("/api/integrations/google-docs/import", { documentId: googleDocId, target });
+ }
+
+ setStatus("Import completed successfully.");
+ setStep("imported");
+ if (canImport) {
+ const response = await api.get<{ jobs: ImportJobSummary[] }>("/api/integrations/import-jobs");
+ setJobs(response.jobs);
+ }
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Import failed.");
+ }
+ }
+
+ const sourceReady =
+ source === "markdown" ||
+ (source === "github" && githubIntegration?.status === "configured") ||
+ (source === "google_docs" && googleIntegration?.status === "configured");
+
+ return (
+ <div className="stack-page">
+ <PageHeader
+ eyebrow="Imports"
+ title="Import documentation"
+ description="Select a source, preview imported content, choose the target space, and keep a clean history of every import run."
+ />
+
+ {!canImport ? (
+ <section className="panel">
+ <EmptyState
+ title="Imports require editor access"
+ description="Sign in with an editor, admin, or owner account to import content into Ledger."
+ />
+ </section>
+ ) : (
+ <>
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Step 1</p>
+ <h3>Select a source</h3>
+ </div>
+ </div>
+ <div className="integration-list">
+ {sourceCards.map((card) => (
+ <button
+ key={card.key}
+ type="button"
+ className={`integration-card integration-card--selectable${source === card.key ? " is-selected" : ""}`}
+ onClick={() => {
+ setSource(card.key);
+ setStep("configure");
+ }}
+ >
+ <div className="feedback-item__header">
+ <strong>{card.title}</strong>
+ <span className={`badge badge-${sourceBadgeTone(card.key)}`}>
+ {card.status}
+ </span>
+ </div>
+ <p>{card.description}</p>
+ </button>
+ ))}
+ </div>
+ </section>
+
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Step 2</p>
+ <h3>Configure target</h3>
+ </div>
+ </div>
+ <div className="field-grid">
+ <label className="field">
+ Target space
+ <select value={target.spaceId} onChange={(event) => setTarget((current) => ({ ...current, spaceId: event.target.value }))}>
+ {spaces.map((space) => (
+ <option key={space.id} value={space.id}>
+ {space.name}
+ </option>
+ ))}
+ </select>
+ </label>
+ <label className="field">
+ Publish state
+ <select value={target.state} onChange={(event) => setTarget((current) => ({ ...current, state: event.target.value }))}>
+ <option value="draft">Draft</option>
+ <option value="published">Published</option>
+ </select>
+ </label>
+ </div>
+ <label className="field">
+ Visibility
+ <select value={target.visibility} onChange={(event) => setTarget((current) => ({ ...current, visibility: event.target.value }))}>
+ <option value="internal">Internal</option>
+ <option value="public">Public</option>
+ <option value="restricted">Restricted</option>
+ </select>
+ </label>
+ </section>
+
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Step 3</p>
+ <h3>Preview source content</h3>
+ </div>
+ </div>
+
+ {!sourceReady ? (
+ <EmptyState
+ title="Source not configured"
+ description={`Configure ${source === "github" ? "GitHub" : source === "google_docs" ? "Google Docs" : "Markdown import"} before previewing imports.`}
+ />
+ ) : null}
+
+ {source === "markdown" ? (
+ <div className="stack">
+ <input type="file" accept=".md,.markdown,text/markdown" multiple onChange={(event) => void handleMarkdownFiles(event.target.files)} />
+ <p className="muted">Multiple-file upload is supported directly. Folder upload varies by browser, so Ledger uses multiple files as the reliable default.</p>
+ {markdownPreview ? (
+ <div className="preview-list">
+ {markdownPreview.map((document) => (
+ <div key={document.title} className="preview-item">
+ <strong>{document.title}</strong>
+ <p>{document.excerpt ?? "No excerpt available."}</p>
+ </div>
+ ))}
+ </div>
+ ) : null}
+ </div>
+ ) : null}
+
+ {source === "github" ? (
+ <div className="stack">
+ <div className="field-grid">
+ <label className="field">
+ Repository
+ <input value={github.repo} onChange={(event) => setGithub((current) => ({ ...current, repo: event.target.value }))} placeholder="org/repo" />
+ </label>
+ <label className="field">
+ Branch
+ <input value={github.branch} onChange={(event) => setGithub((current) => ({ ...current, branch: event.target.value }))} />
+ </label>
+ </div>
+ <label className="field">
+ Path
+ <input value={github.path} onChange={(event) => setGithub((current) => ({ ...current, path: event.target.value }))} placeholder="docs/getting-started.md" />
+ </label>
+ <div className="panel__footer">
+ <button type="button" className="button-secondary" disabled={!github.repo || !github.path || !sourceReady} onClick={previewGithub}>
+ Preview GitHub file
+ </button>
+ </div>
+ {githubPreview ? <div className="preview-item"><strong>{githubPreview.title}</strong><p>{githubPreview.excerpt ?? "No excerpt available."}</p></div> : null}
+ </div>
+ ) : null}
+
+ {source === "google_docs" ? (
+ <div className="stack">
+ <label className="field">
+ Google Doc ID
+ <input value={googleDocId} onChange={(event) => setGoogleDocId(event.target.value)} placeholder="Document ID" />
+ </label>
+ <div className="panel__footer">
+ <button type="button" className="button-secondary" disabled={!googleDocId || !sourceReady} onClick={previewGoogle}>
+ Preview Google Doc
+ </button>
+ </div>
+ {googlePreview ? <div className="preview-item"><strong>{googlePreview.title}</strong><p>{googlePreview.excerpt ?? "No excerpt available."}</p></div> : null}
+ </div>
+ ) : null}
+ </section>
+
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Step 4</p>
+ <h3>Import</h3>
+ </div>
+ </div>
+ <div className="panel__footer">
+ <button
+ type="button"
+ disabled={
+ !sourceReady ||
+ (source === "markdown" && !markdownPreview?.length) ||
+ (source === "github" && !githubPreview) ||
+ (source === "google_docs" && !googlePreview)
+ }
+ onClick={runImport}
+ >
+ Import into Ledger
+ </button>
+ {status ? <p className="muted">{status}</p> : null}
+ {step === "imported" ? <p className="muted">Import finished. Source metadata will appear on imported pages.</p> : null}
+ </div>
+ </section>
+
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">History</p>
+ <h3>Recent imports</h3>
+ </div>
+ </div>
+ {jobs.length === 0 ? (
+ <EmptyState
+ title="No imports recorded yet"
+ description="Import history will appear here after you complete your first import."
+ />
+ ) : (
+ <div className="feedback-list">
+ {jobs.map((job) => (
+ <div key={job.id} className="feedback-item">
+ <div className="feedback-item__header">
+ <strong>{job.sourceLabel}</strong>
+ <span className={`badge badge-${job.status === "completed" ? "public" : "restricted"}`}>{job.status}</span>
+ </div>
+ <p>{job.provider} • {job.importedCount} pages</p>
+ {job.errorMessage ? <p className="muted">{job.errorMessage}</p> : null}
+ </div>
+ ))}
+ </div>
+ )}
+ </section>
+ </>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/PageHeader.tsx b/apps/web/src/components/PageHeader.tsx
new file mode 100644
index 0000000..4f47928
--- /dev/null
+++ b/apps/web/src/components/PageHeader.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from "react";
+
+export function PageHeader({
+ eyebrow,
+ title,
+ description,
+ actions
+}: {
+ eyebrow?: string;
+ title: string;
+ description?: string;
+ actions?: ReactNode;
+}) {
+ return (
+ <header className="page-header">
+ <div>
+ {eyebrow ? <p className="eyebrow">{eyebrow}</p> : null}
+ <h1>{title}</h1>
+ {description ? <p className="lede">{description}</p> : null}
+ </div>
+ {actions ? <div className="page-header__actions">{actions}</div> : null}
+ </header>
+ );
+}
diff --git a/apps/web/src/components/SearchPage.tsx b/apps/web/src/components/SearchPage.tsx
new file mode 100644
index 0000000..89d6b61
--- /dev/null
+++ b/apps/web/src/components/SearchPage.tsx
@@ -0,0 +1,89 @@
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import type { PageSummary } from "@ledger/shared";
+import { EmptyState } from "./EmptyState";
+import { PageHeader } from "./PageHeader";
+import { SearchBar } from "./SearchBar";
+
+type Space = {
+ id: string;
+ name: string;
+ key: string;
+ visibility: string;
+};
+
+export function SearchPage({
+ spaces,
+ results,
+ isLoading,
+ onSearch
+}: {
+ spaces: Space[];
+ results: PageSummary[];
+ isLoading: boolean;
+ onSearch: (query: string) => Promise<void>;
+}) {
+ const [query, setQuery] = useState("");
+
+ async function handleSearch(nextQuery: string) {
+ setQuery(nextQuery);
+ await onSearch(nextQuery);
+ }
+
+ return (
+ <div className="stack-page">
+ <PageHeader
+ eyebrow="Search"
+ title="Find answers quickly"
+ description="Search across every page you’re allowed to read. Results respect the same visibility and group rules as the rest of Ledger."
+ />
+
+ <section className="panel">
+ <SearchBar
+ initialQuery={query}
+ onSearch={handleSearch}
+ placeholder="Search pages, policies, onboarding docs, and runbooks"
+ />
+ </section>
+
+ <section className="panel">
+ <div className="panel__header">
+ <div>
+ <p className="eyebrow">Results</p>
+ <h3>{query ? `Results for "${query}"` : "Search results"}</h3>
+ </div>
+ </div>
+
+ {isLoading ? <p className="muted">Searching documentation...</p> : null}
+
+ {!isLoading && query && results.length === 0 ? (
+ <EmptyState
+ title="No results found"
+ description="Try a broader term, a collection name, or a more specific process keyword."
+ />
+ ) : null}
+
+ {!isLoading && !query ? (
+ <EmptyState
+ title="Start with a search"
+ description="Search titles and document content. Use Cmd/Ctrl + K anywhere in the app for quick search."
+ />
+ ) : null}
+
+ {!isLoading && results.length > 0 ? (
+ <div className="article-list">
+ {results.map((page) => (
+ <Link key={page.id} to={`/page/${page.slug}`} className="article-list__item">
+ <div className="article-list__meta">
+ <strong>{page.title}</strong>
+ <span>{spaces.find((space) => space.id === page.spaceId)?.name ?? "Collection"}</span>
+ </div>
+ <p>{page.excerpt ?? "Open this page to read more."}</p>
+ </Link>
+ ))}
+ </div>
+ ) : null}
+ </section>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/SpacesPage.tsx b/apps/web/src/components/SpacesPage.tsx
new file mode 100644
index 0000000..1746f26
--- /dev/null
+++ b/apps/web/src/components/SpacesPage.tsx
@@ -0,0 +1,51 @@
+import { Link } from "react-router-dom";
+import type { PageSummary } from "@ledger/shared";
+import { EmptyState } from "./EmptyState";
+import { PageHeader } from "./PageHeader";
+
+type Space = {
+ id: string;
+ name: string;
+ key: string;
+ visibility: string;
+};
+
+export function SpacesPage({
+ spaces,
+ pagesBySpace
+}: {
+ spaces: Space[];
+ pagesBySpace: Record<string, PageSummary[]>;
+}) {
+ return (
+ <div className="stack-page">
+ <PageHeader
+ eyebrow="Spaces"
+ title="Browse knowledge by space"
+ description="Each space organizes a collection of public or internal documentation. Use spaces to separate teams, products, or domains."
+ />
+
+ {spaces.length === 0 ? (
+ <section className="panel">
+ <EmptyState
+ title="No spaces available"
+ description="Create a space from the admin area before publishing documents."
+ />
+ </section>
+ ) : (
+ <section className="collection-cards">
+ {spaces.map((space) => (
+ <Link key={space.id} to={`/space/${space.key}`} className="collection-card">
+ <div className="collection-card__icon" />
+ <div>
+ <strong>{space.name}</strong>
+ <p>{space.visibility} visibility</p>
+ </div>
+ <span>{(pagesBySpace[space.key] ?? []).length} docs</span>
+ </Link>
+ ))}
+ </section>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/docs-theme.css b/apps/web/src/docs-theme.css
index c2c9a3c..087010a 100644
--- a/apps/web/src/docs-theme.css
+++ b/apps/web/src/docs-theme.css
@@ -891,7 +891,8 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.citation-list,
.delivery-list,
-.preview-list {
+.preview-list,
+.checkbox-list {
display: grid;
gap: 0.75rem;
}
@@ -929,6 +930,86 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
margin-top: 1rem;
}
+.stack-page {
+ display: grid;
+ gap: 1rem;
+}
+
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 0.4rem 0 0.8rem;
+}
+
+.page-header__actions {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+}
+
+.admin-shell {
+ display: grid;
+ grid-template-columns: 260px minmax(0, 1fr);
+ gap: 1rem;
+}
+
+.admin-sidebar {
+ position: sticky;
+ top: 1rem;
+ align-self: start;
+}
+
+.admin-nav {
+ display: grid;
+ gap: 0.4rem;
+}
+
+.admin-nav__item {
+ display: flex;
+ align-items: center;
+ min-height: 2.8rem;
+ padding: 0.75rem 0.9rem;
+ border-radius: 14px;
+ color: var(--text-soft);
+}
+
+.admin-nav__item.is-current {
+ background: rgba(36, 92, 255, 0.08);
+ color: var(--accent-strong);
+}
+
+.admin-content,
+.admin-grid,
+.list-grid {
+ display: grid;
+ gap: 1rem;
+}
+
+.admin-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.list-item {
+ display: grid;
+ gap: 0.25rem;
+ padding: 0.95rem 1rem;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.88);
+}
+
+.integration-card--selectable {
+ text-align: left;
+ cursor: pointer;
+}
+
+.integration-card--selectable.is-selected {
+ border-color: rgba(36, 92, 255, 0.35);
+ box-shadow: 0 0 0 1px rgba(36, 92, 255, 0.12);
+}
+
.mobile-only {
display: none;
}
@@ -943,7 +1024,9 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.overview-grid,
.manage-grid,
.auth-layout,
- .setup-screen {
+ .setup-screen,
+ .admin-shell,
+ .admin-grid {
grid-template-columns: 1fr;
}