@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -18,6 +18,7 @@ import {
18
18
  Menu,
19
19
  Package,
20
20
  ScrollText,
21
+ Languages,
21
22
  Shield,
22
23
  Signpost,
23
24
  SlidersHorizontal,
@@ -25,9 +26,12 @@ import {
25
26
  Users,
26
27
  } from "lucide-react";
27
28
  import { useQuery, useQueryClient } from "@tanstack/react-query";
29
+ import { useCallback, useRef } from "react";
28
30
  import { useServerFn } from "@tanstack/react-start";
29
31
 
32
+ import { MISSING_CREATED_ID } from "../../tracking/injections";
30
33
  import type { SiteSettingOverrides } from "../../config/site-config";
34
+ import type { SiteTypography } from "../../config/typography";
31
35
  import { AuditView } from "../AuditView";
32
36
  import { BackupView } from "../BackupView";
33
37
  import { DomainsView } from "../DomainsView";
@@ -42,10 +46,16 @@ import { RedirectsView } from "../RedirectsView";
42
46
  import { RolesView } from "../RolesView";
43
47
  import { SitemapView, type SitemapCheck } from "../SitemapView";
44
48
  import { SiteSettingsView } from "../SiteSettingsView";
45
- import { TrackingView } from "../TrackingView";
49
+ import { TrackingView, type TrackingDeps } from "../TrackingView";
46
50
  import { UsersView } from "../UsersView";
47
51
  import { VersionView } from "../VersionView";
48
- import { useFeatureGate } from "../shell/use-resolved-config";
52
+ import { parseEffectiveAdminHost } from "../../site/locale-domains";
53
+ import {
54
+ useFeatureEnabled,
55
+ useFeatureGate,
56
+ useModuleGate,
57
+ useMyRoles,
58
+ } from "../shell/use-resolved-config";
49
59
  import { backofficeHost, backofficePaths } from "./host";
50
60
 
51
61
  export * from "./host";
@@ -200,13 +210,54 @@ function TrackingRouteInner() {
200
210
  const q = useQuery({ queryKey: ["admin", "injections"], queryFn: () => list() });
201
211
  const domainsQ = useQuery({ queryKey: ["admin", "locale-domains"], queryFn: () => domains() });
202
212
 
213
+ /*
214
+ * A create whose answer carried no id, if one has happened.
215
+ *
216
+ * The insert may well have succeeded — what is missing is the identity of
217
+ * the row it wrote, not evidence that it wrote none. Sending the same draft
218
+ * again would therefore be as likely to create a second profile as to retry
219
+ * the first, and the screen has no way to tell which. So no further create
220
+ * is attempted at all until a reload has reconciled the list with what the
221
+ * server actually holds. Updates carry their own id and are unaffected.
222
+ */
223
+ const createUnresolved = useRef(false);
224
+
225
+ const saveInjection = useCallback<TrackingDeps["saveInjection"]>(
226
+ async (input) => {
227
+ const isCreate = !input.data.id;
228
+ if (isCreate && createUnresolved.current) throw new Error(MISSING_CREATED_ID);
229
+ let result: { id?: unknown } | null | undefined;
230
+ try {
231
+ result = (await save(input as never)) as { id?: unknown } | null | undefined;
232
+ } catch (err) {
233
+ /*
234
+ * The server refuses an insert it cannot identify, and that refusal is
235
+ * indeterminate in exactly the way the check below is: the row may have
236
+ * landed. Any other failure is a plain no, so only this one closes
237
+ * creates — a rejected create is otherwise still worth another try.
238
+ */
239
+ if (isCreate && err instanceof Error && err.message === MISSING_CREATED_ID) {
240
+ createUnresolved.current = true;
241
+ }
242
+ throw err;
243
+ }
244
+ const id = typeof result?.id === "string" ? result.id.trim() : "";
245
+ if (isCreate && !id) {
246
+ createUnresolved.current = true;
247
+ throw new Error(MISSING_CREATED_ID);
248
+ }
249
+ return { id };
250
+ },
251
+ [save],
252
+ );
253
+
203
254
  return (
204
255
  <TrackingView
205
256
  deps={{
206
257
  profiles: q.data ?? [],
207
258
  domains: domainsQ.data ?? [],
208
259
  locales: locales as never,
209
- saveInjection: (input) => save(input as never),
260
+ saveInjection,
210
261
  deleteInjection: (input) => remove(input),
211
262
  refresh: () => q.refetch(),
212
263
  }}
@@ -245,6 +296,9 @@ function FormSettingsRouteInner() {
245
296
  const load = useServerFn(fns.getFormDefaults);
246
297
  const save = useServerFn(fns.saveFormDefaults);
247
298
  const purge = useServerFn(fns.purgeOldSubmissions);
299
+ // Optional: a host that never registered the forms group still renders here.
300
+ const testSheetFn = host.forms?.fns.testSheetConnection;
301
+ const pingSheet = useServerFn(testSheetFn ?? (async () => ({ ok: false })));
248
302
  const formsPath = backofficePaths(host).forms;
249
303
  const { data, isLoading } = useQuery({ queryKey: ["form-defaults"], queryFn: () => load() });
250
304
 
@@ -255,6 +309,7 @@ function FormSettingsRouteInner() {
255
309
  isLoading,
256
310
  saveDefaults: (input) => save(input as never),
257
311
  purgeOldSubmissions: (input) => purge(input),
312
+ ...(testSheetFn ? { testSheetConnection: (input) => pingSheet(input as never) } : {}),
258
313
  previewLocale: host.previewLocale ?? host.siteConfig.defaultLocale,
259
314
  BackLink: ({ className, children }) => (
260
315
  <Link to={formsPath} className={className}>
@@ -308,9 +363,20 @@ export function AdminLayoutRoute() {
308
363
  export function AdminOverviewRoute() {
309
364
  const host = backofficeHost();
310
365
  const paths = backofficePaths(host);
311
- const fetchRoles = useServerFn(host.fns.getMyRoles);
312
- const rolesQ = useQuery({ queryKey: ["admin", "my-roles"], queryFn: () => fetchRoles() });
313
- const isSuper = ((rolesQ.data ?? []) as string[]).includes("superadmin");
366
+ const { roles } = useMyRoles();
367
+ const isSuper = roles.includes("superadmin");
368
+ // Both screens write through server functions that ask `has_role(user,
369
+ // 'admin')`, so a card offered to anyone else is an invitation to a screen
370
+ // whose every save comes back refused. The role is read from the list the
371
+ // host returns; holding `superadmin` is not the same grant.
372
+ const isAdmin = roles.includes("admin");
373
+ // The live flags, not the compiled ones. Both route gates already read the
374
+ // live value, so a card driven by the compiled default disagrees with the
375
+ // screen behind it: an override that switched a module on leaves its card
376
+ // missing, and one that switched a module off leaves a card that opens
377
+ // straight into a no-access screen.
378
+ const domainsEnabled = useFeatureEnabled("domains") === true;
379
+ const trackingEnabled = useFeatureEnabled("tracking") === true;
314
380
 
315
381
  const cards = [
316
382
  {
@@ -360,7 +426,7 @@ export function AdminOverviewRoute() {
360
426
  body: "Company address, org. number, email and phone.",
361
427
  icon: Building2,
362
428
  },
363
- ...(host.siteConfig.features.tracking
429
+ ...(isAdmin && trackingEnabled
364
430
  ? [
365
431
  {
366
432
  to: paths.tracking,
@@ -370,6 +436,16 @@ export function AdminOverviewRoute() {
370
436
  },
371
437
  ]
372
438
  : []),
439
+ ...(isAdmin && domainsEnabled
440
+ ? [
441
+ {
442
+ to: paths.domains,
443
+ title: "Languages & domains",
444
+ body: "One domain per language, the default fallback and the hostname the backoffice is served from.",
445
+ icon: Languages,
446
+ },
447
+ ]
448
+ : []),
373
449
  {
374
450
  to: paths.redirects,
375
451
  title: "Redirects",
@@ -545,6 +621,15 @@ export function NavigationRoute() {
545
621
  const site = host.content.useSite();
546
622
  const overrides = host.content.useOverrides();
547
623
  const save = useServerFn(host.fns.saveContentSection);
624
+ // Menu and footer fonts live in `site_settings`, not in the section content,
625
+ // so this screen reads and writes them through the settings functions.
626
+ const loadSettings = useServerFn(host.fns.getSiteSettings);
627
+ const saveSettings = useServerFn(host.fns.saveSiteSettings);
628
+ const settingsQ = useQuery({
629
+ queryKey: ["admin", "site-settings"],
630
+ queryFn: () => loadSettings(),
631
+ });
632
+ const settingOverrides = (settingsQ.data?.overrides ?? {}) as SiteSettingOverrides;
548
633
 
549
634
  return (
550
635
  <NavigationView
@@ -555,11 +640,30 @@ export function NavigationRoute() {
555
640
  SectionForm: host.components.SectionForm,
556
641
  saveSection: (input) => save(input as never),
557
642
  refresh: () => qc.invalidateQueries({ queryKey: host.content.overridesQueryKey }),
643
+ typography: {
644
+ value:
645
+ (settingsQ.data?.config?.typography as SiteTypography | undefined) ??
646
+ settingOverrides.typography ??
647
+ host.siteConfig.typography ??
648
+ {},
649
+ overrides: settingOverrides.typography,
650
+ save: (typography) => saveSettings({ data: { typography } } as never),
651
+ refresh: () => settingsQ.refetch(),
652
+ },
558
653
  }}
559
654
  />
560
655
  );
561
656
  }
562
657
 
658
+ /**
659
+ * Stand-in for the optional effective-admin-host function.
660
+ *
661
+ * `useServerFn` is a hook and cannot be called conditionally, so an
662
+ * unregistered function still needs something to hand it. This one is never
663
+ * invoked: the query that would call it stays disabled.
664
+ */
665
+ const noEffectiveAdminHost = async () => null;
666
+
563
667
  /** Per-locale domain mapping. */
564
668
  function DomainsRouteInner() {
565
669
  const host = backofficeHost();
@@ -568,19 +672,40 @@ function DomainsRouteInner() {
568
672
  const list = useServerFn(host.fns.getLocaleDomains);
569
673
  const save = useServerFn(host.fns.saveLocaleDomain);
570
674
  const del = useServerFn(host.fns.deleteLocaleDomain);
571
- const q = useQuery({ queryKey: ["locale-domains"], queryFn: () => list() });
675
+ const q = useQuery({ queryKey: ["admin", "locale-domains"], queryFn: () => list() });
676
+
677
+ // Where the backoffice is actually served is server knowledge — `SITE_URL`
678
+ // and the request that arrived. A host that has not registered the function
679
+ // gets no statement about it at all, which is the honest answer: the editor
680
+ // keeps working, it just stops explaining a hostname it cannot know.
681
+ const getAdminHost = useServerFn(host.fns.getEffectiveAdminHost ?? noEffectiveAdminHost);
682
+ const adminHostQuery = useQuery({
683
+ queryKey: ["admin", "effective-admin-host"],
684
+ queryFn: () => getAdminHost(),
685
+ enabled: Boolean(host.fns.getEffectiveAdminHost),
686
+ });
687
+
688
+ const domains = q.data ?? [];
572
689
 
573
690
  return (
574
691
  <DomainsView
575
692
  deps={{
576
- domains: q.data ?? [],
693
+ domains,
577
694
  locales: host.siteConfig.locales,
578
695
  defaultLocale: host.siteConfig.defaultLocale,
579
- backPath: paths.base,
696
+ backPath: paths.admin,
580
697
  domainPlaceholder: host.placeholders?.domain ?? "",
698
+ adminHost: parseEffectiveAdminHost(adminHostQuery.data) ?? undefined,
699
+ backofficePath: paths.base,
581
700
  saveDomain: (input) => save(input as never),
582
701
  deleteDomain: (input) => del(input as never),
583
- refresh: () => qc.invalidateQueries({ queryKey: ["locale-domains"] }),
702
+ // A saved or deleted mapping can move the backoffice itself, and the
703
+ // banner above the table states where that is. Refreshing only the
704
+ // table leaves it stating the hostname from before the change.
705
+ refresh: async () => {
706
+ await qc.invalidateQueries({ queryKey: ["admin", "locale-domains"] });
707
+ await qc.invalidateQueries({ queryKey: ["admin", "effective-admin-host"] });
708
+ },
584
709
  }}
585
710
  />
586
711
  );
@@ -659,16 +784,16 @@ export function ReviewsRoute() {
659
784
  return <ReviewsRouteInner />;
660
785
  }
661
786
 
662
- /** DomainsRoute guarded by the "domains" module switch. */
787
+ /** DomainsRoute guarded by the "domains" module switch and its admin role. */
663
788
  export function DomainsRoute() {
664
- const gate = useFeatureGate("domains", "Domains");
789
+ const gate = useModuleGate("domains", "Languages & domains");
665
790
  if (gate) return <>{gate}</>;
666
791
  return <DomainsRouteInner />;
667
792
  }
668
793
 
669
- /** TrackingRoute guarded by the "tracking" module switch. */
794
+ /** TrackingRoute guarded by the "tracking" module switch and its admin role. */
670
795
  export function TrackingRoute() {
671
- const gate = useFeatureGate("tracking", "Code & tracking");
796
+ const gate = useModuleGate("tracking", "Code & tracking");
672
797
  if (gate) return <>{gate}</>;
673
798
  return <TrackingRouteInner />;
674
799
  }
@@ -20,6 +20,7 @@ import { FormEditorView } from "../FormEditorView";
20
20
  import { FormSubmissionsView, SUBMISSIONS_PAGE_SIZE } from "../FormSubmissionsView";
21
21
  import { FormDeliveriesView, type DeliveryChannel } from "../FormDeliveriesView";
22
22
  import { backofficeHost, backofficePaths, requireHostGroup } from "./host";
23
+ import { siteConfig } from "../../config/site-config";
23
24
 
24
25
  /** Router-agnostic link that forwards loose `to`/`params`/`search` values. */
25
26
  function HostLink({
@@ -90,6 +91,7 @@ export function MediaRoute() {
90
91
  trashLoading: trashQ.isLoading,
91
92
  usage: usageQ.data as MediaLibraryDeps["usage"],
92
93
  isSuperadmin: ((rolesQ.data ?? []) as string[]).includes("superadmin"),
94
+ timezone: siteConfig().timezone,
93
95
  metaMap,
94
96
  deleteMedia: (input) => del(input as never),
95
97
  restoreMedia: (input) => restore(input as never),
@@ -170,6 +172,8 @@ function FormEditorRouteInner() {
170
172
  const sync = useServerFn(forms.fns.syncSubmissionsToSheet);
171
173
  const dup = useServerFn(forms.fns.duplicateForm);
172
174
  const pingWebhook = useServerFn(forms.fns.testWebhook);
175
+ const testSheetFn = forms.fns.testSheetConnection;
176
+ const pingSheet = useServerFn(testSheetFn ?? (async () => ({ ok: false })));
173
177
  const loadDefaults = useServerFn(fns.getFormDefaults);
174
178
 
175
179
  const { data: rows, isLoading } = useQuery({
@@ -196,6 +200,7 @@ function FormEditorRouteInner() {
196
200
  duplicateForm: (input) => dup(input as never),
197
201
  syncSubmissions: (input) => sync(input as never),
198
202
  testWebhook: (input) => pingWebhook(input as never),
203
+ ...(testSheetFn ? { testSheetConnection: (input) => pingSheet(input as never) } : {}),
199
204
  LinkComponent: HostLink as never,
200
205
  FieldEditorComponent: C.FieldEditor,
201
206
  FormPreviewComponent: C.FormPreview,
@@ -38,7 +38,21 @@ export type PageSeoPanelViewProps = {
38
38
  pageKey?: string | null;
39
39
  /** Current registry route for a locale, shown in the preview panel. */
40
40
  registryRoute: (locale: string) => string;
41
- /** Public origin used to build the canonical preview URL. */
41
+ /**
42
+ * The public hostname this locale is published on, or `""` when there is
43
+ * none.
44
+ *
45
+ * Since m7.10 a deployment's locale-to-domain table is explicit: a locale
46
+ * absent from it has no public site. `""` is that answer — NO PUBLIC URL
47
+ * EXISTS — and it must never be read as "use a relative URL", "use whatever
48
+ * hostname the backoffice is open at", or "borrow another locale's domain".
49
+ * Each of those states an address the page is not served at, under the one
50
+ * heading a search engine is asked to believe.
51
+ *
52
+ * A host in single-origin mode — no mappings at all, every language under a
53
+ * locale prefix on one hostname — answers with that hostname, so the
54
+ * canonical stays absolute there.
55
+ */
42
56
  originForLocale: (locale: string) => string;
43
57
  /** Images found on this page, for the "use hero / pick from page" buttons. */
44
58
  pageImages: readonly PageSeoImageCandidate[];
@@ -171,10 +185,13 @@ export function PageSeoPanelView({
171
185
  locale: activeLocale,
172
186
  defaultLocale,
173
187
  });
174
- const previewCanonical = pageSeoPreviewCanonical({
175
- path: previewPath,
176
- origin: originForLocale(activeLocale),
177
- });
188
+ const previewOrigin = originForLocale(activeLocale);
189
+ // No hostname, no canonical. The path alone is not a fallback: printed here
190
+ // it reads as a URL on the hostname this screen is being served from, and an
191
+ // unmapped locale has no public hostname at all.
192
+ const previewCanonical = previewOrigin
193
+ ? pageSeoPreviewCanonical({ path: previewPath, origin: previewOrigin })
194
+ : null;
178
195
 
179
196
  return (
180
197
  <div>
@@ -344,7 +361,15 @@ export function PageSeoPanelView({
344
361
  <div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Preview</div>
345
362
  <dl className="mt-4 grid grid-cols-1 md:grid-cols-[140px_1fr] gap-x-6 gap-y-2 text-sm">
346
363
  <dt className="text-ink/50">Canonical</dt>
347
- <dd className="font-mono text-ink/80 break-all">{previewCanonical}</dd>
364
+ <dd className="text-ink/80 break-all">
365
+ {previewCanonical ? (
366
+ <span className="font-mono">{previewCanonical}</span>
367
+ ) : (
368
+ <span className="text-ink/55">
369
+ No public URL — no domain configured for this language
370
+ </span>
371
+ )}
372
+ </dd>
348
373
  <dt className="text-ink/50">Route</dt>
349
374
  <dd className="font-mono text-ink/80">
350
375
  {previewPath} (current registry: {registryRoute(activeLocale)})
@@ -210,7 +210,17 @@ export function pageSeoPreviewPath(args: {
210
210
  return slug === "" ? `/${args.locale}` : `/${args.locale}/${slug}`;
211
211
  }
212
212
 
213
- /** Absolute canonical preview URL; falls back to the path when no domain is known. */
213
+ /**
214
+ * Absolute canonical preview URL for a page path on a known public origin.
215
+ *
216
+ * With no origin there is no URL to build, and the bare path returned instead
217
+ * is not one either: shown under a heading like "Canonical" it reads as an
218
+ * address on whatever hostname is being browsed — which, for a locale with no
219
+ * configured domain, is the backoffice rather than a public site. Deciding what
220
+ * an empty origin means belongs to the caller; `PageSeoPanelView` asks first
221
+ * and says so in words rather than printing this. The fallback is kept for
222
+ * callers that already depend on it.
223
+ */
214
224
  export function pageSeoPreviewCanonical(args: { path: string; origin?: string | null }): string {
215
225
  return args.origin ? `https://${args.origin}${args.path}` : args.path;
216
226
  }
@@ -3,25 +3,25 @@
3
3
  *
4
4
  * Returns the host the backoffice should be served from, or null when the
5
5
  * current host is fine (local dev, Lovable preview, or already the admin host).
6
+ *
7
+ * This is canonical-host routing, not an authorization boundary — roles are.
8
+ * So every uncertain case returns null and stays put: a missing, blank or
9
+ * malformed admin domain must not bounce an administrator to a hostname that
10
+ * serves nothing, which is a lockout with no way back in.
6
11
  */
7
- const DEV_HOST_SUFFIXES = [".lovable.app", ".lovableproject.com"];
12
+ import { isValidCanonicalDomain, normalizeCanonicalDomain } from "../../site/host";
8
13
 
9
- function normalizeHost(value: string): string {
10
- return value
11
- .replace(/^https?:\/\//, "")
12
- .replace(/\/+$/, "")
13
- .replace(/^www\./, "")
14
- .toLowerCase();
15
- }
14
+ const DEV_HOST_SUFFIXES = [".lovable.app", ".lovableproject.com"];
16
15
 
17
16
  /** True for hosts where the admin-domain guard must not apply. */
18
17
  export function isDevelopmentHost(host: string): boolean {
19
- const h = normalizeHost(host);
18
+ const h = normalizeCanonicalDomain(host);
20
19
  return (
21
20
  DEV_HOST_SUFFIXES.some((suffix) => h.endsWith(suffix)) ||
22
21
  h === "localhost" ||
23
- h.startsWith("localhost:") ||
24
- h.startsWith("127.0.0.1")
22
+ h.endsWith(".localhost") ||
23
+ h === "127.0.0.1" ||
24
+ h === "[::1]"
25
25
  );
26
26
  }
27
27
 
@@ -31,8 +31,21 @@ export function backofficeRedirectHost(
31
31
  adminDomain: string | null | undefined,
32
32
  ): string | null {
33
33
  if (!currentHost || isDevelopmentHost(currentHost)) return null;
34
- const current = normalizeHost(currentHost);
35
- const admin = normalizeHost(adminDomain ?? "");
36
- if (!admin || current === admin) return null;
34
+
35
+ // The stored form is the destination — `www.` included when configured —
36
+ // and the comparison is against that exact hostname, not against an alias
37
+ // key. Apex and `www.` are aliases for *finding* the row; they are two
38
+ // different addresses to serve from, and the configured one is the answer.
39
+ // Folding them together here is what left `voyara.se` sitting on a
40
+ // deployment that had chosen `www.voyara.se`, cookies and all.
41
+ // Validated before normalizing, not after: normalizing strips the scheme, so
42
+ // `ftp://voyara.se` checked afterwards is the valid `voyara.se`.
43
+ const admin = isValidCanonicalDomain(adminDomain) ? normalizeCanonicalDomain(adminDomain ?? "") : "";
44
+ if (!admin) return null;
45
+ // A development host is never a destination either. `localhost` is what an
46
+ // unconfigured install resolves to, and sending a public request there is
47
+ // the lockout this guard exists to avoid.
48
+ if (isDevelopmentHost(admin)) return null;
49
+ if (normalizeCanonicalDomain(currentHost) === admin) return null;
37
50
  return admin;
38
51
  }
@@ -9,9 +9,14 @@ export type { BackofficeNavItem, BackofficeNavKey, BackofficeNavOptions } from "
9
9
  export { backofficeRedirectHost, isDevelopmentHost } from "./domain-guard";
10
10
  export {
11
11
  FeatureDisabledNotice,
12
+ ModuleRoleNotice,
13
+ MY_ROLES_QUERY_KEY,
12
14
  SITE_SETTINGS_QUERY_KEY,
13
15
  useBackofficeNav,
14
16
  useFeatureEnabled,
15
17
  useFeatureGate,
18
+ useModuleGate,
19
+ useModuleRoleAllowed,
20
+ useMyRoles,
16
21
  useResolvedSiteConfig,
17
22
  } from "./use-resolved-config";
@@ -12,6 +12,7 @@ import type { ReactNode } from "react";
12
12
  import { useQuery } from "@tanstack/react-query";
13
13
  import { useServerFn } from "@tanstack/react-start";
14
14
 
15
+ import { findCmsModule, moduleRoleSatisfied } from "../../config/modules";
15
16
  import type { SiteConfig, SiteFeatureFlags } from "../../config/site-config";
16
17
  import { resolveSiteConfig } from "../../config/site-config";
17
18
  import { backofficeHost } from "../routes/host";
@@ -82,3 +83,69 @@ export function useFeatureGate(flag: keyof SiteFeatureFlags, label?: string): Re
82
83
  if (enabled) return null;
83
84
  return <FeatureDisabledNotice label={label} />;
84
85
  }
86
+
87
+ /** Query key shared by every screen that asks what the signed-in user holds. */
88
+ export const MY_ROLES_QUERY_KEY = ["admin", "my-roles"] as const;
89
+
90
+ /**
91
+ * Platform roles of the signed-in viewer.
92
+ *
93
+ * Non-string entries are dropped rather than trusted: this list decides what a
94
+ * screen offers, and a host that answers with something other than an array of
95
+ * role names should grant nothing, not everything.
96
+ */
97
+ export function useMyRoles(): { roles: string[]; isLoading: boolean } {
98
+ const host = backofficeHost();
99
+ const load = useServerFn(host.fns.getMyRoles);
100
+ const q = useQuery({ queryKey: MY_ROLES_QUERY_KEY, queryFn: () => load() as Promise<unknown> });
101
+ const roles = Array.isArray(q.data)
102
+ ? q.data.filter((role): role is string => typeof role === "string")
103
+ : [];
104
+ return { roles, isLoading: q.isLoading };
105
+ }
106
+
107
+ /**
108
+ * Whether the viewer's roles reach a module's screens. `undefined` while they
109
+ * are still being read, so a caller can wait rather than deny access to
110
+ * somebody who in fact holds the role.
111
+ */
112
+ export function useModuleRoleAllowed(flag: keyof SiteFeatureFlags): boolean | undefined {
113
+ const { roles, isLoading } = useMyRoles();
114
+ if (isLoading) return undefined;
115
+ return moduleRoleSatisfied(findCmsModule(flag)?.requiresRole, roles);
116
+ }
117
+
118
+ /** Notice shown instead of a screen the viewer's roles do not reach. */
119
+ export function ModuleRoleNotice({ label, role }: { label?: string; role: string }) {
120
+ return (
121
+ <div className="p-8 md:p-12">
122
+ <h1 className="font-serif text-2xl text-ink">
123
+ {label ? `${label} needs the ${role} role` : `This screen needs the ${role} role`}
124
+ </h1>
125
+ <p className="mt-3 max-w-md text-sm text-ink/65">
126
+ Your account does not hold it, so this screen is read-only to you — every save here would be
127
+ refused by the server. A superadministrator can grant the role under Admin → User roles.
128
+ </p>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Guard for a module screen that also has a role requirement.
135
+ *
136
+ * Both gates apply, and the module switch is answered first: a module that is
137
+ * turned off is off for an administrator too, and saying "you need a role" for
138
+ * a screen nobody can reach would be a lie about why it is unavailable.
139
+ */
140
+ export function useModuleGate(flag: keyof SiteFeatureFlags, label?: string): ReactNode | null {
141
+ const featureGate = useFeatureGate(flag, label);
142
+ const allowed = useModuleRoleAllowed(flag);
143
+ if (featureGate) return featureGate;
144
+ // Worded differently from the settings wait above on purpose: the two are
145
+ // waiting on different answers, and only this one is a wait that must never
146
+ // be mistaken for a refusal.
147
+ if (allowed === undefined)
148
+ return <div className="p-8 text-sm text-ink/50">Checking your access…</div>;
149
+ if (allowed) return null;
150
+ return <ModuleRoleNotice label={label} role={findCmsModule(flag)?.requiresRole ?? "admin"} />;
151
+ }