NobGit
public anord read

Ledger

Why work hard when you can work easier?

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 86% CSS 10% SQL 3% Shell 1% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/orgs/anord/ledger.git
ssh://[email protected]:2222/orgs/anord/ledger.git

Commit

Continuing UI fixes

ea3e37e
Alex Nord <[email protected]> 3 months ago
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;
   }