@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.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  import { BrowserRouter, Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import React, { Component, createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
4
- import { Badge, Button, Card, EmptyState, FeatureGate as FeatureGate$1, FormField, IconButton, Skeleton, TextField, ThemeProvider, ThemeSwitcher, useFloatingPanel } from "@nubitio/ui";
4
+ import { Badge, Button, Card, EmptyState, FeatureGate as FeatureGate$1, FormField, IconButton, Skeleton, TextField, ThemeProvider, ThemeSwitcher, useFloatingPanel, useUiStrings } from "@nubitio/ui";
5
+ import { CoreConfigProvider, CoreProvider, DEFAULT_CSRF_HEADER_NAME, MercureProvider, readCsrfToken, useCoreHttpClient, useMercureSubscription } from "@nubitio/core";
5
6
  import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6
- import { CoreConfigProvider, CoreProvider, MercureProvider, useCoreHttpClient, useMercureSubscription } from "@nubitio/core";
7
7
  import { DevToolsProvider, SessionPermissionsProvider, SmartCrudRolesProvider, isDevEnvironment, useDevTools } from "@nubitio/crud";
8
8
  import { HydraResourceSchemaProvider, HydraResourceStoreProvider, SchemaProvider } from "@nubitio/hydra";
9
9
  //#region packages/admin/FeatureHubLayout.tsx
@@ -124,50 +124,61 @@ function ActionPopover({ action }) {
124
124
  });
125
125
  }
126
126
  function UserMenuPopover({ renderUserMenu }) {
127
+ const strings = useUiStrings();
127
128
  const { open, toggle, setOpen, containerRef } = useFloatingPanel();
128
129
  return /* @__PURE__ */ jsxs("div", {
129
130
  className: "nb-admin-header-popover",
130
131
  ref: containerRef,
131
132
  children: [/* @__PURE__ */ jsx(IconButton, {
132
133
  icon: "ph ph-user-circle",
133
- label: "User menu",
134
+ label: strings.userMenu,
134
135
  "aria-expanded": open,
135
136
  onClick: toggle
136
137
  }), open && /* @__PURE__ */ jsx("div", {
137
138
  className: "nb-admin-header-popover__panel",
138
139
  role: "dialog",
139
- "aria-label": "User menu",
140
+ "aria-label": strings.userMenu,
140
141
  children: renderUserMenu({ close: () => setOpen(false) })
141
142
  })]
142
143
  });
143
144
  }
144
- const AdminHeader = ({ title, menuToggleEnabled, toggleMenu, className, actions = [], renderUserMenu, renderThemeSwitcher }) => /* @__PURE__ */ jsx("header", {
145
- className: ["nb-admin-header-component", className].filter(Boolean).join(" "),
146
- children: /* @__PURE__ */ jsxs("div", {
147
- className: "nb-admin-header-toolbar",
148
- role: "toolbar",
149
- "aria-label": "Main toolbar",
150
- children: [/* @__PURE__ */ jsxs("div", {
151
- className: "nb-admin-header-toolbar__before",
152
- children: [menuToggleEnabled && /* @__PURE__ */ jsx(IconButton, {
153
- className: "nb-admin-menu-button",
154
- icon: "ph ph-list",
155
- label: "Toggle menu",
156
- onClick: toggleMenu
157
- }), title && /* @__PURE__ */ jsx("div", {
158
- className: "nb-admin-header-title",
159
- children: title
160
- })]
161
- }), /* @__PURE__ */ jsxs("div", {
162
- className: "nb-admin-header-toolbar__after",
145
+ const AdminHeader = ({ title, menuToggleEnabled, toggleMenu, className, actions = [], content, renderUserMenu, renderThemeSwitcher }) => {
146
+ const strings = useUiStrings();
147
+ return /* @__PURE__ */ jsx("header", {
148
+ className: ["nb-admin-header-component", className].filter(Boolean).join(" "),
149
+ children: /* @__PURE__ */ jsxs("div", {
150
+ className: "nb-admin-header-toolbar",
151
+ role: "toolbar",
152
+ "aria-label": strings.mainToolbar,
163
153
  children: [
164
- renderThemeSwitcher?.(),
165
- actions.map((action) => /* @__PURE__ */ jsx(ActionPopover, { action }, action.id)),
166
- renderUserMenu && /* @__PURE__ */ jsx(UserMenuPopover, { renderUserMenu })
154
+ /* @__PURE__ */ jsxs("div", {
155
+ className: "nb-admin-header-toolbar__before",
156
+ children: [menuToggleEnabled && /* @__PURE__ */ jsx(IconButton, {
157
+ className: "nb-admin-menu-button",
158
+ icon: "ph ph-list",
159
+ label: strings.toggleMenu,
160
+ onClick: toggleMenu
161
+ }), title && /* @__PURE__ */ jsx("div", {
162
+ className: "nb-admin-header-title",
163
+ children: title
164
+ })]
165
+ }),
166
+ content && /* @__PURE__ */ jsx("div", {
167
+ className: "nb-admin-header-content",
168
+ children: content
169
+ }),
170
+ /* @__PURE__ */ jsxs("div", {
171
+ className: "nb-admin-header-toolbar__after",
172
+ children: [
173
+ renderThemeSwitcher?.(),
174
+ actions.map((action) => /* @__PURE__ */ jsx(ActionPopover, { action }, action.id)),
175
+ renderUserMenu && /* @__PURE__ */ jsx(UserMenuPopover, { renderUserMenu })
176
+ ]
177
+ })
167
178
  ]
168
- })]
169
- })
170
- });
179
+ })
180
+ });
181
+ };
171
182
  //#endregion
