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 { resolveDisplayedMcpEndpoint } from "../lib/mcp"; import { EmptyState } from "./EmptyState"; 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 & MCP"], ["import-history", "Import"], ["activity", "Activity"] ] as const; const adminGroups = [ { label: "Workspace", items: [ ["general", "General"], ["members", "Members"], ["permissions", "Permissions"] ] }, { label: "Platform", items: [ ["integrations", "Integrations"], ["webhooks", "Webhooks"], ["ai", "AI & MCP"], ["import-history", "Import"], ["activity", "Activity"] ] } ] as const; const webhookEvents = [ "page.created", "page.updated", "page.deleted", "page.published", "feedback.created", "user.invited", "search.no_results" ] as const; const roleDetails: Record = { owner: "Full control over settings, members, content, and infrastructure features.", admin: "Manage workspace configuration, members, imports, integrations, and webhook behavior.", editor: "Create and update documents, drafts, and import content where enabled.", viewer: "Read internal content that is available to authenticated members.", public: "Read only public content without signing in." }; export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUser; spaces: Space[] }) { const { section = "general" } = useParams(); const normalizedSection = section === "mcp" ? "ai" : section; const currentSection = adminNav.some(([key]) => key === normalizedSection) ? normalizedSection : "general"; const [brandingForm, setBrandingForm] = useState({ siteName: "Ledger", logoUrl: "", brandColor: "#245cff", publicKnowledgeBaseEnabled: true, footerLinks: [] as Array<{ label: string; href: string }> }); const [users, setUsers] = useState([]); const [groups, setGroups] = useState([]); const [roles, setRoles] = useState>([]); const [integrations, setIntegrations] = useState([]); const [importJobs, setImportJobs] = useState([]); const [webhooks, setWebhooks] = useState([]); const [deliveries, setDeliveries] = useState>({}); const [activity, setActivity] = useState([]); const [aiSettings, setAiSettings] = useState({ provider: "none", model: "", isEnabled: false, hasApiKey: false, apiKey: "" }); const [integrationForms, setIntegrationForms] = useState>({ 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(null); const [webhookForm, setWebhookForm] = useState({ name: "", targetUrl: "", signingSecret: "", isActive: true, events: ["page.created", "page.updated"] as string[] }); const [status, setStatus] = useState(null); const [mcpSettings, setMcpSettings] = useState({ endpoint: "/mcp", authMode: "session_cookie" }); const currentWebhook = useMemo( () => webhooks.find((webhook) => webhook.id === editingWebhookId) ?? null, [editingWebhookId, webhooks] ); const displayedMcpEndpoint = useMemo( () => resolveDisplayedMcpEndpoint(mcpSettings.endpoint), [mcpSettings.endpoint] ); 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; footer_links: Array<{ label: string; href: string }> }; 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, publicKnowledgeBaseEnabled: settingsResponse.branding.public_knowledge_base_enabled, footerLinks: settingsResponse.branding.footer_links ?? [] }); setMcpSettings(settingsResponse.mcp); 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, publicKnowledgeBaseEnabled: brandingForm.publicKnowledgeBaseEnabled, footerLinks: brandingForm.footerLinks.filter((link) => link.label.trim() && link.href.trim()) }); 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, check connector health, and jump into importing content."]; case "webhooks": return ["Admin", "Webhooks", "Manage outbound event delivery, signing secrets, and recent delivery attempts."]; case "ai": return ["Admin", "AI & MCP", "Configure AI providers, review answer behavior, and inspect the MCP surface exposed by Ledger."]; case "import-history": return ["Admin", "Import", "Launch imports and review recent import activity from one place."]; case "activity": return ["Admin", "Activity", "Review recent audit activity across the workspace."]; default: return ["Admin", "Admin", ""]; } } const [eyebrow, title, description] = sectionMeta(); return (
{status ?

{status}

: null} {currentSection === "general" ? ( <>

Workspace

Site name

Name shown across the workspace and navigation.

setBrandingForm((current) => ({ ...current, siteName: event.target.value }))} />
Logo URL

Optional logo used in the workspace header.

setBrandingForm((current) => ({ ...current, logoUrl: event.target.value }))} />
Brand color

Primary accent used for actions and highlights.

setBrandingForm((current) => ({ ...current, brandColor: event.target.value }))} />
Public knowledge base

Allow public visitors to read public pages without signing in.

Footer links

Add support, status, or policy links beside the fixed Ledger footer line.

