@pablozaiden/webapp 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,6 +476,114 @@ 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 [error, setError] = useState<string>();
491
+
492
+ const refreshUsers = useCallback(async () => {
493
+ if (config.userManagement.canManageUsers) {
494
+ setUsers(await json<WebAppUserSummary[]>("/api/users"));
495
+ }
496
+ }, [config.userManagement.canManageUsers]);
497
+
498
+ useEffect(() => void refreshUsers().catch(() => undefined), [refreshUsers]);
499
+
500
+ async function createUser() {
501
+ try {
502
+ setError(undefined);
503
+ const result = await json<CreatedUserResponse>("/api/users", { method: "POST", body: JSON.stringify({ username, role }) });
504
+ setUsername("");
505
+ setRole("user");
506
+ setSetupLink(result.setupLink.url);
507
+ await refreshUsers();
508
+ } catch (err) {
509
+ setError(err instanceof Error ? err.message : String(err));
510
+ }
511
+ }
512
+
513
+ async function updateRole(user: WebAppUserSummary, nextRole: WebAppUserRole) {
514
+ try {
515
+ setError(undefined);
516
+ await json(`/api/users/${encodeURIComponent(user.id)}/role`, { method: "PATCH", body: JSON.stringify({ role: nextRole }) });
517
+ await refreshUsers();
518
+ } catch (err) {
519
+ setError(err instanceof Error ? err.message : String(err));
520
+ }
521
+ }
522
+
523
+ async function resetUser(user: WebAppUserSummary) {
524
+ try {
525
+ setError(undefined);
526
+ const result = await json<CreatedUserResponse>(`/api/users/${encodeURIComponent(user.id)}/reset`, { method: "POST", body: "{}" });
527
+ setSetupLink(result.setupLink.url);
528
+ await refreshUsers();
529
+ } catch (err) {
530
+ setError(err instanceof Error ? err.message : String(err));
531
+ }
532
+ }
533
+
534
+ async function deleteUser(user: WebAppUserSummary) {
535
+ try {
536
+ setError(undefined);
537
+ await json(`/api/users/${encodeURIComponent(user.id)}`, { method: "DELETE" });
538
+ await refreshUsers();
539
+ } catch (err) {
540
+ setError(err instanceof Error ? err.message : String(err));
541
+ }
542
+ }
543
+
544
+ if (!config.userManagement.canManageUsers) return null;
545
+ return (
546
+ <FormSection title="User management" description="Create users, reset passkeys, and manage admin access.">
547
+ {error ? <p className="wapp-error">{error}</p> : null}
548
+ <br />
549
+ <div className="wapp-settings-row stacked">
550
+ <div>
551
+ <TextField label="Username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} placeholder="new-user" />
552
+ <br />
553
+ <SelectField label="Role" value={role} onChange={(event) => setRole(event.currentTarget.value as WebAppUserRole)}>
554
+ <option value="user">User</option>
555
+ <option value="admin">Admin</option>
556
+ </SelectField>
557
+ </div>
558
+ <br />
559
+ <div className="wapp-row-actions"><Button type="button" variant="primary" disabled={!username.trim()} onClick={() => void createUser()}>Create setup link</Button></div>
560
+ {setupLink ? <code className="wapp-token">{setupLink}</code> : null}
561
+ </div>
562
+ <br />
563
+ <div className="wapp-list">
564
+ {users.map((user) => (
565
+ <div className="wapp-list-row" key={user.id}>
566
+ <span>
567
+ <strong>{user.username}</strong>
568
+ <small>{user.role} · passkey {user.passkeyConfigured ? "configured" : "pending"} · created {user.createdAt}</small>
569
+ </span>
570
+ <div className="wapp-row-actions">
571
+ {user.role !== "owner" ? (
572
+ <select className="wapp-inline-select" aria-label={`Role for ${user.username}`} value={user.role} onChange={(event) => void updateRole(user, event.currentTarget.value as WebAppUserRole)}>
573
+ <option value="user">User</option>
574
+ <option value="admin">Admin</option>
575
+ </select>
576
+ ) : <Badge variant="success">Owner</Badge>}
577
+ {user.role !== "owner" ? <Button type="button" onClick={() => void resetUser(user)}>Reset</Button> : null}
578
+ <Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() => void deleteUser(user)}>Delete</Button>
579
+ </div>
580
+ </div>
581
+ ))}
582
+ </div>
583
+ </FormSection>
584
+ );
585
+ }
586
+
410
587
  function SettingsView({ config, refresh, customSections, theme, setTheme }: { config: WebAppConfigResponse; refresh: () => Promise<void>; customSections: SettingsSection[]; theme: ThemePreference; setTheme: (theme: ThemePreference) => void }) {
411
588
  const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([]);
412
589
  const [authSessions, setAuthSessions] = useState<AuthSessionSummary[]>([]);
@@ -451,9 +628,9 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
451
628
  async function setupPasskey() {
452
629
  try {
453
630
  setError(undefined);
454
- const options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/passkey-auth/registration/options", { method: "POST", body: "{}" });
631
+ const options = await json<PublicKeyCredentialCreationOptionsJSON>("/api/passkey-auth/owner-setup/options", { method: "POST", body: "{}" });
455
632
  const credential = await startRegistration({ optionsJSON: options as never });
456
- await json("/api/passkey-auth/registration/verify", { method: "POST", body: JSON.stringify(credential) });
633
+ await json("/api/passkey-auth/owner-setup/verify", { method: "POST", body: JSON.stringify(credential) });
457
634
  await refresh();
458
635
  } catch (err) {
459
636
  setError(err instanceof Error ? err.message : String(err));
@@ -480,25 +657,36 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
480
657
  return (
481
658
  <div className="wapp-settings">
482
659
  {error ? <p className="wapp-error">{error}</p> : null}
660
+ {config.currentUser ? (
661
+ <FormSection title="Account">
662
+ <p>Signed in as <strong>{config.currentUser.username}</strong> <Badge variant={config.currentUser.isAdmin ? "success" : "disabled"}>{config.currentUser.role}</Badge></p>
663
+ </FormSection>
664
+ ) : null}
483
665
  <FormSection title="Display Settings">
484
- <SelectField label="Theme" value={theme} onChange={(event) => setTheme(event.currentTarget.value as ThemePreference)}>
666
+ <SelectField label="Theme" value={theme} onChange={(event) => {
667
+ const next = event.currentTarget.value as ThemePreference;
668
+ setTheme(next);
669
+ void json("/api/preferences/theme", { method: "PUT", body: JSON.stringify({ theme: next }) }).catch((err) => setError(String(err)));
670
+ }}>
485
671
  <option value="system">System</option>
486
672
  <option value="light">Light</option>
487
673
  <option value="dark">Dark</option>
488
674
  </SelectField>
489
675
  </FormSection>
490
676
 
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>
677
+ {config.currentUser?.isAdmin ? (
678
+ <FormSection title="Developer Settings">
679
+ <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)}>
680
+ {["trace", "debug", "info", "warn", "error"].map((level) => <option key={level} value={level}>{level}</option>)}
681
+ </SelectField>
682
+ </FormSection>
683
+ ) : null}
496
684
 
497
685
  <FormSection title="Security">
498
686
  <div className="wapp-settings-row">
499
687
  <div>
500
688
  <strong>Passkey</strong>
501
- <p>{config.passkeyAuth.passkeyConfigured ? "Passkey protection is configured." : "No passkey configured yet."}</p>
689
+ <p>{config.passkeyAuth.passkeyConfigured ? "Your passkey protects this account." : "No passkey configured yet."}</p>
502
690
  </div>
503
691
  <div className="wapp-row-actions">
504
692
  {config.passkeyAuth.passkeyConfigured ? (
@@ -507,7 +695,7 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
507
695
  <Button type="button" variant="danger" onClick={() => setConfirmDeletePasskey(true)}>Delete passkey</Button>
508
696
  </>
509
697
  ) : (
510
- <Button type="button" variant="primary" onClick={() => void setupPasskey()}>Set up passkey</Button>
698
+ config.currentUser?.isOwner ? <Button type="button" variant="primary" onClick={() => void setupPasskey()}>Set up passkey</Button> : null
511
699
  )}
512
700
  </div>
513
701
  </div>
@@ -549,12 +737,19 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
549
737
  ) : null}
