@pablozaiden/webapp 0.5.8 → 0.6.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.
Files changed (55) hide show
  1. package/README.md +11 -3
  2. package/docs/auth-validation.md +11 -0
  3. package/docs/auth.md +25 -0
  4. package/docs/cli.md +73 -3
  5. package/docs/deployment.md +84 -7
  6. package/docs/getting-started.md +69 -6
  7. package/docs/github-actions.md +39 -0
  8. package/docs/release.md +5 -5
  9. package/docs/server.md +34 -4
  10. package/docs/settings.md +5 -0
  11. package/docs/ui-guidelines.md +12 -4
  12. package/package.json +2 -4
  13. package/src/build/build-binary.ts +67 -24
  14. package/src/cli/api-command.ts +53 -16
  15. package/src/cli/credentials.ts +275 -4
  16. package/src/cli/device-auth.ts +83 -22
  17. package/src/cli/environment-auth.ts +57 -0
  18. package/src/cli/index.ts +1 -0
  19. package/src/package-resolution.ts +34 -0
  20. package/src/server/auth/device-auth.ts +2 -2
  21. package/src/server/auth/passkeys.ts +16 -16
  22. package/src/server/auth/request-origin.ts +97 -16
  23. package/src/server/authentication.ts +265 -0
  24. package/src/server/create-web-app-server.ts +85 -1391
  25. package/src/server/framework-endpoints.ts +451 -0
  26. package/src/server/public-route-dispatch.ts +85 -0
  27. package/src/server/request-schemas.ts +144 -0
  28. package/src/server/responses.ts +69 -3
  29. package/src/server/route-dispatch.ts +109 -0
  30. package/src/server/runtime-config.ts +67 -0
  31. package/src/server/same-origin.ts +1 -1
  32. package/src/server/server-lifecycle.ts +138 -0
  33. package/src/server/server-types.ts +87 -0
  34. package/src/server/web-document.ts +532 -0
  35. package/src/web/WebAppRoot.tsx +69 -1214
  36. package/src/web/api-client.ts +14 -4
  37. package/src/web/app-shell.tsx +198 -0
  38. package/src/web/auth-screens.tsx +186 -0
  39. package/src/web/components/index.tsx +10 -3
  40. package/src/web/index.ts +1 -0
  41. package/src/web/mobile-hooks.ts +194 -0
  42. package/src/web/mobile.ts +12 -0
  43. package/src/web/root-types.ts +61 -0
  44. package/src/web/routing.ts +61 -0
  45. package/src/web/settings/account-section.tsx +52 -0
  46. package/src/web/settings/resource-state.tsx +17 -0
  47. package/src/web/settings/security-section.tsx +119 -0
  48. package/src/web/settings/sessions-section.tsx +66 -0
  49. package/src/web/settings/settings-view.tsx +96 -0
  50. package/src/web/settings/shutdown-section.tsx +95 -0
  51. package/src/web/settings/user-management.tsx +123 -0
  52. package/src/web/settings-view.tsx +3 -0
  53. package/src/web/sidebar-state.ts +108 -0
  54. package/src/web/sidebar-tree.tsx +89 -0
  55. package/src/web/styles.css +76 -64
@@ -98,13 +98,14 @@ export async function appRequest(input: RequestInfo | URL, init?: RequestInit):
98
98
  }
99
99
 
100
100
  export async function appFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
101
+ const headers = new Headers(init?.headers);
102
+ if (!headers.has("accept")) {
103
+ headers.set("accept", "application/json");
104
+ }
101
105
  const response = await appRequest(input, {
102
106
  ...init,
103
107
  credentials: init?.credentials ?? "same-origin",
104
- headers: {
105
- accept: "application/json",
106
- ...init?.headers,
107
- },
108
+ headers,
108
109
  });
109
110
  if (response.headers.get("x-webapp-passkey-required") === "true" || response.headers.get("x-passkey-auth-required") === "true") {
110
111
  emitAuthRequired();
@@ -121,6 +122,15 @@ export async function appFetch(input: RequestInfo | URL, init?: RequestInit): Pr
121
122
  return response;
122
123
  }
123
124
 
