@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.
@@ -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
- async function register() {
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 options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/passkey-auth/registration/options", { method: "POST", body: "{}" });
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("/api/passkey-auth/registration/verify", { method: "POST", body: JSON.stringify(credential) });
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
- <Panel title="Passkey required" description={status.passkeyConfigured ? "Authenticate to continue." : "Set up the first passkey for this app."}>
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
- <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>
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/registration/options", { method: "POST", body: "{}" });
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/registration/verify", { method: "POST", body: JSON.stringify(credential) });
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) => setTheme(event.currentTarget.value as ThemePreference)}>
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
- <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>
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 ? "Passkey protection is configured." : "No passkey configured yet."}</p>
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={() => void json(`/api/auth/sessions/${session.id}`, { method: "DELETE" }).then(refreshAuthSessions)}>Revoke</Button>
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
- <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>
807
+ <UserManagement config={config} />
556
808
 
557
- {customSections.map((section) => <StructuredSettingsSection key={section.id} section={section} />)}
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 (config.passkeyAuth.enabled && !config.passkeyAuth.passkeyDisabled && (!config.passkeyAuth.passkeyConfigured || (config.passkeyAuth.passkeyRequired && !config.passkeyAuth.authenticated))) {
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 ${defaultTitle}`} />
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
- <div className="wapp-dialog" role="dialog" aria-modal="true" aria-label={title}>
268
- <div className="wapp-dialog-title">
269
- <h2>{title}</h2>
270
- <button type="button" className="wapp-dialog-close" aria-label="Close dialog" onClick={onCancel}>×</button>
271
- </div>
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
- </div>
279
- </div>
308
+ </>
309
+ )}
310
+ >
311
+ <p>{message}</p>
312
+ </Dialog>
280
313
  </div>,
281
314
  document.body,
282
315
  );
package/src/web/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from "./WebAppRoot";
2
2
  export * from "./components";
3
+ export * from "./render";
4
+ export * from "./api-client";
3
5
  export * from "./sidebar/types";
4
6
  export * from "./realtime/useRealtime";
5
7
  export type { ThemePreference } from "../contracts";