550
738
  </FormSection>
551
739
 
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>
740
+ <UserManagement config={config} />
556
741
 
557
- {customSections.map((section) => <StructuredSettingsSection key={section.id} section={section} />)}
742
+ {config.currentUser?.isAdmin ? (
743
+ <FormSection title="Server operations">
744
+ {killRequested ? <p className="wapp-notice">Server is shutting down. Reloading soon...</p> : null}
745
+ <Button type="button" variant="danger" onClick={() => void killServer().catch((err) => setError(String(err)))}>Kill server</Button>
746
+ </FormSection>
747
+ ) : null}
748
+
749
+ {customSections.filter((section) => isScopeVisible(section.scope, config)).map((section) => ({
750
+ ...section,
751
+ rows: section.rows?.filter((row) => isScopeVisible(row.scope, config)),
752
+ })).map((section) => <StructuredSettingsSection key={section.id} section={section} />)}
558
753
 
559
754
  <FormSection title="About">
560
755
  <p>{config.appName} {config.version}</p>
@@ -634,13 +829,23 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
634
829
  ];
635
830
  }, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, search, sidebar.pinning]);
636
831
 
832
+ useEffect(() => {
833
+ if (!config?.currentUser) return;
834
+ void json<{ theme: ThemePreference }>("/api/preferences/theme")
835
+ .then((result) => setTheme(result.theme))
836
+ .catch(() => undefined);
837
+ }, [config?.currentUser?.id, setTheme]);
838
+
637
839
  if (error) {
638
840
  return <main className="wapp-auth-screen"><Panel title="Unable to load app" description={error} /></main>;
639
841
  }
