@pablozaiden/webapp 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +11 -3
  2. package/docs/auth-validation.md +11 -0
  3. package/docs/auth.md +25 -0
  4. package/docs/cli.md +73 -3
  5. package/docs/deployment.md +84 -7
  6. package/docs/getting-started.md +69 -6
  7. package/docs/github-actions.md +39 -0
  8. package/docs/release.md +5 -5
  9. package/docs/server.md +34 -4
  10. package/docs/settings.md +5 -0
  11. package/docs/ui-guidelines.md +12 -4
  12. package/package.json +2 -4
  13. package/src/build/build-binary.ts +67 -24
  14. package/src/cli/api-command.ts +53 -16
  15. package/src/cli/credentials.ts +275 -4
  16. package/src/cli/device-auth.ts +83 -22
  17. package/src/cli/environment-auth.ts +57 -0
  18. package/src/cli/index.ts +1 -0
  19. package/src/package-resolution.ts +34 -0
  20. package/src/server/auth/device-auth.ts +2 -2
  21. package/src/server/auth/passkeys.ts +16 -16
  22. package/src/server/auth/request-origin.ts +97 -16
  23. package/src/server/authentication.ts +265 -0
  24. package/src/server/create-web-app-server.ts +85 -1391
  25. package/src/server/framework-endpoints.ts +451 -0
  26. package/src/server/public-route-dispatch.ts +85 -0
  27. package/src/server/request-schemas.ts +144 -0
  28. package/src/server/responses.ts +69 -3
  29. package/src/server/route-dispatch.ts +109 -0
  30. package/src/server/runtime-config.ts +67 -0
  31. package/src/server/same-origin.ts +1 -1
  32. package/src/server/server-lifecycle.ts +138 -0
  33. package/src/server/server-types.ts +87 -0
  34. package/src/server/web-document.ts +532 -0
  35. package/src/web/WebAppRoot.tsx +69 -1214
  36. package/src/web/api-client.ts +14 -4
  37. package/src/web/app-shell.tsx +198 -0
  38. package/src/web/auth-screens.tsx +186 -0
  39. package/src/web/components/index.tsx +10 -3
  40. package/src/web/index.ts +1 -0
  41. package/src/web/mobile-hooks.ts +194 -0
  42. package/src/web/mobile.ts +12 -0
  43. package/src/web/root-types.ts +61 -0
  44. package/src/web/routing.ts +61 -0
  45. package/src/web/settings/account-section.tsx +52 -0
  46. package/src/web/settings/resource-state.tsx +17 -0
  47. package/src/web/settings/security-section.tsx +119 -0
  48. package/src/web/settings/sessions-section.tsx +66 -0
  49. package/src/web/settings/settings-view.tsx +96 -0
  50. package/src/web/settings/shutdown-section.tsx +95 -0
  51. package/src/web/settings/user-management.tsx +123 -0
  52. package/src/web/settings-view.tsx +3 -0
  53. package/src/web/sidebar-state.ts +108 -0
  54. package/src/web/sidebar-tree.tsx +89 -0
  55. package/src/web/styles.css +76 -64
@@ -1,329 +1,25 @@
1
- import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
2
1
  import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
