@nubitio/admin 1.1.1 → 1.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/dist/index.cjs CHANGED
@@ -16,7 +16,7 @@ var __copyProps = (to, from, except, desc) => {
16
16
  }
17
17
  return to;
18
18
  };
19
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
20
20
  value: mod,
21
21
  enumerable: true
22
22
  }) : target, mod));
@@ -26,8 +26,8 @@ let react_jsx_runtime = require("react/jsx-runtime");
26
26
  let react = require("react");
27
27
  react = __toESM(react, 1);
28
28
  let _nubitio_ui = require("@nubitio/ui");
29
- let _tanstack_react_query = require("@tanstack/react-query");
30
29
  let _nubitio_core = require("@nubitio/core");
30
+ let _tanstack_react_query = require("@tanstack/react-query");
31
31
  let _nubitio_crud = require("@nubitio/crud");
32
32
  let _nubitio_hydra = require("@nubitio/hydra");
33
33
  //#region packages/admin/FeatureHubLayout.tsx
@@ -148,50 +148,61 @@ function ActionPopover({ action }) {
148
148
  });
149
149
  }
150
150
  function UserMenuPopover({ renderUserMenu }) {
151
+ const strings = (0, _nubitio_ui.useUiStrings)();
151
152
  const { open, toggle, setOpen, containerRef } = (0, _nubitio_ui.useFloatingPanel)();
152
153
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
153
154
  className: "nb-admin-header-popover",
154
155
  ref: containerRef,
155
156
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.IconButton, {
156
157
  icon: "ph ph-user-circle",
157
- label: "User menu",
158
+ label: strings.userMenu,
158
159
  "aria-expanded": open,
159
160
  onClick: toggle
160
161
  }), open && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
161
162
  className: "nb-admin-header-popover__panel",
162
163
  role: "dialog",
163
- "aria-label": "User menu",
164
+ "aria-label": strings.userMenu,
164
165
  children: renderUserMenu({ close: () => setOpen(false) })
165
166
  })]
166
167
  });
167
168
  }
168
- const AdminHeader = ({ title, menuToggleEnabled, toggleMenu, className, actions = [], renderUserMenu, renderThemeSwitcher }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("header", {
169
- className: ["nb-admin-header-component", className].filter(Boolean).join(" "),
170
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
171
- className: "nb-admin-header-toolbar",
172
- role: "toolbar",
173
- "aria-label": "Main toolbar",
174
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
175
- className: "nb-admin-header-toolbar__before",
176
- children: [menuToggleEnabled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.IconButton, {
177
- className: "nb-admin-menu-button",
178
- icon: "ph ph-list",
179
- label: "Toggle menu",
180
- onClick: toggleMenu
181
- }), title && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
182
- className: "nb-admin-header-title",
183
- children: title
184
- })]
185
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
186
- className: "nb-admin-header-toolbar__after",
169
+ const AdminHeader = ({ title, menuToggleEnabled, toggleMenu, className, actions = [], content, renderUserMenu, renderThemeSwitcher }) => {
170
+ const strings = (0, _nubitio_ui.useUiStrings)();
171
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("header", {
172
+ className: ["nb-admin-header-component", className].filter(Boolean).join(" "),
173
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
174
+ className: "nb-admin-header-toolbar",
175
+ role: "toolbar",
176
+ "aria-label": strings.mainToolbar,
187
177
  children: [
188
- renderThemeSwitcher?.(),
189
- actions.map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionPopover, { action }, action.id)),
190
- renderUserMenu && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMenuPopover, { renderUserMenu })
178
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
179
+ className: "nb-admin-header-toolbar__before",
180
+ children: [menuToggleEnabled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.IconButton, {
181
+ className: "nb-admin-menu-button",
182
+ icon: "ph ph-list",
183
+ label: strings.toggleMenu,
184
+ onClick: toggleMenu
185
+ }), title && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
186
+ className: "nb-admin-header-title",
187
+ children: title
188
+ })]
189
+ }),
190
+ content && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
191
+ className: "nb-admin-header-content",
192
+ children: content
193
+ }),
194
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
195
+ className: "nb-admin-header-toolbar__after",
196
+ children: [
197
+ renderThemeSwitcher?.(),
198
+ actions.map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionPopover, { action }, action.id)),
199
+ renderUserMenu && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMenuPopover, { renderUserMenu })
200
+ ]
201
+ })
191
202
  ]
