public
anord
read
Ledger
Why work hard when you can work easier?
Languages
Repository composition by tracked source files.
TypeScript
86%
CSS
10%
SQL
3%
Shell
1%
HTML
0%
Trace
apps/web/src/components/AdminConsole.tsx
Trace helps you understand code history line by line. See who changed each line, when it changed, and which commit introduced it.
Author
Date
Commit
Line
Code
1
import { useEffect, useMemo, useState } from "react";
2
import { Link, useParams } from "react-router-dom";
3
import type {
4
ImportJobSummary,
5
IntegrationSummary,
6
SessionUser,
7
WebhookDeliverySummary,
8
WebhookSummary
9
} from "@ledger/shared";
10
import { api } from "../lib/api";
11
import { resolveDisplayedMcpEndpoint } from "../lib/mcp";
12
import { EmptyState } from "./EmptyState";
13
import { PageHeader } from "./PageHeader";
15
type Space = {
16
id: string;
17
name: string;
18
key: string;
19
visibility: string;
20
};
22
type AdminUser = { id: string; email: string; display_name: string; role_key: string };
23
type AdminGroup = { id: string; name: string; description: string | null };
24
type ActivityItem = {
25
id: string;
26
action: string;
27
resource_type: string;
28
resource_id: string;
29
actor_name: string;
30
created_at: string;
31
};
33
const adminNav = [
34
["general", "General"],
35
["members", "Members"],
36
["permissions", "Permissions"],
37
["integrations", "Integrations"],
38
["webhooks", "Webhooks"],
39
["ai", "AI & MCP"],
40
["import-history", "Import"],
41
["activity", "Activity"]
42
] as const;
44
const adminGroups = [
45
{
46
label: "Workspace",
47
items: [
48
["general", "General"],
49
["members", "Members"],
50
["permissions", "Permissions"]
51
]
52
},
53
{
54
label: "Platform",
55
items: [
56
["integrations", "Integrations"],
57
["webhooks", "Webhooks"],
58
["ai", "AI & MCP"],
59
["import-history", "Import"],
60
["activity", "Activity"]
61
]
62
}
63
] as const;
65
const webhookEvents = [
66
"page.created",
67
"page.updated",
68
"page.deleted",
69
"page.published",
70
"feedback.created",
71
"user.invited",
72
"search.no_results"
73
] as const;
75
const roleDetails: Record<string, string> = {
76
owner: "Full control over settings, members, content, and infrastructure features.",
77
admin: "Manage workspace configuration, members, imports, integrations, and webhook behavior.",
78
editor: "Create and update documents, drafts, and import content where enabled.",
79
viewer: "Read internal content that is available to authenticated members.",
80
public: "Read only public content without signing in."
81
};
83
export function AdminConsole({ user: _user, spaces: _spaces }: { user: SessionUser; spaces: Space[] }) {
84
const { section = "general" } = useParams();
85
const normalizedSection = section === "mcp" ? "ai" : section;
86
const currentSection = adminNav.some(([key]) => key === normalizedSection) ? normalizedSection : "general";
87
const [brandingForm, setBrandingForm] = useState({
88
siteName: "Ledger",
89
logoUrl: "",
90
brandColor: "#245cff",
91
publicKnowledgeBaseEnabled: true,
92
footerLinks: [] as Array<{ label: string; href: string }>
93
});
94
const [users, setUsers] = useState<AdminUser[]>([]);
95
const [groups, setGroups] = useState<AdminGroup[]>([]);
96
const [roles, setRoles] = useState<Array<{ id: string; key: string; name: string }>>([]);
97
const [integrations, setIntegrations] = useState<IntegrationSummary[]>([]);
98
const [importJobs, setImportJobs] = useState<ImportJobSummary[]>([]);
99
const [webhooks, setWebhooks] = useState<WebhookSummary[]>([]);
100
const [deliveries, setDeliveries] = useState<Record<string, WebhookDeliverySummary[]>>({});
101
const [activity, setActivity] = useState<ActivityItem[]>([]);
102
const [aiSettings, setAiSettings] = useState({
103
provider: "none",
104
model: "",
105
isEnabled: false,
106
hasApiKey: false,
107
apiKey: ""
108
});
109
const [integrationForms, setIntegrationForms] = useState<Record<string, { name: string; isEnabled: boolean; token: string; accessToken: string }>>({
110
github: { name: "GitHub", isEnabled: false, token: "", accessToken: "" },
111
google_docs: { name: "Google Docs", isEnabled: false, token: "", accessToken: "" },
112
markdown_import: { name: "Markdown Import", isEnabled: true, token: "", accessToken: "" }
113
});
114
const [editingWebhookId, setEditingWebhookId] = useState<string | null>(null);
115
const [webhookForm, setWebhookForm] = useState({
116
name: "",
117
targetUrl: "",
118
signingSecret: "",
119
isActive: true,
120
events: ["page.created", "page.updated"] as string[]
121
});
122
const [status, setStatus] = useState<string | null>(null);
123
const [mcpSettings, setMcpSettings] = useState({
124
endpoint: "/mcp",
125
authMode: "session_cookie"
126
});
128
const currentWebhook = useMemo(
129
() => webhooks.find((webhook) => webhook.id === editingWebhookId) ?? null,
130
[editingWebhookId, webhooks]
131
);
132
const displayedMcpEndpoint = useMemo(
133
() => resolveDisplayedMcpEndpoint(mcpSettings.endpoint),
134
[mcpSettings.endpoint]
135
);
137
useEffect(() => {
138
async function load() {
139
const [usersResponse, groupsResponse, rolesResponse, settingsResponse, integrationsResponse, jobsResponse, aiResponse, webhooksResponse, activityResponse] =
140
await Promise.all([
141
api.get<{ users: AdminUser[] }>("/api/admin/users"),
142
api.get<{ groups: AdminGroup[] }>("/api/admin/groups"),
143
api.get<{ roles: Array<{ id: string; key: string; name: string }> }>("/api/roles"),
144
api.get<{
145
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 }> };
146
mcp: { endpoint: string; authMode: string };
147
}>("/api/settings/admin"),
148
api.get<{ integrations: IntegrationSummary[] }>("/api/integrations"),
149
api.get<{ jobs: ImportJobSummary[] }>("/api/integrations/import-jobs"),
150
api.get<{ settings: { provider: string; model: string; isEnabled: boolean; hasApiKey: boolean } }>("/api/ai/settings"),
151
api.get<{ webhooks: WebhookSummary[] }>("/api/webhooks"),
152
api.get<{ activity: ActivityItem[] }>("/api/admin/activity")
153
]);
155
setUsers(usersResponse.users);
156
setGroups(groupsResponse.groups);
157
setRoles(rolesResponse.roles);
158
setBrandingForm({
159
siteName: settingsResponse.branding.site_name,
160
logoUrl: settingsResponse.branding.logo_url ?? "",
161
brandColor: settingsResponse.branding.brand_color,
162
publicKnowledgeBaseEnabled: settingsResponse.branding.public_knowledge_base_enabled,
163
footerLinks: settingsResponse.branding.footer_links ?? []
164
});
165
setMcpSettings(settingsResponse.mcp);
166
setIntegrations(integrationsResponse.integrations);
167
setImportJobs(jobsResponse.jobs);
168
setAiSettings((current) => ({ ...current, ...aiResponse.settings, apiKey: "" }));
169
setWebhooks(webhooksResponse.webhooks);
170
setActivity(activityResponse.activity);
171
setIntegrationForms((current) => {
172
const next = { ...current };
173
for (const integration of integrationsResponse.integrations) {
174
next[integration.provider] = {
175
name: integration.name,
176
isEnabled: integration.isEnabled,
177
token: "",
178
accessToken: ""
179
};
180
}
181
return next;
182
});
183
}
185
void load();
186
}, []);
188
useEffect(() => {
189
if (!currentWebhook) {
190
setWebhookForm({
191
name: "",
192
targetUrl: "",
193
signingSecret: "",
194
isActive: true,
195
events: ["page.created", "page.updated"]
196
});
197
return;
198
}
200
setWebhookForm({
201
name: currentWebhook.name,
202
targetUrl: currentWebhook.targetUrl,
203
signingSecret: "",
204
isActive: currentWebhook.isActive,
205
events: currentWebhook.events
206
});
207
}, [currentWebhook]);
209
async function refreshWebhooks() {
210
const response = await api.get<{ webhooks: WebhookSummary[] }>("/api/webhooks");
211
setWebhooks(response.webhooks);
212
}
214
async function refreshIntegrations() {
215
const [integrationsResponse, jobsResponse] = await Promise.all([
216
api.get<{ integrations: IntegrationSummary[] }>("/api/integrations"),
217
api.get<{ jobs: ImportJobSummary[] }>("/api/integrations/import-jobs")
218
]);
219
setIntegrations(integrationsResponse.integrations);
220
setImportJobs(jobsResponse.jobs);
221
}
223
async function saveBranding() {
224
try {
225
await api.put("/api/settings/branding", {
226
siteName: brandingForm.siteName,
227
logoUrl: brandingForm.logoUrl || null,
228
brandColor: brandingForm.brandColor,
229
publicKnowledgeBaseEnabled: brandingForm.publicKnowledgeBaseEnabled,
230
footerLinks: brandingForm.footerLinks.filter((link) => link.label.trim() && link.href.trim())
231
});
232
setStatus("General settings saved.");
233
} catch (error) {
234
setStatus(error instanceof Error ? error.message : "Could not save general settings.");
235
}
236
}
238
async function saveIntegration(provider: "github" | "google_docs" | "markdown_import") {
239
try {
240
const form = integrationForms[provider];
241
await api.put(`/api/integrations/${provider}`, {
242
name: form.name,
243
isEnabled: form.isEnabled,
244
config:
245
provider === "github"
246
? { token: form.token }
247
: provider === "google_docs"
248
? { accessToken: form.accessToken }
249
: {}
250
});
251
await refreshIntegrations();
252
setStatus(`${provider.replace("_", " ")} settings saved.`);
253
} catch (error) {
254
setStatus(error instanceof Error ? error.message : "Could not save integration settings.");
255
}
256
}
258
async function saveWebhook() {
259
try {
260
const payload = {
261
...webhookForm,
262
events: webhookForm.events
263
};
265
if (editingWebhookId) {
266
await api.put(`/api/webhooks/${editingWebhookId}`, payload);
267
setStatus("Webhook updated.");
268
} else {
269
await api.post("/api/webhooks", payload);
270
setStatus("Webhook created.");
271
}
273
setEditingWebhookId(null);
274
await refreshWebhooks();
275
} catch (error) {
276
setStatus(error instanceof Error ? error.message : "Could not save webhook.");
277
}
278
}
280
async function deleteWebhook(webhookId: string) {
281
if (!window.confirm("Delete this webhook?")) return;
282
try {
283
await api.delete(`/api/webhooks/${webhookId}`);
284
await refreshWebhooks();
285
setStatus("Webhook deleted.");
286
} catch (error) {
287
setStatus(error instanceof Error ? error.message : "Could not delete webhook.");
288
}
289
}
291
async function loadDeliveries(webhookId: string) {
292
try {
293
const response = await api.get<{ deliveries: WebhookDeliverySummary[] }>(`/api/webhooks/${webhookId}/deliveries`);
294
setDeliveries((current) => ({ ...current, [webhookId]: response.deliveries }));
295
} catch (error) {
296
setStatus(error instanceof Error ? error.message : "Could not load deliveries.");
297
}
298
}
300
async function testWebhook(webhookId: string) {
301
try {
302
await api.post(`/api/webhooks/${webhookId}/test`);
303
setStatus("Test webhook queued.");
304
await loadDeliveries(webhookId);
305
} catch (error) {
306
setStatus(error instanceof Error ? error.message : "Could not queue test webhook.");
307
}
308
}
310
async function saveAi() {
311
try {
312
await api.put("/api/ai/settings", {
313
provider: aiSettings.provider,
314
model: aiSettings.model,
315
apiKey: aiSettings.apiKey || null,
316
isEnabled: aiSettings.isEnabled
317
});
318
setStatus("AI settings saved.");
319
} catch (error) {
320
setStatus(error instanceof Error ? error.message : "Could not save AI settings.");
321
}
322
}
324
function sectionMeta() {
325
switch (currentSection) {
326
case "general":
327
return ["Admin", "General settings", "Manage workspace branding, public visibility, and core document publishing."];
328
case "members":
329
return ["Admin", "Members", "Review who has access to Ledger and how groups are organized."];
330
case "permissions":
331
return ["Admin", "Permissions", "Review role capabilities and how restricted pages are enforced."];
332
case "integrations":
333
return ["Admin", "Integrations", "Configure source systems, check connector health, and jump into importing content."];
334
case "webhooks":
335
return ["Admin", "Webhooks", "Manage outbound event delivery, signing secrets, and recent delivery attempts."];
336
case "ai":
337
return ["Admin", "AI & MCP", "Configure AI providers, review answer behavior, and inspect the MCP surface exposed by Ledger."];
338
case "import-history":
339
return ["Admin", "Import", "Launch imports and review recent import activity from one place."];
340
case "activity":
341
return ["Admin", "Activity", "Review recent audit activity across the workspace."];
342
default:
343
return ["Admin", "Admin", ""];
344
}
345
}
347
const [eyebrow, title, description] = sectionMeta();
349
return (
350
<div className="admin-shell">
351
<aside className="admin-sidebar panel">
352
<Link to="/spaces" className="admin-backlink">Back to app</Link>
353
<div className="panel__header">
354
<div>
355
<p className="eyebrow">Admin</p>
356
<h3>Workspace controls</h3>
357
</div>
358
</div>
359
{adminGroups.map((group) => (
360
<div key={group.label} className="admin-nav-group">
361
<p className="admin-nav-group__label">{group.label}</p>
362
<nav className="admin-nav">
363
{group.items.map(([key, label]) => (
364
<Link key={key} to={`/admin/${key}`} className={`admin-nav__item${currentSection === key ? " is-current" : ""}`}>
365
{label}
366
</Link>
367
))}
368
</nav>
369
</div>
370
))}
371
</aside>
373
<div className="admin-content stack-page">
374
<PageHeader eyebrow={eyebrow} title={title} description={description} />
375
{status ? <p className="muted">{status}</p> : null}
377
{currentSection === "general" ? (
378
<>
379
<section className="settings-section">
380
<h2 className="settings-section__title">Workspace</h2>
382
<div className="settings-row">
383
<div className="settings-row__content">
384
<strong>Site name</strong>
385
<p>Name shown across the workspace and navigation.</p>
386
</div>
387
<div className="settings-row__control">
388
<input value={brandingForm.siteName} onChange={(event) => setBrandingForm((current) => ({ ...current, siteName: event.target.value }))} />
389
</div>
390
</div>
392
<div className="settings-row">
393
<div className="settings-row__content">
394
<strong>Logo URL</strong>
395
<p>Optional logo used in the workspace header.</p>
396
</div>
397
<div className="settings-row__control">
398
<input value={brandingForm.logoUrl} onChange={(event) => setBrandingForm((current) => ({ ...current, logoUrl: event.target.value }))} />
399
</div>
400
</div>
402
<div className="settings-row">
403
<div className="settings-row__content">
404
<strong>Brand color</strong>
405
<p>Primary accent used for actions and highlights.</p>
406
</div>
407
<div className="settings-row__control">
408
<input value={brandingForm.brandColor} onChange={(event) => setBrandingForm((current) => ({ ...current, brandColor: event.target.value }))} />
409
</div>
410
</div>
412
<div className="settings-row">
413
<div className="settings-row__content">
414
<strong>Public knowledge base</strong>
415
<p>Allow public visitors to read public pages without signing in.</p>
416
</div>
417
<label className="toggle-switch">
418
<input
419
type="checkbox"
420
checked={brandingForm.publicKnowledgeBaseEnabled}
421
onChange={(event) => setBrandingForm((current) => ({ ...current, publicKnowledgeBaseEnabled: event.target.checked }))}
422
/>
423
<span className="toggle-switch__track" />
424
</label>
425
</div>
427
<div className="settings-row">
428
<div className="settings-row__content">
429
<strong>Footer links</strong>
430
<p>Add support, status, or policy links beside the fixed Ledger footer line.</p>
431
</div>
432
<div className="settings-row__control settings-row__control-stack">
433
{brandingForm.footerLinks.length === 0 ? <p className="muted">No footer links configured yet.</p> : null}
434
{brandingForm.footerLinks.map((link, index) => (
435
<div key={`${link.label}-${index}`} className="settings-inline-grid">
436
<input
437
value={link.label}
438
placeholder="Label"
439
onChange={(event) =>
440
setBrandingForm((current) => ({
441
...current,
442
footerLinks: current.footerLinks.map((item, itemIndex) =>
443
itemIndex === index ? { ...item, label: event.target.value } : item
444
)
445
}))
446
}
447
/>
448
<input
449
value={link.href}
450
placeholder="https://example.com"
451
onChange={(event) =>
452
setBrandingForm((current) => ({
453
...current,
454
footerLinks: current.footerLinks.map((item, itemIndex) =>
455
itemIndex === index ? { ...item, href: event.target.value } : item
456
)
457
}))
458
}
459
/>
460
<button
461
type="button"
462
className="button-secondary"
463
onClick={() =>
464
setBrandingForm((current) => ({
465
...current,
466
footerLinks: current.footerLinks.filter((_, itemIndex) => itemIndex !== index)
467
}))
468
}
469
>
470
Remove
471
</button>
472
</div>
473
))}
474
<button
475
type="button"
476
className="button-secondary"
477
onClick={() =>
478
setBrandingForm((current) => ({
479
...current,
480
footerLinks: [...current.footerLinks, { label: "", href: "" }]
481
}))
482
}
483
>
484
Add footer link
485
</button>
486
</div>
487
</div>
488
</section>
490
<div className="settings-actions">
491
<button onClick={saveBranding}>Save general settings</button>
492
</div>
493
</>
494
) : null}
496
{currentSection === "members" ? (
497
<>
498
<section className="settings-section">
499
<h2 className="settings-section__title">Members</h2>
500
<div className="settings-row settings-row--summary">
501
<div className="settings-row__content">
502
<strong>Workspace access</strong>
503
<p>Members are listed here with their current effective role so admins can quickly verify who can browse, edit, and manage the knowledge base.</p>
504
</div>
505
<div className="settings-row__value">{users.length} members</div>
506
</div>
507
{users.map((member) => (
508
<div key={member.id} className="settings-row">
509
<div className="settings-row__content">
510
<strong>{member.display_name}</strong>
511
<p>{member.email}</p>
512
</div>
513
<div className="settings-row__value">
514
<span className="badge badge-internal">{member.role_key}</span>
515
</div>
516
</div>
517
))}
518
</section>
519
<section className="settings-section settings-section-subtle">
520
<h2 className="settings-section__title">Groups</h2>
521
{groups.length === 0 ? (
522
<EmptyState title="No groups yet" description="Create groups in the backend or seed data to organize restricted pages by team." />
523
) : (
524
groups.map((group) => (
525
<div key={group.id} className="settings-row">
526
<div className="settings-row__content">
527
<strong>{group.name}</strong>
528
<p>{group.description ?? "No description"}</p>
529
</div>
530
</div>
531
))
532
)}
533
</section>
534
</>
535
) : null}
537
{currentSection === "permissions" ? (
538
<>
539
<section className="settings-section">
540
<h2 className="settings-section__title">Roles</h2>
541
{roles.map((role) => (
542
<div key={role.id} className="settings-row">
543
<div className="settings-row__content">
544
<strong>{role.name}</strong>
545
<p>{roleDetails[role.key] ?? role.key}</p>
546
</div>
547
<div className="settings-row__value">{role.key}</div>
548
</div>
549
))}
550
</section>
551
<section className="settings-section settings-section-subtle">
552
<h2 className="settings-section__title">Permission behavior</h2>
553
{[
554
["Public pages", "Readable without authentication when the public knowledge base is enabled."],
555
["Internal pages", "Readable only by authenticated users with at least viewer access."],
556
["Restricted pages", "Require explicit role or group permissions. Search, AI, and MCP follow the same backend checks."]
557
].map(([label, detail]) => (
558
<div key={label} className="settings-row">
559
<div className="settings-row__content">
560
<strong>{label}</strong>
561
<p>{detail}</p>
562
</div>
563
</div>
564
))}
565
</section>
566
</>
567
) : null}
569
{currentSection === "integrations" ? (
570
<section className="integration-list">
571
{(["google_docs", "github", "markdown_import"] as const).map((provider) => {
572
const integration = integrations.find((item) => item.provider === provider);
573
const lastJob = importJobs.find((job) => job.provider === provider);
574
const form = integrationForms[provider];
575
const canRunImport = provider === "markdown_import" || integration?.status === "configured";
576
const integrationTitle = provider === "google_docs" ? "Google Docs" : provider === "github" ? "GitHub" : "Markdown Import";
577
const integrationDescription =
578
provider === "google_docs"
579
? "Import Google Docs content into Ledger Markdown pages and preserve source metadata."
580
: provider === "github"
581
? "Connect a GitHub repository and import Markdown from a chosen branch and path."
582
: "Upload Markdown files directly through Ledger without an external connector.";
583
return (
584
<section key={provider} className="settings-section settings-section-subtle integration-section">
585
<div className="settings-row settings-row--summary">
586
<div className="settings-row__content">
587
<strong>{integrationTitle}</strong>
588
<p>{integrationDescription}</p>
589
</div>
590
<div className="settings-row__value">
591
<span className={`badge badge-${integration?.status === "configured" ? "public" : "restricted"}`}>
592
{integration?.statusMessage ?? "Not configured"}
593
</span>
594
</div>
595
</div>
596
<div className="settings-row">
597
<div className="settings-row__content">
598
<strong>Connection name</strong>
599
<p>Used in the admin UI to identify this source.</p>
600
</div>
601
<div className="settings-row__control">
602
<input value={form.name} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], name: event.target.value } }))} />
603
</div>
604
</div>
605
{provider === "github" ? (
606
<div className="settings-row">
607
<div className="settings-row__content">
608
<strong>GitHub token</strong>
609
<p>Required for repository previews and imports.</p>
610
</div>
611
<div className="settings-row__control">
612
<input type="password" value={form.token} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], token: event.target.value } }))} />
613
</div>
614
</div>
615
) : null}
616
{provider === "google_docs" ? (
617
<div className="settings-row">
618
<div className="settings-row__content">
619
<strong>Google access token</strong>
620
<p>Required for previewing and importing Google Docs.</p>
621
</div>
622
<div className="settings-row__control">
623
<input type="password" value={form.accessToken} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], accessToken: event.target.value } }))} />
624
</div>
625
</div>
626
) : null}
627
<div className="settings-row">
628
<div className="settings-row__content">
629
<strong>Enabled</strong>
630
<p>Allow this integration to preview and import content.</p>
631
</div>
632
<label className="toggle-switch">
633
<input type="checkbox" checked={form.isEnabled} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], isEnabled: event.target.checked } }))} />
634
<span className="toggle-switch__track" />
635
</label>
636
</div>
637
<div className="settings-row">
638
<div className="settings-row__content">
639
<strong>Last import</strong>
640
<p>Latest import activity for this source.</p>
641
</div>
642
<div className="settings-row__value">{lastJob ? new Date(lastJob.updatedAt).toLocaleString() : "None yet"}</div>
643
</div>
644
<div className="panel__footer panel__footer-start">
645
<button onClick={() => saveIntegration(provider)}>Save configuration</button>
646
{canRunImport ? <Link to="/imports" className="button-secondary">Open import</Link> : <button type="button" className="button-secondary" disabled title="Configure this integration before importing.">Import unavailable</button>}
647
</div>
648
</section>
649
);
650
})}
651
</section>
652
) : null}
654
{currentSection === "webhooks" ? (
655
<>
656
<section className="panel">
657
<div className="panel__header">
658
<div>
659
<p className="eyebrow">Webhook editor</p>
660
<h3>{editingWebhookId ? "Edit webhook" : "Create webhook"}</h3>
661
</div>
662
</div>
663
<div className="field-grid">
664
<label className="field">
665
Name
666
<input value={webhookForm.name} onChange={(event) => setWebhookForm((current) => ({ ...current, name: event.target.value }))} />
667
</label>
668
<label className="field">
669
Target URL
670
<input value={webhookForm.targetUrl} onChange={(event) => setWebhookForm((current) => ({ ...current, targetUrl: event.target.value }))} />
671
</label>
672
</div>
673
<label className="field">
674
Signing secret
675
<input value={webhookForm.signingSecret} onChange={(event) => setWebhookForm((current) => ({ ...current, signingSecret: event.target.value }))} placeholder={editingWebhookId ? "Enter a new secret to rotate it." : "Required"} />
676
</label>
677
<label className="field">
678
Event checklist
679
<div className="checkbox-list">
680
{webhookEvents.map((eventName) => (
681
<label key={eventName} className="checkbox-row checkbox-card">
682
<input
683
type="checkbox"
684
checked={webhookForm.events.includes(eventName)}
685
onChange={(event) =>
686
setWebhookForm((current) => ({
687
...current,
688
events: event.target.checked
689
? [...current.events, eventName]
690
: current.events.filter((value) => value !== eventName)
691
}))
692
}
693
/>
694
<span>{eventName}</span>
695
</label>
696
))}
697
</div>
698
</label>
699
<label className="checkbox-row checkbox-card">
700
<input type="checkbox" checked={webhookForm.isActive} onChange={(event) => setWebhookForm((current) => ({ ...current, isActive: event.target.checked }))} />
701
<span>Active</span>
702
</label>
703
<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>
704
<div className="panel__footer">
705
<button onClick={saveWebhook}>{editingWebhookId ? "Save webhook" : "Create webhook"}</button>
706
{editingWebhookId ? <button type="button" className="button-secondary" onClick={() => setEditingWebhookId(null)}>Cancel edit</button> : null}
707
</div>
708
</section>
709
<section className="panel">
710
<div className="panel__header">
711
<div>
712
<p className="eyebrow">Configured endpoints</p>
713
<h3>Webhook endpoints</h3>
714
</div>
715
</div>
716
{webhooks.length === 0 ? (
717
<EmptyState title="No webhooks configured" description="Create a webhook to deliver Ledger events to another system." />
718
) : (
719
<div className="feedback-list">
720
{webhooks.map((webhook) => (
721
<div key={webhook.id} className="feedback-item">
722
<div className="settings-row__content">
723
<strong>{webhook.name}</strong>
724
<span className={`badge badge-${webhook.isActive ? "public" : "restricted"}`}>{webhook.isActive ? "Active" : "Disabled"}</span>
725
</div>
726
<p>{webhook.targetUrl}</p>
727
<p className="muted">{webhook.events.join(", ")}</p>
728
<div className="panel__footer">
729
<button type="button" className="button-secondary" onClick={() => setEditingWebhookId(webhook.id)}>Edit</button>
730
<button type="button" className="button-secondary" onClick={() => loadDeliveries(webhook.id)}>View deliveries</button>
731
<button type="button" className="button-secondary" onClick={() => testWebhook(webhook.id)}>Test webhook</button>
732
<button type="button" className="button-secondary" onClick={() => deleteWebhook(webhook.id)}>Delete</button>
733
</div>
734
{deliveries[webhook.id]?.length ? (
735
<div className="delivery-list">
736
{deliveries[webhook.id].map((delivery) => (
737
<div key={delivery.id} className="delivery-item">
738
<strong>{delivery.eventName}</strong>
739
<span>{delivery.responseStatus ?? "pending"}</span>
740
<span>{delivery.success ? "Success" : delivery.errorMessage ?? "Failed"}</span>
741
</div>
742
))}
743
</div>
744
) : null}
745
</div>
746
))}
747
</div>
748
)}
749
</section>
750
</>
751
) : null}
753
{currentSection === "ai" ? (
754
<>
755
<section className="settings-section">
756
<h2 className="settings-section__title">AI provider</h2>
757
<div className="settings-row">
758
<div className="settings-row__content">
759
<strong>Provider</strong>
760
<p>Select the provider type Ledger should use for answers.</p>
761
</div>
762
<div className="settings-row__control">
763
<select value={aiSettings.provider} onChange={(event) => setAiSettings((current) => ({ ...current, provider: event.target.value }))}>
764
<option value="none">Disabled</option>
765
<option value="openai_compatible">OpenAI-compatible</option>
766
<option value="anthropic_compatible">Anthropic-compatible</option>
767
</select>
768
</div>
769
</div>
770
<div className="settings-row">
771
<div className="settings-row__content">
772
<strong>Model</strong>
773
<p>Model identifier used by the configured provider.</p>
774
</div>
775
<div className="settings-row__control">
776
<input value={aiSettings.model} onChange={(event) => setAiSettings((current) => ({ ...current, model: event.target.value }))} />
777
</div>
778
</div>
779
<div className="settings-row">
780
<div className="settings-row__content">
781
<strong>API key</strong>
782
<p>{aiSettings.hasApiKey ? "A provider key is already stored. Enter a new key to rotate it." : "Paste a provider key to enable server-side requests."}</p>
783
</div>
784
<div className="settings-row__control">
785
<input type="password" value={aiSettings.apiKey} onChange={(event) => setAiSettings((current) => ({ ...current, apiKey: event.target.value }))} placeholder={aiSettings.hasApiKey ? "Configured" : "Paste provider key"} />
786
</div>
787
</div>
788
<div className="settings-row">
789
<div className="settings-row__content">
790
<strong>Enable AI answers</strong>
791
<p>Allow Ask AI to answer from permission-filtered Ledger content.</p>
792
</div>
793
<label className="toggle-switch">
794
<input type="checkbox" checked={aiSettings.isEnabled} onChange={(event) => setAiSettings((current) => ({ ...current, isEnabled: event.target.checked }))} />
795
<span className="toggle-switch__track" />
796
</label>
797
</div>
798
<div className="settings-row"><div className="settings-row__content"><strong>Retrieval settings</strong><p>Ledger searches only pages the current user can access before generating an answer.</p></div></div>
799
<div className="settings-row"><div className="settings-row__content"><strong>Answer behavior</strong><p>If the knowledge base does not contain enough information, Ledger refuses instead of inventing an answer.</p></div></div>
800
<div className="settings-row"><div className="settings-row__content"><strong>Citation behavior</strong><p>Answers cite the exact source pages the user is already allowed to open.</p></div></div>
801
</section>
802
{aiSettings.provider === "none" || !aiSettings.isEnabled ? (
803
<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." />
804
) : null}
805
<section className="settings-section settings-section-subtle">
806
<h2 className="settings-section__title">MCP server</h2>
807
<div className="settings-row"><div className="settings-row__content"><strong>Hosted endpoint</strong><p>{displayedMcpEndpoint}</p></div><div className="settings-row__value">Live path</div></div>
808
<div className="settings-row"><div className="settings-row__content"><strong>Tools</strong><p><code>search_knowledge_base</code>, <code>read_page</code>, <code>list_spaces</code>, <code>get_page_metadata</code>, <code>create_draft_page</code></p></div></div>
809
<div className="settings-row"><div className="settings-row__content"><strong>Auth</strong><p>MCP currently uses {mcpSettings.authMode === "session_cookie" ? "the active Ledger session cookie" : mcpSettings.authMode}. Dedicated API tokens are not implemented yet.</p></div></div>
810
<div className="settings-row"><div className="settings-row__content"><strong>Permission behavior</strong><p>MCP calls inherit the same backend visibility checks as page reads, search, and AI retrieval.</p></div></div>
811
</section>
812
<div className="settings-actions">
813
<button onClick={saveAi}>Save AI settings</button>
814
</div>
815
</>
816
) : null}
818
{currentSection === "import-history" ? (
819
<section className="settings-section">
820
<div className="settings-actions">
821
<Link to="/imports" className="button-secondary">Open import flow</Link>
822
</div>
823
{importJobs.length === 0 ? (
824
<EmptyState title="No imports yet" description="Run an import from the Imports area to populate this history." />
825
) : (
826
<div className="settings-section settings-section-subtle">
827
{importJobs.map((job) => (
828
<div key={job.id} className="settings-row">
829
<div className="settings-row__content">
830
<strong>{job.sourceLabel}</strong>
831
<p>{job.provider} • {job.importedCount} pages • {new Date(job.updatedAt).toLocaleString()}</p>
832
</div>
833
<p>{job.provider} • {job.importedCount} pages</p>
834
{job.errorMessage ? <p className="muted">{job.errorMessage}</p> : null}
835
</div>
836
))}
837
</div>
838
)}
839
</section>
840
) : null}
842
{currentSection === "activity" ? (
843
<section className="panel">
844
{activity.length === 0 ? (
845
<EmptyState title="No activity logged" description="Recent audit activity will appear here as users change content and settings." />
846
) : (
847
<div className="list-grid">
848
{activity.map((item) => (
849
<div key={item.id} className="list-item">
850
<strong>{item.action}</strong>
851
<span>{item.actor_name} • {new Date(item.created_at).toLocaleString()}</span>
852
<span>{item.resource_type}:{item.resource_id}</span>
853
</div>
854
))}
855
</div>
856
)}
857
</section>
858
) : null}
859
</div>
860
</div>
861
);
862
}