@pablozaiden/webapp 0.1.0 → 0.2.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 +4 -3
- package/docs/auth-validation.md +24 -5
- package/docs/auth.md +33 -9
- package/docs/cli.md +45 -0
- package/docs/deployment.md +3 -0
- package/docs/getting-started.md +47 -7
- package/docs/github-actions.md +8 -3
- package/docs/realtime.md +7 -2
- package/docs/server.md +89 -4
- package/docs/settings.md +15 -5
- package/docs/sidebar.md +3 -3
- package/docs/ui-guidelines.md +8 -2
- package/package.json +2 -1
- package/src/cli/api-command.ts +119 -0
- package/src/cli/credentials.ts +67 -0
- package/src/cli/device-auth.ts +179 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/runtime.ts +51 -0
- package/src/contracts/index.ts +53 -0
- package/src/server/auth/api-keys.ts +13 -8
- package/src/server/auth/device-auth.ts +34 -11
- package/src/server/auth/passkeys.ts +191 -73
- package/src/server/auth/sqlite-store.ts +394 -44
- package/src/server/auth/store.ts +57 -13
- package/src/server/auth/types.ts +7 -3
- package/src/server/auth/users.ts +83 -0
- package/src/server/create-web-app-server.ts +451 -54
- package/src/server/index.ts +1 -0
- package/src/server/realtime/bus.ts +8 -2
- package/src/server/route-catalog.ts +147 -0
- package/src/server/routes.ts +35 -4
- package/src/server/runtime-config.ts +1 -1
- package/src/web/WebAppRoot.tsx +336 -30
- package/src/web/api-client.ts +64 -0
- package/src/web/components/index.tsx +44 -11
- package/src/web/index.ts +2 -0
- package/src/web/realtime/useRealtime.ts +2 -2
- package/src/web/render.tsx +26 -0
- package/src/web/styles.css +104 -2
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
|
2
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";
|
|
3
|
+
import type { ApiKeySummary, AuthSessionSummary, CreatedApiKeyResponse, CreatedUserResponse, DeviceVerificationDetails, PasskeyAuthStatusResponse, ThemePreference, UserSetupDetails, WebAppConfigResponse, WebAppUserRole, WebAppUserSummary } from "../contracts";
|
|
4
|
+
import { ActionMenu, Badge, Button, ConfirmDialog, ContextMenu, DangerZone, Dialog, EmptyState, FormSection, IconButton, Panel, SelectField, TextField, type ContextMenuPosition } from "./components";
|
|
5
5
|
import type { ActionMenuItem, SidebarAction, SidebarBuildContext, SidebarNode, WebAppRoute } from "./sidebar/types";
|
|
6
6
|
|
|
7
7
|
type SettingsSection = {
|
|
8
8
|
id: string;
|
|
9
9
|
title: string;
|
|
10
10
|
description?: string;
|
|
11
|
+
scope?: "user" | "admin" | "owner";
|
|
11
12
|
rows?: SettingsRow[];
|
|
12
13
|
render?: () => ReactNode;
|
|
13
14
|
};
|
|
@@ -16,6 +17,7 @@ type SettingsRow = {
|
|
|
16
17
|
id: string;
|
|
17
18
|
title: string;
|
|
18
19
|
description?: string;
|
|
20
|
+
scope?: "user" | "admin" | "owner";
|
|
19
21
|
content?: ReactNode;
|
|
20
22
|
actions?: ReactNode | SettingsAction[];
|
|
21
23
|
danger?: boolean;
|
|
@@ -217,14 +219,23 @@ function ActionIcon({ icon }: { icon?: ReactNode }) {
|
|
|
217
219
|
function PasskeyAuthScreen({ status, refresh }: { status: PasskeyAuthStatusResponse; refresh: () => Promise<void> }) {
|
|
218
220
|
const [error, setError] = useState<string>();
|
|
219
221
|
const [busy, setBusy] = useState(false);
|
|
220
|
-
|
|
222
|
+
const [username, setUsername] = useState("");
|
|
223
|
+
const description = status.bootstrapRequired
|
|
224
|
+
? "Choose the username for the owner"
|
|
225
|
+
: status.ownerPasskeySetupRequired
|
|
226
|
+
? "The owner passkey was removed. Set it up again to continue."
|
|
227
|
+
: "Authenticate to continue.";
|
|
228
|
+
|
|
229
|
+
async function register(endpoint: "bootstrap" | "owner-setup") {
|
|
221
230
|
setBusy(true);
|
|
222
231
|
setError(undefined);
|
|
223
232
|
try {
|
|
224
|
-
const
|
|
233
|
+
const body = endpoint === "bootstrap" ? JSON.stringify({ username }) : "{}";
|
|
234
|
+
const options = await json<PublicKeyCredentialCreationOptionsJSON>(`/api/passkey-auth/${endpoint}/options`, { method: "POST", body });
|
|
225
235
|
const credential = await startRegistration({ optionsJSON: options as never });
|
|
226
|
-
await json(
|
|
236
|
+
await json(`/api/passkey-auth/${endpoint}/verify`, { method: "POST", body: JSON.stringify(credential) });
|
|
227
237
|
await refresh();
|
|
238
|
+
window.location.reload();
|
|
228
239
|
} catch (err) {
|
|
229
240
|
setError(err instanceof Error ? err.message : String(err));
|
|
230
241
|
} finally {
|
|
@@ -239,6 +250,7 @@ function PasskeyAuthScreen({ status, refresh }: { status: PasskeyAuthStatusRespo
|
|
|
239
250
|
const credential = await startAuthentication({ optionsJSON: options as never });
|
|
240
251
|
await json("/api/passkey-auth/authentication/verify", { method: "POST", body: JSON.stringify(credential) });
|
|
241
252
|
await refresh();
|
|
253
|
+
window.location.reload();
|
|
242
254
|
} catch (err) {
|
|
243
255
|
setError(err instanceof Error ? err.message : String(err));
|
|
244
256
|
} finally {
|
|
@@ -247,12 +259,69 @@ function PasskeyAuthScreen({ status, refresh }: { status: PasskeyAuthStatusRespo
|
|
|
247
259
|
}
|
|
248
260
|
return (
|
|
249
261
|
<main className="wapp-auth-screen">
|
|
250
|
-
<
|
|
262
|
+
<Dialog
|
|
263
|
+
className="wapp-auth-dialog"
|
|
264
|
+
title={status.bootstrapRequired ? "Create owner user" : status.ownerPasskeySetupRequired ? "Set up owner passkey" : "Passkey required"}
|
|
265
|
+
actions={status.bootstrapRequired ? (
|
|
266
|
+
<Button type="button" variant="primary" disabled={busy || !username.trim()} onClick={() => void register("bootstrap")}>Create owner</Button>
|
|
267
|
+
) : status.ownerPasskeySetupRequired ? (
|
|
268
|
+
<Button type="button" variant="primary" disabled={busy} onClick={() => void register("owner-setup")}>Set up owner passkey</Button>
|
|
269
|
+
) : (
|
|
270
|
+
<Button type="button" variant="primary" disabled={busy} onClick={() => void login()}>Authenticate</Button>
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
<p>{description}</p>
|
|
251
274
|
{error ? <p className="wapp-error">{error}</p> : null}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
275
|
+
{status.bootstrapRequired ? <><br /><TextField label="Username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} placeholder="owner" /></> : null}
|
|
276
|
+
</Dialog>
|
|
277
|
+
</main>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function UserSetupScreen({ refresh }: { refresh: () => Promise<void> }) {
|
|
282
|
+
const token = new URLSearchParams(window.location.search).get("token") ?? "";
|
|
283
|
+
const [details, setDetails] = useState<UserSetupDetails>();
|
|
284
|
+
const [error, setError] = useState<string>();
|
|
285
|
+
const [busy, setBusy] = useState(false);
|
|
286
|
+
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!token) {
|
|
289
|
+
setError("Setup token is missing");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
void json<UserSetupDetails>(`/api/user-setup?token=${encodeURIComponent(token)}`)
|
|
293
|
+
.then(setDetails)
|
|
294
|
+
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
|
295
|
+
}, [token]);
|
|
296
|
+
|
|
297
|
+
async function setup() {
|
|
298
|
+
setBusy(true);
|
|
299
|
+
setError(undefined);
|
|
300
|
+
try {
|
|
301
|
+
const options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/user-setup/options", { method: "POST", body: JSON.stringify({ token }) });
|
|
302
|
+
const credential = await startRegistration({ optionsJSON: options as never });
|
|
303
|
+
await json("/api/user-setup/verify", { method: "POST", body: JSON.stringify({ token, response: credential }) });
|
|
304
|
+
window.history.replaceState(null, "", "/");
|
|
305
|
+
await refresh();
|
|
306
|
+
window.location.reload();
|
|
307
|
+
} catch (err) {
|
|
308
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
309
|
+
} finally {
|
|
310
|
+
setBusy(false);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<main className="wapp-auth-screen">
|
|
316
|
+
<Dialog
|
|
317
|
+
className="wapp-auth-dialog"
|
|
318
|
+
title={details?.kind === "reset" ? "Reset passkey" : "Finish user setup"}
|
|
319
|
+
actions={<Button type="button" variant="primary" disabled={busy || !details} onClick={() => void setup()}>Set up passkey</Button>}
|
|
320
|
+
>
|
|
321
|
+
<p>{details ? `Username: ${details.username}` : "Loading setup link..."}</p>
|
|
322
|
+
{details ? <p className="wapp-muted">Role: {details.role}. This link expires at {details.expiresAt}.</p> : null}
|
|
323
|
+
{error ? <p className="wapp-error">{error}</p> : null}
|
|
324
|
+
</Dialog>
|
|
256
325
|
</main>
|
|
257
326
|
);
|
|
258
327
|
}
|
|
@@ -407,14 +476,174 @@ function StructuredSettingsSection({ section }: { section: SettingsSection }) {
|
|
|
407
476
|
);
|
|
408
477
|
}
|
|
409
478
|
|
|
479
|
+
function isScopeVisible(scope: "user" | "admin" | "owner" | undefined, config: WebAppConfigResponse): boolean {
|
|
480
|
+
if (!scope || scope === "user") return Boolean(config.currentUser);
|
|
481
|
+
if (scope === "admin") return Boolean(config.currentUser?.isAdmin);
|
|
482
|
+
return Boolean(config.currentUser?.isOwner);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function UserManagement({ config }: { config: WebAppConfigResponse }) {
|
|
486
|
+
const [users, setUsers] = useState<WebAppUserSummary[]>([]);
|
|
487
|
+
const [username, setUsername] = useState("");
|
|
488
|
+
const [role, setRole] = useState<WebAppUserRole>("user");
|
|
489
|
+
const [setupLink, setSetupLink] = useState<string>();
|
|
490
|
+
const [userToDelete, setUserToDelete] = useState<WebAppUserSummary>();
|
|
491
|
+
const [error, setError] = useState<string>();
|
|
492
|
+
|
|
493
|
+
const refreshUsers = useCallback(async () => {
|
|
494
|
+
if (config.userManagement.canManageUsers) {
|
|
495
|
+
setUsers(await json<WebAppUserSummary[]>("/api/users"));
|
|
496
|
+
}
|
|
497
|
+
}, [config.userManagement.canManageUsers]);
|
|
498
|
+
|
|
499
|
+
useEffect(() => void refreshUsers().catch(() => undefined), [refreshUsers]);
|
|
500
|
+
|
|
501
|
+
async function createUser() {
|
|
502
|
+
try {
|
|
503
|
+
setError(undefined);
|
|
504
|
+
const result = await json<CreatedUserResponse>("/api/users", { method: "POST", body: JSON.stringify({ username, role }) });
|
|
505
|
+
setUsername("");
|
|
506
|
+
setRole("user");
|
|
507
|
+
setSetupLink(result.setupLink.url);
|
|
508
|
+
await refreshUsers();
|
|
509
|
+
} catch (err) {
|
|
510
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function updateRole(user: WebAppUserSummary, nextRole: WebAppUserRole) {
|
|
515
|
+
try {
|
|
516
|
+
setError(undefined);
|
|
517
|
+
await json(`/api/users/${encodeURIComponent(user.id)}/role`, { method: "PATCH", body: JSON.stringify({ role: nextRole }) });
|
|
518
|
+
await refreshUsers();
|
|
519
|
+
} catch (err) {
|
|
520
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function resetUser(user: WebAppUserSummary) {
|
|
525
|
+
try {
|
|
526
|
+
setError(undefined);
|
|
527
|
+
const result = await json<CreatedUserResponse>(`/api/users/${encodeURIComponent(user.id)}/reset`, { method: "POST", body: "{}" });
|
|
528
|
+
setSetupLink(result.setupLink.url);
|
|
529
|
+
await refreshUsers();
|
|
530
|
+
} catch (err) {
|
|
531
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function deleteUser(user: WebAppUserSummary) {
|
|
536
|
+
try {
|
|
537
|
+
setError(undefined);
|
|
538
|
+
await json(`/api/users/${encodeURIComponent(user.id)}`, { method: "DELETE" });
|
|
539
|
+
setUserToDelete(undefined);
|
|
540
|
+
await refreshUsers();
|
|
541
|
+
} catch (err) {
|
|
542
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!config.userManagement.canManageUsers) return null;
|
|
547
|
+
return (
|
|
548
|
+
<FormSection title="User management" description="Create users, reset passkeys, and manage admin access.">
|
|
549
|
+
{error ? <p className="wapp-error">{error}</p> : null}
|
|
550
|
+
<br />
|
|
551
|
+
<div className="wapp-settings-row stacked">
|
|
552
|
+
<div>
|
|
553
|
+
<TextField label="Username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} placeholder="new-user" />
|
|
554
|
+
<br />
|
|
555
|
+
<SelectField label="Role" value={role} onChange={(event) => setRole(event.currentTarget.value as WebAppUserRole)}>
|
|
556
|
+
<option value="user">User</option>
|
|
557
|
+
<option value="admin">Admin</option>
|
|
558
|
+
</SelectField>
|
|
559
|
+
</div>
|
|
560
|
+
<br />
|
|
561
|
+
<div className="wapp-row-actions"><Button type="button" variant="primary" disabled={!username.trim()} onClick={() => void createUser()}>Create setup link</Button></div>
|
|
562
|
+
{setupLink ? <code className="wapp-token">{setupLink}</code> : null}
|
|
563
|
+
</div>
|
|
564
|
+
<br />
|
|
565
|
+
<div className="wapp-list">
|
|
566
|
+
{users.map((user) => (
|
|
567
|
+
<div className="wapp-list-row" key={user.id}>
|
|
568
|
+
<span>
|
|
569
|
+
<strong>{user.username}</strong>
|
|
570
|
+
<small>{user.role} · passkey {user.passkeyConfigured ? "configured" : "pending"} · created {user.createdAt}</small>
|
|
571
|
+
</span>
|
|
572
|
+
<div className="wapp-row-actions">
|
|
573
|
+
{user.role !== "owner" ? (
|
|
574
|
+
<select className="wapp-inline-select" aria-label={`Role for ${user.username}`} value={user.role} onChange={(event) => void updateRole(user, event.currentTarget.value as WebAppUserRole)}>
|
|
575
|
+
<option value="user">User</option>
|
|
576
|
+
<option value="admin">Admin</option>
|
|
577
|
+
</select>
|
|
578
|
+
) : <Badge variant="success">Owner</Badge>}
|
|
579
|
+
{user.role !== "owner" ? <Button type="button" onClick={() => void resetUser(user)}>Reset</Button> : null}
|
|
580
|
+
<Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() => setUserToDelete(user)}>Delete</Button>
|
|
581
|
+
</div>
|
|
582
|
+
</div>
|
|
583
|
+
))}
|
|
584
|
+
</div>
|
|
585
|
+
<ConfirmDialog
|
|
586
|
+
open={Boolean(userToDelete)}
|
|
587
|
+
title="Delete user?"
|
|
588
|
+
message={userToDelete ? `This permanently deletes "${userToDelete.username}" and revokes their setup links, API keys, passkeys and device sessions.` : ""}
|
|
589
|
+
confirmLabel="Delete user"
|
|
590
|
+
danger
|
|
591
|
+
onCancel={() => setUserToDelete(undefined)}
|
|
592
|
+
onConfirm={() => userToDelete && void deleteUser(userToDelete)}
|
|
593
|
+
/>
|
|
594
|
+
</FormSection>
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const KILL_SERVER_COUNTDOWN_SECONDS = 15;
|
|
599
|
+
|
|
600
|
+
function computeProgressPercent(countdown: number, total: number): number {
|
|
601
|
+
return total <= 0 ? 0 : (countdown / total) * 100;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function useCountdownReload(active: boolean, onComplete: () => void, durationSeconds = KILL_SERVER_COUNTDOWN_SECONDS): { countdown: number; progressPercent: number } {
|
|
605
|
+
const [countdown, setCountdown] = useState(durationSeconds);
|
|
606
|
+
|
|
607
|
+
useEffect(() => {
|
|
608
|
+
if (!active) {
|
|
609
|
+
setCountdown(durationSeconds);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
setCountdown(durationSeconds);
|
|
614
|
+
const interval = window.setInterval(() => {
|
|
615
|
+
setCountdown((previous) => {
|
|
616
|
+
const next = previous - 1;
|
|
617
|
+
if (next <= 0) {
|
|
618
|
+
window.clearInterval(interval);
|
|
619
|
+
onComplete();
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
622
|
+
return next;
|
|
623
|
+
});
|
|
624
|
+
}, 1000);
|
|
625
|
+
|
|
626
|
+
return () => window.clearInterval(interval);
|
|
627
|
+
}, [active, durationSeconds, onComplete]);
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
countdown,
|
|
631
|
+
progressPercent: computeProgressPercent(countdown, durationSeconds),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
410
635
|
function SettingsView({ config, refresh, customSections, theme, setTheme }: { config: WebAppConfigResponse; refresh: () => Promise<void>; customSections: SettingsSection[]; theme: ThemePreference; setTheme: (theme: ThemePreference) => void }) {
|
|
411
636
|
const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([]);
|
|
412
637
|
const [authSessions, setAuthSessions] = useState<AuthSessionSummary[]>([]);
|
|
413
638
|
const [createdToken, setCreatedToken] = useState<string>();
|
|
414
639
|
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeySummary>();
|
|
640
|
+
const [authSessionToRevoke, setAuthSessionToRevoke] = useState<AuthSessionSummary>();
|
|
415
641
|
const [confirmDeletePasskey, setConfirmDeletePasskey] = useState(false);
|
|
642
|
+
const [confirmKillServer, setConfirmKillServer] = useState(false);
|
|
416
643
|
const [killRequested, setKillRequested] = useState(false);
|
|
417
644
|
const [error, setError] = useState<string>();
|
|
645
|
+
const reloadPage = useCallback(() => window.location.reload(), []);
|
|
646
|
+
const { countdown, progressPercent } = useCountdownReload(killRequested, reloadPage);
|
|
418
647
|
|
|
419
648
|
const refreshApiKeys = useCallback(async () => {
|
|
420
649
|
if (config.apiKeys.enabled) {
|
|
@@ -451,9 +680,9 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
451
680
|
async function setupPasskey() {
|
|
452
681
|
try {
|
|
453
682
|
setError(undefined);
|
|
454
|
-
const options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/passkey-auth/
|
|
683
|
+
const options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/passkey-auth/owner-setup/options", { method: "POST", body: "{}" });
|
|
455
684
|
const credential = await startRegistration({ optionsJSON: options as never });
|
|
456
|
-
await json("/api/passkey-auth/
|
|
685
|
+
await json("/api/passkey-auth/owner-setup/verify", { method: "POST", body: JSON.stringify(credential) });
|
|
457
686
|
await refresh();
|
|
458
687
|
} catch (err) {
|
|
459
688
|
setError(err instanceof Error ? err.message : String(err));
|
|
@@ -471,34 +700,60 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
471
700
|
await refresh();
|
|
472
701
|
}
|
|
473
702
|
|
|
703
|
+
async function revokeAuthSession(session: AuthSessionSummary) {
|
|
704
|
+
try {
|
|
705
|
+
setError(undefined);
|
|
706
|
+
await json(`/api/auth/sessions/${session.id}`, { method: "DELETE" });
|
|
707
|
+
setAuthSessionToRevoke(undefined);
|
|
708
|
+
await refreshAuthSessions();
|
|
709
|
+
} catch (err) {
|
|
710
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
474
714
|
async function killServer() {
|
|
715
|
+
setError(undefined);
|
|
716
|
+
setConfirmKillServer(false);
|
|
717
|
+
const response = await fetch("/api/server/kill", { method: "POST" });
|
|
718
|
+
if (!response.ok) {
|
|
719
|
+
throw new Error("Failed to kill server. Please try again.");
|
|
720
|
+
}
|
|
475
721
|
setKillRequested(true);
|
|
476
|
-
await fetch("/api/server/kill", { method: "POST" });
|
|
477
|
-
setTimeout(() => window.location.reload(), 3500);
|
|
478
722
|
}
|
|
479
723
|
|
|
480
724
|
return (
|
|
481
725
|
<div className="wapp-settings">
|
|
482
726
|
{error ? <p className="wapp-error">{error}</p> : null}
|
|
727
|
+
{config.currentUser ? (
|
|
728
|
+
<FormSection title="Account">
|
|
729
|
+
<p>Signed in as <strong>{config.currentUser.username}</strong> <Badge variant={config.currentUser.isAdmin ? "success" : "disabled"}>{config.currentUser.role}</Badge></p>
|
|
730
|
+
</FormSection>
|
|
731
|
+
) : null}
|
|
483
732
|
<FormSection title="Display Settings">
|
|
484
|
-
<SelectField label="Theme" value={theme} onChange={(event) =>
|
|
733
|
+
<SelectField label="Theme" value={theme} onChange={(event) => {
|
|
734
|
+
const next = event.currentTarget.value as ThemePreference;
|
|
735
|
+
setTheme(next);
|
|
736
|
+
void json("/api/preferences/theme", { method: "PUT", body: JSON.stringify({ theme: next }) }).catch((err) => setError(String(err)));
|
|
737
|
+
}}>
|
|
485
738
|
<option value="system">System</option>
|
|
486
739
|
<option value="light">Light</option>
|
|
487
740
|
<option value="dark">Dark</option>
|
|
488
741
|
</SelectField>
|
|
489
742
|
</FormSection>
|
|
490
743
|
|
|
491
|
-
|
|
492
|
-
<
|
|
493
|
-
{
|
|
494
|
-
|
|
495
|
-
|
|
744
|
+
{config.currentUser?.isAdmin ? (
|
|
745
|
+
<FormSection title="Developer Settings">
|
|
746
|
+
<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)}>
|
|
747
|
+
{["trace", "debug", "info", "warn", "error"].map((level) => <option key={level} value={level}>{level}</option>)}
|
|
748
|
+
</SelectField>
|
|
749
|
+
</FormSection>
|
|
750
|
+
) : null}
|
|
496
751
|
|
|
497
752
|
<FormSection title="Security">
|
|
498
753
|
<div className="wapp-settings-row">
|
|
499
754
|
<div>
|
|
500
755
|
<strong>Passkey</strong>
|
|
501
|
-
<p>{config.passkeyAuth.passkeyConfigured ? "
|
|
756
|
+
<p>{config.passkeyAuth.passkeyConfigured ? "Your passkey protects this account." : "No passkey configured yet."}</p>
|
|
502
757
|
</div>
|
|
503
758
|
<div className="wapp-row-actions">
|
|
504
759
|
{config.passkeyAuth.passkeyConfigured ? (
|
|
@@ -507,7 +762,7 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
507
762
|
<Button type="button" variant="danger" onClick={() => setConfirmDeletePasskey(true)}>Delete passkey</Button>
|
|
508
763
|
</>
|
|
509
764
|
) : (
|
|
510
|
-
<Button type="button" variant="primary" onClick={() => void setupPasskey()}>Set up passkey</Button>
|
|
765
|
+
config.currentUser?.isOwner ? <Button type="button" variant="primary" onClick={() => void setupPasskey()}>Set up passkey</Button> : null
|
|
511
766
|
)}
|
|
512
767
|
</div>
|
|
513
768
|
</div>
|
|
@@ -541,7 +796,7 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
541
796
|
{authSessions.length ? authSessions.map((session) => (
|
|
542
797
|
<div className="wapp-list-row" key={session.id}>
|
|
543
798
|
<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={() =>
|
|
799
|
+
<Button type="button" variant="danger" disabled={!session.active} onClick={() => setAuthSessionToRevoke(session)}>Revoke</Button>
|
|
545
800
|
</div>
|
|
546
801
|
)) : <EmptyState title="No device sessions" />}
|
|
547
802
|
</div>
|
|
@@ -549,12 +804,34 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
549
804
|
) : null}
|
|
550
805
|
</FormSection>
|
|
551
806
|
|
|
552
|
-
<
|
|
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>
|
|
807
|
+
<UserManagement config={config} />
|
|
556
808
|
|
|
557
|
-
{
|
|
809
|
+
{config.currentUser?.isAdmin ? (
|
|
810
|
+
<FormSection title="Server operations">
|
|
811
|
+
<div className="wapp-settings-row">
|
|
812
|
+
<div>
|
|
813
|
+
<strong>Kill server</strong>
|
|
814
|
+
<p>Stop the server process. If your deployment restarts it automatically, the app will come back after a moment.</p>
|
|
815
|
+
{killRequested ? (
|
|
816
|
+
<div className="wapp-shutdown-countdown" aria-live="polite">
|
|
817
|
+
<div className="wapp-shutdown-message">Server is shutting down... Reloading in {countdown}s</div>
|
|
818
|
+
<div className="wapp-shutdown-progress" aria-hidden="true">
|
|
819
|
+
<div className="wapp-shutdown-progress-bar" style={{ width: `${progressPercent}%` }} />
|
|
820
|
+
</div>
|
|
821
|
+
</div>
|
|
822
|
+
) : null}
|
|
823
|
+
</div>
|
|
824
|
+
<div className="wapp-row-actions">
|
|
825
|
+
<Button type="button" variant="danger" disabled={killRequested} onClick={() => setConfirmKillServer(true)}>Kill server</Button>
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
</FormSection>
|
|
829
|
+
) : null}
|
|
830
|
+
|
|
831
|
+
{customSections.filter((section) => isScopeVisible(section.scope, config)).map((section) => ({
|
|
832
|
+
...section,
|
|
833
|
+
rows: section.rows?.filter((row) => isScopeVisible(row.scope, config)),
|
|
834
|
+
})).map((section) => <StructuredSettingsSection key={section.id} section={section} />)}
|
|
558
835
|
|
|
559
836
|
<FormSection title="About">
|
|
560
837
|
<p>{config.appName} {config.version}</p>
|
|
@@ -570,6 +847,24 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
570
847
|
onConfirm={() => apiKeyToDelete && void deleteKey(apiKeyToDelete.id)}
|
|
571
848
|
/>
|
|
572
849
|
<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()} />
|
|
850
|
+
<ConfirmDialog
|
|
851
|
+
open={Boolean(authSessionToRevoke)}
|
|
852
|
+
title="Revoke device session?"
|
|
853
|
+
message={authSessionToRevoke ? `This revokes the active "${authSessionToRevoke.clientId}" refresh-token session.` : ""}
|
|
854
|
+
confirmLabel="Revoke session"
|
|
855
|
+
danger
|
|
856
|
+
onCancel={() => setAuthSessionToRevoke(undefined)}
|
|
857
|
+
onConfirm={() => authSessionToRevoke && void revokeAuthSession(authSessionToRevoke)}
|
|
858
|
+
/>
|
|
859
|
+
<ConfirmDialog
|
|
860
|
+
open={confirmKillServer}
|
|
861
|
+
title="Kill server?"
|
|
862
|
+
message="Are you sure you want to kill the server?"
|
|
863
|
+
confirmLabel="Kill server"
|
|
864
|
+
danger
|
|
865
|
+
onCancel={() => setConfirmKillServer(false)}
|
|
866
|
+
onConfirm={() => void killServer().catch((err) => setError(String(err)))}
|
|
867
|
+
/>
|
|
573
868
|
</div>
|
|
574
869
|
);
|
|
575
870
|
}
|
|
@@ -634,13 +929,23 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
|
|
|
634
929
|
];
|
|
635
930
|
}, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, search, sidebar.pinning]);
|
|
636
931
|
|
|
932
|
+
useEffect(() => {
|
|
933
|
+
if (!config?.currentUser) return;
|
|
934
|
+
void json<{ theme: ThemePreference }>("/api/preferences/theme")
|
|
935
|
+
.then((result) => setTheme(result.theme))
|
|
936
|
+
.catch(() => undefined);
|
|
937
|
+
}, [config?.currentUser?.id, setTheme]);
|
|
938
|
+
|
|
637
939
|
if (error) {
|
|
638
940
|
return <main className="wapp-auth-screen"><Panel title="Unable to load app" description={error} /></main>;
|
|
639
941
|
}
|
|
640
942
|
if (!config) {
|
|
641
943
|
return <main className="wapp-auth-screen">Loading...</main>;
|
|
642
944
|
}
|
|
643
|
-
if (
|
|
945
|
+
if (window.location.pathname === "/setup") {
|
|
946
|
+
return <UserSetupScreen refresh={refresh} />;
|
|
947
|
+
}
|
|
948
|
+
if (config.passkeyAuth.enabled && (config.passkeyAuth.bootstrapRequired || config.passkeyAuth.ownerPasskeySetupRequired || (!config.passkeyAuth.passkeyDisabled && config.passkeyAuth.passkeyRequired && !config.passkeyAuth.authenticated))) {
|
|
644
949
|
return <PasskeyAuthScreen status={config.passkeyAuth} refresh={refresh} />;
|
|
645
950
|
}
|
|
646
951
|
if (config.deviceAuth.enabled && window.location.pathname === "/device") {
|
|
@@ -668,6 +973,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
|
|
|
668
973
|
...(activePinningAction ? [activePinningAction] : []),
|
|
669
974
|
];
|
|
670
975
|
const headerTitle = header?.renderTitle?.(headerContext) ?? defaultTitle;
|
|
976
|
+
const headerActionLabel = typeof headerTitle === "string" ? headerTitle : defaultTitle;
|
|
671
977
|
|
|
672
978
|
return (
|
|
673
979
|
<main className={`wapp-shell ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${sidebarOpen ? "sidebar-open" : ""}`}>
|
|
@@ -695,7 +1001,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
|
|
|
695
1001
|
</div>
|
|
696
1002
|
{headerActions.length ? (
|
|
697
1003
|
<div className="wapp-main-header-actions">
|
|
698
|
-
<ActionMenu items={headerActions} ariaLabel={`Actions for ${
|
|
1004
|
+
<ActionMenu items={headerActions} ariaLabel={`Actions for ${headerActionLabel}`} />
|
|
699
1005
|
</div>
|
|
700
1006
|
) : null}
|
|
701
1007
|
</header>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export class WebAppApiError extends Error {
|
|
2
|
+
status: number;
|
|
3
|
+
error?: string;
|
|
4
|
+
details?: unknown;
|
|
5
|
+
|
|
6
|
+
constructor(message: string, status: number, error?: string, details?: unknown) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "WebAppApiError";
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.error = error;
|
|
11
|
+
this.details = details;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type AuthRequiredListener = () => void;
|
|
16
|
+
|
|
17
|
+
const authRequiredListeners = new Set<AuthRequiredListener>();
|
|
18
|
+
|
|
19
|
+
export function onAuthRequired(listener: AuthRequiredListener): () => void {
|
|
20
|
+
authRequiredListeners.add(listener);
|
|
21
|
+
return () => authRequiredListeners.delete(listener);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function emitAuthRequired(): void {
|
|
25
|
+
for (const listener of authRequiredListeners) {
|
|
26
|
+
listener();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function appPath(path: string): string {
|
|
31
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) return path;
|
|
32
|
+
const base = document.querySelector("base")?.getAttribute("href") ?? "/";
|
|
33
|
+
return new URL(path.replace(/^\/+/, ""), new URL(base, window.location.href)).toString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function appWebSocketUrl(path = "/api/ws"): string {
|
|
37
|
+
const url = new URL(appPath(path));
|
|
38
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
39
|
+
return url.toString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function appFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
43
|
+
const response = await fetch(typeof input === "string" ? appPath(input) : input, {
|
|
44
|
+
...init,
|
|
45
|
+
credentials: init?.credentials ?? "same-origin",
|
|
46
|
+
headers: {
|
|
47
|
+
accept: "application/json",
|
|
48
|
+
...init?.headers,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (response.headers.get("x-webapp-passkey-required") === "true" || response.headers.get("x-passkey-auth-required") === "true") {
|
|
52
|
+
emitAuthRequired();
|
|
53
|
+
}
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
let body: { error?: string; message?: string; details?: unknown } | undefined;
|
|
56
|
+
try {
|
|
57
|
+
body = await response.clone().json() as typeof body;
|
|
58
|
+
} catch {
|
|
59
|
+
body = undefined;
|
|
60
|
+
}
|
|
61
|
+
throw new WebAppApiError(body?.message ?? `Request failed with status ${response.status}`, response.status, body?.error, body?.details);
|
|
62
|
+
}
|
|
63
|
+
return response;
|
|
64
|
+
}
|
|
@@ -233,6 +233,40 @@ export function CodeValue({ value, label, copyLabel = "Copy" }: { value: string;
|
|
|
233
233
|
);
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
export function Dialog({
|
|
237
|
+
title,
|
|
238
|
+
description,
|
|
239
|
+
children,
|
|
240
|
+
actions,
|
|
241
|
+
onClose,
|
|
242
|
+
className = "",
|
|
243
|
+
}: {
|
|
244
|
+
title: string;
|
|
245
|
+
description?: ReactNode;
|
|
246
|
+
children?: ReactNode;
|
|
247
|
+
actions?: ReactNode;
|
|
248
|
+
onClose?: () => void;
|
|
249
|
+
className?: string;
|
|
250
|
+
}) {
|
|
251
|
+
return (
|
|
252
|
+
<div className={`wapp-dialog ${className}`} role="dialog" aria-modal="true" aria-label={title}>
|
|
253
|
+
<div className="wapp-dialog-title">
|
|
254
|
+
<div>
|
|
255
|
+
<h2>{title}</h2>
|
|
256
|
+
{description ? <p>{description}</p> : null}
|
|
257
|
+
</div>
|
|
258
|
+
{onClose ? <button type="button" className="wapp-dialog-close" aria-label="Close dialog" onClick={onClose}>×</button> : null}
|
|
259
|
+
</div>
|
|
260
|
+
<div className="wapp-dialog-body">
|
|
261
|
+
{children}
|
|
262
|
+
</div>
|
|
263
|
+
<div className="wapp-dialog-actions">
|
|
264
|
+
{actions}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
236
270
|
export function ConfirmDialog({
|
|
237
271
|
open,
|
|
238
272
|
title,
|
|
@@ -264,19 +298,18 @@ export function ConfirmDialog({
|
|
|
264
298
|
if (!open) return null;
|
|
265
299
|
return createPortal(
|
|
266
300
|
<div className="wapp-dialog-backdrop" role="presentation">
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
<div className="wapp-dialog-body">
|
|
273
|
-
<p>{message}</p>
|
|
274
|
-
</div>
|
|
275
|
-
<div className="wapp-dialog-actions">
|
|
301
|
+
<Dialog
|
|
302
|
+
title={title}
|
|
303
|
+
onClose={onCancel}
|
|
304
|
+
actions={(
|
|
305
|
+
<>
|
|
276
306
|
<Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button>
|
|
277
307
|
<Button type="button" variant={danger ? "danger" : "primary"} onClick={onConfirm}>{confirmLabel}</Button>
|
|
278
|
-
|
|
279
|
-
|
|
308
|
+
</>
|
|
309
|
+
)}
|
|
310
|
+
>
|
|
311
|
+
<p>{message}</p>
|
|
312
|
+
</Dialog>
|
|
280
313
|
</div>,
|
|
281
314
|
document.body,
|
|
282
315
|
);
|
package/src/web/index.ts
CHANGED