@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.
- package/README.md +11 -3
- package/docs/auth-validation.md +11 -0
- package/docs/auth.md +25 -0
- package/docs/cli.md +73 -3
- package/docs/deployment.md +84 -7
- package/docs/getting-started.md +69 -6
- package/docs/github-actions.md +39 -0
- package/docs/release.md +5 -5
- package/docs/server.md +34 -4
- package/docs/settings.md +5 -0
- package/docs/sidebar.md +2 -0
- package/docs/ui-guidelines.md +12 -4
- package/package.json +2 -4
- package/src/build/build-binary.ts +67 -24
- package/src/cli/api-command.ts +53 -16
- package/src/cli/credentials.ts +275 -4
- package/src/cli/device-auth.ts +83 -22
- package/src/cli/environment-auth.ts +57 -0
- package/src/cli/index.ts +1 -0
- package/src/package-resolution.ts +34 -0
- package/src/server/auth/device-auth.ts +2 -2
- package/src/server/auth/passkeys.ts +16 -16
- package/src/server/auth/request-origin.ts +97 -16
- package/src/server/authentication.ts +265 -0
- package/src/server/create-web-app-server.ts +85 -1391
- package/src/server/framework-endpoints.ts +451 -0
- package/src/server/public-route-dispatch.ts +85 -0
- package/src/server/request-schemas.ts +144 -0
- package/src/server/responses.ts +69 -3
- package/src/server/route-dispatch.ts +109 -0
- package/src/server/runtime-config.ts +67 -0
- package/src/server/same-origin.ts +1 -1
- package/src/server/server-lifecycle.ts +138 -0
- package/src/server/server-types.ts +87 -0
- package/src/server/web-document.ts +532 -0
- package/src/web/WebAppRoot.tsx +69 -1132
- package/src/web/api-client.ts +14 -4
- package/src/web/app-shell.tsx +198 -0
- package/src/web/auth-screens.tsx +186 -0
- package/src/web/components/index.tsx +10 -3
- package/src/web/index.ts +1 -0
- package/src/web/mobile-hooks.ts +194 -0
- package/src/web/mobile.ts +12 -0
- package/src/web/root-types.ts +61 -0
- package/src/web/routing.ts +61 -0
- package/src/web/settings/account-section.tsx +52 -0
- package/src/web/settings/resource-state.tsx +17 -0
- package/src/web/settings/security-section.tsx +119 -0
- package/src/web/settings/sessions-section.tsx +66 -0
- package/src/web/settings/settings-view.tsx +96 -0
- package/src/web/settings/shutdown-section.tsx +95 -0
- package/src/web/settings/user-management.tsx +123 -0
- package/src/web/settings-view.tsx +3 -0
- package/src/web/sidebar-state.ts +108 -0
- package/src/web/sidebar-tree.tsx +89 -0
- package/src/web/styles.css +78 -61
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -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 {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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)
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1145
|
-
if (!config?.currentUser)
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
1171
|
-
}, [
|
|
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
|
-
<
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
}
|