172
183
  //#region packages/admin/useScreenSize.ts
173
184
  let handlers = [];
@@ -231,8 +242,9 @@ const getIconClassName = (icon) => {
231
242
  if (!icon) return "";
232
243
  return icon.includes(" ") ? icon : `ph ph-${icon}`;
233
244
  };
234
- const AdminSidebarMenu = ({ items, compactMode = false, selectedItemChanged, openMenu, footer }) => {
245
+ const AdminSidebarMenu = ({ items, compactMode = false, selectedItemChanged, openMenu, footer, brand, context }) => {
235
246
  const [expandedItemKeys, setExpandedItemKeys] = useState(/* @__PURE__ */ new Set());
247
+ const strings = useUiStrings();
236
248
  const { isLarge } = useScreenSize();
237
249
  const location = useLocation();
238
250
  useEffect(() => {
@@ -279,74 +291,86 @@ const AdminSidebarMenu = ({ items, compactMode = false, selectedItemChanged, ope
279
291
  return /* @__PURE__ */ jsxs("div", {
280
292
  className: `nb-admin-menu${compactMode ? " compact" : ""}`,
281
293
  onPointerDown: openMenu,
282
- children: [/* @__PURE__ */ jsx("div", {
283
- className: "nb-admin-menu-container theme-dependent",
284
- children: /* @__PURE__ */ jsx("nav", {
285
- className: "nb-admin-menu__nav",
286
- "aria-label": "Main navigation",
287
- children: items.map((item) => {
288
- const selected = isSelected(item.path);
289
- const containsSelected = parentContainsSelected(item);
290
- const itemKey = item.path || item.text;
291
- const hasChildren = (item.items?.length ?? 0) > 0;
292
- const expanded = !compactMode && hasChildren && expandedItemKeys.has(itemKey);
293
- return /* @__PURE__ */ jsxs("section", {
294
- className: `nb-admin-menu__section${containsSelected ? " has-selected-child" : ""}`,
295
- children: [/* @__PURE__ */ jsxs("button", {
296
- className: `nb-admin-menu__item nb-admin-menu__item--parent${selected ? " is-selected" : ""}`,
297
- type: "button",
298
- "aria-current": selected ? "page" : void 0,
299
- "aria-expanded": hasChildren ? expanded : void 0,
300
- onClick: (event) => {
301
- if (hasChildren) {
302
- toggleExpanded(itemKey);
303
- return;
304
- }
305
- handleSelect(item.path, selected, event);
306
- },
307
- children: [
308
- /* @__PURE__ */ jsx("span", {
309
- className: `nb-admin-menu__icon ${getIconClassName(item.icon)}`,
310
- "aria-hidden": "true"
311
- }),
312
- /* @__PURE__ */ jsx("span", {
313
- className: "nb-admin-menu__text",
314
- children: item.text
315
- }),
316
- hasChildren && /* @__PURE__ */ jsx("span", {
317
- className: `nb-admin-menu__chevron${expanded ? " is-expanded" : ""}`,
318
- "aria-hidden": "true"
319
- })
320
- ]
321
- }), expanded && (item.items?.length ?? 0) > 0 && /* @__PURE__ */ jsx("div", {
322
- className: "nb-admin-menu__children",
323
- children: item.items.map((subItem) => {
324
- const childSelected = isSelected(subItem.path);
325
- return /* @__PURE__ */ jsx("button", {
326
- className: `nb-admin-menu__item nb-admin-menu__item--child${childSelected ? " is-selected" : ""}`,
327
- type: "button",
328
- "aria-current": childSelected ? "page" : void 0,
329
- onClick: (event) => handleSelect(subItem.path, childSelected, event),
330
- children: /* @__PURE__ */ jsx("span", {
294
+ children: [
295
+ brand && !compactMode && /* @__PURE__ */ jsx("div", {
296
+ className: "nb-admin-menu__brand",
297
+ children: brand
298
+ }),
299
+ /* @__PURE__ */ jsx("div", {
300
+ className: "nb-admin-menu-container theme-dependent",
301
+ children: /* @__PURE__ */ jsx("nav", {
302
+ className: "nb-admin-menu__nav",
303
+ "aria-label": strings.mainNavigation,
304
+ children: items.map((item) => {
305
+ const selected = isSelected(item.path);
306
+ const containsSelected = parentContainsSelected(item);
307
+ const itemKey = item.path || item.text;
308
+ const hasChildren = (item.items?.length ?? 0) > 0;
309
+ const expanded = !compactMode && hasChildren && expandedItemKeys.has(itemKey);
310
+ return /* @__PURE__ */ jsxs("section", {
311
+ className: `nb-admin-menu__section${containsSelected ? " has-selected-child" : ""}`,
312
+ children: [/* @__PURE__ */ jsxs("button", {
313
+ className: `nb-admin-menu__item nb-admin-menu__item--parent${selected ? " is-selected" : ""}`,
314
+ type: "button",
315
+ "aria-current": selected ? "page" : void 0,
316
+ "aria-expanded": hasChildren ? expanded : void 0,
317
+ onClick: (event) => {
318
+ if (hasChildren) {
319
+ toggleExpanded(itemKey);
320
+ return;
321
+ }
322
+ handleSelect(item.path, selected, event);
323
+ },
324
+ children: [
325
+ /* @__PURE__ */ jsx("span", {
326
+ className: `nb-admin-menu__icon ${getIconClassName(item.icon)}`,
327
+ "aria-hidden": "true"
328
+ }),
329
+ /* @__PURE__ */ jsx("span", {
331
330
  className: "nb-admin-menu__text",
332
- children: subItem.text
331
+ children: item.text
332
+ }),
333
+ hasChildren && /* @__PURE__ */ jsx("span", {
334
+ className: `nb-admin-menu__chevron${expanded ? " is-expanded" : ""}`,
335
+ "aria-hidden": "true"
333
336
  })
334
- }, subItem.path || subItem.text);
335
- })
336
- })]
337
- }, itemKey);
337
+ ]
338
+ }), expanded && (item.items?.length ?? 0) > 0 && /* @__PURE__ */ jsx("div", {
339
+ className: "nb-admin-menu__children",
340
+ children: item.items.map((subItem) => {
341
+ const childSelected = isSelected(subItem.path);
342
+ return /* @__PURE__ */ jsx("button", {
343
+ className: `nb-admin-menu__item nb-admin-menu__item--child${childSelected ? " is-selected" : ""}`,
344
+ type: "button",
345
+ "aria-current": childSelected ? "page" : void 0,
346
+ onClick: (event) => handleSelect(subItem.path, childSelected, event),
347
+ children: /* @__PURE__ */ jsx("span", {
348
+ className: "nb-admin-menu__text",
349
+ children: subItem.text
350
+ })
351
+ }, subItem.path || subItem.text);
352
+ })
353
+ })]
354
+ }, itemKey);
355
+ })
338
356
  })
357
+ }),
358
+ context && !compactMode && /* @__PURE__ */ jsx("div", {
359
+ className: "nb-admin-menu__context",
360
+ children: context
361
+ }),
362
+ footer && /* @__PURE__ */ jsx("footer", {
363
+ className: "nb-admin-footer",
364
+ children: footer
339
365
  })
340
- }), footer && /* @__PURE__ */ jsx("footer", {
341
- className: "nb-admin-footer",
342
- children: footer
343
- })]
366
+ ]
344
367
  });
345
368
  };
346
369
  //#endregion
347
370
  //#region packages/admin/AdminShell.tsx
348
- const AdminShell = ({ title, menuItems, headerActions, renderUserMenu, renderThemeSwitcher, footer, children }) => {
371
+ const AdminShell = ({ title, menuItems, headerActions, renderUserMenu, renderThemeSwitcher, footer, variant = "default", brand, headerContent, sidebarContext, bottomNavigation, children }) => {
349
372
  const navigate = useNavigate();
373
+ const strings = useUiStrings();
350
374
  const { isXSmall, isLarge } = useScreenSize();
351
375
  const [menuStatus, setMenuStatus] = useState(null);
352
376
  const getDefaultMenuOpenState = useCallback(() => isLarge ? 2 : 1, [isLarge]);
@@ -400,45 +424,121 @@ const AdminShell = ({ title, menuItems, headerActions, renderUserMenu, renderThe
400
424
  isXSmall ? "is-xsmall" : ""
401
425
  ].filter(Boolean).join(" ");
402
426
  return /* @__PURE__ */ jsxs("div", {
403
- className: "nb-admin-shell",
404
- children: [/* @__PURE__ */ jsx(AdminHeader, {
405
- className: "nb-admin-layout-header",
406
- menuToggleEnabled: true,
407
- toggleMenu,
408
- title,
409
- actions: headerActions,
410
- renderUserMenu,
411
- renderThemeSwitcher
412
- }), /* @__PURE__ */ jsxs("div", {
413
- className: bodyClassName,
414
- children: [
415
- /* @__PURE__ */ jsx("aside", {
416
- className: "nb-admin-shell__panel",
417
- "aria-label": "Main menu",
418
- children: /* @__PURE__ */ jsx(AdminSidebarMenu, {
419
- items: menuItems,
420
- compactMode: isCompact,
421
- selectedItemChanged: onNavigationChanged,
422
- openMenu: temporaryOpenMenu,
423
- footer
427
+ className: [
428
+ "nb-admin-shell",
429
+ variant === "workspace" && "nb-admin-shell--workspace",
430
+ variant === "workspace" && isLarge && "is-workspace-desktop",
431
+ variant === "workspace" && isLarge && isCompact && "is-workspace-compact"
432
+ ].filter(Boolean).join(" "),
433
+ children: [
434
+ /* @__PURE__ */ jsx(AdminHeader, {
435
+ className: "nb-admin-layout-header",
436
+ menuToggleEnabled: true,
437
+ toggleMenu,
438
+ title,
439
+ actions: headerActions,
440
+ content: headerContent,
441
+ renderUserMenu,
442
+ renderThemeSwitcher
443
+ }),
444
+ /* @__PURE__ */ jsxs("div", {
445
+ className: bodyClassName,
446
+ children: [
447
+ /* @__PURE__ */ jsx("aside", {
448
+ className: "nb-admin-shell__panel",
449
+ "aria-label": strings.mainMenu,
450
+ children: /* @__PURE__ */ jsx(AdminSidebarMenu, {
451
+ items: menuItems,
452
+ compactMode: isCompact,
453
+ selectedItemChanged: onNavigationChanged,
454
+ openMenu: temporaryOpenMenu,
455
+ footer,
456
+ brand,
457
+ context: sidebarContext
458
+ })
459
+ }),
460
+ !isLarge && isMenuOpen && /* @__PURE__ */ jsx("button", {
461
+ className: "nb-admin-shell__scrim",
462
+ type: "button",
463
+ "aria-label": strings.closeMenu,
464
+ onClick: closeMenuFromOverlay
465
+ }),
466
+ /* @__PURE__ */ jsx("main", {
467
+ className: "nb-admin-shell__content content",
468
+ tabIndex: 0,
469
+ "aria-label": title ? `${title} content` : strings.mainContent,
470
+ children
424
471
  })
472
+ ]
473
+ }),
474
+ isXSmall && bottomNavigation && /* @__PURE__ */ jsx("div", {
475
+ className: "nb-admin-shell__bottom-navigation",
476
+ children: bottomNavigation
477
+ })
478
+ ]
479
+ });
480
+ };
481
+ //#endregion
482
+ //#region packages/admin/AdminBottomNavigation.tsx
483
+ function AdminBottomNavigation({ items, label }) {
484
+ const { pathname } = useLocation();
485
+ const strings = useUiStrings();
486
+ const normalize = (path) => path.replace(/\/+$/, "") || "/";
487
+ const currentPath = normalize(pathname);
488
+ const activePath = items.map((item) => normalize(item.path)).filter((path) => currentPath === path || path !== "/" && currentPath.startsWith(`${path}/`)).sort((a, b) => b.length - a.length)[0];
489
+ return /* @__PURE__ */ jsx("nav", {
490
+ className: "nb-admin-bottom-nav",
491
+ "aria-label": label ?? strings.mainNavigation,
492
+ children: items.map((item) => /* @__PURE__ */ jsxs(Link, {
493
+ to: item.path,
494
+ className: "nb-admin-bottom-nav__item",
495
+ "aria-current": normalize(item.path) === activePath ? "page" : void 0,
496
+ children: [
497
+ item.icon && /* @__PURE__ */ jsx("span", {
498
+ className: `nb-admin-bottom-nav__icon ${item.icon.includes(" ") ? item.icon : `ph ph-${item.icon}`}`,
499
+ "aria-hidden": "true"
425
500
  }),
426
- !isLarge && isMenuOpen && /* @__PURE__ */ jsx("button", {
427
- className: "nb-admin-shell__scrim",
428
- type: "button",
429
- "aria-label": "Close menu",
430
- onClick: closeMenuFromOverlay
501
+ /* @__PURE__ */ jsx("span", {
502
+ className: "nb-admin-bottom-nav__label",
503
+ children: item.text
431
504
  }),
432
- /* @__PURE__ */ jsx("main", {
433
- className: "nb-admin-shell__content content",
434
- tabIndex: 0,
435
- "aria-label": title ? `${title} content` : "Main content",
436
- children
505
+ item.badge != null && /* @__PURE__ */ jsx("span", {
506
+ className: "nb-admin-bottom-nav__badge",
507
+ children: item.badge
437
508
  })
438
509
  ]
439
- })]
510
+ }, item.path))
440
511
  });
441
- };
512
+ }
513
+ //#endregion
514
+ //#region packages/admin/auth/csrfFetch.ts
515
+ const MUTATING_METHODS = /* @__PURE__ */ new Set([
516
+ "POST",
517
+ "PUT",
518
+ "PATCH",
519
+ "DELETE"
520
+ ]);
521
+ /**
522
+ * `fetch` that echoes the double-submit CSRF cookie as `X-CSRF-Token` on
523
+ * state-changing requests.
524
+ *
525
+ * The backend rejects cookie-authenticated POST/PUT/PATCH/DELETE that lack it
526
+ * (`nubit_admin.auth.csrf_protection`, on by default), so the auth pages' own
527
+ * calls — logout, change password, TOTP, session revocation — need it exactly
528
+ * as the CRUD client does. Reads, and requests made before login has issued the
529
+ * cookie, pass through untouched.
530
+ */
531
+ function csrfFetch(input, init = {}) {
532
+ const method = (init.method ?? "GET").toUpperCase();
533
+ const token = MUTATING_METHODS.has(method) ? readCsrfToken() : void 0;
534
+ if (token === void 0) return fetch(input, init);
535
+ const headers = new Headers(init.headers);
536
+ if (!headers.has(DEFAULT_CSRF_HEADER_NAME)) headers.set(DEFAULT_CSRF_HEADER_NAME, token);
537
+ return fetch(input, {
538
+ ...init,
539
+ headers
540
+ });
541
+ }
442
542
  //#endregion
443
543
  //#region packages/admin/auth/SessionContext.tsx
444
544
  const SessionContext = createContext(null);
@@ -454,9 +554,10 @@ function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "au
454
554
  setSession({ status: "anonymous" });
455
555
  return;
456
556
  }
557
+ const profile = await response.json();
457
558
  setSession({
458
559
  status: "authenticated",
459
- profile: await response.json()
560
+ profile
460
561
  });
461
562
  } catch {
462
563
  setSession({ status: "anonymous" });
@@ -466,7 +567,7 @@ function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "au
466
567
  refresh();
467
568
  }, [refresh]);
468
569
  const logout = useCallback(async () => {
469
- await fetch(joinApiPath$7(apiBaseUrl, logoutPath), {
570
+ await csrfFetch(joinApiPath$7(apiBaseUrl, logoutPath), {
470
571
  method: "POST",
471
572
  credentials: "include"
472
573
  });
@@ -527,9 +628,10 @@ function useFeatureConfig(featureKey) {
527
628
  //#endregion
528
629
  //#region packages/admin/features/FeatureGate.tsx
529
630
  function FeatureGate({ featureKey, ...props }) {
631
+ const enabled = useFeature(featureKey);
530
632
  return /* @__PURE__ */ jsx(FeatureGate$1, {
531
633
  featureKey,
532
- enabled: useFeature(featureKey),
634
+ enabled,
533
635
  ...props
534
636
  });
535
637
  }
@@ -577,10 +679,13 @@ function PlanPanel({ plans, currentPlanId, subscriptionStatus, trialEndsAt, onSe
577
679
  const currentBadge = labels?.currentBadge ?? "Current plan";
578
680
  const selectingLabel = labels?.selecting ?? "Updating…";
579
681
  let statusLine = null;
580
- if (subscriptionStatus === "trialing" && trialEndsAt) statusLine = /* @__PURE__ */ jsx("p", {
581
- className: "nb-plan-panel__status",
582
- children: (labels?.trialEnds ?? "Trial ends {date}").replace("{date}", formatTrialEnds(trialEndsAt))
583
- });
682
+ if (subscriptionStatus === "trialing" && trialEndsAt) {
683
+ const trialTemplate = labels?.trialEnds ?? "Trial ends {date}";
684
+ statusLine = /* @__PURE__ */ jsx("p", {
685
+ className: "nb-plan-panel__status",
686
+ children: trialTemplate.replace("{date}", formatTrialEnds(trialEndsAt))
687
+ });
688
+ }
584
689
  return /* @__PURE__ */ jsxs("section", {
585
690
  className: `nb-plan-panel${className ? ` ${className}` : ""}`,
586
691
  "aria-labelledby": "plan-panel-title",
@@ -976,7 +1081,7 @@ function ForgotPasswordPage({ apiBaseUrl = "/api/" }) {
976
1081
  event.preventDefault();
977
1082
  setBusy(true);
978
1083
  try {
979
- await fetch(joinApiPath$5(apiBaseUrl, "auth/password/forgot"), {
1084
+ await csrfFetch(joinApiPath$5(apiBaseUrl, "auth/password/forgot"), {
980
1085
  method: "POST",
981
1086
  headers: { "Content-Type": "application/json" },
982
1087
  credentials: "include",
@@ -1052,7 +1157,7 @@ function ResetPasswordPage({ apiBaseUrl = "/api/" }) {
1052
1157
  setBusy(true);
1053
1158
  setError(null);
1054
1159
  try {
1055
- const response = await fetch(joinApiPath$4(apiBaseUrl, "auth/password/reset"), {
1160
+ const response = await csrfFetch(joinApiPath$4(apiBaseUrl, "auth/password/reset"), {
1056
1161
  method: "POST",
1057
1162
  headers: { "Content-Type": "application/json" },
1058
1163
  credentials: "include",
@@ -1062,7 +1167,8 @@ function ResetPasswordPage({ apiBaseUrl = "/api/" }) {
1062
1167
  })
1063
1168
  });
1064
1169
  if (!response.ok) {
1065
- setError((await response.json().catch(() => null))?.message ?? "Reset failed");
1170
+ const payload = await response.json().catch(() => null);
1171
+ setError(payload?.message ?? "Reset failed");
1066
1172
  return;
1067
1173
  }
1068
1174
  setDone(true);
@@ -1144,13 +1250,14 @@ function AcceptInvitationPage({ apiBaseUrl = "/api/" }) {
1144
1250
  const [busy, setBusy] = useState(false);
1145
1251
  useEffect(() => {
1146
1252
  let cancelled = false;
1147
- fetch(joinApiPath$3(apiBaseUrl, `invitations/${token}`), { credentials: "include" }).then(async (response) => {
1253
+ csrfFetch(joinApiPath$3(apiBaseUrl, `invitations/${token}`), { credentials: "include" }).then(async (response) => {
1148
1254
  if (cancelled) return;
1149
1255
  if (!response.ok) {
1150
1256
  setError("This invitation is no longer valid.");
1151
1257
  return;
1152
1258
  }
1153
- setEmail((await response.json()).email ?? null);
1259
+ const payload = await response.json();
1260
+ setEmail(payload.email ?? null);
1154
1261
  }).catch(() => {
1155
1262
  if (!cancelled) setError("Network error");
1156
1263
  });
@@ -1163,14 +1270,15 @@ function AcceptInvitationPage({ apiBaseUrl = "/api/" }) {
1163
1270
  setBusy(true);
1164
1271
  setError(null);
1165
1272
  try {
1166
- const response = await fetch(joinApiPath$3(apiBaseUrl, `invitations/${token}/accept`), {
1273
+ const response = await csrfFetch(joinApiPath$3(apiBaseUrl, `invitations/${token}/accept`), {
1167
1274
  method: "POST",
1168
1275
  headers: { "Content-Type": "application/json" },
1169
1276
  credentials: "include",
1170
1277
  body: JSON.stringify({ password })
1171
1278
  });
1172
1279
  if (!response.ok) {
1173
- setError((await response.json().catch(() => null))?.message ?? "Could not accept the invitation");
1280
+ const payload = await response.json().catch(() => null);
1281
+ setError(payload?.message ?? "Could not accept the invitation");
1174
1282
  return;
1175
1283
  }
1176
1284
  setDone(true);
@@ -1270,7 +1378,10 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1270
1378
  })]);
1271
1379
  if (signal?.aborted) return;
1272
1380
  if (totpResponse.ok) setTotp(await totpResponse.json());
1273
- if (sessionResponse.ok) setSessions((await sessionResponse.json()).sessions ?? []);
1381
+ if (sessionResponse.ok) {
1382
+ const payload = await sessionResponse.json();
1383
+ setSessions(payload.sessions ?? []);
1384
+ }
1274
1385
  }, [apiBaseUrl]);
1275
1386
  useEffect(() => {
1276
1387
  const controller = new AbortController();
@@ -1282,7 +1393,7 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1282
1393
  const changePassword = async (event) => {
1283
1394
  event.preventDefault();
1284
1395
  setPasswordMessage(null);
1285
- const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/change-password"), {
1396
+ const response = await csrfFetch(joinApiPath$2(apiBaseUrl, "auth/change-password"), {
1286
1397
  method: "POST",
1287
1398
  headers: { "Content-Type": "application/json" },
1288
1399
  credentials: "include",
@@ -1292,7 +1403,8 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1292
1403
  })
1293
1404
  });
1294
1405
  if (!response.ok) {
1295
- setPasswordMessage((await response.json().catch(() => null))?.message ?? "Could not change password");
1406
+ const payload = await response.json().catch(() => null);
1407
+ setPasswordMessage(payload?.message ?? "Could not change password");
1296
1408
  return;
1297
1409
  }
1298
1410
  setCurrentPassword("");
@@ -1302,12 +1414,13 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1302
1414
  };
1303
1415
  const beginTotp = async () => {
1304
1416
  setTotpMessage(null);
1305
- const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1417
+ const response = await csrfFetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1306
1418
  method: "POST",
1307
1419
  credentials: "include"
1308
1420
  });
1309
1421
  if (!response.ok) {
1310
- setTotpMessage((await response.json().catch(() => null))?.message ?? "Could not start enrolment");
1422
+ const payload = await response.json().catch(() => null);
1423
+ setTotpMessage(payload?.message ?? "Could not start enrolment");
1311
1424
  return;
1312
1425
  }
1313
1426
  const payload = await response.json();
@@ -1316,14 +1429,15 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1316
1429
  };
1317
1430
  const confirmTotp = async (event) => {
1318
1431
  event.preventDefault();
1319
- const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp/confirm"), {
1432
+ const response = await csrfFetch(joinApiPath$2(apiBaseUrl, "auth/totp/confirm"), {
1320
1433
  method: "POST",
1321
1434
  headers: { "Content-Type": "application/json" },
1322
1435
  credentials: "include",
1323
1436
  body: JSON.stringify({ code: totpCode })
1324
1437
  });
1325
1438
  if (!response.ok) {
1326
- setTotpMessage((await response.json().catch(() => null))?.message ?? "That code is not valid");
1439
+ const payload = await response.json().catch(() => null);
1440
+ setTotpMessage(payload?.message ?? "That code is not valid");
1327
1441
  return;
1328
1442
  }
1329
1443
  setTotpCode("");
@@ -1333,14 +1447,15 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1333
1447
  };
1334
1448
  const disableTotp = async (event) => {
1335
1449
  event.preventDefault();
1336
- const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1450
+ const response = await csrfFetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1337
1451
  method: "DELETE",
1338
1452
  headers: { "Content-Type": "application/json" },
1339
1453
  credentials: "include",
1340
1454
  body: JSON.stringify({ code: totpCode })
1341
1455
  });
1342
1456
  if (!response.ok) {
1343
- setTotpMessage((await response.json().catch(() => null))?.message ?? "Could not disable the second factor");
1457
+ const payload = await response.json().catch(() => null);
1458
+ setTotpMessage(payload?.message ?? "Could not disable the second factor");
1344
1459
  return;
1345
1460
  }
1346
1461
  setTotpCode("");
@@ -1348,7 +1463,7 @@ function AccountPage({ apiBaseUrl = "/api/" }) {
1348
1463
  await load();
1349
1464
  };
1350
1465
  const revokeSession = async (id) => {
1351
- await fetch(joinApiPath$2(apiBaseUrl, `auth/sessions/${id}`), {
1466
+ await csrfFetch(joinApiPath$2(apiBaseUrl, `auth/sessions/${id}`), {
1352
1467
  method: "DELETE",
1353
1468
  credentials: "include"
1354
1469
  });
@@ -1534,7 +1649,8 @@ function initialValues(fields) {
1534
1649
  return Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? ""]));
1535
1650
  }
1536
1651
  function RegisterPage({ fields, onRegistered, apiBaseUrl = "/api/", registerPath = "auth/register", title = "Create account", hint, submitLabel = "Create account", busyLabel = "Creating…", loginLink, loginPrompt = "Already have an account?" }) {
1537
- const [values, setValues] = useState(useMemo(() => initialValues(fields), [fields]));
1652
+ const defaults = useMemo(() => initialValues(fields), [fields]);
1653
+ const [values, setValues] = useState(defaults);
1538
1654
  const [error, setError] = useState(null);
1539
1655
  const [busy, setBusy] = useState(false);
1540
1656
  const setValue = (name, value) => {
@@ -1548,7 +1664,7 @@ function RegisterPage({ fields, onRegistered, apiBaseUrl = "/api/", registerPath
1548
1664
  setBusy(true);
1549
1665
  setError(null);
1550
1666
  try {
1551
- const response = await fetch(joinApiPath$1(apiBaseUrl, registerPath), {
1667
+ const response = await csrfFetch(joinApiPath$1(apiBaseUrl, registerPath), {
1552
1668
  method: "POST",
1553
1669
  headers: { "Content-Type": "application/json" },
1554
1670
  credentials: "include",
@@ -1692,9 +1808,10 @@ function useRuntimeConfig({ apiBaseUrl = "/api/", path = "runtime-config", enabl
1692
1808
  setState({ status: "error" });
1693
1809
  return;
1694
1810
  }
1811
+ const config = await response.json();
1695
1812
  setState({
1696
1813
  status: "ready",
1697
- config: await response.json()
1814
+ config
1698
1815
  });
1699
1816
  } catch {
1700
1817
  setState({ status: "error" });
@@ -2035,6 +2152,7 @@ function resolveShellMenu(config, ctx) {
2035
2152
  }
2036
2153
  function NubitAuthenticatedApp({ config }) {
2037
2154
  const session = useSession();
2155
+ const strings = useUiStrings();
2038
2156
  const { runtime, toasts, dismiss } = useAppRuntime();
2039
2157
  const apiBaseUrl = config.apiBaseUrl ?? "/api/";
2040
2158
  const homePath = config.homePath ?? config.routes[0]?.path ?? "/";
@@ -2051,7 +2169,7 @@ function NubitAuthenticatedApp({ config }) {
2051
2169
  if (session.session.status === "loading") return /* @__PURE__ */ jsxs("div", {
2052
2170
  style: { padding: 24 },
2053
2171
  "aria-busy": "true",
2054
- "aria-label": "Loading session",
2172
+ "aria-label": strings.loadingSession,
2055
2173
  children: [
2056
2174
  /* @__PURE__ */ jsx(Skeleton, {
2057
2175
  variant: "rect",
@@ -2069,8 +2187,7 @@ function NubitAuthenticatedApp({ config }) {
2069
2187
  const shell = /* @__PURE__ */ jsx(AdminShell, {
2070
2188
  title: config.title,
2071
2189
  menuItems,
2072
- headerActions: config.shell?.headerActions,
2073
- footer: config.shell?.footer,
2190
+ ...config.shell,
2074
2191
  renderThemeSwitcher,
2075
2192
  renderUserMenu: ({ close }) => renderUserMenu({
2076
2193
  ...menuContext,
@@ -2102,6 +2219,7 @@ function NubitAuthenticatedApp({ config }) {
2102
2219
  baseUrl: apiBaseUrl,
2103
2220
  refreshPath: "auth/refresh",
2104
2221
  loginPath: "auth/login",
2222
+ getCsrfToken: () => readCsrfToken(),
2105
2223
  onUnauthorized: () => {
2106
2224
  session.logout();
2107
2225
  }
@@ -2168,6 +2286,7 @@ function createNubitApp(config) {
2168
2286
  client: queryClient,
2169
2287
  children: /* @__PURE__ */ jsx(ThemeProvider, {
2170
2288
  basePath: config.themeBasePath,
2289
+ visualStyle: config.visualStyle,
2171
2290
  children: /* @__PURE__ */ jsx(SessionProvider, {
2172
2291
  apiBaseUrl,
2173
2292
  children: /* @__PURE__ */ jsx(NubitAuthenticatedApp, { config })
@@ -2178,4 +2297,4 @@ function createNubitApp(config) {
2178
2297
  return { App };
2179
2298
  }
2180
2299
  //#endregion
2181
- export { AcceptInvitationPage, AccountPage, AdminHeader, AdminShell, AdminSidebarMenu, FeatureGate, FeatureHubLayout, ForgotPasswordPage, LoginPage, NotificationPanel, NubitDevToolsPanel, PlanPanel, QuotaUsageBanner, RegisterPage, ResetPasswordPage, SessionProvider, StaticSessionProvider, ToastHost, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useNotifications, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
2300
+ export { AcceptInvitationPage, AccountPage, AdminBottomNavigation, AdminHeader, AdminShell, AdminSidebarMenu, FeatureGate, FeatureHubLayout, ForgotPasswordPage, LoginPage, NotificationPanel, NubitDevToolsPanel, PlanPanel, QuotaUsageBanner, RegisterPage, ResetPasswordPage, SessionProvider, StaticSessionProvider, ToastHost, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useNotifications, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };