@pablozaiden/webapp 0.0.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/LICENSE +21 -0
- package/README.md +26 -0
- package/docs/auth-validation.md +34 -0
- package/docs/auth.md +35 -0
- package/docs/deployment.md +49 -0
- package/docs/getting-started.md +62 -0
- package/docs/github-actions.md +421 -0
- package/docs/realtime.md +61 -0
- package/docs/release.md +64 -0
- package/docs/server.md +45 -0
- package/docs/settings.md +42 -0
- package/docs/sidebar.md +80 -0
- package/docs/ui-guidelines.md +55 -0
- package/package.json +60 -0
- package/src/build/build-binary.ts +42 -0
- package/src/build/index.ts +1 -0
- package/src/contracts/index.ts +86 -0
- package/src/server/auth/api-keys.ts +61 -0
- package/src/server/auth/crypto.ts +34 -0
- package/src/server/auth/device-auth.ts +324 -0
- package/src/server/auth/passkeys.ts +280 -0
- package/src/server/auth/request-origin.ts +33 -0
- package/src/server/auth/sqlite-store.ts +301 -0
- package/src/server/auth/store.ts +91 -0
- package/src/server/auth/types.ts +25 -0
- package/src/server/create-web-app-server.ts +447 -0
- package/src/server/index.ts +7 -0
- package/src/server/logger.ts +44 -0
- package/src/server/realtime/bus.ts +104 -0
- package/src/server/responses.ts +54 -0
- package/src/server/routes.ts +67 -0
- package/src/server/runtime-config.ts +101 -0
- package/src/server/same-origin.ts +35 -0
- package/src/types.d.ts +11 -0
- package/src/web/WebAppRoot.tsx +706 -0
- package/src/web/components/index.tsx +471 -0
- package/src/web/index.ts +5 -0
- package/src/web/realtime/useRealtime.ts +189 -0
- package/src/web/sidebar/types.ts +45 -0
- package/src/web/styles.css +1295 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
3
|
+
import type { ApiKeySummary, AuthSessionSummary, CreatedApiKeyResponse, DeviceVerificationDetails, PasskeyAuthStatusResponse, ThemePreference, WebAppConfigResponse } from "../contracts";
|
|
4
|
+
import { ActionMenu, Badge, Button, ConfirmDialog, ContextMenu, DangerZone, EmptyState, FormSection, IconButton, Panel, SelectField, TextField, type ContextMenuPosition } from "./components";
|
|
5
|
+
import type { ActionMenuItem, SidebarAction, SidebarBuildContext, SidebarNode, WebAppRoute } from "./sidebar/types";
|
|
6
|
+
|
|
7
|
+
type SettingsSection = {
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
rows?: SettingsRow[];
|
|
12
|
+
render?: () => ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type SettingsRow = {
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
content?: ReactNode;
|
|
20
|
+
actions?: ReactNode | SettingsAction[];
|
|
21
|
+
danger?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type SettingsAction = {
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
variant?: "default" | "primary" | "danger" | "ghost";
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
onAction: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type HeaderContext = {
|
|
33
|
+
route: WebAppRoute;
|
|
34
|
+
defaultTitle: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface WebAppRootProps {
|
|
38
|
+
appName: string;
|
|
39
|
+
homeRoute: WebAppRoute;
|
|
40
|
+
sidebar: {
|
|
41
|
+
topActions?: SidebarAction[];
|
|
42
|
+
pinning?: false | {
|
|
43
|
+
sectionTitle?: string;
|
|
44
|
+
storageKey?: string;
|
|
45
|
+
};
|
|
46
|
+
getNodes: (ctx: SidebarBuildContext) => SidebarNode[];
|
|
47
|
+
};
|
|
48
|
+
routes: Record<string, ReactNode | ((route: WebAppRoute) => ReactNode)>;
|
|
49
|
+
header?: {
|
|
50
|
+
renderTitle?: (ctx: HeaderContext) => ReactNode;
|
|
51
|
+
getActions?: (ctx: HeaderContext) => ActionMenuItem[];
|
|
52
|
+
};
|
|
53
|
+
settings?: {
|
|
54
|
+
sections?: SettingsSection[];
|
|
55
|
+
};
|
|
56
|
+
version?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type StoredSidebarPin = {
|
|
60
|
+
id: string;
|
|
61
|
+
title: string;
|
|
62
|
+
subtitle?: string;
|
|
63
|
+
badge?: string;
|
|
64
|
+
badgeVariant?: SidebarNode["badgeVariant"];
|
|
65
|
+
route: WebAppRoute;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function routeToHash(route: WebAppRoute): string {
|
|
69
|
+
const params = new URLSearchParams();
|
|
70
|
+
for (const [key, value] of Object.entries(route)) {
|
|
71
|
+
if (key !== "view" && value !== undefined) {
|
|
72
|
+
params.set(key, String(value));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return `#/${route.view}${params.size ? `?${params.toString()}` : ""}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseRoute(defaultRoute: WebAppRoute): WebAppRoute {
|
|
79
|
+
const hash = window.location.hash.replace(/^#\/?/, "");
|
|
80
|
+
if (!hash) return defaultRoute;
|
|
81
|
+
const [view = defaultRoute.view, query = ""] = hash.split("?", 2);
|
|
82
|
+
const params = Object.fromEntries(new URLSearchParams(query).entries());
|
|
83
|
+
return { view: view.replace(/^\//, ""), ...params };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function useRoute(defaultRoute: WebAppRoute) {
|
|
87
|
+
const [route, setRoute] = useState(() => parseRoute(defaultRoute));
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const listener = () => setRoute(parseRoute(defaultRoute));
|
|
90
|
+
window.addEventListener("hashchange", listener);
|
|
91
|
+
return () => window.removeEventListener("hashchange", listener);
|
|
92
|
+
}, [defaultRoute]);
|
|
93
|
+
const navigate = useCallback((next: WebAppRoute) => {
|
|
94
|
+
window.location.hash = routeToHash(next);
|
|
95
|
+
setRoute(next);
|
|
96
|
+
}, []);
|
|
97
|
+
return { route, navigate };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function json<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
101
|
+
const response = await fetch(path, {
|
|
102
|
+
...init,
|
|
103
|
+
headers: {
|
|
104
|
+
"content-type": "application/json",
|
|
105
|
+
...init.headers,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
const data = await response.json().catch(() => ({})) as { message?: string; error?: string };
|
|
110
|
+
throw new Error(data.message ?? data.error ?? `Request failed with ${response.status}`);
|
|
111
|
+
}
|
|
112
|
+
return await response.json() as T;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function useConfig() {
|
|
116
|
+
const [config, setConfig] = useState<WebAppConfigResponse>();
|
|
117
|
+
const [error, setError] = useState<string>();
|
|
118
|
+
const refresh = useCallback(async () => {
|
|
119
|
+
try {
|
|
120
|
+
setConfig(await json<WebAppConfigResponse>("/api/config"));
|
|
121
|
+
setError(undefined);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
124
|
+
}
|
|
125
|
+
}, []);
|
|
126
|
+
useEffect(() => void refresh(), [refresh]);
|
|
127
|
+
return { config, error, refresh };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function useTheme() {
|
|
131
|
+
const [theme, setTheme] = useState<ThemePreference>(() => (localStorage.getItem("webapp.theme") as ThemePreference | null) ?? "system");
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const dark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
134
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
135
|
+
localStorage.setItem("webapp.theme", theme);
|
|
136
|
+
}, [theme]);
|
|
137
|
+
return { theme, setTheme };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function routeMatches(left: WebAppRoute | undefined, right: WebAppRoute): boolean {
|
|
141
|
+
if (!left) return false;
|
|
142
|
+
return left.view === right.view && Object.entries(left).every(([key, value]) => key === "view" || right[key] === value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function flattenSidebarItems(nodes: SidebarNode[]): SidebarNode[] {
|
|
146
|
+
return nodes.flatMap((node) => [
|
|
147
|
+
node,
|
|
148
|
+
...(node.children ? flattenSidebarItems(node.children) : []),
|
|
149
|
+
]).filter((node) => node.type === "item");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function pinStorageKey(appName: string, explicitKey?: string): string {
|
|
153
|
+
return explicitKey ?? `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.pins`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function toStoredPin(node: SidebarNode): StoredSidebarPin | undefined {
|
|
157
|
+
if (!node.route) return undefined;
|
|
158
|
+
return {
|
|
159
|
+
id: node.pinId ?? node.id,
|
|
160
|
+
title: node.title,
|
|
161
|
+
subtitle: node.subtitle,
|
|
162
|
+
badge: node.badge,
|
|
163
|
+
badgeVariant: node.badgeVariant,
|
|
164
|
+
route: node.route,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function useSidebarPins(appName: string, storageKey?: string) {
|
|
169
|
+
const key = pinStorageKey(appName, storageKey);
|
|
170
|
+
const [pins, setPins] = useState<StoredSidebarPin[]>(() => {
|
|
171
|
+
try {
|
|
172
|
+
const raw = localStorage.getItem(key);
|
|
173
|
+
return raw ? JSON.parse(raw) as StoredSidebarPin[] : [];
|
|
174
|
+
} catch {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
localStorage.setItem(key, JSON.stringify(pins));
|
|
181
|
+
}, [key, pins]);
|
|
182
|
+
|
|
183
|
+
const pinIds = useMemo(() => new Set(pins.map((pin) => pin.id)), [pins]);
|
|
184
|
+
const pin = useCallback((node: SidebarNode) => {
|
|
185
|
+
const stored = toStoredPin(node);
|
|
186
|
+
if (!stored) return;
|
|
187
|
+
setPins((current) => [...current.filter((item) => item.id !== stored.id), stored]);
|
|
188
|
+
}, []);
|
|
189
|
+
const unpin = useCallback((id: string) => {
|
|
190
|
+
setPins((current) => current.filter((item) => item.id !== id));
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
return { pins, pinIds, pin, unpin };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function Icon({ name }: { name: "settings" | "sidebar" | "plus" | "home" | "search" | "bolt" | "chat" | "code" | "refresh" }) {
|
|
197
|
+
const common = { "aria-hidden": true, viewBox: "0 0 24 24", className: "wapp-svg" };
|
|
198
|
+
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>;
|
|
199
|
+
if (name === "sidebar") return <svg {...common}><rect x="4" y="5" width="16" height="14" rx="2" /><path d="M10 5v14" /></svg>;
|
|
200
|
+
if (name === "plus") return <svg {...common}><path d="M12 5v14M5 12h14" /></svg>;
|
|
201
|
+
if (name === "bolt") return <svg {...common}><path d="m13 2-8 12h7l-1 8 8-12h-7l1-8Z" /></svg>;
|
|
202
|
+
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>;
|
|
203
|
+
if (name === "code") return <svg {...common}><path d="m16 18 6-6-6-6M8 6l-6 6 6 6" /></svg>;
|
|
204
|
+
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>;
|
|
205
|
+
return <svg {...common}><path d="M4 10.5 12 4l8 6.5V20H5v-7h14" /></svg>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function ActionIcon({ icon }: { icon?: ReactNode }) {
|
|
209
|
+
if (icon === "+") return <Icon name="plus" />;
|
|
210
|
+
if (icon === "↯") return <Icon name="bolt" />;
|
|
211
|
+
if (icon === "chat") return <Icon name="chat" />;
|
|
212
|
+
if (icon === "code") return <Icon name="code" />;
|
|
213
|
+
if (!icon) return <Icon name="bolt" />;
|
|
214
|
+
return <>{icon}</>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function PasskeyAuthScreen({ status, refresh }: { status: PasskeyAuthStatusResponse; refresh: () => Promise<void> }) {
|
|
218
|
+
const [error, setError] = useState<string>();
|
|
219
|
+
const [busy, setBusy] = useState(false);
|
|
220
|
+
async function register() {
|
|
221
|
+
setBusy(true);
|
|
222
|
+
setError(undefined);
|
|
223
|
+
try {
|
|
224
|
+
const options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/passkey-auth/registration/options", { method: "POST", body: "{}" });
|
|
225
|
+
const credential = await startRegistration({ optionsJSON: options as never });
|
|
226
|
+
await json("/api/passkey-auth/registration/verify", { method: "POST", body: JSON.stringify(credential) });
|
|
227
|
+
await refresh();
|
|
228
|
+
} catch (err) {
|
|
229
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
230
|
+
} finally {
|
|
231
|
+
setBusy(false);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function login() {
|
|
235
|
+
setBusy(true);
|
|
236
|
+
setError(undefined);
|
|
237
|
+
try {
|
|
238
|
+
const options = await json<PublicKeyCredentialRequestOptionsJSON>("/api/passkey-auth/authentication/options", { method: "POST", body: "{}" });
|
|
239
|
+
const credential = await startAuthentication({ optionsJSON: options as never });
|
|
240
|
+
await json("/api/passkey-auth/authentication/verify", { method: "POST", body: JSON.stringify(credential) });
|
|
241
|
+
await refresh();
|
|
242
|
+
} catch (err) {
|
|
243
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
244
|
+
} finally {
|
|
245
|
+
setBusy(false);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return (
|
|
249
|
+
<main className="wapp-auth-screen">
|
|
250
|
+
<Panel title="Passkey required" description={status.passkeyConfigured ? "Authenticate to continue." : "Set up the first passkey for this app."}>
|
|
251
|
+
{error ? <p className="wapp-error">{error}</p> : null}
|
|
252
|
+
<div className="wapp-row-actions">
|
|
253
|
+
{status.passkeyConfigured ? <Button type="button" variant="primary" disabled={busy} onClick={() => void login()}>Authenticate</Button> : <Button type="button" variant="primary" disabled={busy} onClick={() => void register()}>Set up passkey</Button>}
|
|
254
|
+
</div>
|
|
255
|
+
</Panel>
|
|
256
|
+
</main>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function DeviceVerificationScreen() {
|
|
261
|
+
const params = new URLSearchParams(window.location.search);
|
|
262
|
+
const initialCode = params.get("user_code") ?? "";
|
|
263
|
+
const [userCode, setUserCode] = useState(initialCode);
|
|
264
|
+
const [details, setDetails] = useState<DeviceVerificationDetails>();
|
|
265
|
+
const [error, setError] = useState<string>();
|
|
266
|
+
const [busy, setBusy] = useState(false);
|
|
267
|
+
|
|
268
|
+
const load = useCallback(async () => {
|
|
269
|
+
if (!userCode.trim()) {
|
|
270
|
+
setDetails(undefined);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
setBusy(true);
|
|
274
|
+
setError(undefined);
|
|
275
|
+
try {
|
|
276
|
+
setDetails(await json<DeviceVerificationDetails>(`/api/auth/device/verification?user_code=${encodeURIComponent(userCode.trim())}`));
|
|
277
|
+
} catch (err) {
|
|
278
|
+
setDetails(undefined);
|
|
279
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
280
|
+
} finally {
|
|
281
|
+
setBusy(false);
|
|
282
|
+
}
|
|
283
|
+
}, [userCode]);
|
|
284
|
+
|
|
285
|
+
useEffect(() => void load(), [load]);
|
|
286
|
+
|
|
287
|
+
async function decide(action: "approve" | "deny") {
|
|
288
|
+
if (!details) return;
|
|
289
|
+
setBusy(true);
|
|
290
|
+
setError(undefined);
|
|
291
|
+
try {
|
|
292
|
+
setDetails(await json<DeviceVerificationDetails>(`/api/auth/device/${action}`, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
body: JSON.stringify({ user_code: details.userCode }),
|
|
295
|
+
}));
|
|
296
|
+
} catch (err) {
|
|
297
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
298
|
+
} finally {
|
|
299
|
+
setBusy(false);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<main className="wapp-device-screen">
|
|
305
|
+
<Panel title="Authorize device" description="Enter the code shown by the CLI or external device.">
|
|
306
|
+
<div className="wapp-device-stack">
|
|
307
|
+
<TextField label="User code" value={userCode} onChange={(event) => setUserCode(event.currentTarget.value.toUpperCase())} placeholder="ABCD-2345" />
|
|
308
|
+
{error ? <p className="wapp-error">{error}</p> : null}
|
|
309
|
+
{details ? (
|
|
310
|
+
<div className="wapp-device-card">
|
|
311
|
+
<div><strong>Client</strong><span>{details.clientId}</span></div>
|
|
312
|
+
<div><strong>Scope</strong><span>{details.scope}</span></div>
|
|
313
|
+
<div><strong>Status</strong><Badge variant={details.status === "approved" ? "success" : details.status === "denied" ? "error" : details.status === "consumed" ? "disabled" : "warning"}>{details.status}</Badge></div>
|
|
314
|
+
<div><strong>Expires</strong><span>{details.expiresAt}</span></div>
|
|
315
|
+
</div>
|
|
316
|
+
) : null}
|
|
317
|
+
<div className="wapp-row-actions">
|
|
318
|
+
<Button type="button" variant="ghost" disabled={busy || !details || details.status !== "pending"} onClick={() => void decide("deny")}>Deny</Button>
|
|
319
|
+
<Button type="button" variant="primary" disabled={busy || !details || details.status !== "pending"} onClick={() => void decide("approve")}>Approve</Button>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
</Panel>
|
|
323
|
+
</main>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function SidebarTree({ nodes, route, navigate, level = 0 }: { nodes: SidebarNode[]; route: WebAppRoute; navigate: (route: WebAppRoute) => void; level?: number }) {
|
|
328
|
+
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
|
329
|
+
const [contextMenu, setContextMenu] = useState<{ position: ContextMenuPosition; items: ActionMenuItem[]; title: string } | null>(null);
|
|
330
|
+
return (
|
|
331
|
+
<>
|
|
332
|
+
{nodes.map((node) => {
|
|
333
|
+
const hasChildren = Boolean(node.children?.length);
|
|
334
|
+
const isCollapsed = collapsed[node.id] ?? node.defaultCollapsed ?? false;
|
|
335
|
+
if (node.type === "section") {
|
|
336
|
+
return (
|
|
337
|
+
<section className="wapp-sidebar-section" key={node.id}>
|
|
338
|
+
<div className="wapp-sidebar-section-title" style={{ marginLeft: level ? `${level * 0.375}rem` : undefined }}>
|
|
339
|
+
<button type="button" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => setCollapsed((current) => ({ ...current, [node.id]: !isCollapsed }))}>
|
|
340
|
+
<span>{isCollapsed ? "▶" : "▼"}</span>{node.title}
|
|
341
|
+
</button>
|
|
342
|
+
{node.action ? <button type="button" className="wapp-sidebar-action" onClick={node.action.onAction ?? (() => node.action?.route && navigate(node.action.route))}>{node.action.label ?? "New"}</button> : null}
|
|
343
|
+
</div>
|
|
344
|
+
{!isCollapsed && node.children ? <SidebarTree nodes={node.children} route={route} navigate={navigate} level={level + 1} /> : null}
|
|
345
|
+
{!isCollapsed && !node.children?.length ? <div className="wapp-sidebar-empty">No items.</div> : null}
|
|
346
|
+
</section>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const active = node.route?.view === route.view && Object.entries(node.route).every(([key, value]) => key === "view" || route[key] === value);
|
|
350
|
+
return (
|
|
351
|
+
<div className={`wapp-sidebar-item-wrap ${hasChildren ? "has-toggle" : ""}`} key={node.id} style={{ marginLeft: level ? `${level * 0.375}rem` : undefined }}>
|
|
352
|
+
{hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => setCollapsed((current) => ({ ...current, [node.id]: !isCollapsed }))}>{isCollapsed ? "▶" : "▼"}</button> : null}
|
|
353
|
+
<button
|
|
354
|
+
type="button"
|
|
355
|
+
className={`wapp-sidebar-item ${active ? "active" : ""}`}
|
|
356
|
+
onClick={() => node.route && navigate(node.route)}
|
|
357
|
+
onContextMenu={(event) => {
|
|
358
|
+
if (!node.actions?.length) return;
|
|
359
|
+
event.preventDefault();
|
|
360
|
+
setContextMenu({ position: { x: event.clientX, y: event.clientY }, items: node.actions, title: node.title });
|
|
361
|
+
}}
|
|
362
|
+
>
|
|
363
|
+
<span>
|
|
364
|
+
<strong>{node.title}</strong>
|
|
365
|
+
{node.subtitle ? <small>{node.subtitle}</small> : null}
|
|
366
|
+
</span>
|
|
367
|
+
{node.badge ? <Badge variant={node.badgeVariant}>{node.badge}</Badge> : null}
|
|
368
|
+
</button>
|
|
369
|
+
{!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} level={level + 1} /></div> : null}
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
})}
|
|
373
|
+
<ContextMenu items={contextMenu?.items ?? []} position={contextMenu?.position ?? null} ariaLabel={contextMenu ? `Actions for ${contextMenu.title}` : "Actions"} onClose={() => setContextMenu(null)} />
|
|
374
|
+
</>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function renderSettingsActions(actions: SettingsRow["actions"]): ReactNode {
|
|
379
|
+
if (!actions) return null;
|
|
380
|
+
if (!Array.isArray(actions)) return actions;
|
|
381
|
+
return actions.map((action) => (
|
|
382
|
+
<Button key={action.id} type="button" variant={action.variant} disabled={action.disabled} onClick={action.onAction}>{action.label}</Button>
|
|
383
|
+
));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function StructuredSettingsSection({ section }: { section: SettingsSection }) {
|
|
387
|
+
return (
|
|
388
|
+
<FormSection title={section.title} description={section.description}>
|
|
389
|
+
{section.rows?.map((row) => {
|
|
390
|
+
const actions = renderSettingsActions(row.actions);
|
|
391
|
+
if (row.danger) {
|
|
392
|
+
return <DangerZone key={row.id} title={row.title} description={row.description} actions={actions} />;
|
|
393
|
+
}
|
|
394
|
+
return (
|
|
395
|
+
<div className="wapp-settings-row" key={row.id}>
|
|
396
|
+
<div>
|
|
397
|
+
<strong>{row.title}</strong>
|
|
398
|
+
{row.description ? <p>{row.description}</p> : null}
|
|
399
|
+
{row.content ? <div className="wapp-settings-row-content">{row.content}</div> : null}
|
|
400
|
+
</div>
|
|
401
|
+
{actions ? <div className="wapp-row-actions">{actions}</div> : null}
|
|
402
|
+
</div>
|
|
403
|
+
);
|
|
404
|
+
})}
|
|
405
|
+
{section.render?.()}
|
|
406
|
+
</FormSection>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function SettingsView({ config, refresh, customSections, theme, setTheme }: { config: WebAppConfigResponse; refresh: () => Promise<void>; customSections: SettingsSection[]; theme: ThemePreference; setTheme: (theme: ThemePreference) => void }) {
|
|
411
|
+
const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([]);
|
|
412
|
+
const [authSessions, setAuthSessions] = useState<AuthSessionSummary[]>([]);
|
|
413
|
+
const [createdToken, setCreatedToken] = useState<string>();
|
|
414
|
+
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeySummary>();
|
|
415
|
+
const [confirmDeletePasskey, setConfirmDeletePasskey] = useState(false);
|
|
416
|
+
const [killRequested, setKillRequested] = useState(false);
|
|
417
|
+
const [error, setError] = useState<string>();
|
|
418
|
+
|
|
419
|
+
const refreshApiKeys = useCallback(async () => {
|
|
420
|
+
if (config.apiKeys.enabled) {
|
|
421
|
+
setApiKeys(await json<ApiKeySummary[]>("/api/api-keys"));
|
|
422
|
+
}
|
|
423
|
+
}, [config.apiKeys.enabled]);
|
|
424
|
+
|
|
425
|
+
const refreshAuthSessions = useCallback(async () => {
|
|
426
|
+
if (config.deviceAuth.enabled) {
|
|
427
|
+
setAuthSessions(await json<AuthSessionSummary[]>("/api/auth/sessions"));
|
|
428
|
+
}
|
|
429
|
+
}, [config.deviceAuth.enabled]);
|
|
430
|
+
|
|
431
|
+
useEffect(() => void refreshApiKeys().catch(() => undefined), [refreshApiKeys]);
|
|
432
|
+
useEffect(() => void refreshAuthSessions().catch(() => undefined), [refreshAuthSessions]);
|
|
433
|
+
|
|
434
|
+
async function createKey() {
|
|
435
|
+
const result = await json<CreatedApiKeyResponse>("/api/api-keys", { method: "POST", body: JSON.stringify({ name: "Browser key", scopes: ["*"] }) });
|
|
436
|
+
setCreatedToken(result.token);
|
|
437
|
+
await refreshApiKeys();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function deleteKey(id: string) {
|
|
441
|
+
try {
|
|
442
|
+
setError(undefined);
|
|
443
|
+
await json(`/api/api-keys/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
444
|
+
setApiKeyToDelete(undefined);
|
|
445
|
+
await refreshApiKeys();
|
|
446
|
+
} catch (err) {
|
|
447
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function setupPasskey() {
|
|
452
|
+
try {
|
|
453
|
+
setError(undefined);
|
|
454
|
+
const options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/passkey-auth/registration/options", { method: "POST", body: "{}" });
|
|
455
|
+
const credential = await startRegistration({ optionsJSON: options as never });
|
|
456
|
+
await json("/api/passkey-auth/registration/verify", { method: "POST", body: JSON.stringify(credential) });
|
|
457
|
+
await refresh();
|
|
458
|
+
} catch (err) {
|
|
459
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function logout() {
|
|
464
|
+
await json("/api/passkey-auth/logout", { method: "POST", body: "{}" });
|
|
465
|
+
await refresh();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function deleteConfiguredPasskey() {
|
|
469
|
+
await json("/api/passkey-auth/passkey", { method: "DELETE" });
|
|
470
|
+
setConfirmDeletePasskey(false);
|
|
471
|
+
await refresh();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function killServer() {
|
|
475
|
+
setKillRequested(true);
|
|
476
|
+
await fetch("/api/server/kill", { method: "POST" });
|
|
477
|
+
setTimeout(() => window.location.reload(), 3500);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<div className="wapp-settings">
|
|
482
|
+
{error ? <p className="wapp-error">{error}</p> : null}
|
|
483
|
+
<FormSection title="Display Settings">
|
|
484
|
+
<SelectField label="Theme" value={theme} onChange={(event) => setTheme(event.currentTarget.value as ThemePreference)}>
|
|
485
|
+
<option value="system">System</option>
|
|
486
|
+
<option value="light">Light</option>
|
|
487
|
+
<option value="dark">Dark</option>
|
|
488
|
+
</SelectField>
|
|
489
|
+
</FormSection>
|
|
490
|
+
|
|
491
|
+
<FormSection title="Developer Settings">
|
|
492
|
+
<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)}>
|
|
493
|
+
{["trace", "debug", "info", "warn", "error"].map((level) => <option key={level} value={level}>{level}</option>)}
|
|
494
|
+
</SelectField>
|
|
495
|
+
</FormSection>
|
|
496
|
+
|
|
497
|
+
<FormSection title="Security">
|
|
498
|
+
<div className="wapp-settings-row">
|
|
499
|
+
<div>
|
|
500
|
+
<strong>Passkey</strong>
|
|
501
|
+
<p>{config.passkeyAuth.passkeyConfigured ? "Passkey protection is configured." : "No passkey configured yet."}</p>
|
|
502
|
+
</div>
|
|
503
|
+
<div className="wapp-row-actions">
|
|
504
|
+
{config.passkeyAuth.passkeyConfigured ? (
|
|
505
|
+
<>
|
|
506
|
+
<Button type="button" onClick={() => void logout()}>Logout</Button>
|
|
507
|
+
<Button type="button" variant="danger" onClick={() => setConfirmDeletePasskey(true)}>Delete passkey</Button>
|
|
508
|
+
</>
|
|
509
|
+
) : (
|
|
510
|
+
<Button type="button" variant="primary" onClick={() => void setupPasskey()}>Set up passkey</Button>
|
|
511
|
+
)}
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
{config.apiKeys.enabled ? (
|
|
515
|
+
<div className="wapp-settings-row stacked">
|
|
516
|
+
<div>
|
|
517
|
+
<strong>API keys</strong>
|
|
518
|
+
<p>Create bearer tokens for scripts and agents.</p>
|
|
519
|
+
</div>
|
|
520
|
+
<div className="wapp-row-actions"><Button type="button" onClick={() => void createKey().catch((err) => setError(String(err)))}>Create API key</Button></div>
|
|
521
|
+
{createdToken ? <code className="wapp-token">{createdToken}</code> : null}
|
|
522
|
+
{apiKeys.length ? (
|
|
523
|
+
<div className="wapp-list">
|
|
524
|
+
{apiKeys.map((key) => (
|
|
525
|
+
<div className="wapp-list-row" key={key.id}>
|
|
526
|
+
<span><strong>{key.name}</strong><small>{key.scopes.join(", ")} · {key.createdAt}</small></span>
|
|
527
|
+
<Button type="button" variant="danger" onClick={() => setApiKeyToDelete(key)}>Delete</Button>
|
|
528
|
+
</div>
|
|
529
|
+
))}
|
|
530
|
+
</div>
|
|
531
|
+
) : null}
|
|
532
|
+
</div>
|
|
533
|
+
) : null}
|
|
534
|
+
{config.deviceAuth.enabled ? (
|
|
535
|
+
<div className="wapp-settings-row stacked">
|
|
536
|
+
<div>
|
|
537
|
+
<strong>Device auth sessions</strong>
|
|
538
|
+
<p>Refresh-token sessions created through the device flow.</p>
|
|
539
|
+
</div>
|
|
540
|
+
<div className="wapp-list">
|
|
541
|
+
{authSessions.length ? authSessions.map((session) => (
|
|
542
|
+
<div className="wapp-list-row" key={session.id}>
|
|
543
|
+
<span><strong>{session.clientId}</strong><small>{session.scope} · {session.active ? "active" : "inactive"} · {session.updatedAt}</small></span>
|
|
544
|
+
<Button type="button" variant="danger" disabled={!session.active} onClick={() => void json(`/api/auth/sessions/${session.id}`, { method: "DELETE" }).then(refreshAuthSessions)}>Revoke</Button>
|
|
545
|
+
</div>
|
|
546
|
+
)) : <EmptyState title="No device sessions" />}
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
) : null}
|
|
550
|
+
</FormSection>
|
|
551
|
+
|
|
552
|
+
<FormSection title="Server operations">
|
|
553
|
+
{killRequested ? <p className="wapp-notice">Server is shutting down. Reloading soon...</p> : null}
|
|
554
|
+
<Button type="button" variant="danger" onClick={() => void killServer().catch((err) => setError(String(err)))}>Kill server</Button>
|
|
555
|
+
</FormSection>
|
|
556
|
+
|
|
557
|
+
{customSections.map((section) => <StructuredSettingsSection key={section.id} section={section} />)}
|
|
558
|
+
|
|
559
|
+
<FormSection title="About">
|
|
560
|
+
<p>{config.appName} {config.version}</p>
|
|
561
|
+
</FormSection>
|
|
562
|
+
|
|
563
|
+
<ConfirmDialog
|
|
564
|
+
open={Boolean(apiKeyToDelete)}
|
|
565
|
+
title="Delete API key?"
|
|
566
|
+
message={apiKeyToDelete ? `This permanently deletes "${apiKeyToDelete.name}". Scripts and agents using this token will stop working.` : ""}
|
|
567
|
+
confirmLabel="Delete API key"
|
|
568
|
+
danger
|
|
569
|
+
onCancel={() => setApiKeyToDelete(undefined)}
|
|
570
|
+
onConfirm={() => apiKeyToDelete && void deleteKey(apiKeyToDelete.id)}
|
|
571
|
+
/>
|
|
572
|
+
<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()} />
|
|
573
|
+
</div>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settings, version }: WebAppRootProps) {
|
|
578
|
+
const { config, error, refresh } = useConfig();
|
|
579
|
+
const { route, navigate } = useRoute(homeRoute);
|
|
580
|
+
const { theme, setTheme } = useTheme();
|
|
581
|
+
const [search, setSearch] = useState("");
|
|
582
|
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
583
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
584
|
+
const pinningEnabled = sidebar.pinning !== false;
|
|
585
|
+
const sidebarPins = useSidebarPins(appName, sidebar.pinning ? sidebar.pinning.storageKey : undefined);
|
|
586
|
+
const baseNodes = useMemo(() => sidebar.getNodes({ search: "" }), [sidebar]);
|
|
587
|
+
const filteredNodes = useMemo(() => sidebar.getNodes({ search }), [sidebar, search]);
|
|
588
|
+
const allPinnableItems = useMemo(() => flattenSidebarItems(baseNodes).filter((node) => node.pinnable && node.route), [baseNodes]);
|
|
589
|
+
const currentPins = useMemo(() => {
|
|
590
|
+
const byId = new Map(allPinnableItems.map((node) => [node.pinId ?? node.id, node]));
|
|
591
|
+
return sidebarPins.pins.map((pin) => {
|
|
592
|
+
const current = byId.get(pin.id);
|
|
593
|
+
return current ? toStoredPin(current) ?? pin : pin;
|
|
594
|
+
});
|
|
595
|
+
}, [allPinnableItems, sidebarPins.pins]);
|
|
596
|
+
const pinningActionFor = useCallback((node: SidebarNode): ActionMenuItem | undefined => {
|
|
597
|
+
if (!pinningEnabled || !node.pinnable || !node.route) return undefined;
|
|
598
|
+
const id = node.pinId ?? node.id;
|
|
599
|
+
const pinned = sidebarPins.pinIds.has(id);
|
|
600
|
+
return pinned
|
|
601
|
+
? { id: "unpin", label: "Unpin from sidebar", onAction: () => sidebarPins.unpin(id) }
|
|
602
|
+
: { id: "pin", label: "Pin to sidebar", onAction: () => sidebarPins.pin(node) };
|
|
603
|
+
}, [pinningEnabled, sidebarPins]);
|
|
604
|
+
const augmentPinningActions = useCallback((inputNodes: SidebarNode[]): SidebarNode[] => inputNodes.map((node) => {
|
|
605
|
+
const children = node.children ? augmentPinningActions(node.children) : undefined;
|
|
606
|
+
const pinAction = pinningActionFor(node);
|
|
607
|
+
return {
|
|
608
|
+
...node,
|
|
609
|
+
...(children ? { children } : {}),
|
|
610
|
+
...(pinAction ? { actions: [...(node.actions ?? []).filter((action) => action.id !== "pin" && action.id !== "unpin"), pinAction] } : {}),
|
|
611
|
+
};
|
|
612
|
+
}), [pinningActionFor]);
|
|
613
|
+
const nodes = useMemo(() => {
|
|
614
|
+
const augmented = augmentPinningActions(filteredNodes);
|
|
615
|
+
if (!pinningEnabled || search.trim() || currentPins.length === 0) return augmented;
|
|
616
|
+
const augmentedByPinId = new Map(flattenSidebarItems(augmentPinningActions(baseNodes)).map((node) => [node.pinId ?? node.id, node]));
|
|
617
|
+
const pinnedChildren = currentPins.map((pin) => ({
|
|
618
|
+
...(augmentedByPinId.get(pin.id) ?? {
|
|
619
|
+
type: "item" as const,
|
|
620
|
+
title: pin.title,
|
|
621
|
+
subtitle: pin.subtitle,
|
|
622
|
+
badge: pin.badge,
|
|
623
|
+
badgeVariant: pin.badgeVariant,
|
|
624
|
+
route: pin.route,
|
|
625
|
+
pinnable: true,
|
|
626
|
+
}),
|
|
627
|
+
id: `pinned:${pin.id}`,
|
|
628
|
+
pinId: pin.id,
|
|
629
|
+
children: undefined,
|
|
630
|
+
} satisfies SidebarNode));
|
|
631
|
+
return [
|
|
632
|
+
{ type: "section" as const, id: "framework:pinned", title: sidebar.pinning ? sidebar.pinning.sectionTitle ?? "Pinned" : "Pinned", children: pinnedChildren },
|
|
633
|
+
...augmented,
|
|
634
|
+
];
|
|
635
|
+
}, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, search, sidebar.pinning]);
|
|
636
|
+
|
|
637
|
+
if (error) {
|
|
638
|
+
return <main className="wapp-auth-screen"><Panel title="Unable to load app" description={error} /></main>;
|
|
639
|
+
}
|
|
640
|
+
if (!config) {
|
|
641
|
+
return <main className="wapp-auth-screen">Loading...</main>;
|
|
642
|
+
}
|
|
643
|
+
if (config.passkeyAuth.enabled && !config.passkeyAuth.passkeyDisabled && (!config.passkeyAuth.passkeyConfigured || (config.passkeyAuth.passkeyRequired && !config.passkeyAuth.authenticated))) {
|
|
644
|
+
return <PasskeyAuthScreen status={config.passkeyAuth} refresh={refresh} />;
|
|
645
|
+
}
|
|
646
|
+
if (config.deviceAuth.enabled && window.location.pathname === "/device") {
|
|
647
|
+
return <DeviceVerificationScreen />;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const effectiveVersion = version ?? config.version;
|
|
651
|
+
let view: ReactNode;
|
|
652
|
+
if (route.view === "settings") {
|
|
653
|
+
view = <SettingsView config={config} refresh={refresh} customSections={settings?.sections ?? []} theme={theme} setTheme={setTheme} />;
|
|
654
|
+
} else {
|
|
655
|
+
const registeredView = routes[route.view];
|
|
656
|
+
view = typeof registeredView === "function"
|
|
657
|
+
? registeredView(route)
|
|
658
|
+
: registeredView ?? <EmptyState title="Not found" description={`No view registered for ${route.view}.`} />;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const topActions = sidebar.topActions?.slice(0, 2) ?? [];
|
|
662
|
+
const defaultTitle = route.view === "settings" ? "Settings" : route.view === homeRoute.view ? appName : route.view.replace(/-/g, " ");
|
|
663
|
+
const headerContext = { route, defaultTitle };
|
|
664
|
+
const activePinnableNode = allPinnableItems.find((node) => routeMatches(node.route, route));
|
|
665
|
+
const activePinningAction = activePinnableNode ? pinningActionFor(activePinnableNode) : undefined;
|
|
666
|
+
const headerActions = [
|
|
667
|
+
...(header?.getActions?.(headerContext) ?? []),
|
|
668
|
+
...(activePinningAction ? [activePinningAction] : []),
|
|
669
|
+
];
|
|
670
|
+
const headerTitle = header?.renderTitle?.(headerContext) ?? defaultTitle;
|
|
671
|
+
|
|
672
|
+
return (
|
|
673
|
+
<main className={`wapp-shell ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${sidebarOpen ? "sidebar-open" : ""}`}>
|
|
674
|
+
<div className="wapp-mobile-backdrop" onClick={() => setSidebarOpen(false)} />
|
|
675
|
+
<aside className="wapp-sidebar">
|
|
676
|
+
<div className="wapp-sidebar-header">
|
|
677
|
+
<button type="button" className="wapp-brand" onClick={() => navigate(homeRoute)}>{appName}</button>
|
|
678
|
+
<div className="wapp-sidebar-actions">
|
|
679
|
+
{topActions.map((action) => <IconButton key={action.id} className="wapp-sidebar-top-button" title={action.title} aria-label={action.title} onClick={action.onAction ?? (() => action.route && navigate(action.route))}><ActionIcon icon={action.icon} /></IconButton>)}
|
|
680
|
+
<IconButton className="wapp-sidebar-top-button" title="Settings" aria-label="Open settings" active={route.view === "settings"} onClick={() => navigate({ view: "settings" })}><Icon name="settings" /></IconButton>
|
|
681
|
+
<IconButton className="wapp-sidebar-top-button" title="Collapse sidebar" aria-label="Collapse sidebar" onClick={() => { setSidebarCollapsed(true); setSidebarOpen(false); }}><Icon name="sidebar" /></IconButton>
|
|
682
|
+
</div>
|
|
683
|
+
</div>
|
|
684
|
+
<div className="wapp-sidebar-scroll">
|
|
685
|
+
<label className="wapp-search"><span className="sr-only">Search</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search" /></label>
|
|
686
|
+
<SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} />
|
|
687
|
+
</div>
|
|
688
|
+
<div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
|
|
689
|
+
</aside>
|
|
690
|
+
<section className="wapp-main">
|
|
691
|
+
<header className="wapp-main-header">
|
|
692
|
+
<div className="wapp-main-header-title">
|
|
693
|
+
{sidebarCollapsed ? <IconButton className="wapp-sidebar-top-button" aria-label="Show sidebar" title="Show sidebar" onClick={() => { setSidebarCollapsed(false); setSidebarOpen(true); }}><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>}
|
|
694
|
+
<h1>{headerTitle}</h1>
|
|
695
|
+
</div>
|
|
696
|
+
{headerActions.length ? (
|
|
697
|
+
<div className="wapp-main-header-actions">
|
|
698
|
+
<ActionMenu items={headerActions} ariaLabel={`Actions for ${defaultTitle}`} />
|
|
699
|
+
</div>
|
|
700
|
+
) : null}
|
|
701
|
+
</header>
|
|
702
|
+
<div className="wapp-main-content">{view}</div>
|
|
703
|
+
</section>
|
|
704
|
+
</main>
|
|
705
|
+
);
|
|
706
|
+
}
|