@pablozaiden/webapp 0.5.7 → 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 (56) 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/sidebar.md +2 -0
  12. package/docs/ui-guidelines.md +12 -4
  13. package/package.json +2 -4
  14. package/src/build/build-binary.ts +67 -24
  15. package/src/cli/api-command.ts +53 -16
  16. package/src/cli/credentials.ts +275 -4
  17. package/src/cli/device-auth.ts +83 -22
  18. package/src/cli/environment-auth.ts +57 -0
  19. package/src/cli/index.ts +1 -0
  20. package/src/package-resolution.ts +34 -0
  21. package/src/server/auth/device-auth.ts +2 -2
  22. package/src/server/auth/passkeys.ts +16 -16
  23. package/src/server/auth/request-origin.ts +97 -16
  24. package/src/server/authentication.ts +265 -0
  25. package/src/server/create-web-app-server.ts +85 -1391
  26. package/src/server/framework-endpoints.ts +451 -0
  27. package/src/server/public-route-dispatch.ts +85 -0
  28. package/src/server/request-schemas.ts +144 -0
  29. package/src/server/responses.ts +69 -3
  30. package/src/server/route-dispatch.ts +109 -0
  31. package/src/server/runtime-config.ts +67 -0
  32. package/src/server/same-origin.ts +1 -1
  33. package/src/server/server-lifecycle.ts +138 -0
  34. package/src/server/server-types.ts +87 -0
  35. package/src/server/web-document.ts +532 -0
  36. package/src/web/WebAppRoot.tsx +69 -1132
  37. package/src/web/api-client.ts +14 -4
  38. package/src/web/app-shell.tsx +198 -0
  39. package/src/web/auth-screens.tsx +186 -0
  40. package/src/web/components/index.tsx +10 -3
  41. package/src/web/index.ts +1 -0
  42. package/src/web/mobile-hooks.ts +194 -0
  43. package/src/web/mobile.ts +12 -0
  44. package/src/web/root-types.ts +61 -0
  45. package/src/web/routing.ts +61 -0
  46. package/src/web/settings/account-section.tsx +52 -0
  47. package/src/web/settings/resource-state.tsx +17 -0
  48. package/src/web/settings/security-section.tsx +119 -0
  49. package/src/web/settings/sessions-section.tsx +66 -0
  50. package/src/web/settings/settings-view.tsx +96 -0
  51. package/src/web/settings/shutdown-section.tsx +95 -0
  52. package/src/web/settings/user-management.tsx +123 -0
  53. package/src/web/settings-view.tsx +3 -0
  54. package/src/web/sidebar-state.ts +108 -0
  55. package/src/web/sidebar-tree.tsx +89 -0
  56. package/src/web/styles.css +78 -61
@@ -1,248 +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 handleKeyboardBoundary = () => {
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
- mobileQuery.addEventListener("change", scheduleSync);
204
- document.addEventListener("focusin", handleKeyboardBoundary);
205
- document.addEventListener("focusout", handleKeyboardBoundary);
206
-
207
- return () => {
208
- if (frame) {
209
- cancelAnimationFrame(frame);
210
- }
211
- for (const timer of timers) {
212
- clearTimeout(timer);
213
- }
214
- viewport?.removeEventListener("resize", scheduleSync);
215
- viewport?.removeEventListener("scroll", scheduleSync);
216
- window.removeEventListener("resize", scheduleSync);
217
- mobileQuery.removeEventListener("change", scheduleSync);
218
- document.removeEventListener("focusin", handleKeyboardBoundary);
219
- document.removeEventListener("focusout", handleKeyboardBoundary);
220
- clearViewportHeight();
221
- };
222
- }, []);
223
- }
224
-
225
- async function json<T>(path: string, init: RequestInit = {}): Promise<T> {
226
- const response = await fetch(path, {
227
- ...init,
228
- headers: {
229
- "content-type": "application/json",
230
- ...init.headers,
231
- },
232
- });
233
- if (!response.ok) {
234
- const data = await response.json().catch(() => ({})) as { message?: string; error?: string };
235
- throw new Error(data.message ?? data.error ?? `Request failed with ${response.status}`);
236
- }
237
- return await response.json() as T;
238
- }
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";
239
16
 
