@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.1",
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
- setLogLevel(config.logLevelFromEnv ? config.logLevel : savedLogLevel ?? config.logLevel);
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,
@@ -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";
@@ -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
+ }