125
+ export async function appJson<T>(input: RequestInfo | URL, init: RequestInit = {}): Promise<T> {
126
+ const headers = new Headers(init.headers);
127
+ if (init.body != null && !headers.has("content-type")) {
128
+ headers.set("content-type", "application/json");
129
+ }
130
+ const response = await appFetch(input, { ...init, headers });
131
+ return await response.json() as T;
132
+ }
133
+
124
134
  function normalizeOptionalBaseUrl(rawValue?: string | null): string | undefined {
125
135
  const trimmedValue = rawValue?.trim();
126
136
  if (!trimmedValue) {
@@ -0,0 +1,198 @@
1
+ import { useEffect, type ReactNode, type RefObject } from "react";
2
+ import { ActionMenu, IconButton } from "./components";
3
+ import { SidebarTree } from "./sidebar-tree";
4
+ import type { SidebarCollapsedState } from "./sidebar-state";
5
+ import type { ActionMenuItem, SidebarAction, SidebarNode, WebAppRoute } from "./sidebar/types";
6
+
7
+ function isSidebarShortcutEditableTarget(target: EventTarget | null): boolean {
8
+ if (!(target instanceof HTMLElement)) {
9
+ return false;
10
+ }
11
+ if (target.isContentEditable || target.closest("[contenteditable=''], [contenteditable='true']")) {
12
+ return true;
13
+ }
14
+ return target instanceof HTMLInputElement
15
+ || target instanceof HTMLTextAreaElement
16
+ || target instanceof HTMLSelectElement;
17
+ }
18
+
19
+ function Icon({ name }: { name: "settings" | "sidebar" | "plus" | "home" | "search" | "bolt" | "chat" | "code" | "refresh" }) {
20
+ const common = { "aria-hidden": true, viewBox: "0 0 24 24", className: "wapp-svg" };
21
+ if (name === "settings") return <svg {...common}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82V22a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-.6-1 1.65 1.65 0 0 0-1.82-.33H2a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-.6 1.65 1.65 0 0 0 .33-1.82V2a2 2 0 1 1 4 0v.09A1.65 1.65 0 0 0 15 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.14.33.34.64.6 1 .26.36.61.6 1 .6H22a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-2.51.4Z" /></svg>;
22
+ if (name === "sidebar") return <svg {...common}><rect x="4" y="5" width="16" height="14" rx="2" /><path d="M10 5v14" /></svg>;
23
+ if (name === "plus") return <svg {...common}><path d="M12 5v14M5 12h14" /></svg>;
24
+ if (name === "bolt") return <svg {...common}><path d="m13 2-8 12h7l-1 8 8-12h-7l1-8Z" /></svg>;
25
+ if (name === "chat") return <svg {...common}><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z" /><path d="M8 9h8M8 13h5" /></svg>;
26
+ if (name === "code") return <svg {...common}><path d="M8 8 4 12l4 4M16 8l4 4-4 4M14 4l-4 16" /></svg>;
27
+ if (name === "refresh") return <svg {...common}><path d="M21 12a9 9 0 0 1-15.5 6.3L3 16M3 21v-5h5M3 12A9 9 0 0 1 18.5 5.7L21 8M21 3v5h-5" /></svg>;
28
+ return <svg {...common}><path d="M4 10.5 12 4l8 6.5V20H5v-7h14" /></svg>;
29
+ }
30
+
31
+ function ActionIcon({ icon }: { icon?: ReactNode }) {
32
+ if (icon === "+") return <Icon name="plus" />;
33
+ if (icon === "↯") return <Icon name="bolt" />;
34
+ if (icon === "chat") return <Icon name="chat" />;
35
+ if (icon === "code") return <Icon name="code" />;
36
+ if (!icon) return <Icon name="bolt" />;
37
+ return <>{icon}</>;
38
+ }
39
+
40
+ export interface AppShellProps {
41
+ appName: string;
42
+ homeRoute: WebAppRoute;
43
+ topActions: SidebarAction[];
44
+ nodes: SidebarNode[];
45
+ route: WebAppRoute;
46
+ navigate: (route: WebAppRoute) => void;
47
+ sidebarSearchEnabled: boolean;
48
+ search: string;
49
+ onSearchChange: (search: string) => void;
50
+ sidebarSearchId: string;
51
+ sidebarSearchInputRef: RefObject<HTMLInputElement | null>;
52
+ sidebarOpen: boolean;
53
+ setSidebarOpen: (open: boolean) => void;
54
+ sidebarCollapsed: boolean;
55
+ toggleSidebarCollapsed: () => void;
56
+ collapsed: SidebarCollapsedState;
57
+ toggleCollapsed: (id: string, isCollapsed: boolean) => void;
58
+ searchActive: boolean;
59
+ effectiveVersion: string;
60
+ headerTitle: ReactNode;
61
+ headerActionLabel: string;
62
+ primaryHeaderActions?: ReactNode;
63
+ headerActions: ActionMenuItem[];
64
+ view: ReactNode;
65
+ }
66
+
67
+ export function AppShell({
68
+ appName,
69
+ homeRoute,
70
+ topActions,
71
+ nodes,
72
+ route,
73
+ navigate,
74
+ sidebarSearchEnabled,
75
+ search,
76
+ onSearchChange,
77
+ sidebarSearchId,
78
+ sidebarSearchInputRef,
79
+ sidebarOpen,
80
+ setSidebarOpen,
81
+ sidebarCollapsed,
82
+ toggleSidebarCollapsed,
83
+ collapsed,
84
+ toggleCollapsed,
85
+ searchActive,
86
+ effectiveVersion,
87
+ headerTitle,
88
+ headerActionLabel,
89
+ primaryHeaderActions,
90
+ headerActions,
91
+ view,
92
+ }: AppShellProps) {
93
+ useEffect(() => {
94
+ function handleSidebarShortcut(event: KeyboardEvent) {
95
+ if (
96
+ event.key.toLowerCase() !== "b"
97
+ || event.altKey
98
+ || event.shiftKey
99
+ || event.ctrlKey === event.metaKey
100
+ || event.isComposing
101
+ || event.repeat
102
+ || isSidebarShortcutEditableTarget(event.target)
103
+ ) {
104
+ return;
105
+ }
106
+
107
+ event.preventDefault();
108
+ toggleSidebarCollapsed();
109
+ }
110
+
111
+ document.addEventListener("keydown", handleSidebarShortcut);
112
+ return () => document.removeEventListener("keydown", handleSidebarShortcut);
113
+ }, [toggleSidebarCollapsed]);
114
+
115
+ const topSidebarActions = topActions.slice(0, 2);
116
+ const sidebarToggleLabel = sidebarCollapsed ? "Show sidebar" : "Collapse sidebar";
117
+ const closeSidebar = () => setSidebarOpen(false);
118
+ const navigateFromSidebarHeader = (nextRoute: WebAppRoute) => {
119
+ navigate(nextRoute);
120
+ closeSidebar();
121
+ };
122
+ const runSidebarHeaderAction = (action: SidebarAction) => {
123
+ if (action.onAction) {
124
+ action.onAction();
125
+ } else if (action.route) {
126
+ navigate(action.route);
127
+ }
128
+ closeSidebar();
129
+ };
130
+
131
+ return (
132
+ <main className={`wapp-shell ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${sidebarOpen ? "sidebar-open" : ""}`}>
133
+ <div
134
+ className="wapp-mobile-backdrop"
135
+ role="button"
136
+ tabIndex={0}
137
+ aria-label="Close sidebar"
138
+ onClick={closeSidebar}
139
+ onKeyDown={(event) => {
140
+ if (event.key === "Enter" || event.key === " ") {
141
+ event.preventDefault();
142
+ closeSidebar();
143
+ }
144
+ }}
145
+ />
146
+ <aside id="wapp-sidebar" className="wapp-sidebar">
147
+ <div className="wapp-sidebar-header">
148
+ <button type="button" className="wapp-brand" onClick={() => navigateFromSidebarHeader(homeRoute)}>{appName}</button>
149
+ <div className="wapp-sidebar-actions">
150
+ {topSidebarActions.map((action) => <IconButton key={action.id} className="wapp-sidebar-top-button" title={action.title} aria-label={action.title} onClick={() => runSidebarHeaderAction(action)}><ActionIcon icon={action.icon} /></IconButton>)}
151
+ <IconButton className="wapp-sidebar-top-button" title="Settings" aria-label="Open settings" active={route.view === "settings"} onClick={() => navigateFromSidebarHeader({ view: "settings" })}><Icon name="settings" /></IconButton>
152
+ <IconButton className="wapp-sidebar-top-button" title={sidebarToggleLabel} aria-label={sidebarToggleLabel} aria-expanded={!sidebarCollapsed} aria-controls="wapp-sidebar" onClick={toggleSidebarCollapsed}><Icon name="sidebar" /></IconButton>
153
+ </div>
154
+ </div>
155
+ <div className="wapp-sidebar-scroll">
156
+ {sidebarSearchEnabled ? (
157
+ <div className="wapp-search">
158
+ <label className="sr-only" htmlFor={sidebarSearchId}>Search</label>
159
+ <div className={`wapp-search-input-wrap${search.length > 0 ? " wapp-search-input-wrap--clearable" : ""}`}>
160
+ <input id={sidebarSearchId} ref={sidebarSearchInputRef} value={search} onInput={(event) => onSearchChange(event.currentTarget.value)} placeholder="Search" />
161
+ {search.length > 0 ? (
162
+ <button
163
+ type="button"
164
+ className="wapp-search-clear"
165
+ aria-label="Clear search"
166
+ onClick={() => {
167
+ onSearchChange("");
168
+ sidebarSearchInputRef.current?.focus();
169
+ }}
170
+ >
171
+ ×
172
+ </button>
173
+ ) : null}
174
+ </div>
175
+ </div>
176
+ ) : null}
177
+ <SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} />
178
+ <div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
179
+ </div>
180
+ </aside>
181
+ <section className="wapp-main">
182
+ <header className="wapp-main-header">
183
+ <div className="wapp-main-header-title">
184
+ {sidebarCollapsed ? <IconButton className="wapp-sidebar-top-button" aria-label={sidebarToggleLabel} title={sidebarToggleLabel} aria-expanded={!sidebarCollapsed} aria-controls="wapp-sidebar" onClick={toggleSidebarCollapsed}><Icon name="sidebar" /></IconButton> : <IconButton className="wapp-mobile-only wapp-sidebar-top-button" aria-label="Show sidebar" title="Show sidebar" aria-expanded={sidebarOpen} aria-controls="wapp-sidebar" onClick={() => setSidebarOpen(true)}><Icon name="sidebar" /></IconButton>}
185
+ <h1>{headerTitle}</h1>
186
+ </div>
187
+ {primaryHeaderActions || headerActions.length ? (
188
+ <div className="wapp-main-header-actions">
189
+ {primaryHeaderActions}
190
+ {headerActions.length ? <ActionMenu items={headerActions} ariaLabel={`Actions for ${headerActionLabel}`} /> : null}
191
+ </div>
192
+ ) : null}
193
+ </header>
194
+ <div className="wapp-main-content">{view}</div>
195
+ </section>
196
+ </main>
197
+ );
198
+ }
@@ -0,0 +1,186 @@
1
+ import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import type { DeviceVerificationDetails, PasskeyAuthStatusResponse, UserSetupDetails } from "../contracts";
4
+ import { appJson } from "./api-client";
5
+ import { Badge, Button, Dialog, Panel, TextField } from "./components";
6
+
7
+ export function PasskeyAuthScreen({ status, refresh }: { status: PasskeyAuthStatusResponse; refresh: () => Promise<void> }) {
8
+ const [error, setError] = useState<string>();
9
+ const [busy, setBusy] = useState(false);
10
+ const [username, setUsername] = useState("");
11
+ const description = status.bootstrapRequired
12
+ ? "Choose the username for the owner"
13
+ : status.ownerPasskeySetupRequired
14
+ ? "The owner passkey was removed. Set it up again to continue."
15
+ : "Authenticate to continue.";
16
+
17
+ async function register(endpoint: "bootstrap" | "owner-setup") {
18
+ setBusy(true);
19
+ setError(undefined);
20
+ try {
21
+ const body = endpoint === "bootstrap" ? JSON.stringify({ username }) : "{}";
22
+ const options = await appJson<PublicKeyCredentialCreationOptionsJSON>(`/api/passkey-auth/${endpoint}/options`, { method: "POST", body });
23
+ const credential = await startRegistration({ optionsJSON: options as never });
24
+ await appJson(`/api/passkey-auth/${endpoint}/verify`, { method: "POST", body: JSON.stringify(credential) });
25
+ await refresh();
26
+ window.location.reload();
27
+ } catch (err) {
28
+ setError(err instanceof Error ? err.message : String(err));
29
+ } finally {
30
+ setBusy(false);
31
+ }
32
+ }
33
+
34
+ async function login() {
35
+ setBusy(true);
36
+ setError(undefined);
37
+ try {
38
+ const options = await appJson<PublicKeyCredentialRequestOptionsJSON>("/api/passkey-auth/authentication/options", { method: "POST", body: "{}" });
39
+ const credential = await startAuthentication({ optionsJSON: options as never });
40
+ await appJson("/api/passkey-auth/authentication/verify", { method: "POST", body: JSON.stringify(credential) });
41
+ await refresh();
42
+ window.location.reload();
43
+ } catch (err) {
44
+ setError(err instanceof Error ? err.message : String(err));
45
+ } finally {
46
+ setBusy(false);
47
+ }
48
+ }
49
+
50
+ return (
51
+ <main className="wapp-auth-screen">
52
+ <Dialog
53
+ className="wapp-auth-dialog"
54
+ title={status.bootstrapRequired ? "Create owner user" : status.ownerPasskeySetupRequired ? "Set up owner passkey" : "Passkey required"}
55
+ actions={status.bootstrapRequired ? (
56
+ <Button type="button" variant="primary" disabled={busy || !username.trim()} onClick={() => void register("bootstrap")}>Create owner</Button>
57
+ ) : status.ownerPasskeySetupRequired ? (
58
+ <Button type="button" variant="primary" disabled={busy} onClick={() => void register("owner-setup")}>Set up owner passkey</Button>
59
+ ) : (
60
+ <Button type="button" variant="primary" disabled={busy} onClick={() => void login()}>Authenticate</Button>
61
+ )}
62
+ >
63
+ <p>{description}</p>
64
+ {error ? <p className="wapp-error">{error}</p> : null}
65
+ {status.bootstrapRequired ? <><br /><TextField label="Username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} placeholder="owner" /></> : null}
66
+ </Dialog>
67
+ </main>
68
+ );
69
+ }
70
+
71
+ export function UserSetupScreen({ refresh }: { refresh: () => Promise<void> }) {
72
+ const token = new URLSearchParams(window.location.search).get("token") ?? "";
73
+ const [details, setDetails] = useState<UserSetupDetails>();
74
+ const [error, setError] = useState<string>();
75
+ const [busy, setBusy] = useState(false);
76
+
77
+ useEffect(() => {
78
+ if (!token) {
79
+ setError("Setup token is missing");
80
+ return;
81
+ }
82
+ void appJson<UserSetupDetails>(`/api/user-setup?token=${encodeURIComponent(token)}`)
83
+ .then(setDetails)
84
+ .catch((err) => setError(err instanceof Error ? err.message : String(err)));
85
+ }, [token]);
86
+
87
+ async function setup() {
88
+ setBusy(true);
89
+ setError(undefined);
90
+ try {
91
+ const options = await appJson<PublicKeyCredentialCreationOptionsJSON>("/api/user-setup/options", { method: "POST", body: JSON.stringify({ token }) });
92
+ const credential = await startRegistration({ optionsJSON: options as never });
93
+ await appJson("/api/user-setup/verify", { method: "POST", body: JSON.stringify({ token, response: credential }) });
94
+ window.history.replaceState(null, "", "/");
95
+ await refresh();
96
+ window.location.reload();
97
+ } catch (err) {
98
+ setError(err instanceof Error ? err.message : String(err));
99
+ } finally {
100
+ setBusy(false);
101
+ }
102
+ }
103
+
104
+ return (
105
+ <main className="wapp-auth-screen">
106
+ <Dialog
107
+ className="wapp-auth-dialog"
108
+ title={details?.kind === "reset" ? "Reset passkey" : "Finish user setup"}
109
+ actions={<Button type="button" variant="primary" disabled={busy || !details} onClick={() => void setup()}>Set up passkey</Button>}
110
+ >
111
+ <p>{details ? `Username: ${details.username}` : "Loading setup link..."}</p>
112
+ {details ? <p className="wapp-muted">Role: {details.role}. This link expires at {details.expiresAt}.</p> : null}
113
+ {error ? <p className="wapp-error">{error}</p> : null}
114
+ </Dialog>
115
+ </main>
116
+ );
117
+ }
118
+
119
+ export function DeviceVerificationScreen() {
120
+ const params = new URLSearchParams(window.location.search);
121
+ const initialCode = params.get("user_code") ?? "";
122
+ const [userCode, setUserCode] = useState(initialCode);
123
+ const [details, setDetails] = useState<DeviceVerificationDetails>();
124
+ const [error, setError] = useState<string>();
125
+ const [busy, setBusy] = useState(false);
126
+
127
+ const load = useCallback(async () => {
128
+ if (!userCode.trim()) {
129
+ setDetails(undefined);
130
+ return;
131
+ }
132
+ setBusy(true);
133
+ setError(undefined);
134
+ try {
135
+ setDetails(await appJson<DeviceVerificationDetails>(`/api/auth/device/verification?user_code=${encodeURIComponent(userCode.trim())}`));
136
+ } catch (err) {
137
+ setDetails(undefined);
138
+ setError(err instanceof Error ? err.message : String(err));
139
+ } finally {
140
+ setBusy(false);
141
+ }
142
+ }, [userCode]);
143
+
144
+ useEffect(() => void load(), [load]);
145
+
146
+ async function decide(action: "approve" | "deny") {
147
+ if (!details) {
148
+ return;
149
+ }
150
+ setBusy(true);
151
+ setError(undefined);
152
+ try {
153
+ setDetails(await appJson<DeviceVerificationDetails>(`/api/auth/device/${action}`, {
154
+ method: "POST",
155
+ body: JSON.stringify({ user_code: details.userCode }),
156
+ }));
157
+ } catch (err) {
158
+ setError(err instanceof Error ? err.message : String(err));
159
+ } finally {
160
+ setBusy(false);
161
+ }
162
+ }
163
+
164
+ return (
165
+ <main className="wapp-device-screen">
166
+ <Panel title="Authorize device" description="Enter the code shown by the CLI or external device.">
167
+ <div className="wapp-device-stack">
168
+ <TextField label="User code" value={userCode} onChange={(event) => setUserCode(event.currentTarget.value.toUpperCase())} placeholder="ABCD-2345" />
169
+ {error ? <p className="wapp-error">{error}</p> : null}
170
+ {details ? (
171
+ <div className="wapp-device-card">
172
+ <div><strong>Client</strong><span>{details.clientId}</span></div>
173
+ <div><strong>Scope</strong><span>{details.scope}</span></div>
174
+ <div><strong>Status</strong><Badge variant={details.status === "approved" ? "success" : details.status === "denied" ? "error" : details.status === "consumed" ? "disabled" : "warning"}>{details.status}</Badge></div>
175
+ <div><strong>Expires</strong><span>{details.expiresAt}</span></div>
176
+ </div>
177
+ ) : null}
178
+ <div className="wapp-row-actions">
179
+ <Button type="button" variant="ghost" disabled={busy || !details || details.status !== "pending"} onClick={() => void decide("deny")}>Deny</Button>
180
+ <Button type="button" variant="primary" disabled={busy || !details || details.status !== "pending"} onClick={() => void decide("approve")}>Approve</Button>
181
+ </div>
182
+ </div>
183
+ </Panel>
184
+ </main>
185
+ );
186
+ }
@@ -30,8 +30,15 @@ export function Badge({ variant = "default", className = "", children, ...props
30
30
  return <span {...props} className={`wapp-badge wapp-badge-${variant} ${className}`}>{children}</span>;
31
31
  }
32
32
 
33
- export function Page({ className = "", children, ...props }: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
34
- return <div {...props} className={`wapp-page ${className}`}>{children}</div>;
33
+ export type PageLayout = "padded" | "full";
34
+
35
+ export function Page({
36
+ layout = "padded",
37
+ className = "",
38
+ children,
39
+ ...props
40
+ }: HTMLAttributes<HTMLDivElement> & { children: ReactNode; layout?: PageLayout }) {
41
+ return <div {...props} className={`wapp-page ${layout === "full" ? "wapp-page-full" : ""} ${className}`.trim()}>{children}</div>;
35
42
  }
36
43
 
37
44
  export function Panel({ title, description, actions, children, className = "" }: { title?: string; description?: string; actions?: ReactNode; children?: ReactNode; className?: string }) {
@@ -53,7 +60,7 @@ export function Panel({ title, description, actions, children, className = "" }:
53
60
 
54
61
  export function EmptyState({ title, description, action }: { title: string; description?: string; action?: ReactNode }) {
55
62
  return (
56
- <div className="wapp-empty-state">
63
+ <div className="wapp-empty-state" role="status" aria-label="Empty state">
57
64
  <strong>{title}</strong>
58
65
  {description ? <span>{description}</span> : null}
59
66
  {action}
package/src/web/index.ts CHANGED
@@ -4,5 +4,6 @@ export * from "./render";
4
4
  export * from "./api-client";
5
5
  export * from "./sidebar/types";
6
6
  export * from "./realtime/useRealtime";
7
+ export { MOBILE_BREAKPOINT_PX, MOBILE_MEDIA_QUERY, MOBILE_STATE_ATTRIBUTE } from "./mobile";
7
8
  export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
8
9
  export type { ThemePreference } from "../contracts";
@@ -0,0 +1,194 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ MOBILE_MEDIA_QUERY,
4
+ MOBILE_STATE_ATTRIBUTE,
5
+ MOBILE_VIEWPORT_FINAL_SETTLE_DELAY_MS,
6
+ MOBILE_VIEWPORT_FIRST_SETTLE_DELAY_MS,
7
+ } from "./mobile";
8
+
9
+ export function useMobileBreakpoint(): boolean {
10
+ const [isMobile, setIsMobile] = useState(() => typeof window !== "undefined" && window.matchMedia(MOBILE_MEDIA_QUERY).matches);
11
+
12
+ useEffect(() => {
13
+ if (typeof window === "undefined" || typeof document === "undefined") {
14
+ return;
15
+ }
16
+
17
+ const query = window.matchMedia(MOBILE_MEDIA_QUERY);
18
+ const root = document.documentElement;
19
+ const sync = () => {
20
+ const next = query.matches;
21
+ setIsMobile(next);
22
+ root.toggleAttribute(MOBILE_STATE_ATTRIBUTE, next);
23
+ };
24
+
25
+ sync();
26
+ query.addEventListener("change", sync);
27
+ return () => {
28
+ query.removeEventListener("change", sync);
29
+ root.removeAttribute(MOBILE_STATE_ATTRIBUTE);
30
+ };
31
+ }, []);
32
+
33
+ return isMobile;
34
+ }
35
+
36
+ export function useMobileViewportHeight(isMobile: boolean): void {
37
+ useEffect(() => {
38
+ if (typeof window === "undefined") {
39
+ return;
40
+ }
41
+
42
+ const root = document.documentElement;
43
+ const viewport = window.visualViewport;
44
+ const timers = new Set<number>();
45
+ let frame = 0;
46
+
47
+ const clearViewportHeight = () => {
48
+ root.style.removeProperty("--wapp-viewport-height");
49
+ };
50
+
51
+ const sync = () => {
52
+ frame = 0;
53
+ if (!isMobile) {
54
+ clearViewportHeight();
55
+ return;
56
+ }
57
+
58
+ const height = Math.round(viewport?.height ?? window.innerHeight);
59
+ if (height > 0) {
60
+ root.style.setProperty("--wapp-viewport-height", `${height}px`);
61
+ }
62
+
63
+ const scrollingElement = document.scrollingElement;
64
+ if (scrollingElement && scrollingElement.scrollTop !== 0) {
65
+ scrollingElement.scrollTop = 0;
66
+ }
67
+ };
68
+
69
+ const scheduleSync = () => {
70
+ if (frame) {
71
+ return;
72
+ }
73
+ frame = requestAnimationFrame(sync);
74
+ };
75
+
76
+ const scheduleViewportRetry = (delay: number) => {
77
+ const timer = window.setTimeout(() => {
78
+ timers.delete(timer);
79
+ scheduleSync();
80
+ }, delay);
81
+ timers.add(timer);
82
+ };
83
+
84
+ const handleViewportTransition = () => {
85
+ scheduleSync();
86
+ if (isMobile) {
87
+ scheduleViewportRetry(MOBILE_VIEWPORT_FIRST_SETTLE_DELAY_MS);
88
+ scheduleViewportRetry(MOBILE_VIEWPORT_FINAL_SETTLE_DELAY_MS);
89
+ }
90
+ };
91
+
92
+ scheduleSync();
93
+ viewport?.addEventListener("resize", scheduleSync);
94
+ viewport?.addEventListener("scroll", scheduleSync);
95
+ window.addEventListener("resize", scheduleSync);
96
+ window.addEventListener("orientationchange", handleViewportTransition);
97
+ document.addEventListener("focusin", handleViewportTransition);
98
+ document.addEventListener("focusout", handleViewportTransition);
99
+
100
+ return () => {
101
+ if (frame) {
102
+ cancelAnimationFrame(frame);
103
+ }
104
+ for (const timer of timers) {
105
+ clearTimeout(timer);
106
+ }
107
+ viewport?.removeEventListener("resize", scheduleSync);
108
+ viewport?.removeEventListener("scroll", scheduleSync);
109
+ window.removeEventListener("resize", scheduleSync);
110
+ window.removeEventListener("orientationchange", handleViewportTransition);
111
+ document.removeEventListener("focusin", handleViewportTransition);
112
+ document.removeEventListener("focusout", handleViewportTransition);
113
+ clearViewportHeight();
114
+ };
115
+ }, [isMobile]);
116
+ }
117
+
118
+ const SIDEBAR_SWIPE_EDGE_WIDTH = 24;
119
+ const SIDEBAR_SWIPE_DISTANCE = 64;
120
+ const SIDEBAR_SWIPE_VERTICAL_TOLERANCE = 48;
121
+
122
+ export function useMobileSidebarSwipe(isMobile: boolean, sidebarOpen: boolean, setSidebarOpen: (open: boolean) => void): void {
123
+ useEffect(() => {
124
+ if (typeof window === "undefined" || typeof document === "undefined") {
125
+ return;
126
+ }
127
+
128
+ let tracking = false;
129
+ let startX = 0;
130
+ let startY = 0;
131
+
132
+ const reset = () => {
133
+ tracking = false;
134
+ };
135
+
136
+ const handleTouchStart = (event: TouchEvent) => {
137
+ if (sidebarOpen || !isMobile || event.touches.length !== 1) {
138
+ reset();
139
+ return;
140
+ }
141
+
142
+ const touch = event.touches[0];
143
+ if (!touch || touch.clientX > SIDEBAR_SWIPE_EDGE_WIDTH) {
144
+ reset();
145
+ return;
146
+ }
147
+
148
+ tracking = true;
149
+ startX = touch.clientX;
150
+ startY = touch.clientY;
151
+ };
152
+
153
+ const handleTouchMove = (event: TouchEvent) => {
154
+ if (!tracking) {
155
+ return;
156
+ }
157
+ if (event.touches.length !== 1) {
158
+ reset();
159
+ return;
160
+ }
161
+
162
+ const touch = event.touches[0];
163
+ if (!touch) {
164
+ reset();
165
+ return;
166
+ }
167
+
168
+ const deltaX = touch.clientX - startX;
169
+ const deltaY = Math.abs(touch.clientY - startY);
170
+ if (deltaX <= 0 || deltaY > SIDEBAR_SWIPE_VERTICAL_TOLERANCE || deltaY > deltaX) {
171
+ reset();
172
+ return;
173
+ }
174
+ if (deltaX < SIDEBAR_SWIPE_DISTANCE) {
175
+ return;
176
+ }
177
+
178
+ event.preventDefault();
179
+ setSidebarOpen(true);
180
+ reset();
181
+ };
182
+
183
+ document.addEventListener("touchstart", handleTouchStart, { passive: true });
184
+ document.addEventListener("touchmove", handleTouchMove, { passive: false });
185
+ document.addEventListener("touchend", reset);
186
+ document.addEventListener("touchcancel", reset);
187
+ return () => {
188
+ document.removeEventListener("touchstart", handleTouchStart);
189
+ document.removeEventListener("touchmove", handleTouchMove);
190
+ document.removeEventListener("touchend", reset);
191
+ document.removeEventListener("touchcancel", reset);
192
+ };
193
+ }, [isMobile, setSidebarOpen, sidebarOpen]);
194
+ }
@@ -0,0 +1,12 @@
1
+ export const MOBILE_BREAKPOINT_PX = 900;
2
+ export const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX}px)`;
3
+ export const MOBILE_STATE_ATTRIBUTE = "data-wapp-mobile";
4
+
5
+ /*
6
+ * Mobile Safari/WebKit and Chromium can report focus or orientation changes
7
+ * before visualViewport reaches its final geometry. The first retry catches
8
+ * the post-event layout pass; the final retry catches the end of the keyboard,
9
+ * browser-chrome, or rotation transition when no further event is emitted.
10
+ */
11
+ export const MOBILE_VIEWPORT_FIRST_SETTLE_DELAY_MS = 120;
12
+ export const MOBILE_VIEWPORT_FINAL_SETTLE_DELAY_MS = 320;