192
- })]
193
- })
194
- });
203
+ })
204
+ });
205
+ };
195
206
  //#endregion
196
207
  //#region packages/admin/useScreenSize.ts
197
208
  let handlers = [];
@@ -255,8 +266,9 @@ const getIconClassName = (icon) => {
255
266
  if (!icon) return "";
256
267
  return icon.includes(" ") ? icon : `ph ph-${icon}`;
257
268
  };
258
- const AdminSidebarMenu = ({ items, compactMode = false, selectedItemChanged, openMenu, footer }) => {
269
+ const AdminSidebarMenu = ({ items, compactMode = false, selectedItemChanged, openMenu, footer, brand, context }) => {
259
270
  const [expandedItemKeys, setExpandedItemKeys] = (0, react.useState)(/* @__PURE__ */ new Set());
271
+ const strings = (0, _nubitio_ui.useUiStrings)();
260
272
  const { isLarge } = useScreenSize();
261
273
  const location = (0, react_router_dom.useLocation)();
262
274
  (0, react.useEffect)(() => {
@@ -303,74 +315,86 @@ const AdminSidebarMenu = ({ items, compactMode = false, selectedItemChanged, ope
303
315
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
304
316
  className: `nb-admin-menu${compactMode ? " compact" : ""}`,
305
317
  onPointerDown: openMenu,
306
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
307
- className: "nb-admin-menu-container theme-dependent",
308
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("nav", {
309
- className: "nb-admin-menu__nav",
310
- "aria-label": "Main navigation",
311
- children: items.map((item) => {
312
- const selected = isSelected(item.path);
313
- const containsSelected = parentContainsSelected(item);
314
- const itemKey = item.path || item.text;
315
- const hasChildren = (item.items?.length ?? 0) > 0;
316
- const expanded = !compactMode && hasChildren && expandedItemKeys.has(itemKey);
317
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
318
- className: `nb-admin-menu__section${containsSelected ? " has-selected-child" : ""}`,
319
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
320
- className: `nb-admin-menu__item nb-admin-menu__item--parent${selected ? " is-selected" : ""}`,
321
- type: "button",
322
- "aria-current": selected ? "page" : void 0,
323
- "aria-expanded": hasChildren ? expanded : void 0,
324
- onClick: (event) => {
325
- if (hasChildren) {
326
- toggleExpanded(itemKey);
327
- return;
328
- }
329
- handleSelect(item.path, selected, event);
330
- },
331
- children: [
332
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
333
- className: `nb-admin-menu__icon ${getIconClassName(item.icon)}`,
334
- "aria-hidden": "true"
335
- }),
336
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
337
- className: "nb-admin-menu__text",
338
- children: item.text
339
- }),
340
- hasChildren && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
341
- className: `nb-admin-menu__chevron${expanded ? " is-expanded" : ""}`,
342
- "aria-hidden": "true"
343
- })
344
- ]
345
- }), expanded && (item.items?.length ?? 0) > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
346
- className: "nb-admin-menu__children",
347
- children: item.items.map((subItem) => {
348
- const childSelected = isSelected(subItem.path);
349
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
350
- className: `nb-admin-menu__item nb-admin-menu__item--child${childSelected ? " is-selected" : ""}`,
351
- type: "button",
352
- "aria-current": childSelected ? "page" : void 0,
353
- onClick: (event) => handleSelect(subItem.path, childSelected, event),
354
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
318
+ children: [
319
+ brand && !compactMode && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
320
+ className: "nb-admin-menu__brand",
321
+ children: brand
322
+ }),
323
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
324
+ className: "nb-admin-menu-container theme-dependent",
325
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("nav", {
326
+ className: "nb-admin-menu__nav",
327
+ "aria-label": strings.mainNavigation,
328
+ children: items.map((item) => {
329
+ const selected = isSelected(item.path);
330
+ const containsSelected = parentContainsSelected(item);
331
+ const itemKey = item.path || item.text;
332
+ const hasChildren = (item.items?.length ?? 0) > 0;
333
+ const expanded = !compactMode && hasChildren && expandedItemKeys.has(itemKey);
334
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
335
+ className: `nb-admin-menu__section${containsSelected ? " has-selected-child" : ""}`,
336
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
337
+ className: `nb-admin-menu__item nb-admin-menu__item--parent${selected ? " is-selected" : ""}`,
338
+ type: "button",
339
+ "aria-current": selected ? "page" : void 0,
340
+ "aria-expanded": hasChildren ? expanded : void 0,
341
+ onClick: (event) => {
342
+ if (hasChildren) {
343
+ toggleExpanded(itemKey);
344
+ return;
345
+ }
346
+ handleSelect(item.path, selected, event);
347
+ },
348
+ children: [
349
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
350
+ className: `nb-admin-menu__icon ${getIconClassName(item.icon)}`,
351
+ "aria-hidden": "true"
352
+ }),
353
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
355
354
  className: "nb-admin-menu__text",
356
- children: subItem.text
355
+ children: item.text
356
+ }),
357
+ hasChildren && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
358
+ className: `nb-admin-menu__chevron${expanded ? " is-expanded" : ""}`,
359
+ "aria-hidden": "true"
357
360
  })
358
- }, subItem.path || subItem.text);
359
- })
360
- })]
361
- }, itemKey);
361
+ ]
362
+ }), expanded && (item.items?.length ?? 0) > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
363
+ className: "nb-admin-menu__children",
364
+ children: item.items.map((subItem) => {
365
+ const childSelected = isSelected(subItem.path);
366
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
367
+ className: `nb-admin-menu__item nb-admin-menu__item--child${childSelected ? " is-selected" : ""}`,
368
+ type: "button",
369
+ "aria-current": childSelected ? "page" : void 0,
370
+ onClick: (event) => handleSelect(subItem.path, childSelected, event),
371
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
372
+ className: "nb-admin-menu__text",
373
+ children: subItem.text
374
+ })
375
+ }, subItem.path || subItem.text);
376
+ })
377
+ })]
378
+ }, itemKey);
379
+ })
362
380
  })
