@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.10
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 +489 -0
- package/README.md +16 -2
- package/docs/EDIT-LOCKS.md +213 -0
- package/docs/POPUPS.md +102 -0
- package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
- package/package.json +31 -3
- package/scripts/create-cms-site.mjs +5 -4
- package/scripts/sync-migrations.mjs +27 -6
- package/src/backoffice/ContactDetailsView.tsx +19 -5
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/EditLock.tsx +513 -0
- package/src/backoffice/EmailSettingsView.tsx +99 -0
- package/src/backoffice/FormEditorView.tsx +531 -512
- package/src/backoffice/FormSettingsView.tsx +16 -4
- package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
- package/src/backoffice/NavigationView.tsx +35 -11
- package/src/backoffice/PageBuilderView.tsx +51 -16
- package/src/backoffice/RichTextEditor.tsx +50 -14
- package/src/backoffice/SectionEditorView.tsx +18 -6
- package/src/backoffice/SectionFormView.tsx +184 -31
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/forms/VersionHistory.tsx +4 -2
- package/src/backoffice/inspectors/blocks.tsx +22 -1
- package/src/backoffice/inspectors/fields.tsx +21 -0
- package/src/backoffice/routes/host.ts +58 -7
- package/src/backoffice/routes/index.tsx +117 -15
- package/src/backoffice/routes/pages.tsx +10 -6
- package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/components/CtaBannerBlock.tsx +18 -12
- package/src/blocks/components/HeroFullBlock.tsx +25 -12
- package/src/blocks/components/MediaSplitBlock.tsx +91 -41
- package/src/blocks/components/PriceTableBlock.tsx +123 -89
- package/src/blocks/components/RichTextBlock.tsx +26 -4
- package/src/blocks/index.ts +8 -0
- package/src/blocks/optional-content.ts +82 -0
- package/src/blocks/registry.tsx +1 -0
- package/src/blocks/types.ts +6 -0
- package/src/cms/edit-lock.ts +491 -0
- package/src/config/modules.ts +208 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +62 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +27 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/navigation/index.ts +29 -0
- package/src/popups/admin.tsx +123 -0
- package/src/popups/cms-bridges.ts +240 -0
- package/src/popups/destinations.tsx +147 -0
- package/src/popups/host.ts +91 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +128 -0
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +22 -5
- package/src/server/domains-core.ts +124 -107
- package/src/server/edit-lock-adapter.ts +91 -0
- package/src/server/edit-lock-supabase-adapter.ts +135 -0
- package/src/server/edit-locks-core.ts +286 -0
- package/src/server/forms-core.ts +37 -2
- package/src/server/injections-core.ts +230 -49
- package/src/server/page-seo-save-core.ts +27 -0
- package/src/server/pages-core.ts +108 -11
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/provider-secrets.ts +100 -4
- package/src/server/schemas.ts +74 -9
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/site/SiteHeader.tsx +11 -5
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/tracking/injections.ts +52 -9
- package/src/types/css-modules.d.ts +6 -0
- package/src/ui/Reveal.tsx +74 -9
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale → domain mapping, as pure routing logic.
|
|
3
|
+
*
|
|
4
|
+
* Request matching, the public projection and the "which hostname serves the
|
|
5
|
+
* backoffice" question all live here rather than next to the database calls,
|
|
6
|
+
* because the backoffice screen needs the same answers as the server and must
|
|
7
|
+
* not pull server-only modules into the browser bundle to get them.
|
|
8
|
+
*
|
|
9
|
+
* Pure module — no database, no environment, no network.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
canonicalDomainProblemMessage,
|
|
14
|
+
domainOrigin,
|
|
15
|
+
hostMatchKey,
|
|
16
|
+
hostsMatch,
|
|
17
|
+
isLoopbackHost,
|
|
18
|
+
isValidCanonicalDomain,
|
|
19
|
+
normalizeCanonicalDomain,
|
|
20
|
+
parseCanonicalDomain,
|
|
21
|
+
} from "./host";
|
|
22
|
+
|
|
23
|
+
export type LocaleDomainRecord = {
|
|
24
|
+
locale: string;
|
|
25
|
+
domain: string;
|
|
26
|
+
isDefault: boolean;
|
|
27
|
+
isAdminDomain: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What public routing and SEO are allowed to see.
|
|
32
|
+
*
|
|
33
|
+
* `isAdminDomain` names the hostname that serves the backoffice. Nothing on
|
|
34
|
+
* the public site needs it, and the migration that revoked anonymous reads of
|
|
35
|
+
* `locale_domains` exists precisely to keep it unpublished — so the public
|
|
36
|
+
* shape omits it rather than relying on every caller to remember.
|
|
37
|
+
*/
|
|
38
|
+
export type PublicLocaleDomain = {
|
|
39
|
+
locale: string;
|
|
40
|
+
domain: string;
|
|
41
|
+
isDefault: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Drop admin-only fields from one mapping. */
|
|
45
|
+
export function toPublicLocaleDomain(record: LocaleDomainRecord): PublicLocaleDomain {
|
|
46
|
+
return { locale: record.locale, domain: record.domain, isDefault: record.isDefault };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Drop admin-only fields from a list of mappings. */
|
|
50
|
+
export function toPublicLocaleDomains(records: LocaleDomainRecord[]): PublicLocaleDomain[] {
|
|
51
|
+
return records.map(toPublicLocaleDomain);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The canonical stored form of a domain. Preserves a configured `www.`. */
|
|
55
|
+
export function normalizeDomain(domain: string): string {
|
|
56
|
+
return normalizeCanonicalDomain(domain);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The key a request host is matched on. Apex and one `www.` fold together. */
|
|
60
|
+
export function normalizeHost(host: string): string {
|
|
61
|
+
return hostMatchKey(host);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The mapping whose domain serves `host`.
|
|
66
|
+
*
|
|
67
|
+
* Apex and exactly one leading `www.` are aliases, so `voyara.se`,
|
|
68
|
+
* `www.voyara.se` and `WWW.VOYARA.SE` all reach the same row — whichever form
|
|
69
|
+
* was configured. Generated URLs still use the stored canonical hostname.
|
|
70
|
+
*
|
|
71
|
+
* A row only becomes eligible once its domain passes the same validation the
|
|
72
|
+
* backoffice applies at the write. Comparing on the normalized value alone
|
|
73
|
+
* lets a legacy `ftp://example.com` win the request for `example.com`, because
|
|
74
|
+
* normalizing strips the scheme and leaves exactly the host being looked up.
|
|
75
|
+
* Everything downstream then treats that row as the answer — its locale, and
|
|
76
|
+
* its unusable domain wherever a URL gets built.
|
|
77
|
+
*/
|
|
78
|
+
export function matchDomain<T extends { domain: string }>(
|
|
79
|
+
domains: T[],
|
|
80
|
+
host: string,
|
|
81
|
+
): T | undefined {
|
|
82
|
+
return domains.find((d) => isValidCanonicalDomain(d.domain) && hostsMatch(d.domain, host));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the active locale for a request host, falling back to the default
|
|
87
|
+
* mapping, then to the first mapping, then to a synthetic host-based record.
|
|
88
|
+
*
|
|
89
|
+
* Only rows whose domain is a usable hostname are eligible for the fallback.
|
|
90
|
+
* A table written before the backoffice validated its input can hold a row
|
|
91
|
+
* that no request will ever match; letting it win the fallback hands the whole
|
|
92
|
+
* site that row's language, and hands generated URLs its unusable host.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveRequestLocale<
|
|
95
|
+
T extends { locale: string; domain: string; isDefault?: boolean },
|
|
96
|
+
>(domains: T[], host: string, defaultLocale: string): { locale: string; domain: T } {
|
|
97
|
+
const match = matchDomain(domains, host);
|
|
98
|
+
if (match) return { locale: match.locale, domain: match };
|
|
99
|
+
|
|
100
|
+
const usable = domains.filter((d) => isValidCanonicalDomain(d.domain));
|
|
101
|
+
const fallback = (usable.find((d) => d.isDefault) ??
|
|
102
|
+
usable[0] ?? {
|
|
103
|
+
locale: defaultLocale,
|
|
104
|
+
domain: normalizeCanonicalDomain(host) || "localhost",
|
|
105
|
+
isDefault: true,
|
|
106
|
+
}) as T;
|
|
107
|
+
return { locale: fallback.locale, domain: fallback };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Absolute origin for a locale, or "" when the locale has no usable mapping.
|
|
112
|
+
*
|
|
113
|
+
* An unusable row is skipped and the scan continues rather than returning ""
|
|
114
|
+
* on the first match for that language: one bad row left over from before the
|
|
115
|
+
* write validation must not hide a good one beside it.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveLocaleOrigin(
|
|
118
|
+
locale: string,
|
|
119
|
+
domains: { locale: string; domain: string }[],
|
|
120
|
+
): string {
|
|
121
|
+
for (const d of domains) {
|
|
122
|
+
if (d.locale !== locale) continue;
|
|
123
|
+
const mapped = domainOrigin(d.domain);
|
|
124
|
+
if (mapped) return mapped;
|
|
125
|
+
}
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type AdminHostSource =
|
|
130
|
+
/** `SITE_URL` — authoritative on a self-hosted install. */
|
|
131
|
+
| "site-url"
|
|
132
|
+
/** The row explicitly flagged `is_admin_domain`. */
|
|
133
|
+
| "database"
|
|
134
|
+
/** Nothing designates one, so the request keeps the host it arrived on. */
|
|
135
|
+
| "request-host"
|
|
136
|
+
/** No request to fall back to, so the mapping flagged as default is used. */
|
|
137
|
+
| "default-mapping"
|
|
138
|
+
/** No request and no default, so the first usable mapping is used. */
|
|
139
|
+
| "first-mapping"
|
|
140
|
+
/** Nothing is configured and there is no request — a local build. */
|
|
141
|
+
| "localhost";
|
|
142
|
+
|
|
143
|
+
export type EffectiveAdminHost = {
|
|
144
|
+
/** Canonical hostname the backoffice should be served from. */
|
|
145
|
+
host: string;
|
|
146
|
+
/** Where that hostname came from — what the UI needs to explain itself. */
|
|
147
|
+
source: AdminHostSource;
|
|
148
|
+
/** Locale of the mapping the hostname came from, when there was one. */
|
|
149
|
+
locale: string;
|
|
150
|
+
/** True when `SITE_URL` overrides the database flag for this install. */
|
|
151
|
+
siteUrlAuthoritative: boolean;
|
|
152
|
+
/** Hostname the flagged database row names, when a row is flagged. */
|
|
153
|
+
flaggedHost: string | null;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export type AdminHostOptions = {
|
|
157
|
+
/** `SITE_URL`'s hostname on a self-hosted install, when configured. */
|
|
158
|
+
forcedHost?: string | null;
|
|
159
|
+
/** Host of the request being served. */
|
|
160
|
+
requestHost?: string | null;
|
|
161
|
+
defaultLocale: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The canonical form of a host, or "" when it could never be reached.
|
|
166
|
+
*
|
|
167
|
+
* The value is validated before it is normalized, not after: normalizing
|
|
168
|
+
* strips the scheme, so `ftp://example.com` checked afterwards is the
|
|
169
|
+
* perfectly valid `example.com` and the rejection never happens.
|
|
170
|
+
*/
|
|
171
|
+
function usableHost(value: string | null | undefined): string {
|
|
172
|
+
if (!isValidCanonicalDomain(value)) return "";
|
|
173
|
+
return normalizeDomain(value ?? "");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Which hostname actually serves the backoffice, and why.
|
|
178
|
+
*
|
|
179
|
+
* The order matters more than any single rule in this file, because getting it
|
|
180
|
+
* wrong locks an administrator out of the only screen that could fix it:
|
|
181
|
+
*
|
|
182
|
+
* 1. `SITE_URL`, when the deployment sets one. It outranks the database on
|
|
183
|
+
* purpose — an imported row from another install must never send the
|
|
184
|
+
* backoffice to that other install's hostname, and this is the recovery
|
|
185
|
+
* path back out of a bad row.
|
|
186
|
+
* 2. The row explicitly flagged as the admin domain, when its host is usable.
|
|
187
|
+
* 3. **The host the request actually arrived on.** Nothing designates a
|
|
188
|
+
* hostname, so the honest answer is the one already working. Reaching past
|
|
189
|
+
* a live request for `domains[0]` — a hostname chosen by nothing but sort
|
|
190
|
+
* order, quite possibly not serving the backoffice at all — turns a
|
|
191
|
+
* missing flag into a redirect away from a session that was fine.
|
|
192
|
+
* 4. Only with no request to speak of (a build step, a job) does the table
|
|
193
|
+
* decide: the mapping flagged as default,
|
|
194
|
+
* 5. then the first usable mapping,
|
|
195
|
+
* 6. then `localhost`, so the answer is never empty.
|
|
196
|
+
*/
|
|
197
|
+
export function resolveEffectiveAdminHost(
|
|
198
|
+
domains: LocaleDomainRecord[],
|
|
199
|
+
options: AdminHostOptions,
|
|
200
|
+
): EffectiveAdminHost {
|
|
201
|
+
/*
|
|
202
|
+
* More than one row can carry the flag, and the first of them may be a
|
|
203
|
+
* legacy value no request will ever match. Taking it and stopping there
|
|
204
|
+
* drops the whole `database` step — the backoffice then follows the request
|
|
205
|
+
* host, silently ignoring a perfectly good flagged row further down the
|
|
206
|
+
* table. The reported `flaggedHost` still falls back to the first flagged
|
|
207
|
+
* row when none is usable, so the banner can say the flag is unusable.
|
|
208
|
+
*/
|
|
209
|
+
const flaggedRows = domains.filter((d) => d.isAdminDomain);
|
|
210
|
+
const flaggedRow = flaggedRows.find((d) => usableHost(d.domain)) ?? flaggedRows[0];
|
|
211
|
+
const flaggedHost = flaggedRow ? normalizeDomain(flaggedRow.domain) : null;
|
|
212
|
+
|
|
213
|
+
const forced = usableHost(options.forcedHost);
|
|
214
|
+
if (forced) {
|
|
215
|
+
return {
|
|
216
|
+
host: forced,
|
|
217
|
+
source: "site-url",
|
|
218
|
+
locale: flaggedRow?.locale ?? domains[0]?.locale ?? options.defaultLocale,
|
|
219
|
+
siteUrlAuthoritative: true,
|
|
220
|
+
flaggedHost,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const rest = { siteUrlAuthoritative: false, flaggedHost };
|
|
225
|
+
|
|
226
|
+
if (flaggedRow && usableHost(flaggedRow.domain)) {
|
|
227
|
+
return {
|
|
228
|
+
host: usableHost(flaggedRow.domain),
|
|
229
|
+
source: "database",
|
|
230
|
+
locale: flaggedRow.locale,
|
|
231
|
+
...rest,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const requestHost = usableHost(options.requestHost);
|
|
236
|
+
if (requestHost) {
|
|
237
|
+
// Whatever language this hostname serves, if any — so the record stays
|
|
238
|
+
// coherent without letting the mapping override where the request is.
|
|
239
|
+
const match = matchDomain(domains, requestHost);
|
|
240
|
+
return {
|
|
241
|
+
host: requestHost,
|
|
242
|
+
source: "request-host",
|
|
243
|
+
locale: match?.locale ?? options.defaultLocale,
|
|
244
|
+
...rest,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const configuredDefault = domains.find((d) => d.isDefault && usableHost(d.domain));
|
|
249
|
+
if (configuredDefault) {
|
|
250
|
+
return {
|
|
251
|
+
host: usableHost(configuredDefault.domain),
|
|
252
|
+
source: "default-mapping",
|
|
253
|
+
locale: configuredDefault.locale,
|
|
254
|
+
...rest,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const first = domains.find((d) => usableHost(d.domain));
|
|
259
|
+
if (first) {
|
|
260
|
+
return {
|
|
261
|
+
host: usableHost(first.domain),
|
|
262
|
+
source: "first-mapping",
|
|
263
|
+
locale: first.locale,
|
|
264
|
+
...rest,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { host: "localhost", source: "localhost", locale: options.defaultLocale, ...rest };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The backoffice hostname as a mapping record, for existing callers. */
|
|
272
|
+
export function resolveAdminDomain(
|
|
273
|
+
domains: LocaleDomainRecord[],
|
|
274
|
+
options: AdminHostOptions,
|
|
275
|
+
): LocaleDomainRecord {
|
|
276
|
+
const effective = resolveEffectiveAdminHost(domains, options);
|
|
277
|
+
const source = domains.find((d) => d.locale === effective.locale);
|
|
278
|
+
return {
|
|
279
|
+
locale: effective.locale,
|
|
280
|
+
domain: effective.host,
|
|
281
|
+
isDefault: source?.isDefault ?? true,
|
|
282
|
+
isAdminDomain: true,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Narrow an untyped server result into an `EffectiveAdminHost`.
|
|
288
|
+
*
|
|
289
|
+
* The value crosses a server-function boundary the package does not own, and
|
|
290
|
+
* the screen states it to an administrator as fact. Anything that is not the
|
|
291
|
+
* full shape becomes `null` — the banner then says nothing, which is the point
|
|
292
|
+
* of it being optional.
|
|
293
|
+
*/
|
|
294
|
+
export function parseEffectiveAdminHost(value: unknown): EffectiveAdminHost | null {
|
|
295
|
+
if (!value || typeof value !== "object") return null;
|
|
296
|
+
const v = value as Record<string, unknown>;
|
|
297
|
+
|
|
298
|
+
// Normalizing is not validating. `normalizeDomain("not a host")` returns
|
|
299
|
+
// "not a host" — non-empty, and previously enough to pass — which the banner
|
|
300
|
+
// then states as the hostname to move to.
|
|
301
|
+
const host = typeof v.host === "string" ? usableHost(v.host) : "";
|
|
302
|
+
if (!host) return null;
|
|
303
|
+
|
|
304
|
+
const sources: AdminHostSource[] = [
|
|
305
|
+
"site-url",
|
|
306
|
+
"database",
|
|
307
|
+
"request-host",
|
|
308
|
+
"default-mapping",
|
|
309
|
+
"first-mapping",
|
|
310
|
+
"localhost",
|
|
311
|
+
];
|
|
312
|
+
if (typeof v.source !== "string" || !sources.includes(v.source as AdminHostSource)) return null;
|
|
313
|
+
const source = v.source as AdminHostSource;
|
|
314
|
+
|
|
315
|
+
const locale = typeof v.locale === "string" ? v.locale : "";
|
|
316
|
+
if (!locale.trim()) return null;
|
|
317
|
+
|
|
318
|
+
// The two ways of saying "SITE_URL decides" must agree. A result claiming
|
|
319
|
+
// SITE_URL authority under any other source, or the `site-url` source
|
|
320
|
+
// without it, is not a shape this package produces, so it is not one the
|
|
321
|
+
// screen should explain to an administrator as fact.
|
|
322
|
+
const siteUrlAuthoritative = v.siteUrlAuthoritative === true;
|
|
323
|
+
if ((source === "site-url") !== siteUrlAuthoritative) return null;
|
|
324
|
+
|
|
325
|
+
// The flagged row is context, not a destination: an unusable one is reported
|
|
326
|
+
// as absent rather than sinking the whole result.
|
|
327
|
+
const flaggedHost = typeof v.flaggedHost === "string" ? usableHost(v.flaggedHost) || null : null;
|
|
328
|
+
|
|
329
|
+
return { host, source, locale, siteUrlAuthoritative, flaggedHost };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export type AdminDomainChange =
|
|
333
|
+
| {
|
|
334
|
+
kind: "save";
|
|
335
|
+
locale: string;
|
|
336
|
+
/** The domain as typed. Included because the impact depends on it. */
|
|
337
|
+
domain: string;
|
|
338
|
+
isDefault: boolean;
|
|
339
|
+
isAdminDomain: boolean;
|
|
340
|
+
}
|
|
341
|
+
| { kind: "delete"; locale: string };
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The mapping table as it would be after a change, mirroring what the server
|
|
345
|
+
* writes: one row upserted, and a set flag cleared everywhere else.
|
|
346
|
+
*/
|
|
347
|
+
export function applyDomainChange(
|
|
348
|
+
domains: LocaleDomainRecord[],
|
|
349
|
+
change: AdminDomainChange,
|
|
350
|
+
): LocaleDomainRecord[] {
|
|
351
|
+
if (change.kind === "delete") return domains.filter((d) => d.locale !== change.locale);
|
|
352
|
+
|
|
353
|
+
const row: LocaleDomainRecord = {
|
|
354
|
+
locale: change.locale,
|
|
355
|
+
domain: normalizeDomain(change.domain),
|
|
356
|
+
isDefault: change.isDefault,
|
|
357
|
+
isAdminDomain: change.isAdminDomain,
|
|
358
|
+
};
|
|
359
|
+
const others = domains
|
|
360
|
+
.filter((d) => d.locale !== change.locale)
|
|
361
|
+
.map((d) => ({
|
|
362
|
+
...d,
|
|
363
|
+
isDefault: change.isDefault ? false : d.isDefault,
|
|
364
|
+
isAdminDomain: change.isAdminDomain ? false : d.isAdminDomain,
|
|
365
|
+
}));
|
|
366
|
+
return domains.some((d) => d.locale === change.locale)
|
|
367
|
+
? domains.map((d) => (d.locale === change.locale ? row : others.find((o) => o.locale === d.locale) ?? d))
|
|
368
|
+
: [...others, row];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export type DomainChangeProblemReason =
|
|
372
|
+
| "invalid-domain"
|
|
373
|
+
| "duplicate-host"
|
|
374
|
+
| "unroutable-admin-host"
|
|
375
|
+
| "lost-default";
|
|
376
|
+
|
|
377
|
+
export type DomainChangeValidationOptions = {
|
|
378
|
+
/** True when `SITE_URL`, not the table, decides where the backoffice is. */
|
|
379
|
+
siteUrlAuthoritative?: boolean;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
export type DomainChangeProblem = {
|
|
383
|
+
reason: DomainChangeProblemReason;
|
|
384
|
+
message: string;
|
|
385
|
+
/** The other locale involved, for a duplicate. */
|
|
386
|
+
locale?: string;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Why a change must be refused outright, or null when it may go ahead.
|
|
391
|
+
*
|
|
392
|
+
* These are the conditions the server also enforces, kept pure so the screen
|
|
393
|
+
* can say the same thing before spending a round trip. They are refusals, not
|
|
394
|
+
* warnings: nothing here is offered as a confirmable choice, because the write
|
|
395
|
+
* would fail anyway. Moving the backoffice to a different hostname is
|
|
396
|
+
* deliberately *not* on this list — that is a valid change the administrator
|
|
397
|
+
* confirms, and `adminHostImpact` is what describes it.
|
|
398
|
+
*/
|
|
399
|
+
export function validateDomainChange(
|
|
400
|
+
domains: LocaleDomainRecord[],
|
|
401
|
+
change: AdminDomainChange,
|
|
402
|
+
options: DomainChangeValidationOptions = {},
|
|
403
|
+
): DomainChangeProblem | null {
|
|
404
|
+
if (change.kind === "save") {
|
|
405
|
+
const parsed = parseCanonicalDomain(change.domain);
|
|
406
|
+
if (!parsed.ok) {
|
|
407
|
+
return { reason: "invalid-domain", message: canonicalDomainProblemMessage(parsed.reason) };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Apex and `www.` resolve to the same site, so handing them to two
|
|
411
|
+
// languages makes the request-to-locale answer depend on which row a query
|
|
412
|
+
// happened to return first.
|
|
413
|
+
const clash = domains.find((d) => d.locale !== change.locale && hostsMatch(d.domain, parsed.domain));
|
|
414
|
+
if (clash) {
|
|
415
|
+
return {
|
|
416
|
+
reason: "duplicate-host",
|
|
417
|
+
locale: clash.locale,
|
|
418
|
+
message: `"${normalizeDomain(clash.domain)}" is already mapped to ${clash.locale}, and a hostname can only serve one language. Apex and www. count as the same hostname.`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Only a lockout where the flag is what routes the backoffice. With
|
|
423
|
+
// `SITE_URL` set the column is inert metadata, so refusing would be
|
|
424
|
+
// pedantry rather than protection.
|
|
425
|
+
if (change.isAdminDomain && !options.siteUrlAuthoritative && isLoopbackHost(parsed.domain)) {
|
|
426
|
+
return {
|
|
427
|
+
reason: "unroutable-admin-host",
|
|
428
|
+
message: `"${parsed.domain}" only resolves on the server itself, so nobody could reach the backoffice there.`,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const after = applyDomainChange(domains, change);
|
|
434
|
+
if (domains.some((d) => d.isDefault) && after.length > 0 && !after.some((d) => d.isDefault)) {
|
|
435
|
+
return {
|
|
436
|
+
reason: "lost-default",
|
|
437
|
+
message:
|
|
438
|
+
"That would leave no default language. Mark another domain as the default first — the default is what x-default and unmatched hosts fall back to.",
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Human-readable reason a change was refused. */
|
|
446
|
+
export function domainChangeProblemMessage(problem: DomainChangeProblem): string {
|
|
447
|
+
return problem.message;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export type AdminHostImpact = {
|
|
451
|
+
/** Hostname serving the backoffice now. */
|
|
452
|
+
from: string;
|
|
453
|
+
fromSource: AdminHostSource;
|
|
454
|
+
/** Hostname that would serve it after the change. */
|
|
455
|
+
to: string;
|
|
456
|
+
toSource: AdminHostSource;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* How a valid change would move the backoffice, or null when it would not.
|
|
461
|
+
*
|
|
462
|
+
* Computed by resolving the effective host on both sides of the change rather
|
|
463
|
+
* than by pattern-matching the edit, so every route to a moved backoffice is
|
|
464
|
+
* caught by construction: retyping the flagged row's domain, promoting another
|
|
465
|
+
* row, deleting or unflagging the current one, and — where the table is what
|
|
466
|
+
* decides — editing the mapping the fallback lands on.
|
|
467
|
+
*
|
|
468
|
+
* A deployment pinned by `SITE_URL` gets `null` for all of them, because none
|
|
469
|
+
* of those edits change where the backoffice is actually served.
|
|
470
|
+
*/
|
|
471
|
+
export function adminHostImpact(
|
|
472
|
+
domains: LocaleDomainRecord[],
|
|
473
|
+
change: AdminDomainChange,
|
|
474
|
+
options: AdminHostOptions,
|
|
475
|
+
): AdminHostImpact | null {
|
|
476
|
+
const before = resolveEffectiveAdminHost(domains, options);
|
|
477
|
+
const after = resolveEffectiveAdminHost(applyDomainChange(domains, change), options);
|
|
478
|
+
if (before.host === after.host) return null;
|
|
479
|
+
return { from: before.host, fromSource: before.source, to: after.host, toSource: after.source };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The confirmation shown before the backoffice moves to another hostname.
|
|
484
|
+
*
|
|
485
|
+
* `authoritative` is false when the caller could not find out whether
|
|
486
|
+
* `SITE_URL` is set — a screen whose host has registered no way to ask. The
|
|
487
|
+
* move is then stated as a condition rather than as a fact, because a
|
|
488
|
+
* deployment pinned by `SITE_URL` would not move at all.
|
|
489
|
+
*/
|
|
490
|
+
export function adminHostImpactMessage(
|
|
491
|
+
impact: AdminHostImpact,
|
|
492
|
+
options: { authoritative?: boolean } = {},
|
|
493
|
+
): string {
|
|
494
|
+
const move = `the backoffice will move from "${impact.from}" to "${impact.to}"`;
|
|
495
|
+
const lead =
|
|
496
|
+
options.authoritative === false
|
|
497
|
+
? `Unless SITE_URL pins it on this deployment, ${move}.`
|
|
498
|
+
: `${move.charAt(0).toUpperCase()}${move.slice(1)}.`;
|
|
499
|
+
return `${lead} Open the backoffice on the new hostname after saving. Continue?`;
|
|
500
|
+
}
|