3
- import type { ApiKeySummary, AuthSessionSummary, CreatedApiKeyResponse, CreatedUserResponse, DeviceVerificationDetails, PasskeyAuthStatusResponse, ThemePreference, UserSetupDetails, WebAppConfigResponse, WebAppUserRole, WebAppUserSummary } from "../contracts";
4
- import { ActionMenu, Badge, Button, ConfirmDialog, ContextMenu, DangerZone, Dialog, EmptyState, FormSection, IconButton, Panel, SelectField, TextField, type ContextMenuPosition } from "./components";
5
- import type { ActionMenuItem, SidebarAction, SidebarBuildContext, SidebarNode, WebAppRoute } from "./sidebar/types";
6
-
7
- type SettingsSection = {
8
- id: string;
9
- title: string;
10
- description?: string;
11
- scope?: "user" | "admin" | "owner";
12
- rows?: SettingsRow[];
13
- render?: () => ReactNode;
14
- };
15
-
16
- type SettingsRow = {
17
- id: string;
18
- title: string;
19
- description?: string;
20
- scope?: "user" | "admin" | "owner";
21
- content?: ReactNode;
22
- actions?: ReactNode | SettingsAction[];
23
- danger?: boolean;
24
- };
25
-
26
- type SettingsAction = {
27
- id: string;
28
- label: string;
29
- variant?: "default" | "primary" | "danger" | "ghost";
30
- disabled?: boolean;
31
- onAction: () => void;
32
- };
33
-
34
- type HeaderContext = {
35
- route: WebAppRoute;
36
- defaultTitle: string;
37
- };
38
-
39
- export interface WebAppRootProps {
40
- appName: string;
41
- homeRoute: WebAppRoute;
42
- sidebar: {
43
- search?: boolean;
44
- topActions?: SidebarAction[];
45
- pinning?: false | {
46
- sectionTitle?: string;
47
- storageKey?: string;
48
- };
49
- getNodes: (ctx: SidebarBuildContext) => SidebarNode[];
50
- };
51
- routes: Record<string, ReactNode | ((route: WebAppRoute) => ReactNode)>;
52
- header?: {
53
- renderTitle?: (ctx: HeaderContext) => ReactNode;
54
- renderActions?: (ctx: HeaderContext) => ReactNode;
55
- getActions?: (ctx: HeaderContext) => ActionMenuItem[];
56
- };
57
- onRouteChange?: (route: WebAppRoute) => void;
58
- settings?: {
59
- sections?: SettingsSection[];
60
- };
61
- version?: string;
62
- }
63
-
64
- type StoredSidebarPin = {
65
- id: string;
66
- title: string;
67
- subtitle?: string;
68
- badge?: string;
69
- badgeVariant?: SidebarNode["badgeVariant"];
70
- route: WebAppRoute;
71
- };
72
-
73
- type SidebarCollapsedState = Record<string, boolean>;
74
-
75
- function isSidebarShortcutEditableTarget(target: EventTarget | null): boolean {
76
- if (!(target instanceof HTMLElement)) {
77
- return false;
78
- }
79
- if (target.isContentEditable || target.closest("[contenteditable=''], [contenteditable='true']")) {
80
- return true;
81
- }
82
- return target instanceof HTMLInputElement
83
- || target instanceof HTMLTextAreaElement
84
- || target instanceof HTMLSelectElement;
85
- }
86
-
87
- export function routeToHash(route: WebAppRoute): string {
88
- const params = new URLSearchParams();
89
- for (const [key, value] of Object.entries(route)) {
90
- if (key !== "view" && value !== undefined) {
91
- params.set(key, String(value));
92
- }
93
- }
94
- return `#/${route.view}${params.size ? `?${params.toString()}` : ""}`;
95
- }
96
-
97
- export function replaceHashRoute(hash: string): boolean {
98
- const normalizedHash = hash.startsWith("#") ? hash : `#${hash}`;
99
- if (window.location.hash === normalizedHash) {
100
- return false;
101
- }
102
-
103
- const previousUrl = window.location.href;
104
- let hashChangeEmitted = false;
105
- const markHashChangeEmitted = () => {
106
- hashChangeEmitted = true;
107
- };
108
- window.addEventListener("hashchange", markHashChangeEmitted, { once: true });
109
- window.history.replaceState(window.history.state, "", normalizedHash);
110
- window.removeEventListener("hashchange", markHashChangeEmitted);
111
- if (!hashChangeEmitted) {
112
- window.dispatchEvent(new HashChangeEvent("hashchange", { oldURL: previousUrl, newURL: window.location.href }));
113
- }
114
- return true;
115
- }
116
-
117
- export function replaceWebAppRoute(route: WebAppRoute): boolean {
118
- return replaceHashRoute(routeToHash(route));
119
- }
120
-
121
- function parseRoute(defaultRoute: WebAppRoute): WebAppRoute {
122
- const hash = window.location.hash.replace(/^#\/?/, "");
123
- if (!hash) return defaultRoute;
124
- const [view = defaultRoute.view, query = ""] = hash.split("?", 2);
125
- const params = Object.fromEntries(new URLSearchParams(query).entries());
126
- return { view: view.replace(/^\//, ""), ...params };
127
- }
128
-
129
- function useRoute(defaultRoute: WebAppRoute) {
130
- const [route, setRoute] = useState(() => parseRoute(defaultRoute));
131
- useEffect(() => {
132
- const listener = () => setRoute(parseRoute(defaultRoute));
133
- window.addEventListener("hashchange", listener);
134
- return () => window.removeEventListener("hashchange", listener);
135
- }, [defaultRoute]);
136
- const navigate = useCallback((next: WebAppRoute) => {
137
- if (replaceWebAppRoute(next)) {
138
- setRoute(next);
139
- }
140
- }, []);
141
- return { route, navigate };
142
- }
143
-
144
- function useMobileViewportHeight() {
145
- useEffect(() => {
146
- if (typeof window === "undefined") {
147
- return;
148
- }
149
-
150
- const root = document.documentElement;
151
- const mobileQuery = window.matchMedia("(max-width: 900px)");
152
- const viewport = window.visualViewport;
153
- const timers = new Set<number>();
154
- let frame = 0;
155
-
156
- const clearViewportHeight = () => {
157
- root.style.removeProperty("--wapp-viewport-height");
158
- };
159
-
160
- const sync = () => {
161
- frame = 0;
162
- if (!mobileQuery.matches) {
163
- clearViewportHeight();
164
- return;
165
- }
166
-
167
- const height = Math.round(viewport?.height ?? window.innerHeight);
168
- if (height > 0) {
169
- root.style.setProperty("--wapp-viewport-height", `${height}px`);
170
- }
171
-
172
- const scrollingElement = document.scrollingElement;
173
- if (scrollingElement && scrollingElement.scrollTop !== 0) {
174
- scrollingElement.scrollTop = 0;
175
- }
176
- };
177
-
178
- const scheduleSync = () => {
179
- if (frame) {
180
- return;
181
- }
182
- frame = requestAnimationFrame(sync);
183
- };
184
-
185
- const scheduleDelayedSync = (delay: number) => {
186
- const timer = window.setTimeout(() => {
187
- timers.delete(timer);
188
- scheduleSync();
189
- }, delay);
190
- timers.add(timer);
191
- };
192
-
193
- const handleViewportTransition = () => {
194
- scheduleSync();
195
- scheduleDelayedSync(120);
196
- scheduleDelayedSync(320);
197
- };
198
-
199
- scheduleSync();
200
- viewport?.addEventListener("resize", scheduleSync);
201
- viewport?.addEventListener("scroll", scheduleSync);
202
- window.addEventListener("resize", scheduleSync);
203
- window.addEventListener("orientationchange", handleViewportTransition);
204
- mobileQuery.addEventListener("change", scheduleSync);
205
- document.addEventListener("focusin", handleViewportTransition);
206
- document.addEventListener("focusout", handleViewportTransition);
207
-
208
- return () => {
209
- if (frame) {
210
- cancelAnimationFrame(frame);
211
- }
212
- for (const timer of timers) {
213
- clearTimeout(timer);
214
- }
215
- viewport?.removeEventListener("resize", scheduleSync);
216
- viewport?.removeEventListener("scroll", scheduleSync);
217
- window.removeEventListener("resize", scheduleSync);
218
- window.removeEventListener("orientationchange", handleViewportTransition);
219
- mobileQuery.removeEventListener("change", scheduleSync);
220
- document.removeEventListener("focusin", handleViewportTransition);
221
- document.removeEventListener("focusout", handleViewportTransition);
222
- clearViewportHeight();
223
- };
224
- }, []);
225
- }
226
-
227
- const MOBILE_SIDEBAR_BREAKPOINT = 900;
228
- const SIDEBAR_SWIPE_EDGE_WIDTH = 24;
229
- const SIDEBAR_SWIPE_DISTANCE = 64;
230
- const SIDEBAR_SWIPE_VERTICAL_TOLERANCE = 48;
231
-
232
- function useMobileSidebarSwipe(sidebarOpen: boolean, setSidebarOpen: (open: boolean) => void) {
233
- useEffect(() => {
234
- if (typeof window === "undefined" || typeof document === "undefined") {
235
- return;
236
- }
237
-
238
- let tracking = false;
239
- let startX = 0;
240
- let startY = 0;
241
-
242
- const reset = () => {
243
- tracking = false;
244
- };
245
-
246
- const handleTouchStart = (event: TouchEvent) => {
247
- if (sidebarOpen || window.innerWidth > MOBILE_SIDEBAR_BREAKPOINT || event.touches.length !== 1) {
248
- reset();
249
- return;
250
- }
251
-
252
- const touch = event.touches[0];
253
- if (!touch || touch.clientX > SIDEBAR_SWIPE_EDGE_WIDTH) {
254
- reset();
255
- return;
256
- }
257
-
258
- tracking = true;
259
- startX = touch.clientX;
260
- startY = touch.clientY;
261
- };
262
-
263
- const handleTouchMove = (event: TouchEvent) => {
264
- if (!tracking) {
265
- return;
266
- }
267
- if (event.touches.length !== 1) {
268
- reset();
269
- return;
270
- }
271
-
272
- const touch = event.touches[0];
273
- if (!touch) {
274
- reset();
275
- return;
276
- }
277
-
278
- const deltaX = touch.clientX - startX;
279
- const deltaY = Math.abs(touch.clientY - startY);
280
- if (deltaX <= 0 || deltaY > SIDEBAR_SWIPE_VERTICAL_TOLERANCE || deltaY > deltaX) {
281
- reset();
282
- return;
283
- }
284
- if (deltaX < SIDEBAR_SWIPE_DISTANCE) {
285
- return;
286
- }
287
-
288
- event.preventDefault();
289
- setSidebarOpen(true);
290
- reset();
291
- };
292
-
293
- document.addEventListener("touchstart", handleTouchStart, { passive: true });
294
- document.addEventListener("touchmove", handleTouchMove, { passive: false });
295
- document.addEventListener("touchend", reset);
296
- document.addEventListener("touchcancel", reset);
297
- return () => {
298
- document.removeEventListener("touchstart", handleTouchStart);
299
- document.removeEventListener("touchmove", handleTouchMove);
300
- document.removeEventListener("touchend", reset);
301
- document.removeEventListener("touchcancel", reset);
302
- };
303
- }, [setSidebarOpen, sidebarOpen]);
304
- }
305
-
306
- async function json<T>(path: string, init: RequestInit = {}): Promise<T> {
307
- const response = await fetch(path, {
308
- ...init,
309
- headers: {
310
- "content-type": "application/json",
311
- ...init.headers,
312
- },
313
- });
314
- if (!response.ok) {
315
- const data = await response.json().catch(() => ({})) as { message?: string; error?: string };
316
- throw new Error(data.message ?? data.error ?? `Request failed with ${response.status}`);
317
- }
318
- return await response.json() as T;
319
- }
2
+ import type { ThemePreference, WebAppConfigResponse } from "../contracts";
3
+ import { appJson } from "./api-client";
4
+ import { AppShell } from "./app-shell";
5
+ import { DeviceVerificationScreen, PasskeyAuthScreen, UserSetupScreen } from "./auth-screens";
6
+ import { EmptyState, Panel } from "./components";
7
+ import { useMobileBreakpoint, useMobileSidebarSwipe, useMobileViewportHeight } from "./mobile-hooks";
8
+ import { useRoute } from "./routing";
9
+ import { flattenSidebarItems, toStoredPin, useSidebarCollapsedState, useSidebarPins } from "./sidebar-state";
10
+ import { SettingsView } from "./settings/settings-view";
11
+ import type { HeaderContext, WebAppRootProps } from "./root-types";
12
+ import type { ActionMenuItem, SidebarNode, WebAppRoute } from "./sidebar/types";
13
+
14
+ export { replaceHashRoute, replaceWebAppRoute, routeToHash } from "./routing";
15
+ export type { HeaderContext, SettingsAction, SettingsRow, SettingsSection, WebAppRootProps } from "./root-types";
320
16
 
321
17
  function useConfig() {
322
18
  const [config, setConfig] = useState<WebAppConfigResponse>();
323
19
  const [error, setError] = useState<string>();
324
20
  const refresh = useCallback(async () => {
325
21
  try {
326
- setConfig(await json<WebAppConfigResponse>("/api/config"));
22
+ setConfig(await appJson<WebAppConfigResponse>("/api/config"));
327
23
  setError(undefined);
328
24
  } catch (err) {
329
25
  setError(err instanceof Error ? err.message : String(err));
@@ -344,821 +40,27 @@ function useTheme() {
344
40
  }
345
41
 
346
42
  function routeMatches(left: WebAppRoute | undefined, right: WebAppRoute): boolean {
347
- if (!left) return false;
348
- return left.view === right.view && Object.entries(left).every(([key, value]) => key === "view" || right[key] === value);
349
- }
350
-
351
- function flattenSidebarItems(nodes: SidebarNode[]): SidebarNode[] {
352
- return nodes.flatMap((node) => [
353
- node,
354
- ...(node.children ? flattenSidebarItems(node.children) : []),
355
- ]).filter((node) => node.type === "item");
356
- }
357
-
358
- function pinStorageKey(appName: string, explicitKey?: string): string {
359
- return explicitKey ?? `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.pins`;
360
- }
361
-
362
- function sidebarCollapsedStorageKey(appName: string): string {
363
- return `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.collapsed`;
364
- }
365
-
366
- function isSidebarCollapsedState(value: unknown): value is SidebarCollapsedState {
367
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
43
+ if (!left) {
368
44
  return false;
369
45
  }
370
- return Object.values(value).every((entry) => typeof entry === "boolean");
371
- }
372
-
373
- function toStoredPin(node: SidebarNode): StoredSidebarPin | undefined {
374
- if (!node.route) return undefined;
375
- return {
376
- id: node.pinId ?? node.id,
377
- title: node.title,
378
- subtitle: node.subtitle,
379
- badge: node.badge,
380
- badgeVariant: node.badgeVariant,
381
- route: node.route,
382
- };
383
- }
384
-
385
- function useSidebarPins(appName: string, storageKey?: string) {
386
- const key = pinStorageKey(appName, storageKey);
387
- const [pins, setPins] = useState<StoredSidebarPin[]>(() => {
388
- try {
389
- const raw = localStorage.getItem(key);
390
- return raw ? JSON.parse(raw) as StoredSidebarPin[] : [];
391
- } catch {
392
- return [];
393
- }
394
- });
395
-
396
- useEffect(() => {
397
- localStorage.setItem(key, JSON.stringify(pins));
398
- }, [key, pins]);
399
-
400
- const pinIds = useMemo(() => new Set(pins.map((pin) => pin.id)), [pins]);
401
- const pin = useCallback((node: SidebarNode) => {
402
- const stored = toStoredPin(node);
403
- if (!stored) return;
404
- setPins((current) => [...current.filter((item) => item.id !== stored.id), stored]);
405
- }, []);
406
- const unpin = useCallback((id: string) => {
407
- setPins((current) => current.filter((item) => item.id !== id));
408
- }, []);
409
-
410
- return { pins, pinIds, pin, unpin };
411
- }
412
-
413
- function useSidebarCollapsedState(appName: string) {
414
- const key = sidebarCollapsedStorageKey(appName);
415
- const [collapsed, setCollapsed] = useState<SidebarCollapsedState>(() => {
416
- try {
417
- const raw = localStorage.getItem(key);
418
- if (!raw) return {};
419
- const parsed: unknown = JSON.parse(raw);
420
- return isSidebarCollapsedState(parsed) ? parsed : {};
421
- } catch {
422
- return {};
423
- }
424
- });
425
-
426
- useEffect(() => {
427
- localStorage.setItem(key, JSON.stringify(collapsed));
428
- }, [key, collapsed]);
429
-
430
- const toggleCollapsed = useCallback((id: string, isCollapsed: boolean) => {
431
- setCollapsed((current) => {
432
- const currentIsCollapsed = current[id] ?? isCollapsed;
433
- return { ...current, [id]: !currentIsCollapsed };
434
- });
435
- }, []);
436
-
437
- return { collapsed, toggleCollapsed };
438
- }
439
-
440
- function Icon({ name }: { name: "settings" | "sidebar" | "plus" | "home" | "search" | "bolt" | "chat" | "code" | "refresh" }) {
441
- const common = { "aria-hidden": true, viewBox: "0 0 24 24", className: "wapp-svg" };
442
- if (name === "settings") return <svg {...common}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82V22a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-.6-1 1.65 1.65 0 0 0-1.82-.33H2a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-.6 1.65 1.65 0 0 0 .33-1.82V2a2 2 0 1 1 4 0v.09A1.65 1.65 0 0 0 15 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.14.33.34.64.6 1 .26.36.61.6 1 .6H22a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-2.51.4Z" /></svg>;
443
- if (name === "sidebar") return <svg {...common}><rect x="4" y="5" width="16" height="14" rx="2" /><path d="M10 5v14" /></svg>;
444
- if (name === "plus") return <svg {...common}><path d="M12 5v14M5 12h14" /></svg>;
445
- if (name === "bolt") return <svg {...common}><path d="m13 2-8 12h7l-1 8 8-12h-7l1-8Z" /></svg>;
446
- if (name === "chat") return <svg {...common}><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z" /><path d="M8 9h8M8 13h5" /></svg>;
447
- if (name === "code") return <svg {...common}><path d="M8 8 4 12l4 4M16 8l4 4-4 4M14 4l-4 16" /></svg>;
448
- if (name === "refresh") return <svg {...common}><path d="M21 12a9 9 0 0 1-15.5 6.3L3 16M3 21v-5h5M3 12A9 9 0 0 1 18.5 5.7L21 8M21 3v5h-5" /></svg>;
449
- return <svg {...common}><path d="M4 10.5 12 4l8 6.5V20H5v-7h14" /></svg>;
450
- }
451
-
452
- function ActionIcon({ icon }: { icon?: ReactNode }) {
453
- if (icon === "+") return <Icon name="plus" />;
454
- if (icon === "↯") return <Icon name="bolt" />;
455
- if (icon === "chat") return <Icon name="chat" />;
456
- if (icon === "code") return <Icon name="code" />;
457
- if (!icon) return <Icon name="bolt" />;
458
- return <>{icon}</>;
459
- }
460
-
461
- function PasskeyAuthScreen({ status, refresh }: { status: PasskeyAuthStatusResponse; refresh: () => Promise<void> }) {
462
- const [error, setError] = useState<string>();
463
- const [busy, setBusy] = useState(false);
464
- const [username, setUsername] = useState("");
465
- const description = status.bootstrapRequired
466
- ? "Choose the username for the owner"
467
- : status.ownerPasskeySetupRequired
468
- ? "The owner passkey was removed. Set it up again to continue."
469
- : "Authenticate to continue.";
470
-
471
- async function register(endpoint: "bootstrap" | "owner-setup") {
472
- setBusy(true);
473
- setError(undefined);
474
- try {
475
- const body = endpoint === "bootstrap" ? JSON.stringify({ username }) : "{}";
476
- const options = await json<PublicKeyCredentialCreationOptionsJSON>(`/api/passkey-auth/${endpoint}/options`, { method: "POST", body });
477
- const credential = await startRegistration({ optionsJSON: options as never });
478
- await json(`/api/passkey-auth/${endpoint}/verify`, { method: "POST", body: JSON.stringify(credential) });
479
- await refresh();
480
- window.location.reload();
481
- } catch (err) {
482
- setError(err instanceof Error ? err.message : String(err));
483
- } finally {
484
- setBusy(false);
485
- }
486
- }
487
- async function login() {
488
- setBusy(true);
489
- setError(undefined);
490
- try {
491
- const options = await json<PublicKeyCredentialRequestOptionsJSON>("/api/passkey-auth/authentication/options", { method: "POST", body: "{}" });
492
- const credential = await startAuthentication({ optionsJSON: options as never });
493
- await json("/api/passkey-auth/authentication/verify", { method: "POST", body: JSON.stringify(credential) });
494
- await refresh();
495
- window.location.reload();
496
- } catch (err) {
497
- setError(err instanceof Error ? err.message : String(err));
498
- } finally {
499
- setBusy(false);
500
- }
501
- }
502
- return (
503
- <main className="wapp-auth-screen">
504
- <Dialog
505
- className="wapp-auth-dialog"
506
- title={status.bootstrapRequired ? "Create owner user" : status.ownerPasskeySetupRequired ? "Set up owner passkey" : "Passkey required"}
507
- actions={status.bootstrapRequired ? (
508
- <Button type="button" variant="primary" disabled={busy || !username.trim()} onClick={() => void register("bootstrap")}>Create owner</Button>
509
- ) : status.ownerPasskeySetupRequired ? (
510
- <Button type="button" variant="primary" disabled={busy} onClick={() => void register("owner-setup")}>Set up owner passkey</Button>
511
- ) : (
512
- <Button type="button" variant="primary" disabled={busy} onClick={() => void login()}>Authenticate</Button>
513
- )}
514
- >
515
- <p>{description}</p>
516
- {error ? <p className="wapp-error">{error}</p> : null}
517
- {status.bootstrapRequired ? <><br /><TextField label="Username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} placeholder="owner" /></> : null}
518
- </Dialog>
519
- </main>
520
- );
521
- }
522
-
523
- function UserSetupScreen({ refresh }: { refresh: () => Promise<void> }) {
524
- const token = new URLSearchParams(window.location.search).get("token") ?? "";
525
- const [details, setDetails] = useState<UserSetupDetails>();
526
- const [error, setError] = useState<string>();
527
- const [busy, setBusy] = useState(false);
528
-
529
- useEffect(() => {
530
- if (!token) {
531
- setError("Setup token is missing");
532
- return;
533
- }
534
- void json<UserSetupDetails>(`/api/user-setup?token=${encodeURIComponent(token)}`)
535
- .then(setDetails)
536
- .catch((err) => setError(err instanceof Error ? err.message : String(err)));
537
- }, [token]);
538
-
539
- async function setup() {
540
- setBusy(true);
541
- setError(undefined);
542
- try {
543
- const options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/user-setup/options", { method: "POST", body: JSON.stringify({ token }) });
544
- const credential = await startRegistration({ optionsJSON: options as never });
545
- await json("/api/user-setup/verify", { method: "POST", body: JSON.stringify({ token, response: credential }) });
546
- window.history.replaceState(null, "", "/");
547
- await refresh();
548
- window.location.reload();
549
- } catch (err) {
550
- setError(err instanceof Error ? err.message : String(err));
551
- } finally {
552
- setBusy(false);
553
- }
554
- }
555
-
556
- return (
557
- <main className="wapp-auth-screen">
558
- <Dialog
559
- className="wapp-auth-dialog"
560
- title={details?.kind === "reset" ? "Reset passkey" : "Finish user setup"}
561
- actions={<Button type="button" variant="primary" disabled={busy || !details} onClick={() => void setup()}>Set up passkey</Button>}
562
- >
563
- <p>{details ? `Username: ${details.username}` : "Loading setup link..."}</p>
564
- {details ? <p className="wapp-muted">Role: {details.role}. This link expires at {details.expiresAt}.</p> : null}
565
- {error ? <p className="wapp-error">{error}</p> : null}
566
- </Dialog>
567
- </main>
568
- );
569
- }
570
-
571
- function DeviceVerificationScreen() {
572
- const params = new URLSearchParams(window.location.search);
573
- const initialCode = params.get("user_code") ?? "";
574
- const [userCode, setUserCode] = useState(initialCode);
575
- const [details, setDetails] = useState<DeviceVerificationDetails>();
576
- const [error, setError] = useState<string>();
577
- const [busy, setBusy] = useState(false);
578
-
579
- const load = useCallback(async () => {
580
- if (!userCode.trim()) {
581
- setDetails(undefined);
582
- return;
583
- }
584
- setBusy(true);
585
- setError(undefined);
586
- try {
587
- setDetails(await json<DeviceVerificationDetails>(`/api/auth/device/verification?user_code=${encodeURIComponent(userCode.trim())}`));
588
- } catch (err) {
589
- setDetails(undefined);
590
- setError(err instanceof Error ? err.message : String(err));
591
- } finally {
592
- setBusy(false);
593
- }
594
- }, [userCode]);
595
-
596
- useEffect(() => void load(), [load]);
597
-
598
- async function decide(action: "approve" | "deny") {
599
- if (!details) return;
600
- setBusy(true);
601
- setError(undefined);
602
- try {
603
- setDetails(await json<DeviceVerificationDetails>(`/api/auth/device/${action}`, {
604
- method: "POST",
605
- body: JSON.stringify({ user_code: details.userCode }),
606
- }));
607
- } catch (err) {
608
- setError(err instanceof Error ? err.message : String(err));
609
- } finally {
610
- setBusy(false);
611
- }
612
- }
613
-
614
- return (
615
- <main className="wapp-device-screen">
616
- <Panel title="Authorize device" description="Enter the code shown by the CLI or external device.">
617
- <div className="wapp-device-stack">
618
- <TextField label="User code" value={userCode} onChange={(event) => setUserCode(event.currentTarget.value.toUpperCase())} placeholder="ABCD-2345" />
619
- {error ? <p className="wapp-error">{error}</p> : null}
620
- {details ? (
621
- <div className="wapp-device-card">
622
- <div><strong>Client</strong><span>{details.clientId}</span></div>
623
- <div><strong>Scope</strong><span>{details.scope}</span></div>
624
- <div><strong>Status</strong><Badge variant={details.status === "approved" ? "success" : details.status === "denied" ? "error" : details.status === "consumed" ? "disabled" : "warning"}>{details.status}</Badge></div>
625
- <div><strong>Expires</strong><span>{details.expiresAt}</span></div>
626
- </div>
627
- ) : null}
628
- <div className="wapp-row-actions">
629
- <Button type="button" variant="ghost" disabled={busy || !details || details.status !== "pending"} onClick={() => void decide("deny")}>Deny</Button>
630
- <Button type="button" variant="primary" disabled={busy || !details || details.status !== "pending"} onClick={() => void decide("approve")}>Approve</Button>
631
- </div>
632
- </div>
633
- </Panel>
634
- </main>
635
- );
636
- }
637
-
638
- type SidebarTreeParentKind = "root" | "section" | "item";
639
-
640
- type SidebarTreeProps = {
641
- nodes: SidebarNode[];
642
- route: WebAppRoute;
643
- navigate: (route: WebAppRoute) => void;
644
- collapsed: SidebarCollapsedState;
645
- toggleCollapsed: (id: string, isCollapsed: boolean) => void;
646
- searchActive: boolean;
647
- level?: number;
648
- parentKind?: SidebarTreeParentKind;
649
- };
650
-
651
- function sidebarIndentStyle(level: number, parentKind: SidebarTreeParentKind): { marginLeft?: string } | undefined {
652
- if (level <= 0) {
653
- return undefined;
654
- }
655
- const baseIndentRem = level * 0.375;
656
- const nestedSectionIndentRem = parentKind === "section" && level > 1 ? 0.875 : 0;
657
- return { marginLeft: `${baseIndentRem + nestedSectionIndentRem}rem` };
658
- }
659
-
660
- function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed, searchActive, level = 0, parentKind = "root" }: SidebarTreeProps) {
661
- const [contextMenu, setContextMenu] = useState<{ position: ContextMenuPosition; items: ActionMenuItem[]; title: string } | null>(null);
662
- return (
663
- <>
664
- {nodes.map((node) => {
665
- const hasChildren = Boolean(node.children?.length);
666
- const storedIsCollapsed = collapsed[node.id] ?? node.defaultCollapsed ?? false;
667
- const isCollapsed = searchActive && hasChildren ? false : storedIsCollapsed;
668
- const toggleAriaLabel = searchActive ? `Toggling unavailable during search for ${node.title}` : `${isCollapsed ? "Expand" : "Collapse"} ${node.title}`;
669
- const toggleNodeCollapsed = () => {
670
- if (!searchActive) {
671
- toggleCollapsed(node.id, storedIsCollapsed);
672
- }
673
- };
674
- if (node.type === "section") {
675
- return (
676
- <section className={`wapp-sidebar-section ${level === 0 ? "top" : "nested"}`} key={node.id}>
677
- <div className="wapp-sidebar-section-title" style={sidebarIndentStyle(level, parentKind)}>
678
- {hasChildren ? (
679
- <button type="button" aria-expanded={!isCollapsed} aria-label={toggleAriaLabel} disabled={searchActive} onClick={toggleNodeCollapsed}>
680
- <span>{isCollapsed ? "▶" : "▼"}</span>{node.title}
681
- </button>
682
- ) : (
683
- <div className="wapp-sidebar-section-label">{node.title}</div>
684
- )}
685
- {node.action ? <button type="button" className="wapp-sidebar-action" title={node.action.title} aria-label={node.action.title} onClick={node.action.onAction ?? (() => node.action?.route && navigate(node.action.route))}>{node.action.label ?? "New"}</button> : null}
686
- </div>
687
- {!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="section" /> : null}
688
- {!isCollapsed && !hasChildren && level === 0 ? <div className="wapp-sidebar-empty">No items.</div> : null}
689
- </section>
690
- );
691
- }
692
- const active = node.route?.view === route.view && Object.entries(node.route).every(([key, value]) => key === "view" || route[key] === value);
693
- return (
694
- <div className={`wapp-sidebar-item-wrap ${hasChildren ? "has-toggle" : ""}`} key={node.id} style={sidebarIndentStyle(level, parentKind)}>
695
- {hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={toggleAriaLabel} disabled={searchActive} onClick={toggleNodeCollapsed}>{isCollapsed ? "▶" : "▼"}</button> : null}
696
- <button
697
- type="button"
698
- className={`wapp-sidebar-item ${active ? "active" : ""}`}
699
- onClick={() => node.route && navigate(node.route)}
700
- onContextMenu={(event) => {
701
- if (!node.actions?.length) return;
702
- event.preventDefault();
703
- setContextMenu({ position: { x: event.clientX, y: event.clientY }, items: node.actions, title: node.title });
704
- }}
705
- >
706
- <span>
707
- <strong>{node.title}</strong>
708
- {node.subtitle ? <small>{node.subtitle}</small> : null}
709
- </span>
710
- {node.badge ? <Badge variant={node.badgeVariant} className="wapp-sidebar-badge" title={node.badge} aria-label={node.badge}> </Badge> : null}
711
- </button>
712
- {!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="item" /></div> : null}
713
- </div>
714
- );
715
- })}
716
- <ContextMenu items={contextMenu?.items ?? []} position={contextMenu?.position ?? null} ariaLabel={contextMenu ? `Actions for ${contextMenu.title}` : "Actions"} onClose={() => setContextMenu(null)} />
717
- </>
718
- );
719
- }
720
-
721
- function renderSettingsActions(actions: SettingsRow["actions"]): ReactNode {
722
- if (!actions) return null;
723
- if (!Array.isArray(actions)) return actions;
724
- return actions.map((action) => (
725
- <Button key={action.id} type="button" variant={action.variant} disabled={action.disabled} onClick={action.onAction}>{action.label}</Button>
726
- ));
727
- }
728
-
729
- function StructuredSettingsSection({ section }: { section: SettingsSection }) {
730
- if (!section.rows?.length && section.render) {
731
- return <section className="wapp-custom-settings-section">{section.render()}</section>;
732
- }
733
-
734
- return (
735
- <FormSection title={section.title} description={section.description}>
736
- {section.rows?.map((row) => {
737
- const actions = renderSettingsActions(row.actions);
738
- if (row.danger) {
739
- return <DangerZone key={row.id} title={row.title} description={row.description} actions={actions} />;
740
- }
741
- return (
742
- <div className="wapp-settings-row" key={row.id}>
743
- <div>
744
- <strong>{row.title}</strong>
745
- {row.description ? <p>{row.description}</p> : null}
746
- {row.content ? <div className="wapp-settings-row-content">{row.content}</div> : null}
747
- </div>
748
- {actions ? <div className="wapp-row-actions">{actions}</div> : null}
749
- </div>
750
- );
751
- })}
752
- {section.render?.()}
753
- </FormSection>
754
- );
755
- }
756
-
757
- function isScopeVisible(scope: "user" | "admin" | "owner" | undefined, config: WebAppConfigResponse): boolean {
758
- if (!scope || scope === "user") return Boolean(config.currentUser);
759
- if (scope === "admin") return Boolean(config.currentUser?.isAdmin);
760
- return Boolean(config.currentUser?.isOwner);
761
- }
762
-
763
- function UserManagement({ config }: { config: WebAppConfigResponse }) {
764
- const [users, setUsers] = useState<WebAppUserSummary[]>([]);
765
- const [username, setUsername] = useState("");
766
- const [role, setRole] = useState<WebAppUserRole>("user");
767
- const [setupLink, setSetupLink] = useState<string>();
768
- const [userToDelete, setUserToDelete] = useState<WebAppUserSummary>();
769
- const [error, setError] = useState<string>();
770
-
771
- const refreshUsers = useCallback(async () => {
772
- if (config.userManagement.canManageUsers) {
773
- setUsers(await json<WebAppUserSummary[]>("/api/users"));
774
- }
775
- }, [config.userManagement.canManageUsers]);
776
-
777
- useEffect(() => void refreshUsers().catch(() => undefined), [refreshUsers]);
778
-
779
- async function createUser() {
780
- try {
781
- setError(undefined);
782
- const result = await json<CreatedUserResponse>("/api/users", { method: "POST", body: JSON.stringify({ username, role }) });
783
- setUsername("");
784
- setRole("user");
785
- setSetupLink(result.setupLink.url);
786
- await refreshUsers();
787
- } catch (err) {
788
- setError(err instanceof Error ? err.message : String(err));
789
- }
790
- }
791
-
792
- async function updateRole(user: WebAppUserSummary, nextRole: WebAppUserRole) {
793
- try {
794
- setError(undefined);
795
- await json(`/api/users/${encodeURIComponent(user.id)}/role`, { method: "PATCH", body: JSON.stringify({ role: nextRole }) });
796
- await refreshUsers();
797
- } catch (err) {
798
- setError(err instanceof Error ? err.message : String(err));
799
- }
800
- }
801
-
802
- async function resetUser(user: WebAppUserSummary) {
803
- try {
804
- setError(undefined);
805
- const result = await json<CreatedUserResponse>(`/api/users/${encodeURIComponent(user.id)}/reset`, { method: "POST", body: "{}" });
806
- setSetupLink(result.setupLink.url);
807
- await refreshUsers();
808
- } catch (err) {
809
- setError(err instanceof Error ? err.message : String(err));
810
- }
811
- }
812
-
813
- async function deleteUser(user: WebAppUserSummary) {
814
- try {
815
- setError(undefined);
816
- await json(`/api/users/${encodeURIComponent(user.id)}`, { method: "DELETE" });
817
- setUserToDelete(undefined);
818
- await refreshUsers();
819
- } catch (err) {
820
- setError(err instanceof Error ? err.message : String(err));
821
- }
822
- }
823
-
824
- if (!config.userManagement.canManageUsers) return null;
825
- return (
826
- <FormSection title="User management" description="Create users, reset passkeys, and manage admin access.">
827
- {error ? <p className="wapp-error">{error}</p> : null}
828
- <br />
829
- <div className="wapp-settings-row stacked">
830
- <div>
831
- <TextField label="Username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} placeholder="new-user" />
832
- <br />
833
- <SelectField label="Role" value={role} onChange={(event) => setRole(event.currentTarget.value as WebAppUserRole)}>
834
- <option value="user">User</option>
835
- <option value="admin">Admin</option>
836
- </SelectField>
837
- </div>
838
- <br />
839
- <div className="wapp-row-actions"><Button type="button" variant="primary" disabled={!username.trim()} onClick={() => void createUser()}>Create setup link</Button></div>
840
- {setupLink ? <code className="wapp-token">{setupLink}</code> : null}
841
- </div>
842
- <br />
843
- <div className="wapp-list">
844
- {users.map((user) => (
845
- <div className="wapp-list-row" key={user.id}>
846
- <span>
847
- <strong>{user.username}</strong>
848
- <small>{user.role} · passkey {user.passkeyConfigured ? "configured" : "pending"} · created {user.createdAt}</small>
849
- </span>
850
- <div className="wapp-row-actions">
851
- {user.role !== "owner" ? (
852
- <select className="wapp-inline-select" aria-label={`Role for ${user.username}`} value={user.role} onChange={(event) => void updateRole(user, event.currentTarget.value as WebAppUserRole)}>
853
- <option value="user">User</option>
854
- <option value="admin">Admin</option>
855
- </select>
856
- ) : <Badge variant="success">Owner</Badge>}
857
- {user.role !== "owner" ? <Button type="button" onClick={() => void resetUser(user)}>Reset</Button> : null}
858
- <Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() => setUserToDelete(user)}>Delete</Button>
859
- </div>
860
- </div>
861
- ))}
862
- </div>
863
- <ConfirmDialog
864
- open={Boolean(userToDelete)}
865
- title="Delete user?"
866
- message={userToDelete ? `This permanently deletes "${userToDelete.username}" and revokes their setup links, API keys, passkeys and device sessions.` : ""}
867
- confirmLabel="Delete user"
868
- danger
869
- onCancel={() => setUserToDelete(undefined)}
870
- onConfirm={() => userToDelete && void deleteUser(userToDelete)}
871
- />
872
- </FormSection>
873
- );
874
- }
875
-
876
- const KILL_SERVER_COUNTDOWN_SECONDS = 15;
877
-
878
- function computeProgressPercent(countdown: number, total: number): number {
879
- return total <= 0 ? 0 : (countdown / total) * 100;
880
- }
881
-
882
- function useCountdownReload(active: boolean, onComplete: () => void, durationSeconds = KILL_SERVER_COUNTDOWN_SECONDS): { countdown: number; progressPercent: number } {
883
- const [countdown, setCountdown] = useState(durationSeconds);
884
-
885
- useEffect(() => {
886
- if (!active) {
887
- setCountdown(durationSeconds);
888
- return;
889
- }
890
-
891
- setCountdown(durationSeconds);
892
- const interval = window.setInterval(() => {
893
- setCountdown((previous) => {
894
- const next = previous - 1;
895
- if (next <= 0) {
896
- window.clearInterval(interval);
897
- onComplete();
898
- return 0;
899
- }
900
- return next;
901
- });
902
- }, 1000);
903
-
904
- return () => window.clearInterval(interval);
905
- }, [active, durationSeconds, onComplete]);
906
-
907
- return {
908
- countdown,
909
- progressPercent: computeProgressPercent(countdown, durationSeconds),
910
- };
911
- }
912
-
913
- function SettingsView({ config, refresh, customSections, theme, setTheme }: { config: WebAppConfigResponse; refresh: () => Promise<void>; customSections: SettingsSection[]; theme: ThemePreference; setTheme: (theme: ThemePreference) => void }) {
914
- const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([]);
915
- const [authSessions, setAuthSessions] = useState<AuthSessionSummary[]>([]);
916
- const [createdToken, setCreatedToken] = useState<string>();
917
- const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeySummary>();
918
- const [authSessionToRevoke, setAuthSessionToRevoke] = useState<AuthSessionSummary>();
919
- const [confirmDeletePasskey, setConfirmDeletePasskey] = useState(false);
920
- const [confirmKillServer, setConfirmKillServer] = useState(false);
921
- const [killRequested, setKillRequested] = useState(false);
922
- const [error, setError] = useState<string>();
923
- const reloadPage = useCallback(() => window.location.reload(), []);
924
- const { countdown, progressPercent } = useCountdownReload(killRequested, reloadPage);
925
-
926
- const refreshApiKeys = useCallback(async () => {
927
- if (config.apiKeys.enabled) {
928
- setApiKeys(await json<ApiKeySummary[]>("/api/api-keys"));
929
- }
930
- }, [config.apiKeys.enabled]);
931
-
932
- const refreshAuthSessions = useCallback(async () => {
933
- if (config.deviceAuth.enabled) {
934
- setAuthSessions(await json<AuthSessionSummary[]>("/api/auth/sessions"));
935
- }
936
- }, [config.deviceAuth.enabled]);
937
-
938
- useEffect(() => void refreshApiKeys().catch(() => undefined), [refreshApiKeys]);
939
- useEffect(() => void refreshAuthSessions().catch(() => undefined), [refreshAuthSessions]);
940
-
941
- async function createKey() {
942
- const result = await json<CreatedApiKeyResponse>("/api/api-keys", { method: "POST", body: JSON.stringify({ name: "Browser key", scopes: ["*"] }) });
943
- setCreatedToken(result.token);
944
- await refreshApiKeys();
945
- }
946
-
947
- async function deleteKey(id: string) {
948
- try {
949
- setError(undefined);
950
- await json(`/api/api-keys/${encodeURIComponent(id)}`, { method: "DELETE" });
951
- setApiKeyToDelete(undefined);
952
- await refreshApiKeys();
953
- } catch (err) {
954
- setError(err instanceof Error ? err.message : String(err));
955
- }
956
- }
957
-
958
- async function setupPasskey() {
959
- try {
960
- setError(undefined);
961
- const options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/passkey-auth/owner-setup/options", { method: "POST", body: "{}" });
962
- const credential = await startRegistration({ optionsJSON: options as never });
963
- await json("/api/passkey-auth/owner-setup/verify", { method: "POST", body: JSON.stringify(credential) });
964
- await refresh();
965
- } catch (err) {
966
- setError(err instanceof Error ? err.message : String(err));
967
- }
968
- }
969
-
970
- async function logout() {
971
- await json("/api/passkey-auth/logout", { method: "POST", body: "{}" });
972
- await refresh();
973
- }
974
-
975
- async function deleteConfiguredPasskey() {
976
- await json("/api/passkey-auth/passkey", { method: "DELETE" });
977
- setConfirmDeletePasskey(false);
978
- await refresh();
979
- }
980
-
981
- async function revokeAuthSession(session: AuthSessionSummary) {
982
- try {
983
- setError(undefined);
984
- await json(`/api/auth/sessions/${session.id}`, { method: "DELETE" });
985
- setAuthSessionToRevoke(undefined);
986
- await refreshAuthSessions();
987
- } catch (err) {
988
- setError(err instanceof Error ? err.message : String(err));
989
- }
990
- }
991
-
992
- async function killServer() {
993
- setError(undefined);
994
- setConfirmKillServer(false);
995
- const response = await fetch("/api/server/kill", { method: "POST" });
996
- if (!response.ok) {
997
- throw new Error("Failed to kill server. Please try again.");
998
- }
999
- setKillRequested(true);
1000
- }
1001
-
1002
- return (
1003
- <div className="wapp-settings">
1004
- {error ? <p className="wapp-error">{error}</p> : null}
1005
- {config.currentUser ? (
1006
- <FormSection title="Account">
1007
- <p>Signed in as <strong>{config.currentUser.username}</strong> <Badge variant={config.currentUser.isAdmin ? "success" : "disabled"}>{config.currentUser.role}</Badge></p>
1008
- </FormSection>
1009
- ) : null}
1010
- <FormSection title="Display Settings">
1011
- <SelectField label="Theme" value={theme} onChange={(event) => {
1012
- const next = event.currentTarget.value as ThemePreference;
1013
- setTheme(next);
1014
- void json("/api/preferences/theme", { method: "PUT", body: JSON.stringify({ theme: next }) }).catch((err) => setError(String(err)));
1015
- }}>
1016
- <option value="system">System</option>
1017
- <option value="light">Light</option>
1018
- <option value="dark">Dark</option>
1019
- </SelectField>
1020
- </FormSection>
1021
-
1022
- {config.currentUser?.isAdmin ? (
1023
- <FormSection title="Developer Settings">
1024
- <SelectField label={config.logLevel.fromEnv ? `Log level (${config.logLevel.level}, controlled by env)` : "Log level"} value={config.logLevel.level} disabled={config.logLevel.fromEnv} onChange={(event) => void json("/api/preferences/log-level", { method: "PUT", body: JSON.stringify({ level: event.currentTarget.value }) }).then(refresh)}>
1025
- {["trace", "debug", "info", "warn", "error"].map((level) => <option key={level} value={level}>{level}</option>)}
1026
- </SelectField>
1027
- </FormSection>
1028
- ) : null}
1029
-
1030
- <FormSection title="Security">
1031
- <div className="wapp-settings-row">
1032
- <div>
1033
- <strong>Passkey</strong>
1034
- <p>{config.passkeyAuth.passkeyConfigured ? "Your passkey protects this account." : "No passkey configured yet."}</p>
1035
- </div>
1036
- <div className="wapp-row-actions">
1037
- {config.passkeyAuth.passkeyConfigured ? (
1038
- <>
1039
- <Button type="button" onClick={() => void logout()}>Logout</Button>
1040
- <Button type="button" variant="danger" onClick={() => setConfirmDeletePasskey(true)}>Delete passkey</Button>
1041
- </>
1042
- ) : (
1043
- config.currentUser?.isOwner ? <Button type="button" variant="primary" onClick={() => void setupPasskey()}>Set up passkey</Button> : null
1044
- )}
1045
- </div>
1046
- </div>
1047
- {config.apiKeys.enabled ? (
1048
- <div className="wapp-settings-row stacked">
1049
- <div>
1050
- <strong>API keys</strong>
1051
- <p>Create bearer tokens for scripts and agents.</p>
1052
- </div>
1053
- <div className="wapp-row-actions"><Button type="button" onClick={() => void createKey().catch((err) => setError(String(err)))}>Create API key</Button></div>
1054
- {createdToken ? <code className="wapp-token">{createdToken}</code> : null}
1055
- {apiKeys.length ? (
1056
- <div className="wapp-list">
1057
- {apiKeys.map((key) => (
1058
- <div className="wapp-list-row" key={key.id}>
1059
- <span><strong>{key.name}</strong><small>{key.scopes.join(", ")} · {key.createdAt}</small></span>
1060
- <Button type="button" variant="danger" onClick={() => setApiKeyToDelete(key)}>Delete</Button>
1061
- </div>
1062
- ))}
1063
- </div>
1064
- ) : null}
1065
- </div>
1066
- ) : null}
1067
- {config.deviceAuth.enabled ? (
1068
- <div className="wapp-settings-row stacked">
1069
- <div>
1070
- <strong>Device auth sessions</strong>
1071
- <p>Refresh-token sessions created through the device flow.</p>
1072
- </div>
1073
- <div className="wapp-list">
1074
- {authSessions.length ? authSessions.map((session) => (
1075
- <div className="wapp-list-row" key={session.id}>
1076
- <span><strong>{session.clientId}</strong><small>{session.scope} · {session.updatedAt}</small></span>
1077
- <Button type="button" variant="danger" onClick={() => setAuthSessionToRevoke(session)}>Revoke</Button>
1078
- </div>
1079
- )) : <EmptyState title="No device sessions" />}
1080
- </div>
1081
- </div>
1082
- ) : null}
1083
- </FormSection>
1084
-
1085
- <UserManagement config={config} />
1086
-
1087
- {config.currentUser?.isAdmin ? (
1088
- <FormSection title="Server operations">
1089
- <div className="wapp-settings-row">
1090
- <div>
1091
- <strong>Kill server</strong>
1092
- <p>Stop the server process. If your deployment restarts it automatically, the app will come back after a moment.</p>
1093
- {killRequested ? (
1094
- <div className="wapp-shutdown-countdown" aria-live="polite">
1095
- <div className="wapp-shutdown-message">Server is shutting down... Reloading in {countdown}s</div>
1096
- <div className="wapp-shutdown-progress" aria-hidden="true">
1097
- <div className="wapp-shutdown-progress-bar" style={{ width: `${progressPercent}%` }} />
1098
- </div>
1099
- </div>
1100
- ) : null}
1101
- </div>
1102
- <div className="wapp-row-actions">
1103
- <Button type="button" variant="danger" disabled={killRequested} onClick={() => setConfirmKillServer(true)}>Kill server</Button>
1104
- </div>
1105
- </div>
1106
- </FormSection>
1107
- ) : null}
1108
-
1109
- {customSections.filter((section) => isScopeVisible(section.scope, config)).map((section) => ({
1110
- ...section,
1111
- rows: section.rows?.filter((row) => isScopeVisible(row.scope, config)),
1112
- })).map((section) => <StructuredSettingsSection key={section.id} section={section} />)}
1113
-
1114
- <FormSection title="About">
1115
- <p>{config.appName} {config.version}</p>
1116
- </FormSection>
1117
-
1118
- <ConfirmDialog
1119
- open={Boolean(apiKeyToDelete)}
1120
- title="Delete API key?"
1121
- message={apiKeyToDelete ? `This permanently deletes "${apiKeyToDelete.name}". Scripts and agents using this token will stop working.` : ""}
1122
- confirmLabel="Delete API key"
1123
- danger
1124
- onCancel={() => setApiKeyToDelete(undefined)}
1125
- onConfirm={() => apiKeyToDelete && void deleteKey(apiKeyToDelete.id)}
1126
- />
1127
- <ConfirmDialog open={confirmDeletePasskey} title="Delete passkey?" message="This removes the configured passkey and invalidates browser sessions." confirmLabel="Delete passkey" danger onCancel={() => setConfirmDeletePasskey(false)} onConfirm={() => void deleteConfiguredPasskey()} />
1128
- <ConfirmDialog
1129
- open={Boolean(authSessionToRevoke)}
1130
- title="Revoke device session?"
1131
- message={authSessionToRevoke ? `This revokes the active "${authSessionToRevoke.clientId}" refresh-token session.` : ""}
1132
- confirmLabel="Revoke session"
1133
- danger
1134
- onCancel={() => setAuthSessionToRevoke(undefined)}
1135
- onConfirm={() => authSessionToRevoke && void revokeAuthSession(authSessionToRevoke)}
1136
- />
1137
- <ConfirmDialog
1138
- open={confirmKillServer}
1139
- title="Kill server?"
1140
- message="Are you sure you want to kill the server?"
1141
- confirmLabel="Kill server"
1142
- danger
1143
- onCancel={() => setConfirmKillServer(false)}
1144
- onConfirm={() => void killServer().catch((err) => setError(String(err)))}
1145
- />
1146
- </div>
1147
- );
46
+ return left.view === right.view && Object.entries(left).every(([key, value]) => key === "view" || right[key] === value);
1148
47
  }
1149
48
 
1150
49
  export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRouteChange, settings, version }: WebAppRootProps) {
1151
- useMobileViewportHeight();
50
+ const isMobile = useMobileBreakpoint();
51
+ useMobileViewportHeight(isMobile);
1152
52
  const { config, error, refresh } = useConfig();
1153
53
  const { route, navigate } = useRoute(homeRoute);
1154
54
  const { theme, setTheme } = useTheme();
55
+ const [themeLoading, setThemeLoading] = useState(false);
56
+ const [themeLoadError, setThemeLoadError] = useState<Error>();
1155
57
  const [search, setSearch] = useState("");
1156
58
  const sidebarSearchId = useId();
1157
59
  const sidebarSearchInputRef = useRef<HTMLInputElement>(null);
1158
60
  const [sidebarOpen, setSidebarOpen] = useState(false);
1159
61
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
1160
62
  const sidebarTreeState = useSidebarCollapsedState(appName);
1161
- useMobileSidebarSwipe(sidebarOpen, setSidebarOpen);
63
+ useMobileSidebarSwipe(isMobile, sidebarOpen, setSidebarOpen);
1162
64
  const toggleSidebarCollapsed = useCallback(() => {
1163
65
  setSidebarCollapsed((current) => {
1164
66
  const nextCollapsed = !current;
@@ -1223,34 +125,28 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1223
125
  }, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, sidebar.pinning, sidebarSearchActive]);
1224
126
  const activeActionNodes = useMemo(() => augmentPinningActions(baseNodes), [augmentPinningActions, baseNodes]);
1225
127
 
1226
- useEffect(() => {
1227
- if (!config?.currentUser) return;
1228
- void json<{ theme: ThemePreference }>("/api/preferences/theme")
1229
- .then((result) => setTheme(result.theme))
1230
- .catch(() => undefined);
1231
- }, [config?.currentUser?.id, setTheme]);
1232
-
1233
- useEffect(() => {
1234
- function handleSidebarShortcut(event: KeyboardEvent) {
1235
- if (
1236
- event.key.toLowerCase() !== "b"
1237
- || event.altKey
1238
- || event.shiftKey
1239
- || event.ctrlKey === event.metaKey
1240
- || event.isComposing
1241
- || event.repeat
1242
- || isSidebarShortcutEditableTarget(event.target)
1243
- ) {
1244
- return;
1245
- }
128
+ const retryThemeLoad = useCallback(async () => {
129
+ if (!config?.currentUser) {
130
+ setThemeLoading(false);
131
+ setThemeLoadError(undefined);
132
+ return;
133
+ }
1246
134
 
1247
- event.preventDefault();
1248
- toggleSidebarCollapsed();
135
+ setThemeLoading(true);
136
+ setThemeLoadError(undefined);
137
+ try {
138
+ const result = await appJson<{ theme: ThemePreference }>("/api/preferences/theme");
139
+ setTheme(result.theme);
140
+ } catch (err) {
141
+ setThemeLoadError(err instanceof Error ? err : new Error(String(err)));
142
+ } finally {
143
+ setThemeLoading(false);
1249
144
  }
145
+ }, [config?.currentUser?.id, setTheme]);
1250
146
 
1251
- document.addEventListener("keydown", handleSidebarShortcut);
1252
- return () => document.removeEventListener("keydown", handleSidebarShortcut);
1253
- }, [toggleSidebarCollapsed]);
147
+ useEffect(() => {
148
+ void retryThemeLoad();
149
+ }, [retryThemeLoad]);
1254
150
 
1255
151
  useEffect(() => {
1256
152
  onRouteChange?.(route);
@@ -1275,7 +171,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1275
171
  const effectiveVersion = version ?? config.version;
1276
172
  let view: ReactNode;
1277
173
  if (route.view === "settings") {
1278
- view = <SettingsView config={config} refresh={refresh} customSections={settings?.sections ?? []} theme={theme} setTheme={setTheme} />;
174
+ view = <SettingsView config={config} refresh={refresh} customSections={settings?.sections ?? []} theme={theme} setTheme={setTheme} themeLoading={themeLoading} themeLoadError={themeLoadError} retryThemeLoad={retryThemeLoad} />;
1279
175
  } else {
1280
176
  const registeredView = routes[route.view];
1281
177
  view = typeof registeredView === "function"
@@ -1283,9 +179,8 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1283
179
  : registeredView ?? <EmptyState title="Not found" description={`No view registered for ${route.view}.`} />;
1284
180
  }
1285
181
 
1286
- const topActions = sidebar.topActions?.slice(0, 2) ?? [];
1287
182
  const defaultTitle = route.view === "settings" ? "Settings" : route.view === homeRoute.view ? appName : route.view.replace(/-/g, " ");
1288
- const headerContext = { route, defaultTitle };
183
+ const headerContext: HeaderContext = { route, defaultTitle };
1289
184
  const activeSidebarNode = flattenSidebarItems(activeActionNodes).find((node) => routeMatches(node.route, route));
1290
185
  const activeSidebarActions = activeSidebarNode?.actions ?? [];
1291
186
  const headerActions = [
@@ -1295,73 +190,33 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1295
190
  const headerTitle = header?.renderTitle?.(headerContext) ?? defaultTitle;
1296
191
  const primaryHeaderActions = header?.renderActions?.(headerContext);
1297
192
  const headerActionLabel = typeof headerTitle === "string" ? headerTitle : defaultTitle;
1298
- const sidebarToggleLabel = sidebarCollapsed ? "Show sidebar" : "Collapse sidebar";
1299
- const navigateFromSidebarHeader = (nextRoute: WebAppRoute) => {
1300
- navigate(nextRoute);
1301
- setSidebarOpen(false);
1302
- };
1303
- const runSidebarHeaderAction = (action: SidebarAction) => {
1304
- if (action.onAction) {
1305
- action.onAction();
1306
- } else if (action.route) {
1307
- navigate(action.route);
1308
- }
1309
- setSidebarOpen(false);
1310
- };
1311
193
 
1312
194
  return (
1313
- <main className={`wapp-shell ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${sidebarOpen ? "sidebar-open" : ""}`}>
1314
- <div className="wapp-mobile-backdrop" onClick={() => setSidebarOpen(false)} />
1315
- <aside className="wapp-sidebar">
1316
- <div className="wapp-sidebar-header">
1317
- <button type="button" className="wapp-brand" onClick={() => navigateFromSidebarHeader(homeRoute)}>{appName}</button>
1318
- <div className="wapp-sidebar-actions">
1319
- {topActions.map((action) => <IconButton key={action.id} className="wapp-sidebar-top-button" title={action.title} aria-label={action.title} onClick={() => runSidebarHeaderAction(action)}><ActionIcon icon={action.icon} /></IconButton>)}
1320
- <IconButton className="wapp-sidebar-top-button" title="Settings" aria-label="Open settings" active={route.view === "settings"} onClick={() => navigateFromSidebarHeader({ view: "settings" })}><Icon name="settings" /></IconButton>
1321
- <IconButton className="wapp-sidebar-top-button" title={sidebarToggleLabel} aria-label={sidebarToggleLabel} onClick={toggleSidebarCollapsed}><Icon name="sidebar" /></IconButton>
1322
- </div>
1323
- </div>
1324
- <div className="wapp-sidebar-scroll">
1325
- {sidebarSearchEnabled ? (
1326
- <div className="wapp-search">
1327
- <label className="sr-only" htmlFor={sidebarSearchId}>Search</label>
1328
- <div className={`wapp-search-input-wrap${search.length > 0 ? " wapp-search-input-wrap--clearable" : ""}`}>
1329
- <input id={sidebarSearchId} ref={sidebarSearchInputRef} value={search} onInput={(event) => setSearch(event.currentTarget.value)} placeholder="Search" />
1330
- {search.length > 0 ? (
1331
- <button
1332
- type="button"
1333
- className="wapp-search-clear"
1334
- aria-label="Clear search"
1335
- onClick={() => {
1336
- setSearch("");
1337
- sidebarSearchInputRef.current?.focus();
1338
- }}
1339
- >
1340
- ×
1341
- </button>
1342
- ) : null}
1343
- </div>
1344
- </div>
1345
- ) : null}
1346
- <SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} collapsed={sidebarTreeState.collapsed} toggleCollapsed={sidebarTreeState.toggleCollapsed} searchActive={sidebarSearchActive} />
1347
- <div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
1348
- </div>
1349
- </aside>
1350
- <section className="wapp-main">
1351
- <header className="wapp-main-header">
1352
- <div className="wapp-main-header-title">
1353
- {sidebarCollapsed ? <IconButton className="wapp-sidebar-top-button" aria-label={sidebarToggleLabel} title={sidebarToggleLabel} onClick={toggleSidebarCollapsed}><Icon name="sidebar" /></IconButton> : <IconButton className="wapp-mobile-only wapp-sidebar-top-button" aria-label="Show sidebar" title="Show sidebar" onClick={() => setSidebarOpen(true)}><Icon name="sidebar" /></IconButton>}
1354
- <h1>{headerTitle}</h1>
1355
- </div>
1356
- {primaryHeaderActions || headerActions.length ? (
1357
- <div className="wapp-main-header-actions">
1358
- {primaryHeaderActions}
1359
- {headerActions.length ? <ActionMenu items={headerActions} ariaLabel={`Actions for ${headerActionLabel}`} /> : null}
1360
- </div>
1361
- ) : null}
1362
- </header>
1363
- <div className="wapp-main-content">{view}</div>
1364
- </section>
1365
- </main>
195
+ <AppShell
196
+ appName={appName}
197
+ homeRoute={homeRoute}
198
+ topActions={sidebar.topActions ?? []}
199
+ nodes={nodes}
200
+ route={route}
201
+ navigate={navigate}
202
+ sidebarSearchEnabled={sidebarSearchEnabled}
203
+ search={search}
204
+ onSearchChange={setSearch}
205
+ sidebarSearchId={sidebarSearchId}
206
+ sidebarSearchInputRef={sidebarSearchInputRef}
207
+ sidebarOpen={sidebarOpen}
208
+ setSidebarOpen={setSidebarOpen}
209
+ sidebarCollapsed={sidebarCollapsed}
210
+ toggleSidebarCollapsed={toggleSidebarCollapsed}
211
+ collapsed={sidebarTreeState.collapsed}
212
+ toggleCollapsed={sidebarTreeState.toggleCollapsed}
213
+ searchActive={sidebarSearchActive}
214
+ effectiveVersion={effectiveVersion}
215
+ headerTitle={headerTitle}
216
+ headerActionLabel={headerActionLabel}
217
+ primaryHeaderActions={primaryHeaderActions}
218
+ headerActions={headerActions}
219
+ view={view}
220
+ />
1366
221
  );
1367
222
  }