381
+ }),
382
+ context && !compactMode && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
383
+ className: "nb-admin-menu__context",
384
+ children: context
385
+ }),
386
+ footer && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("footer", {
387
+ className: "nb-admin-footer",
388
+ children: footer
363
389
  })
364
- }), footer && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("footer", {
365
- className: "nb-admin-footer",
366
- children: footer
367
- })]
390
+ ]
368
391
  });
369
392
  };
370
393
  //#endregion
371
394
  //#region packages/admin/AdminShell.tsx
372
- const AdminShell = ({ title, menuItems, headerActions, renderUserMenu, renderThemeSwitcher, footer, children }) => {
395
+ const AdminShell = ({ title, menuItems, headerActions, renderUserMenu, renderThemeSwitcher, footer, variant = "default", brand, headerContent, sidebarContext, bottomNavigation, children }) => {
373
396
  const navigate = (0, react_router_dom.useNavigate)();
397
+ const strings = (0, _nubitio_ui.useUiStrings)();
374
398
  const { isXSmall, isLarge } = useScreenSize();
375
399
  const [menuStatus, setMenuStatus] = (0, react.useState)(null);
376
400
  const getDefaultMenuOpenState = (0, react.useCallback)(() => isLarge ? 2 : 1, [isLarge]);
@@ -424,45 +448,121 @@ const AdminShell = ({ title, menuItems, headerActions, renderUserMenu, renderThe
424
448
  isXSmall ? "is-xsmall" : ""
425
449
  ].filter(Boolean).join(" ");
426
450
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
427
- className: "nb-admin-shell",
428
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdminHeader, {
429
- className: "nb-admin-layout-header",
430
- menuToggleEnabled: true,
431
- toggleMenu,
432
- title,
433
- actions: headerActions,
434
- renderUserMenu,
435
- renderThemeSwitcher
436
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
437
- className: bodyClassName,
438
- children: [
439
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("aside", {
440
- className: "nb-admin-shell__panel",
441
- "aria-label": "Main menu",
442
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdminSidebarMenu, {
443
- items: menuItems,
444
- compactMode: isCompact,
445
- selectedItemChanged: onNavigationChanged,
446
- openMenu: temporaryOpenMenu,
447
- footer
451
+ className: [
452
+ "nb-admin-shell",
453
+ variant === "workspace" && "nb-admin-shell--workspace",
454
+ variant === "workspace" && isLarge && "is-workspace-desktop",
455
+ variant === "workspace" && isLarge && isCompact && "is-workspace-compact"
456
+ ].filter(Boolean).join(" "),
457
+ children: [
458
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdminHeader, {
459
+ className: "nb-admin-layout-header",
460
+ menuToggleEnabled: true,
461
+ toggleMenu,
462
+ title,
463
+ actions: headerActions,
464
+ content: headerContent,
465
+ renderUserMenu,
466
+ renderThemeSwitcher
467
+ }),
468
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
469
+ className: bodyClassName,
470
+ children: [
471
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("aside", {
472
+ className: "nb-admin-shell__panel",
473
+ "aria-label": strings.mainMenu,
474
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdminSidebarMenu, {
475
+ items: menuItems,
476
+ compactMode: isCompact,
477
+ selectedItemChanged: onNavigationChanged,
478
+ openMenu: temporaryOpenMenu,
479
+ footer,
480
+ brand,
481
+ context: sidebarContext
482
+ })
483
+ }),
484
+ !isLarge && isMenuOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
485
+ className: "nb-admin-shell__scrim",
486
+ type: "button",
487
+ "aria-label": strings.closeMenu,
488
+ onClick: closeMenuFromOverlay
489
+ }),
490
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("main", {
491
+ className: "nb-admin-shell__content content",
492
+ tabIndex: 0,
493
+ "aria-label": title ? `${title} content` : strings.mainContent,
494
+ children
448
495
  })
496
+ ]
497
+ }),
498
+ isXSmall && bottomNavigation && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
499
+ className: "nb-admin-shell__bottom-navigation",
500
+ children: bottomNavigation
501
+ })
502
+ ]
503
+ });
504
+ };
505
+ //#endregion
506
+ //#region packages/admin/AdminBottomNavigation.tsx
507
+ function AdminBottomNavigation({ items, label }) {
508
+ const { pathname } = (0, react_router_dom.useLocation)();
509
+ const strings = (0, _nubitio_ui.useUiStrings)();
510
+ const normalize = (path) => path.replace(/\/+$/, "") || "/";
511
+ const currentPath = normalize(pathname);
512
+ const activePath = items.map((item) => normalize(item.path)).filter((path) => currentPath === path || path !== "/" && currentPath.startsWith(`${path}/`)).sort((a, b) => b.length - a.length)[0];
513
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("nav", {
514
+ className: "nb-admin-bottom-nav",
515
+ "aria-label": label ?? strings.mainNavigation,
516
+ children: items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_router_dom.Link, {
517
+ to: item.path,
518
+ className: "nb-admin-bottom-nav__item",
519
+ "aria-current": normalize(item.path) === activePath ? "page" : void 0,
520
+ children: [
521
+ item.icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
522
+ className: `nb-admin-bottom-nav__icon ${item.icon.includes(" ") ? item.icon : `ph ph-${item.icon}`}`,
523
+ "aria-hidden": "true"
449
524
  }),
450
- !isLarge && isMenuOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
451
- className: "nb-admin-shell__scrim",
452
- type: "button",
453
- "aria-label": "Close menu",
454
- onClick: closeMenuFromOverlay
525
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
526
+ className: "nb-admin-bottom-nav__label",
527
+ children: item.text
455
528
  }),
456
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("main", {
457
- className: "nb-admin-shell__content content",
458
- tabIndex: 0,
459
- "aria-label": title ? `${title} content` : "Main content",
460
- children
529
+ item.badge != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
530
+ className: "nb-admin-bottom-nav__badge",
531
+ children: item.badge
461
532
  })
462
533
  ]
463
- })]
534
+ }, item.path))
464
535
  });
465
- };
536
+ }
537
+ //#endregion
538
+ //#region packages/admin/auth/csrfFetch.ts
539
+ const MUTATING_METHODS = /* @__PURE__ */ new Set([
540
+ "POST",
541
+ "PUT",
542
+ "PATCH",
543
+ "DELETE"
544
+ ]);
545
+ /**
546
+ * `fetch` that echoes the double-submit CSRF cookie as `X-CSRF-Token` on
547
+ * state-changing requests.
548
+ *
549
+ * The backend rejects cookie-authenticated POST/PUT/PATCH/DELETE that lack it
550
+ * (`nubit_admin.auth.csrf_protection`, on by default), so the auth pages' own
551
+ * calls — logout, change password, TOTP, session revocation — need it exactly
552
+ * as the CRUD client does. Reads, and requests made before login has issued the
553
+ * cookie, pass through untouched.
554
+ */
555
+ function csrfFetch(input, init = {}) {
556
+ const method = (init.method ?? "GET").toUpperCase();
557
+ const token = MUTATING_METHODS.has(method) ? (0, _nubitio_core.readCsrfToken)() : void 0;
558
+ if (token === void 0) return fetch(input, init);
559
+ const headers = new Headers(init.headers);
560
+ if (!headers.has(_nubitio_core.DEFAULT_CSRF_HEADER_NAME)) headers.set(_nubitio_core.DEFAULT_CSRF_HEADER_NAME, token);
561
+ return fetch(input, {
562
+ ...init,
563
+ headers
564
+ });
565
+ }
466
566
  //#endregion