240
17
  function useConfig() {
241
18
  const [config, setConfig] = useState<WebAppConfigResponse>();
242
19
  const [error, setError] = useState<string>();
243
20
  const refresh = useCallback(async () => {
244
21
  try {
245
- setConfig(await json<WebAppConfigResponse>("/api/config"));
22
+ setConfig(await appJson<WebAppConfigResponse>("/api/config"));
246
23
  setError(undefined);
247
24
  } catch (err) {
248
25
  setError(err instanceof Error ? err.message : String(err));
@@ -263,820 +40,27 @@ function useTheme() {
263
40
  }
264
41
 
265
42
  function routeMatches(left: WebAppRoute | undefined, right: WebAppRoute): boolean {
266
- if (!left) return false;
267
- return left.view === right.view && Object.entries(left).every(([key, value]) => key === "view" || right[key] === value);
268
- }
269
-
270
- function flattenSidebarItems(nodes: SidebarNode[]): SidebarNode[] {
271
- return nodes.flatMap((node) => [
272
- node,
273
- ...(node.children ? flattenSidebarItems(node.children) : []),
274
- ]).filter((node) => node.type === "item");
275
- }
276
-
277
- function pinStorageKey(appName: string, explicitKey?: string): string {
278
- return explicitKey ?? `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.pins`;
279
- }
280
-
281
- function sidebarCollapsedStorageKey(appName: string): string {
282
- return `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.collapsed`;
283
- }
284
-
285
- function isSidebarCollapsedState(value: unknown): value is SidebarCollapsedState {
286
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
43
+ if (!left) {
287
44
  return false;
288
45
  }
289
- return Object.values(value).every((entry) => typeof entry === "boolean");
290
- }
291
-
292
- function toStoredPin(node: SidebarNode): StoredSidebarPin | undefined {
293
- if (!node.route) return undefined;
294
- return {
295
- id: node.pinId ?? node.id,
296
- title: node.title,
297
- subtitle: node.subtitle,
298
- badge: node.badge,
299
- badgeVariant: node.badgeVariant,
300
- route: node.route,
301
- };
302
- }
303
-
304
- function useSidebarPins(appName: string, storageKey?: string) {
305
- const key = pinStorageKey(appName, storageKey);
306
- const [pins, setPins] = useState<StoredSidebarPin[]>(() => {
307
- try {
308
- const raw = localStorage.getItem(key);
309
- return raw ? JSON.parse(raw) as StoredSidebarPin[] : [];
310
- } catch {
311
- return [];
312
- }
313
- });
314
-
315
- useEffect(() => {
316
- localStorage.setItem(key, JSON.stringify(pins));
317
- }, [key, pins]);
318
-
319
- const pinIds = useMemo(() => new Set(pins.map((pin) => pin.id)), [pins]);
320
- const pin = useCallback((node: SidebarNode) => {
321
- const stored = toStoredPin(node);
322
- if (!stored) return;
323
- setPins((current) => [...current.filter((item) => item.id !== stored.id), stored]);
324
- }, []);
325
- const unpin = useCallback((id: string) => {
326
- setPins((current) => current.filter((item) => item.id !== id));
327
- }, []);
328
-
329
- return { pins, pinIds, pin, unpin };
330
- }
331
-
332
- function useSidebarCollapsedState(appName: string) {
333
- const key = sidebarCollapsedStorageKey(appName);
334
- const [collapsed, setCollapsed] = useState<SidebarCollapsedState>(() => {
335
- try {
336
- const raw = localStorage.getItem(key);
337
- if (!raw) return {};
338
- const parsed: unknown = JSON.parse(raw);
339
- return isSidebarCollapsedState(parsed) ? parsed : {};
340
- } catch {
341
- return {};
342
- }
343
- });
344
-
345
- useEffect(() => {
346
- localStorage.setItem(key, JSON.stringify(collapsed));
347
- }, [key, collapsed]);
348
-
349
- const toggleCollapsed = useCallback((id: string, isCollapsed: boolean) => {
350
- setCollapsed((current) => {
351
- const currentIsCollapsed = current[id] ?? isCollapsed;
352
- return { ...current, [id]: !currentIsCollapsed };
353
- });
354
- }, []);
355
-
356
- return { collapsed, toggleCollapsed };
357
- }
358
-
359
- function Icon({ name }: { name: "settings" | "sidebar" | "plus" | "home" | "search" | "bolt" | "chat" | "code" | "refresh" }) {
360
- const common = { "aria-hidden": true, viewBox: "0 0 24 24", className: "wapp-svg" };
361
- 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>;
362
- if (name === "sidebar") return <svg {...common}><rect x="4" y="5" width="16" height="14" rx="2" /><path d="M10 5v14" /></svg>;
363
- if (name === "plus") return <svg {...common}><path d="M12 5v14M5 12h14" /></svg>;
364
- if (name === "bolt") return <svg {...common}><path d="m13 2-8 12h7l-1 8 8-12h-7l1-8Z" /></svg>;
365
- 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>;
366
- if (name === "code") return <svg {...common}><path d="M8 8 4 12l4 4M16 8l4 4-4 4M14 4l-4 16" /></svg>;
367
- 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>;
368
- return <svg {...common}><path d="M4 10.5 12 4l8 6.5V20H5v-7h14" /></svg>;
369
- }
370
-
371
- function ActionIcon({ icon }: { icon?: ReactNode }) {
372
- if (icon === "+") return <Icon name="plus" />;
373
- if (icon === "↯") return <Icon name="bolt" />;
374
- if (icon === "chat") return <Icon name="chat" />;
375
- if (icon === "code") return <Icon name="code" />;
376
- if (!icon) return <Icon name="bolt" />;
377
- return <>{icon}</>;
378
- }
379
-
380
- function PasskeyAuthScreen({ status, refresh }: { status: PasskeyAuthStatusResponse; refresh: () => Promise<void> }) {
381
- const [error, setError] = useState<string>();
382
- const [busy, setBusy] = useState(false);
383
- const [username, setUsername] = useState("");
384
- const description = status.bootstrapRequired
385
- ? "Choose the username for the owner"
386
- : status.ownerPasskeySetupRequired
387
- ? "The owner passkey was removed. Set it up again to continue."
388
- : "Authenticate to continue.";
389
-
390
- async function register(endpoint: "bootstrap" | "owner-setup") {
391
- setBusy(true);
392
- setError(undefined);
393
- try {
394
- const body = endpoint === "bootstrap" ? JSON.stringify({ username }) : "{}";
395
- const options = await json<PublicKeyCredentialCreationOptionsJSON>(`/api/passkey-auth/${endpoint}/options`, { method: "POST", body });
396
- const credential = await startRegistration({ optionsJSON: options as never });
397
- await json(`/api/passkey-auth/${endpoint}/verify`, { method: "POST", body: JSON.stringify(credential) });
398
- await refresh();
399
- window.location.reload();
400
- } catch (err) {
401
- setError(err instanceof Error ? err.message : String(err));
402
- } finally {
403
- setBusy(false);
404
- }
405
- }
406
- async function login() {
407
- setBusy(true);
408
- setError(undefined);
409
- try {
410
- const options = await json<PublicKeyCredentialRequestOptionsJSON>("/api/passkey-auth/authentication/options", { method: "POST", body: "{}" });
411
- const credential = await startAuthentication({ optionsJSON: options as never });
412
- await json("/api/passkey-auth/authentication/verify", { method: "POST", body: JSON.stringify(credential) });
413
- await refresh();
414
- window.location.reload();
415
- } catch (err) {
416
- setError(err instanceof Error ? err.message : String(err));
417
- } finally {
418
- setBusy(false);
419
- }
420
- }
421
- return (
422
- <main className="wapp-auth-screen">
423
- <Dialog
424
- className="wapp-auth-dialog"
425
- title={status.bootstrapRequired ? "Create owner user" : status.ownerPasskeySetupRequired ? "Set up owner passkey" : "Passkey required"}
426
- actions={status.bootstrapRequired ? (
427
- <Button type="button" variant="primary" disabled={busy || !username.trim()} onClick={() => void register("bootstrap")}>Create owner</Button>
428
- ) : status.ownerPasskeySetupRequired ? (
429
- <Button type="button" variant="primary" disabled={busy} onClick={() => void register("owner-setup")}>Set up owner passkey</Button>
430
- ) : (
431
- <Button type="button" variant="primary" disabled={busy} onClick={() => void login()}>Authenticate</Button>
432
- )}
433
- >
434
- <p>{description}</p>
435
- {error ? <p className="wapp-error">{error}</p> : null}
436
- {status.bootstrapRequired ? <><br /><TextField label="Username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} placeholder="owner" /></> : null}
437
- </Dialog>
438
- </main>
439
- );
440
- }
441
-
442
- function UserSetupScreen({ refresh }: { refresh: () => Promise<void> }) {
443
- const token = new URLSearchParams(window.location.search).get("token") ?? "";
444
- const [details, setDetails] = useState<UserSetupDetails>();
445
- const [error, setError] = useState<string>();
446
- const [busy, setBusy] = useState(false);
447
-
448
- useEffect(() => {
449
- if (!token) {
450
- setError("Setup token is missing");
451
- return;
452
- }
453
- void json<UserSetupDetails>(`/api/user-setup?token=${encodeURIComponent(token)}`)
454
- .then(setDetails)
455
- .catch((err) => setError(err instanceof Error ? err.message : String(err)));
456
- }, [token]);
457
-
458
- async function setup() {
459
- setBusy(true);
460
- setError(undefined);
461
- try {
462
- const options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/user-setup/options", { method: "POST", body: JSON.stringify({ token }) });
463
- const credential = await startRegistration({ optionsJSON: options as never });
464
- await json("/api/user-setup/verify", { method: "POST", body: JSON.stringify({ token, response: credential }) });
465
- window.history.replaceState(null, "", "/");
466
- await refresh();
467
- window.location.reload();
468
- } catch (err) {
469
- setError(err instanceof Error ? err.message : String(err));
470
- } finally {
471
- setBusy(false);
472
- }
473
- }
474
-
475
- return (
476
- <main className="wapp-auth-screen">
477
- <Dialog
478
- className="wapp-auth-dialog"
479
- title={details?.kind === "reset" ? "Reset passkey" : "Finish user setup"}
480
- actions={<Button type="button" variant="primary" disabled={busy || !details} onClick={() => void setup()}>Set up passkey</Button>}
481
- >
482
- <p>{details ? `Username: ${details.username}` : "Loading setup link..."}</p>
483
- {details ? <p className="wapp-muted">Role: {details.role}. This link expires at {details.expiresAt}.</p> : null}
484
- {error ? <p className="wapp-error">{error}</p> : null}
485
- </Dialog>
486
- </main>
487
- );
488
- }
489
-
490
- function DeviceVerificationScreen() {
491
- const params = new URLSearchParams(window.location.search);
492
- const initialCode = params.get("user_code") ?? "";
493
- const [userCode, setUserCode] = useState(initialCode);
494
- const [details, setDetails] = useState<DeviceVerificationDetails>();
495
- const [error, setError] = useState<string>();
496
- const [busy, setBusy] = useState(false);
497
-
498
- const load = useCallback(async () => {
499
- if (!userCode.trim()) {
500
- setDetails(undefined);
501
- return;
502
- }
503
- setBusy(true);
504
- setError(undefined);
505
- try {
506
- setDetails(await json<DeviceVerificationDetails>(`/api/auth/device/verification?user_code=${encodeURIComponent(userCode.trim())}`));
507
- } catch (err) {
508
- setDetails(undefined);
509
- setError(err instanceof Error ? err.message : String(err));
510
- } finally {
511
- setBusy(false);
512
- }
513
- }, [userCode]);
514
-
515
- useEffect(() => void load(), [load]);
516
-
517
- async function decide(action: "approve" | "deny") {
518
- if (!details) return;
519
- setBusy(true);
520
- setError(undefined);
521
- try {
522
- setDetails(await json<DeviceVerificationDetails>(`/api/auth/device/${action}`, {
523
- method: "POST",
524
- body: JSON.stringify({ user_code: details.userCode }),
525
- }));
526
- } catch (err) {
527
- setError(err instanceof Error ? err.message : String(err));
528
- } finally {
529
- setBusy(false);
530
- }
531
- }
532
-
533
- return (
534
- <main className="wapp-device-screen">
535
- <Panel title="Authorize device" description="Enter the code shown by the CLI or external device.">
536
- <div className="wapp-device-stack">
537
- <TextField label="User code" value={userCode} onChange={(event) => setUserCode(event.currentTarget.value.toUpperCase())} placeholder="ABCD-2345" />
538
- {error ? <p className="wapp-error">{error}</p> : null}
539
- {details ? (
540
- <div className="wapp-device-card">
541
- <div><strong>Client</strong><span>{details.clientId}</span></div>
542
- <div><strong>Scope</strong><span>{details.scope}</span></div>
543
- <div><strong>Status</strong><Badge variant={details.status === "approved" ? "success" : details.status === "denied" ? "error" : details.status === "consumed" ? "disabled" : "warning"}>{details.status}</Badge></div>
544
- <div><strong>Expires</strong><span>{details.expiresAt}</span></div>
545
- </div>
546
- ) : null}
547
- <div className="wapp-row-actions">
548
- <Button type="button" variant="ghost" disabled={busy || !details || details.status !== "pending"} onClick={() => void decide("deny")}>Deny</Button>
549
- <Button type="button" variant="primary" disabled={busy || !details || details.status !== "pending"} onClick={() => void decide("approve")}>Approve</Button>
550
- </div>
551
- </div>
552
- </Panel>
553
- </main>
554
- );
555
- }
556
-
557
- type SidebarTreeParentKind = "root" | "section" | "item";
558
-
559
- type SidebarTreeProps = {
560
- nodes: SidebarNode[];
561
- route: WebAppRoute;
562
- navigate: (route: WebAppRoute) => void;
563
- collapsed: SidebarCollapsedState;
564
- toggleCollapsed: (id: string, isCollapsed: boolean) => void;
565
- searchActive: boolean;
566
- level?: number;
567
- parentKind?: SidebarTreeParentKind;
568
- };
569
-
570
- function sidebarIndentStyle(level: number, parentKind: SidebarTreeParentKind): { marginLeft?: string } | undefined {
571
- if (level <= 0) {
572
- return undefined;
573
- }
574
- const baseIndentRem = level * 0.375;
575
- const nestedSectionIndentRem = parentKind === "section" && level > 1 ? 0.875 : 0;
576
- return { marginLeft: `${baseIndentRem + nestedSectionIndentRem}rem` };
577
- }
578
-
579
- function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed, searchActive, level = 0, parentKind = "root" }: SidebarTreeProps) {
580
- const [contextMenu, setContextMenu] = useState<{ position: ContextMenuPosition; items: ActionMenuItem[]; title: string } | null>(null);
581
- return (
582
- <>
583
- {nodes.map((node) => {
584
- const hasChildren = Boolean(node.children?.length);
585
- const storedIsCollapsed = collapsed[node.id] ?? node.defaultCollapsed ?? false;
586
- const isCollapsed = searchActive && hasChildren ? false : storedIsCollapsed;
587
- const toggleAriaLabel = searchActive ? `Toggling unavailable during search for ${node.title}` : `${isCollapsed ? "Expand" : "Collapse"} ${node.title}`;
588
- const toggleNodeCollapsed = () => {
589
- if (!searchActive) {
590
- toggleCollapsed(node.id, storedIsCollapsed);
591
- }
592
- };
593
- if (node.type === "section") {
594
- return (
595
- <section className={`wapp-sidebar-section ${level === 0 ? "top" : "nested"}`} key={node.id}>
596
- <div className="wapp-sidebar-section-title" style={sidebarIndentStyle(level, parentKind)}>
597
- {hasChildren ? (
598
- <button type="button" aria-expanded={!isCollapsed} aria-label={toggleAriaLabel} disabled={searchActive} onClick={toggleNodeCollapsed}>
599
- <span>{isCollapsed ? "▶" : "▼"}</span>{node.title}
600
- </button>
601
- ) : (
602
- <div className="wapp-sidebar-section-label">{node.title}</div>
603
- )}
604
- {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}
605
- </div>
606
- {!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="section" /> : null}
607
- {!isCollapsed && !hasChildren && level === 0 ? <div className="wapp-sidebar-empty">No items.</div> : null}
608
- </section>
609
- );
610
- }
611
- const active = node.route?.view === route.view && Object.entries(node.route).every(([key, value]) => key === "view" || route[key] === value);
612
- return (
613
- <div className={`wapp-sidebar-item-wrap ${hasChildren ? "has-toggle" : ""}`} key={node.id} style={sidebarIndentStyle(level, parentKind)}>
614
- {hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={toggleAriaLabel} disabled={searchActive} onClick={toggleNodeCollapsed}>{isCollapsed ? "▶" : "▼"}</button> : null}
615
- <button
616
- type="button"
617
- className={`wapp-sidebar-item ${active ? "active" : ""}`}
618
- onClick={() => node.route && navigate(node.route)}
619
- onContextMenu={(event) => {
620
- if (!node.actions?.length) return;
621
- event.preventDefault();
622
- setContextMenu({ position: { x: event.clientX, y: event.clientY }, items: node.actions, title: node.title });
623
- }}
624
- >
625
- <span>
626
- <strong>{node.title}</strong>
627
- {node.subtitle ? <small>{node.subtitle}</small> : null}
628
- </span>
629
- {node.badge ? <Badge variant={node.badgeVariant} className="wapp-sidebar-badge" title={node.badge} aria-label={node.badge}> </Badge> : null}
630
- </button>
631
- {!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}
632
- </div>
633
- );
634
- })}
635
- <ContextMenu items={contextMenu?.items ?? []} position={contextMenu?.position ?? null} ariaLabel={contextMenu ? `Actions for ${contextMenu.title}` : "Actions"} onClose={() => setContextMenu(null)} />
636
- </>
637
- );
638
- }
639
-
640
- function renderSettingsActions(actions: SettingsRow["actions"]): ReactNode {
641
- if (!actions) return null;
642
- if (!Array.isArray(actions)) return actions;
643
- return actions.map((action) => (
644
- <Button key={action.id} type="button" variant={action.variant} disabled={action.disabled} onClick={action.onAction}>{action.label}</Button>
645
- ));
646
- }
647
-
648
- function StructuredSettingsSection({ section }: { section: SettingsSection }) {
649
- if (!section.rows?.length && section.render) {
650
- return <section className="wapp-custom-settings-section">{section.render()}</section>;
651
- }
652
-
653
- return (
654
- <FormSection title={section.title} description={section.description}>
655
- {section.rows?.map((row) => {
656
- const actions = renderSettingsActions(row.actions);
657
- if (row.danger) {
658
- return <DangerZone key={row.id} title={row.title} description={row.description} actions={actions} />;
659
- }
660
- return (
661
- <div className="wapp-settings-row" key={row.id}>
662
- <div>
663
- <strong>{row.title}</strong>
664
- {row.description ? <p>{row.description}</p> : null}
665
- {row.content ? <div className="wapp-settings-row-content">{row.content}</div> : null}
666
- </div>
667
- {actions ? <div className="wapp-row-actions">{actions}</div> : null}
668
- </div>
669
- );
670
- })}
671
- {section.render?.()}
672
- </FormSection>
673
- );
674
- }
675
-
676
- function isScopeVisible(scope: "user" | "admin" | "owner" | undefined, config: WebAppConfigResponse): boolean {
677
- if (!scope || scope === "user") return Boolean(config.currentUser);
678
- if (scope === "admin") return Boolean(config.currentUser?.isAdmin);
679
- return Boolean(config.currentUser?.isOwner);
680
- }
681
-
682
- function UserManagement({ config }: { config: WebAppConfigResponse }) {
683
- const [users, setUsers] = useState<WebAppUserSummary[]>([]);
684
- const [username, setUsername] = useState("");
685
- const [role, setRole] = useState<WebAppUserRole>("user");
686
- const [setupLink, setSetupLink] = useState<string>();
687
- const [userToDelete, setUserToDelete] = useState<WebAppUserSummary>();
688
- const [error, setError] = useState<string>();
689
-
690
- const refreshUsers = useCallback(async () => {
691
- if (config.userManagement.canManageUsers) {
692
- setUsers(await json<WebAppUserSummary[]>("/api/users"));
693
- }
694
- }, [config.userManagement.canManageUsers]);
695
-
696
- useEffect(() => void refreshUsers().catch(() => undefined), [refreshUsers]);
697
-
698
- async function createUser() {
699
- try {
700
- setError(undefined);
701
- const result = await json<CreatedUserResponse>("/api/users", { method: "POST", body: JSON.stringify({ username, role }) });
702
- setUsername("");
703
- setRole("user");
704
- setSetupLink(result.setupLink.url);
705
- await refreshUsers();
706
- } catch (err) {
707
- setError(err instanceof Error ? err.message : String(err));
708
- }
709
- }
710
-
711
- async function updateRole(user: WebAppUserSummary, nextRole: WebAppUserRole) {
712
- try {
713
- setError(undefined);
714
- await json(`/api/users/${encodeURIComponent(user.id)}/role`, { method: "PATCH", body: JSON.stringify({ role: nextRole }) });
715
- await refreshUsers();
716
- } catch (err) {
717
- setError(err instanceof Error ? err.message : String(err));
718
- }
719
- }
720
-
721
- async function resetUser(user: WebAppUserSummary) {
722
- try {
723
- setError(undefined);
724
- const result = await json<CreatedUserResponse>(`/api/users/${encodeURIComponent(user.id)}/reset`, { method: "POST", body: "{}" });
725
- setSetupLink(result.setupLink.url);
726
- await refreshUsers();
727
- } catch (err) {
728
- setError(err instanceof Error ? err.message : String(err));
729
- }
730
- }
731
-
732
- async function deleteUser(user: WebAppUserSummary) {
733
- try {
734
- setError(undefined);
735
- await json(`/api/users/${encodeURIComponent(user.id)}`, { method: "DELETE" });
736
- setUserToDelete(undefined);
737
- await refreshUsers();
738
- } catch (err) {
739
- setError(err instanceof Error ? err.message : String(err));
740
- }
741
- }
742
-
743
- if (!config.userManagement.canManageUsers) return null;
744
- return (
745
- <FormSection title="User management" description="Create users, reset passkeys, and manage admin access.">
746
- {error ? <p className="wapp-error">{error}</p> : null}
747
- <br />
748
- <div className="wapp-settings-row stacked">
749
- <div>
750
- <TextField label="Username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} placeholder="new-user" />
751
- <br />
752
- <SelectField label="Role" value={role} onChange={(event) => setRole(event.currentTarget.value as WebAppUserRole)}>
753
- <option value="user">User</option>
754
- <option value="admin">Admin</option>
755
- </SelectField>
756
- </div>
757
- <br />
758
- <div className="wapp-row-actions"><Button type="button" variant="primary" disabled={!username.trim()} onClick={() => void createUser()}>Create setup link</Button></div>
759
- {setupLink ? <code className="wapp-token">{setupLink}</code> : null}
760
- </div>
761
- <br />
762
- <div className="wapp-list">
763
- {users.map((user) => (
764
- <div className="wapp-list-row" key={user.id}>
765
- <span>
766
- <strong>{user.username}</strong>
767
- <small>{user.role} · passkey {user.passkeyConfigured ? "configured" : "pending"} · created {user.createdAt}</small>
768
- </span>
769
- <div className="wapp-row-actions">
770
- {user.role !== "owner" ? (
771
- <select className="wapp-inline-select" aria-label={`Role for ${user.username}`} value={user.role} onChange={(event) => void updateRole(user, event.currentTarget.value as WebAppUserRole)}>
772
- <option value="user">User</option>
773
- <option value="admin">Admin</option>
774
- </select>
775
- ) : <Badge variant="success">Owner</Badge>}
776
- {user.role !== "owner" ? <Button type="button" onClick={() => void resetUser(user)}>Reset</Button> : null}
777
- <Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() => setUserToDelete(user)}>Delete</Button>
778
- </div>
779
- </div>
780
- ))}
781
- </div>
782
- <ConfirmDialog
783
- open={Boolean(userToDelete)}
784
- title="Delete user?"
785
- message={userToDelete ? `This permanently deletes "${userToDelete.username}" and revokes their setup links, API keys, passkeys and device sessions.` : ""}
786
- confirmLabel="Delete user"
787
- danger
788
- onCancel={() => setUserToDelete(undefined)}
789
- onConfirm={() => userToDelete && void deleteUser(userToDelete)}
790
- />
791
- </FormSection>
792
- );
793
- }
794
-
795
- const KILL_SERVER_COUNTDOWN_SECONDS = 15;
796
-
797
- function computeProgressPercent(countdown: number, total: number): number {
798
- return total <= 0 ? 0 : (countdown / total) * 100;
799
- }
800
-
801
- function useCountdownReload(active: boolean, onComplete: () => void, durationSeconds = KILL_SERVER_COUNTDOWN_SECONDS): { countdown: number; progressPercent: number } {
802
- const [countdown, setCountdown] = useState(durationSeconds);
803
-
804
- useEffect(() => {
805
- if (!active) {
806
- setCountdown(durationSeconds);
807
- return;
808
- }
809
-
810
- setCountdown(durationSeconds);
811
- const interval = window.setInterval(() => {
812
- setCountdown((previous) => {
813
- const next = previous - 1;
814
- if (next <= 0) {
815
- window.clearInterval(interval);
816
- onComplete();
817
- return 0;
818
- }
819
- return next;
820
- });
821
- }, 1000);
822
-
823
- return () => window.clearInterval(interval);
824
- }, [active, durationSeconds, onComplete]);
825
-
826
- return {
827
- countdown,
828
- progressPercent: computeProgressPercent(countdown, durationSeconds),
829
- };
830
- }
831
-
832
- function SettingsView({ config, refresh, customSections, theme, setTheme }: { config: WebAppConfigResponse; refresh: () => Promise<void>; customSections: SettingsSection[]; theme: ThemePreference; setTheme: (theme: ThemePreference) => void }) {
833
- const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([]);
834
- const [authSessions, setAuthSessions] = useState<AuthSessionSummary[]>([]);
835
- const [createdToken, setCreatedToken] = useState<string>();
836
- const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeySummary>();
837
- const [authSessionToRevoke, setAuthSessionToRevoke] = useState<AuthSessionSummary>();
838
- const [confirmDeletePasskey, setConfirmDeletePasskey] = useState(false);
839
- const [confirmKillServer, setConfirmKillServer] = useState(false);
840
- const [killRequested, setKillRequested] = useState(false);
841
- const [error, setError] = useState<string>();
842
- const reloadPage = useCallback(() => window.location.reload(), []);
843
- const { countdown, progressPercent } = useCountdownReload(killRequested, reloadPage);
844
-
845
- const refreshApiKeys = useCallback(async () => {
846
- if (config.apiKeys.enabled) {
847
- setApiKeys(await json<ApiKeySummary[]>("/api/api-keys"));
848
- }
849
- }, [config.apiKeys.enabled]);
850
-
851
- const refreshAuthSessions = useCallback(async () => {
852
- if (config.deviceAuth.enabled) {
853
- setAuthSessions(await json<AuthSessionSummary[]>("/api/auth/sessions"));
854
- }
855
- }, [config.deviceAuth.enabled]);
856
-
857
- useEffect(() => void refreshApiKeys().catch(() => undefined), [refreshApiKeys]);
858
- useEffect(() => void refreshAuthSessions().catch(() => undefined), [refreshAuthSessions]);
859
-
860
- async function createKey() {
861
- const result = await json<CreatedApiKeyResponse>("/api/api-keys", { method: "POST", body: JSON.stringify({ name: "Browser key", scopes: ["*"] }) });
862
- setCreatedToken(result.token);
863
- await refreshApiKeys();
864
- }
865
-
866
- async function deleteKey(id: string) {
867
- try {
868
- setError(undefined);
869
- await json(`/api/api-keys/${encodeURIComponent(id)}`, { method: "DELETE" });
870
- setApiKeyToDelete(undefined);
871
- await refreshApiKeys();
872
- } catch (err) {
873
- setError(err instanceof Error ? err.message : String(err));
874
- }
875
- }
876
-
877
- async function setupPasskey() {
878
- try {
879
- setError(undefined);
880
- const options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/passkey-auth/owner-setup/options", { method: "POST", body: "{}" });
881
- const credential = await startRegistration({ optionsJSON: options as never });
882
- await json("/api/passkey-auth/owner-setup/verify", { method: "POST", body: JSON.stringify(credential) });
883
- await refresh();
884
- } catch (err) {
885
- setError(err instanceof Error ? err.message : String(err));
886
- }
887
- }
888
-
889
- async function logout() {
890
- await json("/api/passkey-auth/logout", { method: "POST", body: "{}" });
891
- await refresh();
892
- }
893
-
894
- async function deleteConfiguredPasskey() {
895
- await json("/api/passkey-auth/passkey", { method: "DELETE" });
896
- setConfirmDeletePasskey(false);
897
- await refresh();
898
- }
899
-
900
- async function revokeAuthSession(session: AuthSessionSummary) {
901
- try {
902
- setError(undefined);
903
- await json(`/api/auth/sessions/${session.id}`, { method: "DELETE" });
904
- setAuthSessionToRevoke(undefined);
905
- await refreshAuthSessions();
906
- } catch (err) {
907
- setError(err instanceof Error ? err.message : String(err));
908
- }
909
- }
910
-
911
- async function killServer() {
912
- setError(undefined);
913
- setConfirmKillServer(false);
914
- const response = await fetch("/api/server/kill", { method: "POST" });
915
- if (!response.ok) {
916
- throw new Error("Failed to kill server. Please try again.");
917
- }
918
- setKillRequested(true);
919
- }
920
-
921
- return (
922
- <div className="wapp-settings">
923
- {error ? <p className="wapp-error">{error}</p> : null}
924
- {config.currentUser ? (
925
- <FormSection title="Account">
926
- <p>Signed in as <strong>{config.currentUser.username}</strong> <Badge variant={config.currentUser.isAdmin ? "success" : "disabled"}>{config.currentUser.role}</Badge></p>
927
- </FormSection>
928
- ) : null}
929
- <FormSection title="Display Settings">
930
- <SelectField label="Theme" value={theme} onChange={(event) => {
931
- const next = event.currentTarget.value as ThemePreference;
932
- setTheme(next);
933
- void json("/api/preferences/theme", { method: "PUT", body: JSON.stringify({ theme: next }) }).catch((err) => setError(String(err)));
934
- }}>
935
- <option value="system">System</option>
936
- <option value="light">Light</option>
937
- <option value="dark">Dark</option>
938
- </SelectField>
939
- </FormSection>
940
-
941
- {config.currentUser?.isAdmin ? (
942
- <FormSection title="Developer Settings">
943
- <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)}>
944
- {["trace", "debug", "info", "warn", "error"].map((level) => <option key={level} value={level}>{level}</option>)}
945
- </SelectField>
946
- </FormSection>
947
- ) : null}
948
-
949
- <FormSection title="Security">
950
- <div className="wapp-settings-row">
951
- <div>
952
- <strong>Passkey</strong>
953
- <p>{config.passkeyAuth.passkeyConfigured ? "Your passkey protects this account." : "No passkey configured yet."}</p>
954
- </div>
955
- <div className="wapp-row-actions">
956
- {config.passkeyAuth.passkeyConfigured ? (
957
- <>
958
- <Button type="button" onClick={() => void logout()}>Logout</Button>
959
- <Button type="button" variant="danger" onClick={() => setConfirmDeletePasskey(true)}>Delete passkey</Button>
960
- </>
961
- ) : (
962
- config.currentUser?.isOwner ? <Button type="button" variant="primary" onClick={() => void setupPasskey()}>Set up passkey</Button> : null
963
- )}
964
- </div>
965
- </div>
966
- {config.apiKeys.enabled ? (
967
- <div className="wapp-settings-row stacked">
968
- <div>
969
- <strong>API keys</strong>
970
- <p>Create bearer tokens for scripts and agents.</p>
971
- </div>
972
- <div className="wapp-row-actions"><Button type="button" onClick={() => void createKey().catch((err) => setError(String(err)))}>Create API key</Button></div>
973
- {createdToken ? <code className="wapp-token">{createdToken}</code> : null}
974
- {apiKeys.length ? (
975
- <div className="wapp-list">
976
- {apiKeys.map((key) => (
977
- <div className="wapp-list-row" key={key.id}>
978
- <span><strong>{key.name}</strong><small>{key.scopes.join(", ")} · {key.createdAt}</small></span>
979
- <Button type="button" variant="danger" onClick={() => setApiKeyToDelete(key)}>Delete</Button>
980
- </div>
981
- ))}
982
- </div>
983
- ) : null}
984
- </div>
985
- ) : null}
986
- {config.deviceAuth.enabled ? (
987
- <div className="wapp-settings-row stacked">
988
- <div>
989
- <strong>Device auth sessions</strong>
990
- <p>Refresh-token sessions created through the device flow.</p>
991
- </div>
992
- <div className="wapp-list">
993
- {authSessions.length ? authSessions.map((session) => (
994
- <div className="wapp-list-row" key={session.id}>
995
- <span><strong>{session.clientId}</strong><small>{session.scope} · {session.updatedAt}</small></span>
996
- <Button type="button" variant="danger" onClick={() => setAuthSessionToRevoke(session)}>Revoke</Button>
997
- </div>
998
- )) : <EmptyState title="No device sessions" />}
999
- </div>
1000
- </div>
1001
- ) : null}
1002
- </FormSection>
1003
-
1004
- <UserManagement config={config} />
1005
-
1006
- {config.currentUser?.isAdmin ? (
1007
- <FormSection title="Server operations">
1008
- <div className="wapp-settings-row">
1009
- <div>
1010
- <strong>Kill server</strong>
1011
- <p>Stop the server process. If your deployment restarts it automatically, the app will come back after a moment.</p>
1012
- {killRequested ? (
1013
- <div className="wapp-shutdown-countdown" aria-live="polite">
1014
- <div className="wapp-shutdown-message">Server is shutting down... Reloading in {countdown}s</div>
1015
- <div className="wapp-shutdown-progress" aria-hidden="true">
1016
- <div className="wapp-shutdown-progress-bar" style={{ width: `${progressPercent}%` }} />
1017
- </div>
1018
- </div>
1019
- ) : null}
1020
- </div>
1021
- <div className="wapp-row-actions">
1022
- <Button type="button" variant="danger" disabled={killRequested} onClick={() => setConfirmKillServer(true)}>Kill server</Button>
1023
- </div>
1024
- </div>
1025
- </FormSection>
1026
- ) : null}
1027
-
1028
- {customSections.filter((section) => isScopeVisible(section.scope, config)).map((section) => ({
1029
- ...section,
1030
- rows: section.rows?.filter((row) => isScopeVisible(row.scope, config)),
1031
- })).map((section) => <StructuredSettingsSection key={section.id} section={section} />)}
1032
-
1033
- <FormSection title="About">
1034
- <p>{config.appName} {config.version}</p>
1035
- </FormSection>
1036
-
1037
- <ConfirmDialog
1038
- open={Boolean(apiKeyToDelete)}
1039
- title="Delete API key?"
1040
- message={apiKeyToDelete ? `This permanently deletes "${apiKeyToDelete.name}". Scripts and agents using this token will stop working.` : ""}
1041
- confirmLabel="Delete API key"
1042
- danger
1043
- onCancel={() => setApiKeyToDelete(undefined)}
1044
- onConfirm={() => apiKeyToDelete && void deleteKey(apiKeyToDelete.id)}
1045
- />
1046
- <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()} />
1047
- <ConfirmDialog
1048
- open={Boolean(authSessionToRevoke)}
1049
- title="Revoke device session?"
1050
- message={authSessionToRevoke ? `This revokes the active "${authSessionToRevoke.clientId}" refresh-token session.` : ""}
1051
- confirmLabel="Revoke session"
1052
- danger
1053
- onCancel={() => setAuthSessionToRevoke(undefined)}
1054
- onConfirm={() => authSessionToRevoke && void revokeAuthSession(authSessionToRevoke)}
1055
- />
1056
- <ConfirmDialog
1057
- open={confirmKillServer}
1058
- title="Kill server?"
1059
- message="Are you sure you want to kill the server?"
1060
- confirmLabel="Kill server"
1061
- danger
1062
- onCancel={() => setConfirmKillServer(false)}
1063
- onConfirm={() => void killServer().catch((err) => setError(String(err)))}
1064
- />
1065
- </div>
1066
- );
46
+ return left.view === right.view && Object.entries(left).every(([key, value]) => key === "view" || right[key] === value);
1067
47
  }