640
842
  if (!config) {
641
843
  return <main className="wapp-auth-screen">Loading...</main>;
642
844
  }
643
- if (config.passkeyAuth.enabled && !config.passkeyAuth.passkeyDisabled && (!config.passkeyAuth.passkeyConfigured || (config.passkeyAuth.passkeyRequired && !config.passkeyAuth.authenticated))) {
845
+ if (window.location.pathname === "/setup") {
846
+ return <UserSetupScreen refresh={refresh} />;
847
+ }
848
+ if (config.passkeyAuth.enabled && (config.passkeyAuth.bootstrapRequired || config.passkeyAuth.ownerPasskeySetupRequired || (!config.passkeyAuth.passkeyDisabled && config.passkeyAuth.passkeyRequired && !config.passkeyAuth.authenticated))) {
644
849
  return <PasskeyAuthScreen status={config.passkeyAuth} refresh={refresh} />;
645
850
  }
646
851
  if (config.deviceAuth.enabled && window.location.pathname === "/device") {
@@ -668,6 +873,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
668
873
  ...(activePinningAction ? [activePinningAction] : []),
669
874
  ];
670
875
  const headerTitle = header?.renderTitle?.(headerContext) ?? defaultTitle;
876
+ const headerActionLabel = typeof headerTitle === "string" ? headerTitle : defaultTitle;
671
877
 
672
878
  return (
673
879
  <main className={`wapp-shell ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${sidebarOpen ? "sidebar-open" : ""}`}>
@@ -695,7 +901,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
695
901
  </div>
696
902
  {headerActions.length ? (
697
903
  <div className="wapp-main-header-actions">
698
- <ActionMenu items={headerActions} ariaLabel={`Actions for ${defaultTitle}`} />
904
+ <ActionMenu items={headerActions} ariaLabel={`Actions for ${headerActionLabel}`} />
699
905
  </div>
700
906
  ) : null}
701
907
  </header>
@@ -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,6 @@
1
1
  export * from "./WebAppRoot";
2
2
  export * from "./components";
3
+ export * from "./render";
3
4
  export * from "./sidebar/types";
4
5
  export * from "./realtime/useRealtime";
5
6
  export type { ThemePreference } from "../contracts";
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+
4
+ declare global {
5
+ var __pablozaidenWebAppRoots: WeakMap<Element, Root> | undefined;
6
+ }
7
+
8
+ function roots(): WeakMap<Element, Root> {
9
+ globalThis.__pablozaidenWebAppRoots ??= new WeakMap<Element, Root>();
10
+ return globalThis.__pablozaidenWebAppRoots;
11
+ }
12
+
13
+ export function renderWebApp(element: ReactNode, container: Element | string = "root"): Root {
14
+ const target = typeof container === "string" ? document.getElementById(container) : container;
15
+ if (!target) {
16
+ throw new Error(`Unable to find React root container: ${container}`);
17
+ }
18
+ const registry = roots();
19
+ let root = registry.get(target);
20
+ if (!root) {
21
+ root = createRoot(target);
22
+ registry.set(target, root);
23
+ }
24
+ root.render(element);
25
+ return root;
26
+ }
@@ -444,6 +444,9 @@ textarea::placeholder {
444
444
  }
445
445
 
446
446
  .wapp-panel {
447
+ min-width: 0;
448
+ max-width: 100%;
449
+ box-sizing: border-box;
447
450
  border: 1px solid var(--wapp-border-soft);
448
451
  border-radius: 1.5rem;
449
452
  background: var(--wapp-surface);
@@ -550,6 +553,10 @@ textarea::placeholder {
550
553
  margin-bottom: 1rem;
551
554
  }
552
555
 
556
+ .wapp-entity-header > div:first-child {
557
+ min-width: 0;
558
+ }
559
+
553
560
  .wapp-entity-header h2 {
554
561
  margin: 0;
555
562
  font-size: 1.125rem;
@@ -580,12 +587,15 @@ textarea::placeholder {
580
587
  }
581
588
 
582
589
  .wapp-data-list {
590
+ max-width: 100%;
591
+ box-sizing: border-box;
583
592
  overflow: hidden;
584
593
  border: 1px solid var(--wapp-border-soft);
585
594
  border-radius: 0.875rem;
586
595
  }
587
596
 
588
597
  .wapp-data-list-row {
598
+ box-sizing: border-box;
589
599
  width: 100%;
590
600
  display: flex;
591
601
  align-items: center;
@@ -697,6 +707,14 @@ textarea::placeholder {
697
707
  background: var(--wapp-surface);
698
708
  }
699
709
 
710
+ .wapp-auth-dialog {
711
+ width: min(28rem, calc(100vw - 2rem));
712
+ }
713
+
714
+ .wapp-auth-dialog .wapp-dialog-body:empty {
715
+ display: none;
716
+ }
717
+
700
718
  .wapp-button {
701
719
  min-height: 2.375rem;
702
720
  border: 1px solid var(--wapp-border);
@@ -1014,6 +1032,17 @@ textarea::placeholder {
1014
1032
  font-size: 0.8125rem;
1015
1033
  }
1016
1034
 
1035
+ .wapp-inline-select {
1036
+ min-height: 2.375rem;
1037
+ border: 1px solid var(--wapp-border);
1038
+ border-radius: 0.375rem;
1039
+ background: var(--wapp-surface);
1040
+ color: var(--wapp-text);
1041
+ box-shadow: var(--wapp-shadow);
1042
+ padding: 0.5rem 2rem 0.5rem 0.75rem;
1043
+ font-size: 0.8125rem;
1044
+ }
1045
+
1017
1046
  .wapp-field textarea {
1018
1047
  min-height: 8rem;
1019
1048
  resize: vertical;
@@ -1088,6 +1117,12 @@ textarea::placeholder {
1088
1117
  padding: 0.75rem;
1089
1118
  }
1090
1119
 
1120
+ .wapp-grid-2 {
1121
+ display: grid;
1122
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1123
+ gap: 0.75rem;
1124
+ }
1125
+
1091
1126
  .wapp-list-row + .wapp-list-row {
1092
1127
  border-top: 1px solid var(--wapp-border-soft);
1093
1128
  }
@@ -1180,11 +1215,11 @@ textarea::placeholder {
1180
1215
 
1181
1216
  .wapp-dialog-title {
1182
1217
  display: flex;
1183
- align-items: center;
1218
+ align-items: flex-start;
1184
1219
  justify-content: space-between;
1185
1220
  gap: 1rem;
1186
1221
  border-bottom: 1px solid var(--wapp-border);
1187
- padding: 1rem 1.5rem;
1222
+ padding: 1rem 1.5rem 0.75rem;
1188
1223
  }
1189
1224
 
1190
1225
  .wapp-dialog h2 {
@@ -1192,6 +1227,12 @@ textarea::placeholder {
1192
1227
  font-size: 1.125rem;
1193
1228
  }
1194
1229
 
1230
+ .wapp-dialog-title p {
1231
+ margin: 0.25rem 0 0;
1232
+ color: var(--wapp-muted);
1233
+ line-height: 1.35;
1234
+ }
1235
+
1195
1236
  .wapp-dialog-close {
1196
1237
  display: inline-flex;
1197
1238
  align-items: center;
@@ -1274,6 +1315,28 @@ textarea::placeholder {
1274
1315
 
1275
1316
  .wapp-main-content {
1276
1317
  padding: 1rem;
1318
+ overflow-x: hidden;
1319
+ }
1320
+
1321
+ .wapp-panel-header,
1322
+ .wapp-entity-header {
1323
+ flex-wrap: wrap;
1324
+ }
1325
+
1326
+ .wapp-panel-actions,
1327
+ .wapp-entity-header-actions {
1328
+ width: 100%;
1329
+ justify-content: flex-start;
1330
+ }
1331
+
1332
+ .wapp-data-list-row {
1333
+ gap: 0.5rem;
1334
+ }
1335
+
1336
+ .wapp-data-list-row-meta,
1337
+ .wapp-data-list-row-badge,
1338
+ .wapp-data-list-row-actions {
1339
+ min-width: 0;
1277
1340
  }
1278
1341
 
1279
1342
  .wapp-mobile-only {