@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.
- package/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- 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 {
|
|
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
|
|
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
|
|
312
|
-
const
|
|
313
|
-
|
|
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
|
-
...(
|
|
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
|
|
693
|
+
domains,
|
|
577
694
|
locales: host.siteConfig.locales,
|
|
578
695
|
defaultLocale: host.siteConfig.defaultLocale,
|
|
579
|
-
backPath: paths.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
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="
|
|
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
|
-
/**
|
|
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
|
-
|
|
12
|
+
import { isValidCanonicalDomain, normalizeCanonicalDomain } from "../../site/host";
|
|
8
13
|
|
|
9
|
-
|
|
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 =
|
|
18
|
+
const h = normalizeCanonicalDomain(host);
|
|
20
19
|
return (
|
|
21
20
|
DEV_HOST_SUFFIXES.some((suffix) => h.endsWith(suffix)) ||
|
|
22
21
|
h === "localhost" ||
|
|
23
|
-
h.
|
|
24
|
-
h
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
}
|