{brandingForm.footerLinks.length === 0 ?

No footer links configured yet.

: null} {brandingForm.footerLinks.map((link, index) => (
setBrandingForm((current) => ({ ...current, footerLinks: current.footerLinks.map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value } : item ) })) } /> setBrandingForm((current) => ({ ...current, footerLinks: current.footerLinks.map((item, itemIndex) => itemIndex === index ? { ...item, href: event.target.value } : item ) })) } />
))}
) : null} {currentSection === "members" ? ( <>

Members

Workspace access

Members are listed here with their current effective role so admins can quickly verify who can browse, edit, and manage the knowledge base.

{users.length} members
{users.map((member) => (
{member.display_name}

{member.email}

{member.role_key}
))}

Groups

{groups.length === 0 ? ( ) : ( groups.map((group) => (
{group.name}

{group.description ?? "No description"}

)) )}
) : null} {currentSection === "permissions" ? ( <>

Roles

{roles.map((role) => (
{role.name}

{roleDetails[role.key] ?? role.key}

{role.key}
))}

Permission behavior

{[ ["Public pages", "Readable without authentication when the public knowledge base is enabled."], ["Internal pages", "Readable only by authenticated users with at least viewer access."], ["Restricted pages", "Require explicit role or group permissions. Search, AI, and MCP follow the same backend checks."] ].map(([label, detail]) => (
{label}

{detail}

))}
) : null} {currentSection === "integrations" ? (
{(["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"; const integrationTitle = provider === "google_docs" ? "Google Docs" : provider === "github" ? "GitHub" : "Markdown Import"; const integrationDescription = provider === "google_docs" ? "Import Google Docs content into Ledger Markdown pages and preserve source metadata." : provider === "github" ? "Connect a GitHub repository and import Markdown from a chosen branch and path." : "Upload Markdown files directly through Ledger without an external connector."; return (
{integrationTitle}

{integrationDescription}

{integration?.statusMessage ?? "Not configured"}
Connection name

Used in the admin UI to identify this source.

setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], name: event.target.value } }))} />
{provider === "github" ? (
GitHub token

Required for repository previews and imports.

setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], token: event.target.value } }))} />
) : null} {provider === "google_docs" ? (
Google access token

Required for previewing and importing Google Docs.

setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], accessToken: event.target.value } }))} />
) : null}
Enabled

Allow this integration to preview and import content.

Last import

Latest import activity for this source.

{lastJob ? new Date(lastJob.updatedAt).toLocaleString() : "None yet"}
{canRunImport ? Open import : }
); })}
) : null} {currentSection === "webhooks" ? ( <>

Webhook editor

{editingWebhookId ? "Edit webhook" : "Create webhook"}

Ledger sends `X-Ledger-Event`, `X-Ledger-Timestamp`, and `X-Ledger-Signature` headers. The signature is HMAC SHA-256 over `timestamp.body`.

{editingWebhookId ? : null}

Configured endpoints

Webhook endpoints

{webhooks.length === 0 ? ( ) : (
{webhooks.map((webhook) => (
{webhook.name} {webhook.isActive ? "Active" : "Disabled"}

{webhook.targetUrl}

{webhook.events.join(", ")}

{deliveries[webhook.id]?.length ? (
{deliveries[webhook.id].map((delivery) => (
{delivery.eventName} {delivery.responseStatus ?? "pending"} {delivery.success ? "Success" : delivery.errorMessage ?? "Failed"}
))}
) : null}
))}
)}
) : null} {currentSection === "ai" ? ( <>

AI provider

Provider

Select the provider type Ledger should use for answers.

Model

Model identifier used by the configured provider.

setAiSettings((current) => ({ ...current, model: event.target.value }))} />
API key

{aiSettings.hasApiKey ? "A provider key is already stored. Enter a new key to rotate it." : "Paste a provider key to enable server-side requests."}

setAiSettings((current) => ({ ...current, apiKey: event.target.value }))} placeholder={aiSettings.hasApiKey ? "Configured" : "Paste provider key"} />
Enable AI answers

Allow Ask AI to answer from permission-filtered Ledger content.

Retrieval settings

Ledger searches only pages the current user can access before generating an answer.

Answer behavior

If the knowledge base does not contain enough information, Ledger refuses instead of inventing an answer.

Citation behavior

Answers cite the exact source pages the user is already allowed to open.

{aiSettings.provider === "none" || !aiSettings.isEnabled ? ( ) : null}

MCP server

Hosted endpoint

{displayedMcpEndpoint}

Live path
Tools

search_knowledge_base, read_page, list_spaces, get_page_metadata, create_draft_page

Auth

MCP currently uses {mcpSettings.authMode === "session_cookie" ? "the active Ledger session cookie" : mcpSettings.authMode}. Dedicated API tokens are not implemented yet.

Permission behavior

MCP calls inherit the same backend visibility checks as page reads, search, and AI retrieval.

) : null} {currentSection === "import-history" ? (
Open import flow
{importJobs.length === 0 ? ( ) : (
{importJobs.map((job) => (
{job.sourceLabel}

{job.provider} • {job.importedCount} pages • {new Date(job.updatedAt).toLocaleString()}

{job.provider} • {job.importedCount} pages

{job.errorMessage ?

{job.errorMessage}

: null}
))}
)}
) : null} {currentSection === "activity" ? (
{activity.length === 0 ? ( ) : (
{activity.map((item) => (
{item.action} {item.actor_name} • {new Date(item.created_at).toLocaleString()} {item.resource_type}:{item.resource_id}
))}
)}
) : null}
); }