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