467
567
  //#region packages/admin/auth/SessionContext.tsx
468
568
  const SessionContext = (0, react.createContext)(null);
@@ -478,9 +578,10 @@ function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "au
478
578
  setSession({ status: "anonymous" });
479
579
  return;
480
580
  }
581
+ const profile = await response.json();
481
582
  setSession({
482
583
  status: "authenticated",
483
- profile: await response.json()
584
+ profile
484
585
  });
485
586
  } catch {
486
587
  setSession({ status: "anonymous" });
@@ -490,7 +591,7 @@ function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "au
490
591
  refresh();
491
592
  }, [refresh]);
492
593
  const logout = (0, react.useCallback)(async () => {
493
- await fetch(joinApiPath$7(apiBaseUrl, logoutPath), {
594
+ await csrfFetch(joinApiPath$7(apiBaseUrl, logoutPath), {
494
595
  method: "POST",
495
596
  credentials: "include"
496
597
  });
@@ -551,9 +652,10 @@ function useFeatureConfig(featureKey) {
551
652
  //#endregion
552
653
  //#region packages/admin/features/FeatureGate.tsx
553
654
  function FeatureGate({ featureKey, ...props }) {
655
+ const enabled = useFeature(featureKey);
554
656
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.FeatureGate, {
555
657
  featureKey,
556
- enabled: useFeature(featureKey),
658
+ enabled,
557
659
  ...props
558
660
  });
559
661
  }
@@ -601,10 +703,13 @@ function PlanPanel({ plans, currentPlanId, subscriptionStatus, trialEndsAt, onSe
601
703
  const currentBadge = labels?.currentBadge ?? "Current plan";
602
704
  const selectingLabel = labels?.selecting ?? "Updating…";
603
705
  let statusLine = null;
604
- if (subscriptionStatus === "trialing" && trialEndsAt) statusLine = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
605
- className: "nb-plan-panel__status",
606
- children: (labels?.trialEnds ?? "Trial ends {date}").replace("{date}", formatTrialEnds(trialEndsAt))
607
- });
706
+ if (subscriptionStatus === "trialing" && trialEndsAt) {
707
+ const trialTemplate = labels?.trialEnds ?? "Trial ends {date}";
708
+ statusLine = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
709
+ className: "nb-plan-panel__status",
710
+ children: trialTemplate.replace("{date}", formatTrialEnds(trialEndsAt))
711
+ });
712
+ }
608
713
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
609
714
  className: `nb-plan-panel${className ? ` ${className}` : ""}`,
610
715
  "aria-labelledby": "plan-panel-title",
@@ -1000,7 +1105,7 @@ function ForgotPasswordPage({ apiBaseUrl = "/api/" }) {
1000
1105
  event.preventDefault();
1001
1106
  setBusy(true);
1002
1107
  try {
1003
- await fetch(joinApiPath$5(apiBaseUrl, "auth/password/forgot"), {
1108
+ await csrfFetch(joinApiPath$5(apiBaseUrl, "auth/password/forgot"), {
1004
1109
  method: "POST",
1005
1110
  headers: { "Content-Type": "application/json" },
1006
1111
  credentials: "include",
@@ -1076,7 +1181,7 @@ function ResetPasswordPage({ apiBaseUrl = "/api/" }) {
1076
1181
  setBusy(true);
1077
1182
  setError(null);
1078
1183
  try {
1079
- const response = await fetch(joinApiPath$4(apiBaseUrl, "auth/password/reset"), {
1184
+ const response = await csrfFetch(joinApiPath$4(apiBaseUrl, "auth/password/reset"), {
1080
1185
  method: "POST",
1081
1186
  headers: { "Content-Type": "application/json" },
1082
1187
  credentials: "include",
@@ -1086,7 +1191,8 @@ function ResetPasswordPage({ apiBaseUrl = "/api/" }) {
1086
1191
  })
1087
1192
  });
1088
1193
  if (!response.ok) {
1089
- setError((await response.json().catch(() => null))?.message ?? "Reset failed");
1194
+ const payload = await response.json().catch(() => null);
1195
+ setError(payload?.message ?? "Reset failed");
1090
1196
  return;
1091
1197
  }
1092
1198
  setDone(true);
@@ -1168,13 +1274,14 @@ function AcceptInvitationPage({ apiBaseUrl = "/api/" }) {
1168
1274
  const [busy, setBusy] = (0, react.useState)(false);
1169
1275
  (0, react.useEffect)(() => {
1170
1276
  let cancelled = false;
1171
- fetch(joinApiPath$3(apiBaseUrl, `invitations/${token}`), { credentials: "include" }).then(async (response) => {
1277
+ csrfFetch(joinApiPath$3(apiBaseUrl, `invitations/${token}`), { credentials: "include" }).then(async (response) => {
1172
1278
  if (cancelled) return;
1173
1279
  if (!response.ok) {
1174
1280
  setError("This invitation is no longer valid.");
1175
1281
  return;
1176
1282
  }
1177
- setEmail((await response.json()).email ?? null);
1283
+ const payload = await response.json();
1284
+ setEmail(payload.email ?? null);
1178
1285
  }).catch(() => {
1179
1286
  if (!cancelled) setError("Network error");
1180
1287
  });
@@ -1187,14 +1294,15 @@ function AcceptInvitationPage({ apiBaseUrl = "/api/" }) {
1187
1294
  setBusy(true);
1188
1295
  setError(null);
1189
1296
  try {
1190
- const response = await fetch(joinApiPath$3(apiBaseUrl, `invitations/${token}/accept`), {
1297
+ const response = await csrfFetch(joinApiPath$3(apiBaseUrl, `invitations/${token}/accept`), {
1191
1298
  method: "POST",
1192
1299
  headers: { "Content-Type": "application/json" },
1193
1300
  credentials: "include",
1194
1301
  body: JSON.stringify({ password })
1195
1302
  });
1196
1303
  if (!response.ok) {
1197
- setError((await response.json().catch(() => null))?.message ?? "Could not accept the invitation");
1304
+ const payload = await response.json().catch(() => null);
1305
+ setError(payload?.message ?? "Could not accept the invitation");
1198
1306
  return;
1199
1307
  }
1200
1308
  setDone(true);
@@ -1294,7 +1402,10 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1294
1402
  })]);
1295
1403
  if (signal?.aborted) return;
1296
1404
  if (totpResponse.ok) setTotp(await totpResponse.json());
1297
- if (sessionResponse.ok) setSessions((await sessionResponse.json()).sessions ?? []);
1405
+ if (sessionResponse.ok) {
1406
+ const payload = await sessionResponse.json();
1407
+ setSessions(payload.sessions ?? []);
1408
+ }
1298
1409
  }, [apiBaseUrl]);
1299
1410
  (0, react.useEffect)(() => {
1300
1411
  const controller = new AbortController();
@@ -1306,7 +1417,7 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1306
1417
  const changePassword = async (event) => {
1307
1418
  event.preventDefault();
1308
1419
  setPasswordMessage(null);
1309
- const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/change-password"), {
1420
+ const response = await csrfFetch(joinApiPath$2(apiBaseUrl, "auth/change-password"), {
1310
1421
  method: "POST",
1311
1422
  headers: { "Content-Type": "application/json" },
1312
1423
  credentials: "include",
@@ -1316,7 +1427,8 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1316
1427
  })
1317
1428
  });
1318
1429
  if (!response.ok) {
1319
- setPasswordMessage((await response.json().catch(() => null))?.message ?? "Could not change password");
1430
+ const payload = await response.json().catch(() => null);
1431
+ setPasswordMessage(payload?.message ?? "Could not change password");
1320
1432
  return;
1321
1433
  }
1322
1434
  setCurrentPassword("");
@@ -1326,12 +1438,13 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1326
1438
  };
1327
1439
  const beginTotp = async () => {
1328
1440
  setTotpMessage(null);
1329
- const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1441
+ const response = await csrfFetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1330
1442
  method: "POST",
1331
1443
  credentials: "include"
1332
1444
  });
1333
1445
  if (!response.ok) {
1334
- setTotpMessage((await response.json().catch(() => null))?.message ?? "Could not start enrolment");
1446
+ const payload = await response.json().catch(() => null);
1447
+ setTotpMessage(payload?.message ?? "Could not start enrolment");
1335
1448
  return;
1336
1449
  }
1337
1450
  const payload = await response.json();
@@ -1340,14 +1453,15 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1340
1453
  };
1341
1454
  const confirmTotp = async (event) => {
1342
1455
  event.preventDefault();
1343
- const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp/confirm"), {
1456
+ const response = await csrfFetch(joinApiPath$2(apiBaseUrl, "auth/totp/confirm"), {
1344
1457
  method: "POST",
1345
1458
  headers: { "Content-Type": "application/json" },
1346
1459
  credentials: "include",
1347
1460
  body: JSON.stringify({ code: totpCode })
1348
1461
  });
1349
1462
  if (!response.ok) {
1350
- setTotpMessage((await response.json().catch(() => null))?.message ?? "That code is not valid");
1463
+ const payload = await response.json().catch(() => null);
1464
+ setTotpMessage(payload?.message ?? "That code is not valid");
1351
1465
  return;
1352
1466
  }
1353
1467
  setTotpCode("");
@@ -1357,14 +1471,15 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1357
1471
  };
1358
1472
  const disableTotp = async (event) => {
1359
1473
  event.preventDefault();
1360
- const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1474
+ const response = await csrfFetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1361
1475
  method: "DELETE",
1362
1476
  headers: { "Content-Type": "application/json" },
1363
1477
  credentials: "include",
1364
1478
  body: JSON.stringify({ code: totpCode })
1365
1479
  });
1366
1480
  if (!response.ok) {
1367
- setTotpMessage((await response.json().catch(() => null))?.message ?? "Could not disable the second factor");
1481
+ const payload = await response.json().catch(() => null);
1482
+ setTotpMessage(payload?.message ?? "Could not disable the second factor");
1368
1483
  return;
1369
1484
  }
1370
1485
  setTotpCode("");
@@ -1372,7 +1487,7 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1372
1487
  await load();
1373
1488
  };
1374
1489
  const revokeSession = async (id) => {
1375
- await fetch(joinApiPath$2(apiBaseUrl, `auth/sessions/${id}`), {
1490
+ await csrfFetch(joinApiPath$2(apiBaseUrl, `auth/sessions/${id}`), {
1376
1491
  method: "DELETE",
1377
1492
  credentials: "include"
1378
1493
  });
@@ -1558,7 +1673,8 @@ function initialValues(fields) {
1558
1673
  return Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? ""]));
1559
1674
  }
1560
1675
  function RegisterPage({ fields, onRegistered, apiBaseUrl = "/api/", registerPath = "auth/register", title = "Create account", hint, submitLabel = "Create account", busyLabel = "Creating…", loginLink, loginPrompt = "Already have an account?" }) {
1561
- const [values, setValues] = (0, react.useState)((0, react.useMemo)(() => initialValues(fields), [fields]));
1676
+ const defaults = (0, react.useMemo)(() => initialValues(fields), [fields]);
1677
+ const [values, setValues] = (0, react.useState)(defaults);
1562
1678
  const [error, setError] = (0, react.useState)(null);
1563
1679
  const [busy, setBusy] = (0, react.useState)(false);
1564
1680
  const setValue = (name, value) => {
@@ -1572,7 +1688,7 @@ function RegisterPage({ fields, onRegistered, apiBaseUrl = "/api/", registerPath
1572
1688
  setBusy(true);
1573
1689
  setError(null);
1574
1690
  try {
1575
- const response = await fetch(joinApiPath$1(apiBaseUrl, registerPath), {
1691
+ const response = await csrfFetch(joinApiPath$1(apiBaseUrl, registerPath), {
1576
1692
  method: "POST",
1577
1693
  headers: { "Content-Type": "application/json" },
1578
1694
  credentials: "include",
@@ -1716,9 +1832,10 @@ function useRuntimeConfig({ apiBaseUrl = "/api/", path = "runtime-config", enabl
1716
1832
  setState({ status: "error" });
1717
1833
  return;
1718
1834
  }
1835
+ const config = await response.json();
1719
1836
  setState({
1720
1837
  status: "ready",
1721
- config: await response.json()
1838
+ config
1722
1839
  });
1723
1840
  } catch {
1724
1841
  setState({ status: "error" });
@@ -2059,6 +2176,7 @@ function resolveShellMenu(config, ctx) {
2059
2176
  }
2060
2177
  function NubitAuthenticatedApp({ config }) {
2061
2178
  const session = useSession();
2179
+ const strings = (0, _nubitio_ui.useUiStrings)();
2062
2180
  const { runtime, toasts, dismiss } = useAppRuntime();
2063
2181
  const apiBaseUrl = config.apiBaseUrl ?? "/api/";
2064
2182
  const homePath = config.homePath ?? config.routes[0]?.path ?? "/";
@@ -2075,7 +2193,7 @@ function NubitAuthenticatedApp({ config }) {
2075
2193
  if (session.session.status === "loading") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2076
2194
  style: { padding: 24 },
2077
2195
  "aria-busy": "true",
2078
- "aria-label": "Loading session",
2196
+ "aria-label": strings.loadingSession,
2079
2197
  children: [
2080
2198
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Skeleton, {
2081
2199
  variant: "rect",
@@ -2093,8 +2211,7 @@ function NubitAuthenticatedApp({ config }) {
2093
2211
  const shell = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdminShell, {
2094
2212
  title: config.title,
2095
2213
  menuItems,
2096
- headerActions: config.shell?.headerActions,
2097
- footer: config.shell?.footer,
2214
+ ...config.shell,
2098
2215
  renderThemeSwitcher,
2099
2216
  renderUserMenu: ({ close }) => renderUserMenu({
2100
2217
  ...menuContext,
@@ -2126,6 +2243,7 @@ function NubitAuthenticatedApp({ config }) {
2126
2243
  baseUrl: apiBaseUrl,
2127
2244
  refreshPath: "auth/refresh",
2128
2245
  loginPath: "auth/login",
2246
+ getCsrfToken: () => (0, _nubitio_core.readCsrfToken)(),
2129
2247
  onUnauthorized: () => {
2130
2248
  session.logout();
2131
2249
  }
@@ -2192,6 +2310,7 @@ function createNubitApp(config) {
2192
2310
  client: queryClient,
2193
2311
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.ThemeProvider, {
2194
2312
  basePath: config.themeBasePath,
2313
+ visualStyle: config.visualStyle,
2195
2314
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionProvider, {
2196
2315
  apiBaseUrl,
2197
2316
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NubitAuthenticatedApp, { config })
@@ -2204,6 +2323,7 @@ function createNubitApp(config) {
2204
2323
  //#endregion
2205
2324
  exports.AcceptInvitationPage = AcceptInvitationPage;
2206
2325
  exports.AccountPage = AccountPage;
2326
+ exports.AdminBottomNavigation = AdminBottomNavigation;
2207
2327
  exports.AdminHeader = AdminHeader;
2208
2328
  exports.AdminShell = AdminShell;
2209
2329
  exports.AdminSidebarMenu = AdminSidebarMenu;