@pablozaiden/webapp 0.1.0 → 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.
- package/README.md +3 -3
- package/docs/auth-validation.md +24 -5
- package/docs/auth.md +33 -9
- package/docs/getting-started.md +33 -7
- package/docs/github-actions.md +8 -3
- package/docs/realtime.md +7 -2
- package/docs/server.md +39 -4
- package/docs/settings.md +11 -5
- package/docs/sidebar.md +3 -3
- package/docs/ui-guidelines.md +5 -1
- package/package.json +1 -1
- 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 +327 -29
- package/src/server/realtime/bus.ts +8 -2
- package/src/server/routes.ts +23 -2
- package/src/server/runtime-config.ts +1 -1
- package/src/web/WebAppRoot.tsx +233 -27
- package/src/web/components/index.tsx +44 -11
- package/src/web/index.ts +1 -0
- package/src/web/render.tsx +26 -0
- package/src/web/styles.css +65 -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,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/
|
|
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/
|
|
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) =>
|
|
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
|
-
|
|
492
|
-
<
|
|
493
|
-
{
|
|
494
|
-
|
|
495
|
-
|
|
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 ? "
|
|
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
|
-
<
|
|
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
|
-
{
|
|
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 (
|
|
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 ${
|
|
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
|
-
<
|
|
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
|
@@ -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
|
+
}
|
package/src/web/styles.css
CHANGED
|
@@ -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:
|
|
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 {
|