1068
48
 
1069
49
  export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRouteChange, settings, version }: WebAppRootProps) {
1070
- useMobileViewportHeight();
50
+ const isMobile = useMobileBreakpoint();
51
+ useMobileViewportHeight(isMobile);
1071
52
  const { config, error, refresh } = useConfig();
1072
53
  const { route, navigate } = useRoute(homeRoute);
1073
54
  const { theme, setTheme } = useTheme();
55
+ const [themeLoading, setThemeLoading] = useState(false);
56
+ const [themeLoadError, setThemeLoadError] = useState<Error>();
1074
57
  const [search, setSearch] = useState("");
1075
58
  const sidebarSearchId = useId();
1076
59
  const sidebarSearchInputRef = useRef<HTMLInputElement>(null);
1077
60
  const [sidebarOpen, setSidebarOpen] = useState(false);
1078
61
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
1079
62
  const sidebarTreeState = useSidebarCollapsedState(appName);
63
+ useMobileSidebarSwipe(isMobile, sidebarOpen, setSidebarOpen);
1080
64
  const toggleSidebarCollapsed = useCallback(() => {
1081
65
  setSidebarCollapsed((current) => {
1082
66
  const nextCollapsed = !current;
@@ -1141,34 +125,28 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1141
125
  }, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, sidebar.pinning, sidebarSearchActive]);
1142
126
  const activeActionNodes = useMemo(() => augmentPinningActions(baseNodes), [augmentPinningActions, baseNodes]);
1143
127
 
1144
- useEffect(() => {
1145
- if (!config?.currentUser) return;
1146
- void json<{ theme: ThemePreference }>("/api/preferences/theme")
1147
- .then((result) => setTheme(result.theme))
1148
- .catch(() => undefined);
1149
- }, [config?.currentUser?.id, setTheme]);
1150
-
1151
- useEffect(() => {
1152
- function handleSidebarShortcut(event: KeyboardEvent) {
1153
- if (
1154
- event.key.toLowerCase() !== "b"
1155
- || event.altKey
1156
- || event.shiftKey
1157
- || event.ctrlKey === event.metaKey
1158
- || event.isComposing
1159
- || event.repeat
1160
- || isSidebarShortcutEditableTarget(event.target)
1161
- ) {
1162
- return;
1163
- }
128
+ const retryThemeLoad = useCallback(async () => {
129
+ if (!config?.currentUser) {
130
+ setThemeLoading(false);
131
+ setThemeLoadError(undefined);
132
+ return;
133
+ }
1164
134
 
1165
- event.preventDefault();
1166
- 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);
1167
144
  }
145
+ }, [config?.currentUser?.id, setTheme]);
1168
146
 
1169
- document.addEventListener("keydown", handleSidebarShortcut);
1170
- return () => document.removeEventListener("keydown", handleSidebarShortcut);
1171
- }, [toggleSidebarCollapsed]);
147
+ useEffect(() => {
148
+ void retryThemeLoad();
149
+ }, [retryThemeLoad]);
1172
150
 
1173
151
  useEffect(() => {
1174
152
  onRouteChange?.(route);
@@ -1193,7 +171,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1193
171
  const effectiveVersion = version ?? config.version;
1194
172
  let view: ReactNode;
1195
173
  if (route.view === "settings") {
1196
- 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} />;
1197
175
  } else {
1198
176
  const registeredView = routes[route.view];
1199
177
  view = typeof registeredView === "function"
@@ -1201,9 +179,8 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1201
179
  : registeredView ?? <EmptyState title="Not found" description={`No view registered for ${route.view}.`} />;
1202
180
  }
1203
181
 
1204
- const topActions = sidebar.topActions?.slice(0, 2) ?? [];
1205
182
  const defaultTitle = route.view === "settings" ? "Settings" : route.view === homeRoute.view ? appName : route.view.replace(/-/g, " ");
1206
- const headerContext = { route, defaultTitle };
183
+ const headerContext: HeaderContext = { route, defaultTitle };
1207
184
  const activeSidebarNode = flattenSidebarItems(activeActionNodes).find((node) => routeMatches(node.route, route));
1208
185
  const activeSidebarActions = activeSidebarNode?.actions ?? [];
1209
186
  const headerActions = [
@@ -1213,73 +190,33 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1213
190
  const headerTitle = header?.renderTitle?.(headerContext) ?? defaultTitle;
1214
191
  const primaryHeaderActions = header?.renderActions?.(headerContext);
1215
192
  const headerActionLabel = typeof headerTitle === "string" ? headerTitle : defaultTitle;
1216
- const sidebarToggleLabel = sidebarCollapsed ? "Show sidebar" : "Collapse sidebar";
1217
- const navigateFromSidebarHeader = (nextRoute: WebAppRoute) => {
1218
- navigate(nextRoute);
1219
- setSidebarOpen(false);
1220
- };
1221
- const runSidebarHeaderAction = (action: SidebarAction) => {
1222
- if (action.onAction) {
1223
- action.onAction();
1224
- } else if (action.route) {
1225
- navigate(action.route);
1226
- }
1227
- setSidebarOpen(false);
1228
- };
1229
193
 
1230
194
  return (
1231
- <main className={`wapp-shell ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${sidebarOpen ? "sidebar-open" : ""}`}>
1232
- <div className="wapp-mobile-backdrop" onClick={() => setSidebarOpen(false)} />
1233
- <aside className="wapp-sidebar">
1234
- <div className="wapp-sidebar-header">
1235
- <button type="button" className="wapp-brand" onClick={() => navigateFromSidebarHeader(homeRoute)}>{appName}</button>
1236
- <div className="wapp-sidebar-actions">
1237
- {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>)}
1238
- <IconButton className="wapp-sidebar-top-button" title="Settings" aria-label="Open settings" active={route.view === "settings"} onClick={() => navigateFromSidebarHeader({ view: "settings" })}><Icon name="settings" /></IconButton>
1239
- <IconButton className="wapp-sidebar-top-button" title={sidebarToggleLabel} aria-label={sidebarToggleLabel} onClick={toggleSidebarCollapsed}><Icon name="sidebar" /></IconButton>
1240
- </div>
1241
- </div>
1242
- <div className="wapp-sidebar-scroll">
1243
- {sidebarSearchEnabled ? (
1244
- <div className="wapp-search">
1245
- <label className="sr-only" htmlFor={sidebarSearchId}>Search</label>
1246
- <div className={`wapp-search-input-wrap${search.length > 0 ? " wapp-search-input-wrap--clearable" : ""}`}>
1247
- <input id={sidebarSearchId} ref={sidebarSearchInputRef} value={search} onInput={(event) => setSearch(event.currentTarget.value)} placeholder="Search" />
1248
- {search.length > 0 ? (
1249
- <button
1250
- type="button"
1251
- className="wapp-search-clear"
1252
- aria-label="Clear search"
1253
- onClick={() => {
1254
- setSearch("");
1255
- sidebarSearchInputRef.current?.focus();
1256
- }}
1257
- >
1258
- ×
1259
- </button>
1260
- ) : null}
1261
- </div>
1262
- </div>
1263
- ) : null}
1264
- <SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} collapsed={sidebarTreeState.collapsed} toggleCollapsed={sidebarTreeState.toggleCollapsed} searchActive={sidebarSearchActive} />
1265
- <div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
1266
- </div>
1267
- </aside>
1268
- <section className="wapp-main">
1269
- <header className="wapp-main-header">
1270
- <div className="wapp-main-header-title">
1271
- {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>}
1272
- <h1>{headerTitle}</h1>
1273
- </div>
1274
- {primaryHeaderActions || headerActions.length ? (
1275
- <div className="wapp-main-header-actions">
1276
- {primaryHeaderActions}
1277
- {headerActions.length ? <ActionMenu items={headerActions} ariaLabel={`Actions for ${headerActionLabel}`} /> : null}
1278
- </div>
1279
- ) : null}
1280
- </header>
1281
- <div className="wapp-main-content">{view}</div>
1282
- </section>
1283
- </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
+ />
1284
221
  );
1285
222
  }