@pablozaiden/webapp 0.2.1 → 0.2.3
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/docs/sidebar.md
CHANGED
|
@@ -36,7 +36,7 @@ Sidebar nodes support:
|
|
|
36
36
|
| `badge` | Status/count label |
|
|
37
37
|
| `defaultCollapsed` | Initial collapsed state |
|
|
38
38
|
|
|
39
|
-
Search is intentionally app-defined: `getNodes({ search })` receives raw search text and returns the tree that should be rendered.
|
|
39
|
+
Search is intentionally app-defined: `getNodes({ search })` receives raw search text and returns the tree that should be rendered. Set `sidebar.search: false` when an app has a small fixed navigation tree and should not show the sidebar search box.
|
|
40
40
|
|
|
41
41
|
Use `actions` when an entity needs commands in the sidebar. The same `ActionMenuItem[]` should also be returned from `header.getActions` for that entity route so right-click actions and the title-bar three-line menu stay consistent:
|
|
42
42
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pablozaiden/webapp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Opinionated Bun + React webapp framework for single-server apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -44,11 +44,13 @@
|
|
|
44
44
|
"@simplewebauthn/browser": "13.3.0",
|
|
45
45
|
"@simplewebauthn/server": "13.3.1",
|
|
46
46
|
"jose": "6.2.3",
|
|
47
|
-
"react": "19.2.7",
|
|
48
|
-
"react-dom": "19.2.7",
|
|
49
47
|
"tailwindcss": "4.3.1",
|
|
50
48
|
"zod": "4.4.3"
|
|
51
49
|
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"react": "19.2.7",
|
|
52
|
+
"react-dom": "19.2.7"
|
|
53
|
+
},
|
|
52
54
|
"devDependencies": {
|
|
53
55
|
"@happy-dom/global-registrator": "20.10.3",
|
|
54
56
|
"@testing-library/react": "16.3.2",
|
|
@@ -56,6 +58,8 @@
|
|
|
56
58
|
"@types/react": "19.2.17",
|
|
57
59
|
"@types/react-dom": "19.2.3",
|
|
58
60
|
"playwright": "1.61.0",
|
|
61
|
+
"react": "19.2.7",
|
|
62
|
+
"react-dom": "19.2.7",
|
|
59
63
|
"typescript": "6.0.3"
|
|
60
64
|
}
|
|
61
65
|
}
|
|
@@ -227,14 +227,14 @@ export function passkeyStatus(req: Request, store: WebAppStore, config: RuntimeC
|
|
|
227
227
|
const users = store.countUsers();
|
|
228
228
|
const owner = store.getOwnerUser();
|
|
229
229
|
const passkeyConfigured = store.listPasskeys().length > 0;
|
|
230
|
-
const ownerPasskeySetupRequired = users > 0 && Boolean(owner && !owner.passkeyConfigured);
|
|
230
|
+
const ownerPasskeySetupRequired = enabled && !config.passkeyDisabled && users > 0 && Boolean(owner && !owner.passkeyConfigured);
|
|
231
231
|
return {
|
|
232
232
|
enabled,
|
|
233
233
|
passkeyConfigured,
|
|
234
234
|
passkeyDisabled: config.passkeyDisabled,
|
|
235
235
|
passkeyRequired: enabled && users > 0 && !config.passkeyDisabled,
|
|
236
|
-
authenticated: !enabled || Boolean(getPasskeySessionUser(req, store, config)),
|
|
237
|
-
bootstrapRequired: enabled && users === 0,
|
|
236
|
+
authenticated: !enabled || config.passkeyDisabled || Boolean(getPasskeySessionUser(req, store, config)),
|
|
237
|
+
bootstrapRequired: enabled && !config.passkeyDisabled && users === 0,
|
|
238
238
|
ownerPasskeySetupRequired,
|
|
239
239
|
};
|
|
240
240
|
}
|
|
@@ -60,6 +60,9 @@ export interface WebAppServerConfig<TEvent = unknown> {
|
|
|
60
60
|
realtime?: {
|
|
61
61
|
path?: string;
|
|
62
62
|
};
|
|
63
|
+
logLevel?: {
|
|
64
|
+
onChange?: (level: LogLevelName) => void;
|
|
65
|
+
};
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
export const WEBAPP_SOCKET_HANDLER = "webappSocketHandler";
|
|
@@ -90,6 +93,7 @@ export interface WebAppServer<TEvent = unknown> {
|
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
const log = createLogger("webapp:server");
|
|
96
|
+
const LOG_LEVELS = new Set<LogLevelName>(["trace", "debug", "info", "warn", "error"]);
|
|
93
97
|
|
|
94
98
|
function bearerToken(req: Request): string | undefined {
|
|
95
99
|
const header = req.headers.get("authorization")?.trim();
|
|
@@ -177,6 +181,16 @@ function currentUser(auth: AuthenticatedRequestState): CurrentUser | undefined {
|
|
|
177
181
|
return auth.kind === "anonymous" ? undefined : auth.user;
|
|
178
182
|
}
|
|
179
183
|
|
|
184
|
+
function toCurrentUserRecord(user: { id: string; username: string; role: "owner" | "admin" | "user" }): CurrentUser {
|
|
185
|
+
return {
|
|
186
|
+
id: user.id,
|
|
187
|
+
username: user.username,
|
|
188
|
+
role: user.role,
|
|
189
|
+
isOwner: user.role === "owner",
|
|
190
|
+
isAdmin: user.role === "owner" || user.role === "admin",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
180
194
|
function requireUser(auth: AuthenticatedRequestState): CurrentUser {
|
|
181
195
|
const user = currentUser(auth);
|
|
182
196
|
if (!user) {
|
|
@@ -274,7 +288,9 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
274
288
|
const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
|
|
275
289
|
store.initialize();
|
|
276
290
|
const savedLogLevel = store.getLogLevelPreference();
|
|
277
|
-
|
|
291
|
+
const activeLogLevel = config.logLevelFromEnv ? config.logLevel : savedLogLevel ?? config.logLevel;
|
|
292
|
+
setLogLevel(activeLogLevel);
|
|
293
|
+
input.logLevel?.onChange?.(activeLogLevel);
|
|
278
294
|
const realtime = createRealtimeBus<TEvent>();
|
|
279
295
|
const version = input.version ?? "0.0.0-development";
|
|
280
296
|
const wsPath = input.realtime?.path ?? "/api/ws";
|
|
@@ -285,6 +301,16 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
285
301
|
const apiKeysEnabled = input.auth?.apiKeys ?? false;
|
|
286
302
|
const deviceAuthEnabled = input.auth?.deviceAuth ?? false;
|
|
287
303
|
|
|
304
|
+
function disabledAuthOwner(): CurrentUser {
|
|
305
|
+
const existing = store.getOwnerUser();
|
|
306
|
+
if (existing) {
|
|
307
|
+
return toCurrentUserRecord(existing);
|
|
308
|
+
}
|
|
309
|
+
const owner = createUserRecord({ username: "admin", role: "owner" });
|
|
310
|
+
store.createUser(owner);
|
|
311
|
+
return toCurrentUserRecord(owner);
|
|
312
|
+
}
|
|
313
|
+
|
|
288
314
|
async function authorize(req: Request, required: boolean): Promise<AuthenticatedRequestState | Response> {
|
|
289
315
|
const token = bearerToken(req);
|
|
290
316
|
if (token) {
|
|
@@ -314,6 +340,9 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
314
340
|
return errorResponse(401, "invalid_token", "Bearer token is invalid");
|
|
315
341
|
}
|
|
316
342
|
if (passkeysEnabled) {
|
|
343
|
+
if (config.passkeyDisabled) {
|
|
344
|
+
return { kind: "passkey", user: disabledAuthOwner() };
|
|
345
|
+
}
|
|
317
346
|
const user = getPasskeySessionUser(req, store, config);
|
|
318
347
|
if (user) {
|
|
319
348
|
return { kind: "passkey", user };
|
|
@@ -327,11 +356,16 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
327
356
|
}) : { kind: "anonymous" };
|
|
328
357
|
}
|
|
329
358
|
}
|
|
359
|
+
if (required && passkeysEnabled && !config.passkeyDisabled && store.countUsers() === 0) {
|
|
360
|
+
return errorResponse(401, "authentication_required", "Passkey authentication is required", undefined, {
|
|
361
|
+
headers: { "x-webapp-passkey-required": "true" },
|
|
362
|
+
});
|
|
363
|
+
}
|
|
330
364
|
return { kind: "anonymous" };
|
|
331
365
|
}
|
|
332
366
|
|
|
333
367
|
function configResponse(req: Request): WebAppConfigResponse {
|
|
334
|
-
const user = passkeysEnabled ? getPasskeySessionUser(req, store, config) : undefined;
|
|
368
|
+
const user = passkeysEnabled && config.passkeyDisabled ? disabledAuthOwner() : passkeysEnabled ? getPasskeySessionUser(req, store, config) : undefined;
|
|
335
369
|
return {
|
|
336
370
|
appName: config.appName,
|
|
337
371
|
version,
|
|
@@ -637,8 +671,12 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
637
671
|
if (originFailure) return originFailure;
|
|
638
672
|
if (config.logLevelFromEnv) return errorResponse(409, "log_level_from_env", "Log level is controlled by environment");
|
|
639
673
|
const body = await parseJson<{ level: LogLevelName }>(req);
|
|
674
|
+
if (!LOG_LEVELS.has(body.level)) {
|
|
675
|
+
return errorResponse(400, "invalid_log_level", "Log level must be one of trace, debug, info, warn, error");
|
|
676
|
+
}
|
|
640
677
|
store.setLogLevelPreference(body.level);
|
|
641
678
|
setLogLevel(body.level);
|
|
679
|
+
input.logLevel?.onChange?.(body.level);
|
|
642
680
|
return successResponse({ level: body.level });
|
|
643
681
|
}
|
|
644
682
|
}
|
|
@@ -776,10 +814,12 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
776
814
|
|
|
777
815
|
function start(): Server<WebAppWebSocketData> {
|
|
778
816
|
const dynamicHandler = (req: Request, server: Server<WebAppWebSocketData>) => handleRequest(req, server);
|
|
817
|
+
const publicRouteHandlers = Object.fromEntries(Object.keys(publicRoutes).map((path) => [path, dynamicHandler]));
|
|
779
818
|
const server = Bun.serve<WebAppWebSocketData>({
|
|
780
819
|
hostname: config.host,
|
|
781
820
|
port: config.port,
|
|
782
821
|
routes: {
|
|
822
|
+
...publicRouteHandlers,
|
|
783
823
|
"/api/*": dynamicHandler,
|
|
784
824
|
"/.well-known/*": dynamicHandler,
|
|
785
825
|
"/device": deviceAuthEnabled && !canRespondWithIndex(input.index) ? input.index as never : dynamicHandler,
|
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -40,6 +40,7 @@ export interface WebAppRootProps {
|
|
|
40
40
|
appName: string;
|
|
41
41
|
homeRoute: WebAppRoute;
|
|
42
42
|
sidebar: {
|
|
43
|
+
search?: boolean;
|
|
43
44
|
topActions?: SidebarAction[];
|
|
44
45
|
pinning?: false | {
|
|
45
46
|
sectionTitle?: string;
|
|
@@ -876,10 +877,11 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
|
|
|
876
877
|
const [search, setSearch] = useState("");
|
|
877
878
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
878
879
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
880
|
+
const sidebarSearchEnabled = sidebar.search !== false;
|
|
879
881
|
const pinningEnabled = sidebar.pinning !== false;
|
|
880
882
|
const sidebarPins = useSidebarPins(appName, sidebar.pinning ? sidebar.pinning.storageKey : undefined);
|
|
881
883
|
const baseNodes = useMemo(() => sidebar.getNodes({ search: "" }), [sidebar]);
|
|
882
|
-
const filteredNodes = useMemo(() => sidebar.getNodes({ search }), [sidebar, search]);
|
|
884
|
+
const filteredNodes = useMemo(() => sidebar.getNodes({ search: sidebarSearchEnabled ? search : "" }), [sidebar, search, sidebarSearchEnabled]);
|
|
883
885
|
const allPinnableItems = useMemo(() => flattenSidebarItems(baseNodes).filter((node) => node.pinnable && node.route), [baseNodes]);
|
|
884
886
|
const currentPins = useMemo(() => {
|
|
885
887
|
const byId = new Map(allPinnableItems.map((node) => [node.pinId ?? node.id, node]));
|
|
@@ -907,7 +909,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
|
|
|
907
909
|
}), [pinningActionFor]);
|
|
908
910
|
const nodes = useMemo(() => {
|
|
909
911
|
const augmented = augmentPinningActions(filteredNodes);
|
|
910
|
-
if (!pinningEnabled || search.trim() || currentPins.length === 0) return augmented;
|
|
912
|
+
if (!pinningEnabled || (sidebarSearchEnabled && search.trim()) || currentPins.length === 0) return augmented;
|
|
911
913
|
const augmentedByPinId = new Map(flattenSidebarItems(augmentPinningActions(baseNodes)).map((node) => [node.pinId ?? node.id, node]));
|
|
912
914
|
const pinnedChildren = currentPins.map((pin) => ({
|
|
913
915
|
...(augmentedByPinId.get(pin.id) ?? {
|
|
@@ -927,7 +929,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
|
|
|
927
929
|
{ type: "section" as const, id: "framework:pinned", title: sidebar.pinning ? sidebar.pinning.sectionTitle ?? "Pinned" : "Pinned", children: pinnedChildren },
|
|
928
930
|
...augmented,
|
|
929
931
|
];
|
|
930
|
-
}, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, search, sidebar.pinning]);
|
|
932
|
+
}, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, search, sidebar.pinning, sidebarSearchEnabled]);
|
|
931
933
|
|
|
932
934
|
useEffect(() => {
|
|
933
935
|
if (!config?.currentUser) return;
|
|
@@ -988,7 +990,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, settin
|
|
|
988
990
|
</div>
|
|
989
991
|
</div>
|
|
990
992
|
<div className="wapp-sidebar-scroll">
|
|
991
|
-
<label className="wapp-search"><span className="sr-only">Search</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search" /></label>
|
|
993
|
+
{sidebarSearchEnabled ? <label className="wapp-search"><span className="sr-only">Search</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search" /></label> : null}
|
|
992
994
|
<SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} />
|
|
993
995
|
</div>
|
|
994
996
|
<div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
|
package/src/web/index.ts
CHANGED
|
@@ -4,4 +4,5 @@ export * from "./render";
|
|
|
4
4
|
export * from "./api-client";
|
|
5
5
|
export * from "./sidebar/types";
|
|
6
6
|
export * from "./realtime/useRealtime";
|
|
7
|
+
export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
7
8
|
export type { ThemePreference } from "../contracts";
|
package/src/web/styles.css
CHANGED
|
@@ -725,6 +725,7 @@ textarea::placeholder {
|
|
|
725
725
|
font-size: 0.875rem;
|
|
726
726
|
font-weight: 600;
|
|
727
727
|
padding: 0.5rem 0.875rem;
|
|
728
|
+
white-space: nowrap;
|
|
728
729
|
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
|
|
729
730
|
}
|
|
730
731
|
|
|
@@ -978,6 +979,10 @@ textarea::placeholder {
|
|
|
978
979
|
gap: 0.5rem;
|
|
979
980
|
}
|
|
980
981
|
|
|
982
|
+
.wapp-settings .wapp-row-actions {
|
|
983
|
+
flex: 0 0 auto;
|
|
984
|
+
}
|
|
985
|
+
|
|
981
986
|
.wapp-danger-zone {
|
|
982
987
|
display: flex;
|
|
983
988
|
align-items: flex-start;
|
|
@@ -1156,6 +1161,10 @@ textarea::placeholder {
|
|
|
1156
1161
|
padding: 0.75rem;
|
|
1157
1162
|
}
|
|
1158
1163
|
|
|
1164
|
+
.wapp-list-row > span {
|
|
1165
|
+
min-width: 0;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1159
1168
|
.wapp-grid-2 {
|
|
1160
1169
|
display: grid;
|
|
1161
1170
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
@@ -1395,3 +1404,21 @@ textarea::placeholder {
|
|
|
1395
1404
|
margin-top: 0.75rem;
|
|
1396
1405
|
}
|
|
1397
1406
|
}
|
|
1407
|
+
|
|
1408
|
+
@media (max-width: 640px) {
|
|
1409
|
+
.wapp-settings .wapp-list-row {
|
|
1410
|
+
display: grid;
|
|
1411
|
+
grid-template-columns: minmax(0, 1fr);
|
|
1412
|
+
align-items: start;
|
|
1413
|
+
gap: 0.75rem;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
.wapp-settings .wapp-list-row .wapp-row-actions {
|
|
1417
|
+
justify-content: flex-start;
|
|
1418
|
+
margin-top: 0;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
.wapp-settings .wapp-row-actions .wapp-inline-select {
|
|
1422
|
+
max-width: 100%;
|
|
1423
|
+
}
|
|
1424
|
+
}
|