@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,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editing locks: the vocabulary shared by the database, the server cores and
|
|
3
|
+
* the browser.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here talks to a database or to React. It is the one place that knows
|
|
6
|
+
* what a lock *means* — who holds it, whether it blocks you, how long a client
|
|
7
|
+
* may wait before saying so — so the server and the editor can never disagree
|
|
8
|
+
* about that.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How long a lock survives without a heartbeat. */
|
|
12
|
+
export const DEFAULT_LOCK_TTL_SECONDS = 120;
|
|
13
|
+
|
|
14
|
+
/** Bounds the database enforces too, so a client cannot ask for a lock that outlives its editor. */
|
|
15
|
+
export const MIN_LOCK_TTL_SECONDS = 15;
|
|
16
|
+
export const MAX_LOCK_TTL_SECONDS = 3600;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A resource an editor can hold open.
|
|
20
|
+
*
|
|
21
|
+
* `type` is a free-form, platform-level name — `"page"`, `"global_section"`,
|
|
22
|
+
* `"form"`, `"content_section"`, `"page_seo"` — deliberately not an enum, so a
|
|
23
|
+
* host with its own editable record type can lock it without patching the
|
|
24
|
+
* package or migrating the database.
|
|
25
|
+
*/
|
|
26
|
+
export type EditLockRef = {
|
|
27
|
+
resourceType: string;
|
|
28
|
+
resourceId: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** What the server reports about one resource. */
|
|
32
|
+
export type EditLockState = {
|
|
33
|
+
/** Somebody holds a live lock. */
|
|
34
|
+
locked: boolean;
|
|
35
|
+
/** That somebody is this caller, in this editing session. */
|
|
36
|
+
held: boolean;
|
|
37
|
+
/** The holder is this caller, but a different tab or window. */
|
|
38
|
+
sameUser?: boolean;
|
|
39
|
+
resourceType?: string;
|
|
40
|
+
resourceId?: string;
|
|
41
|
+
userId?: string | null;
|
|
42
|
+
userEmail?: string | null;
|
|
43
|
+
userName?: string | null;
|
|
44
|
+
/** @deprecated Bearer values are never returned by the server. */
|
|
45
|
+
sessionId?: string | null;
|
|
46
|
+
acquiredAt?: string | null;
|
|
47
|
+
refreshedAt?: string | null;
|
|
48
|
+
expiresAt?: string | null;
|
|
49
|
+
/** Database clock at the time of the reply, so clients need no clock of their own. */
|
|
50
|
+
now?: string | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Nothing is holding this resource. */
|
|
54
|
+
export const FREE_EDIT_LOCK: EditLockState = { locked: false, held: false };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read a reply from the lock endpoint.
|
|
58
|
+
*
|
|
59
|
+
* A transport reply is security-relevant: an empty or malformed value must
|
|
60
|
+
* not be presented as proof that a resource is free. Missing migrations are
|
|
61
|
+
* converted to `FREE_EDIT_LOCK` explicitly by the server core before this
|
|
62
|
+
* boundary; every successful endpoint reply has both booleans.
|
|
63
|
+
*/
|
|
64
|
+
export function asEditLockState(data: unknown): EditLockState {
|
|
65
|
+
if (
|
|
66
|
+
data &&
|
|
67
|
+
typeof data === "object" &&
|
|
68
|
+
!Array.isArray(data) &&
|
|
69
|
+
typeof (data as Partial<EditLockState>).locked === "boolean" &&
|
|
70
|
+
typeof (data as Partial<EditLockState>).held === "boolean"
|
|
71
|
+
) {
|
|
72
|
+
return data as EditLockState;
|
|
73
|
+
}
|
|
74
|
+
throw new TypeError(
|
|
75
|
+
"Invalid editing lock response: expected boolean locked and held fields",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A lock exists and it is not ours: editing and saving are both refused. */
|
|
80
|
+
export function isBlocked(state: EditLockState | null | undefined): boolean {
|
|
81
|
+
return Boolean(state?.locked) && !state?.held;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Who to name in the message a blocked editor sees.
|
|
86
|
+
*
|
|
87
|
+
* Falls back through display name, email and finally a neutral phrase — a
|
|
88
|
+
* lock is still worth honouring when we cannot say whose it is.
|
|
89
|
+
*/
|
|
90
|
+
export function lockHolderLabel(
|
|
91
|
+
state: EditLockState | null | undefined,
|
|
92
|
+
fallback = "another user",
|
|
93
|
+
): string {
|
|
94
|
+
const name = state?.userName?.trim();
|
|
95
|
+
if (name) return name;
|
|
96
|
+
const email = state?.userEmail?.trim();
|
|
97
|
+
if (email) return email;
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One sentence explaining why the editor is read-only. */
|
|
102
|
+
export function describeLock(state: EditLockState | null | undefined): string {
|
|
103
|
+
if (!isBlocked(state)) return "";
|
|
104
|
+
if (state?.sameUser) {
|
|
105
|
+
return "You already have this open in another tab. Finish there, or close it and try again after its lock expires.";
|
|
106
|
+
}
|
|
107
|
+
return `${lockHolderLabel(state)} is editing this right now. You can read it, but not change it.`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Seconds until the lock lapses, measured against the server's own clock. */
|
|
111
|
+
export function secondsRemaining(
|
|
112
|
+
state: EditLockState | null | undefined,
|
|
113
|
+
): number | null {
|
|
114
|
+
const expires = state?.expiresAt ? Date.parse(state.expiresAt) : NaN;
|
|
115
|
+
if (Number.isNaN(expires)) return null;
|
|
116
|
+
const now = state?.now ? Date.parse(state.now) : Date.now();
|
|
117
|
+
return Math.max(
|
|
118
|
+
0,
|
|
119
|
+
Math.round((expires - (Number.isNaN(now) ? Date.now() : now)) / 1000),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Heartbeat interval for a given lifetime.
|
|
125
|
+
*
|
|
126
|
+
* A third of the TTL: two heartbeats can be lost — a sleeping tab, a flaky
|
|
127
|
+
* connection — before anyone else can take the resource, and a lock still
|
|
128
|
+
* lapses promptly once the editor really is gone.
|
|
129
|
+
*/
|
|
130
|
+
export function heartbeatIntervalMs(
|
|
131
|
+
ttlSeconds = DEFAULT_LOCK_TTL_SECONDS,
|
|
132
|
+
): number {
|
|
133
|
+
return Math.max(5, Math.floor(clampTtl(ttlSeconds) / 3)) * 1000;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Keep a requested lifetime inside the bounds the database also applies. */
|
|
137
|
+
export function clampTtl(ttlSeconds: number | undefined): number {
|
|
138
|
+
const n = Number.isFinite(ttlSeconds)
|
|
139
|
+
? Number(ttlSeconds)
|
|
140
|
+
: DEFAULT_LOCK_TTL_SECONDS;
|
|
141
|
+
return Math.min(
|
|
142
|
+
MAX_LOCK_TTL_SECONDS,
|
|
143
|
+
Math.max(MIN_LOCK_TTL_SECONDS, Math.round(n)),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Identify one editing session — one tab, across reloads.
|
|
149
|
+
*
|
|
150
|
+
* Kept in `sessionStorage` because that is exactly the lifetime we want: a
|
|
151
|
+
* reload keeps its lock instead of locking the editor out of their own work,
|
|
152
|
+
* while a second tab gets its own id and is told about the first. Falls back
|
|
153
|
+
* to a per-call id where storage is unavailable (private windows, SSR).
|
|
154
|
+
*/
|
|
155
|
+
export function editSessionId(storageKey = "cms.edit-session"): string {
|
|
156
|
+
return readEditSessionId(storageKey).sessionId;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Make sure the stored editing-session id belongs to this live document.
|
|
161
|
+
*
|
|
162
|
+
* Browsers normally give each tab its own `sessionStorage`, but duplicating a
|
|
163
|
+
* tab (and some `window.open` flows) starts the new document with a copy. The
|
|
164
|
+
* copy must not use the old tab's session id: the server would otherwise see
|
|
165
|
+
* both tabs as the same lock holder and accept a stale save from either one.
|
|
166
|
+
*
|
|
167
|
+
* A browser-owned Web Lock proves whether the stored id already belongs to a
|
|
168
|
+
* live document without relying on that document being awake enough to answer.
|
|
169
|
+
* BroadcastChannel is the fallback for browsers without Web Locks. A reload
|
|
170
|
+
* has no other live owner and keeps its id; a copied tab gets a fresh id and
|
|
171
|
+
* writes it into its own copy of session storage. Without either browser API,
|
|
172
|
+
* reload continuity is preserved but a copied tab cannot be distinguished.
|
|
173
|
+
*/
|
|
174
|
+
export function claimEditSessionId(
|
|
175
|
+
storageKey = "cms.edit-session",
|
|
176
|
+
): Promise<string> {
|
|
177
|
+
const candidate = readEditSessionId(storageKey);
|
|
178
|
+
const previous = liveEditSessions.get(storageKey);
|
|
179
|
+
|
|
180
|
+
// Every hook in one document shares one claim and one channel. If storage is
|
|
181
|
+
// unavailable there is nothing stable to compare, so the in-memory claim is
|
|
182
|
+
// the document's identity for the rest of its lifetime.
|
|
183
|
+
if (
|
|
184
|
+
previous &&
|
|
185
|
+
(!candidate.stored ||
|
|
186
|
+
(previous.sessionId === candidate.sessionId &&
|
|
187
|
+
previous.claimId === candidate.claimId))
|
|
188
|
+
) {
|
|
189
|
+
return previous.ready;
|
|
190
|
+
}
|
|
191
|
+
previous?.close();
|
|
192
|
+
|
|
193
|
+
const contextId = freshEditSessionToken("ec_");
|
|
194
|
+
let channel: BroadcastChannel | null = null;
|
|
195
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
196
|
+
let removePageHideListener = () => {};
|
|
197
|
+
let releaseBrowserLock = () => {};
|
|
198
|
+
let closed = false;
|
|
199
|
+
let settled = false;
|
|
200
|
+
let resolveReady: (sessionId: string) => void = () => {};
|
|
201
|
+
const ready = new Promise<string>((resolve) => {
|
|
202
|
+
resolveReady = resolve;
|
|
203
|
+
});
|
|
204
|
+
const live: LiveEditSession = {
|
|
205
|
+
sessionId: candidate.sessionId,
|
|
206
|
+
claimId: candidate.claimId,
|
|
207
|
+
ready,
|
|
208
|
+
close: () => {
|
|
209
|
+
closed = true;
|
|
210
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
211
|
+
removePageHideListener();
|
|
212
|
+
releaseBrowserLock();
|
|
213
|
+
try {
|
|
214
|
+
channel?.close();
|
|
215
|
+
} catch {
|
|
216
|
+
// A browser tearing the document down may already have closed it.
|
|
217
|
+
}
|
|
218
|
+
if (liveEditSessions.get(storageKey) === live) {
|
|
219
|
+
liveEditSessions.delete(storageKey);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
liveEditSessions.set(storageKey, live);
|
|
224
|
+
|
|
225
|
+
if (typeof window !== "undefined") {
|
|
226
|
+
const onPageHide = (event: PageTransitionEvent) => {
|
|
227
|
+
// A real navigation/reload tears this document down before the new one
|
|
228
|
+
// probes, preventing the outgoing page from looking like a copied tab.
|
|
229
|
+
// A bfcache document remains the same live context and keeps listening.
|
|
230
|
+
if (!event.persisted) live.close();
|
|
231
|
+
};
|
|
232
|
+
window.addEventListener("pagehide", onPageHide);
|
|
233
|
+
removePageHideListener = () => {
|
|
234
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const finish = (collision: boolean) => {
|
|
239
|
+
if (settled) return;
|
|
240
|
+
settled = true;
|
|
241
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
242
|
+
if (collision) rotate();
|
|
243
|
+
resolveReady(live.sessionId);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const rotate = () => {
|
|
247
|
+
const copied = { sessionId: live.sessionId, claimId: live.claimId };
|
|
248
|
+
const replacement = {
|
|
249
|
+
sessionId: freshEditSessionToken("es_"),
|
|
250
|
+
claimId: freshEditSessionToken("ec_"),
|
|
251
|
+
};
|
|
252
|
+
live.sessionId = replacement.sessionId;
|
|
253
|
+
live.claimId = replacement.claimId;
|
|
254
|
+
replaceStoredEditSession(storageKey, copied, replacement);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
let broadcastStarted = false;
|
|
258
|
+
const startBroadcastClaim = () => {
|
|
259
|
+
if (broadcastStarted || closed || settled) return;
|
|
260
|
+
broadcastStarted = true;
|
|
261
|
+
try {
|
|
262
|
+
if (
|
|
263
|
+
typeof window === "undefined" ||
|
|
264
|
+
typeof window.BroadcastChannel === "undefined"
|
|
265
|
+
) {
|
|
266
|
+
finish(false);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
channel = new window.BroadcastChannel(
|
|
270
|
+
`cms.edit-session-live:${storageKey}`,
|
|
271
|
+
);
|
|
272
|
+
channel.onmessage = (event: MessageEvent<unknown>) => {
|
|
273
|
+
const message = asEditSessionClaimMessage(event.data);
|
|
274
|
+
if (!message || message.contextId === contextId) return;
|
|
275
|
+
|
|
276
|
+
if (message.type === "probe" && message.claimId === live.claimId) {
|
|
277
|
+
try {
|
|
278
|
+
channel?.postMessage({
|
|
279
|
+
scope: EDIT_SESSION_CLAIM_SCOPE,
|
|
280
|
+
type: "occupied",
|
|
281
|
+
claimId: live.claimId,
|
|
282
|
+
contextId,
|
|
283
|
+
targetContextId: message.contextId,
|
|
284
|
+
} satisfies EditSessionClaimMessage);
|
|
285
|
+
} catch {
|
|
286
|
+
// The probing tab still has the timeout fallback.
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (
|
|
292
|
+
message.type === "occupied" &&
|
|
293
|
+
message.targetContextId === contextId &&
|
|
294
|
+
message.claimId === live.claimId
|
|
295
|
+
) {
|
|
296
|
+
finish(true);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
channel.postMessage({
|
|
300
|
+
scope: EDIT_SESSION_CLAIM_SCOPE,
|
|
301
|
+
type: "probe",
|
|
302
|
+
claimId: live.claimId,
|
|
303
|
+
contextId,
|
|
304
|
+
} satisfies EditSessionClaimMessage);
|
|
305
|
+
// This fallback cannot prove absence: a frozen document may answer late.
|
|
306
|
+
// It is used only when the browser has no exclusive Web Locks primitive.
|
|
307
|
+
timer = setTimeout(() => finish(false), 50);
|
|
308
|
+
} catch {
|
|
309
|
+
finish(false);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
let locks: LockManager | undefined;
|
|
314
|
+
try {
|
|
315
|
+
locks = typeof navigator !== "undefined" ? navigator.locks : undefined;
|
|
316
|
+
} catch {
|
|
317
|
+
// Privacy modes may expose Navigator while refusing individual APIs.
|
|
318
|
+
}
|
|
319
|
+
if (locks && typeof locks.request === "function") {
|
|
320
|
+
const requestBrowserLock = (attempt: number) => {
|
|
321
|
+
// The browser exposes held lock names through `navigator.locks.query()`.
|
|
322
|
+
// Use the copied, non-secret collision id — never the save bearer.
|
|
323
|
+
const name = `cms-edit-context:${storageKey}:${live.claimId}`;
|
|
324
|
+
try {
|
|
325
|
+
void locks
|
|
326
|
+
.request(name, { ifAvailable: true }, async (lock) => {
|
|
327
|
+
if (!lock) {
|
|
328
|
+
if (attempt >= 3) {
|
|
329
|
+
startBroadcastClaim();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
rotate();
|
|
333
|
+
requestBrowserLock(attempt + 1);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
finish(false);
|
|
337
|
+
await new Promise<void>((resolve) => {
|
|
338
|
+
if (closed) resolve();
|
|
339
|
+
else releaseBrowserLock = resolve;
|
|
340
|
+
});
|
|
341
|
+
})
|
|
342
|
+
.catch(() => startBroadcastClaim());
|
|
343
|
+
} catch {
|
|
344
|
+
startBroadcastClaim();
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
requestBrowserLock(0);
|
|
348
|
+
} else {
|
|
349
|
+
startBroadcastClaim();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return ready;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const EDIT_SESSION_CLAIM_SCOPE = "cms-edit-session-claim-v1";
|
|
356
|
+
|
|
357
|
+
type EditSessionClaimMessage = {
|
|
358
|
+
scope: typeof EDIT_SESSION_CLAIM_SCOPE;
|
|
359
|
+
type: "probe" | "occupied";
|
|
360
|
+
claimId: string;
|
|
361
|
+
contextId: string;
|
|
362
|
+
targetContextId?: string;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
type LiveEditSession = {
|
|
366
|
+
sessionId: string;
|
|
367
|
+
claimId: string;
|
|
368
|
+
ready: Promise<string>;
|
|
369
|
+
close: () => void;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const liveEditSessions = new Map<string, LiveEditSession>();
|
|
373
|
+
|
|
374
|
+
function freshEditSessionToken(prefix: "es_" | "ec_"): string {
|
|
375
|
+
try {
|
|
376
|
+
if (
|
|
377
|
+
typeof crypto !== "undefined" &&
|
|
378
|
+
typeof crypto.randomUUID === "function"
|
|
379
|
+
) {
|
|
380
|
+
return `${prefix}${crypto.randomUUID().replaceAll("-", "")}`;
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
// Some privacy modes expose crypto but refuse calls into it.
|
|
384
|
+
}
|
|
385
|
+
return `${prefix}${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function readEditSessionId(storageKey: string): {
|
|
389
|
+
sessionId: string;
|
|
390
|
+
claimId: string;
|
|
391
|
+
stored: boolean;
|
|
392
|
+
} {
|
|
393
|
+
const claimStorageKey = `${storageKey}.claim`;
|
|
394
|
+
try {
|
|
395
|
+
const store = typeof sessionStorage === "undefined" ? null : sessionStorage;
|
|
396
|
+
if (!store) {
|
|
397
|
+
return {
|
|
398
|
+
sessionId: freshEditSessionToken("es_"),
|
|
399
|
+
claimId: freshEditSessionToken("ec_"),
|
|
400
|
+
stored: false,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const existing = store.getItem(storageKey);
|
|
404
|
+
if (existing && existing.trim()) {
|
|
405
|
+
const storedClaim = store.getItem(claimStorageKey);
|
|
406
|
+
const claimId =
|
|
407
|
+
storedClaim && storedClaim.trim()
|
|
408
|
+
? storedClaim
|
|
409
|
+
: freshEditSessionToken("ec_");
|
|
410
|
+
if (!storedClaim?.trim()) store.setItem(claimStorageKey, claimId);
|
|
411
|
+
return { sessionId: existing, claimId, stored: true };
|
|
412
|
+
}
|
|
413
|
+
const next = freshEditSessionToken("es_");
|
|
414
|
+
const claimId = freshEditSessionToken("ec_");
|
|
415
|
+
store.setItem(storageKey, next);
|
|
416
|
+
store.setItem(claimStorageKey, claimId);
|
|
417
|
+
return { sessionId: next, claimId, stored: true };
|
|
418
|
+
} catch {
|
|
419
|
+
return {
|
|
420
|
+
sessionId: freshEditSessionToken("es_"),
|
|
421
|
+
claimId: freshEditSessionToken("ec_"),
|
|
422
|
+
stored: false,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function replaceStoredEditSession(
|
|
428
|
+
storageKey: string,
|
|
429
|
+
copied: { sessionId: string; claimId: string },
|
|
430
|
+
replacement: { sessionId: string; claimId: string },
|
|
431
|
+
): void {
|
|
432
|
+
try {
|
|
433
|
+
const store = typeof sessionStorage === "undefined" ? null : sessionStorage;
|
|
434
|
+
// Do not overwrite a newer decision made by another caller in this same
|
|
435
|
+
// document while the asynchronous handshake was in flight.
|
|
436
|
+
if (
|
|
437
|
+
store?.getItem(storageKey) === copied.sessionId &&
|
|
438
|
+
store.getItem(`${storageKey}.claim`) === copied.claimId
|
|
439
|
+
) {
|
|
440
|
+
store.setItem(storageKey, replacement.sessionId);
|
|
441
|
+
store.setItem(`${storageKey}.claim`, replacement.claimId);
|
|
442
|
+
}
|
|
443
|
+
} catch {
|
|
444
|
+
// The returned in-memory id still keeps this hook internally consistent.
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function asEditSessionClaimMessage(
|
|
449
|
+
value: unknown,
|
|
450
|
+
): EditSessionClaimMessage | null {
|
|
451
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
452
|
+
const message = value as Partial<EditSessionClaimMessage>;
|
|
453
|
+
if (
|
|
454
|
+
message.scope !== EDIT_SESSION_CLAIM_SCOPE ||
|
|
455
|
+
(message.type !== "probe" && message.type !== "occupied") ||
|
|
456
|
+
typeof message.claimId !== "string" ||
|
|
457
|
+
typeof message.contextId !== "string"
|
|
458
|
+
) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
return message as EditSessionClaimMessage;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Resource-type names this package locks.
|
|
466
|
+
*
|
|
467
|
+
* Constants rather than an enum so a host can add one of its own — the
|
|
468
|
+
* database stores whatever string it is given — while everything shipped here
|
|
469
|
+
* agrees on the same spelling.
|
|
470
|
+
*/
|
|
471
|
+
export const EDIT_LOCK_TYPES = {
|
|
472
|
+
page: "page",
|
|
473
|
+
globalSection: "global_section",
|
|
474
|
+
form: "form",
|
|
475
|
+
formDefaults: "form_defaults",
|
|
476
|
+
contentSection: "content_section",
|
|
477
|
+
pageSeo: "page_seo",
|
|
478
|
+
siteSettings: "site_settings",
|
|
479
|
+
} as const;
|
|
480
|
+
|
|
481
|
+
export type EditLockType = (typeof EDIT_LOCK_TYPES)[keyof typeof EDIT_LOCK_TYPES];
|
|
482
|
+
|
|
483
|
+
/** Message thrown when a save is refused because somebody else holds the lock. */
|
|
484
|
+
export function lockedSaveMessage(
|
|
485
|
+
state: EditLockState | null | undefined,
|
|
486
|
+
): string {
|
|
487
|
+
const who = state?.sameUser
|
|
488
|
+
? "another of your own tabs"
|
|
489
|
+
: lockHolderLabel(state);
|
|
490
|
+
return `Not saved — ${who} is editing this. Reload to see the current version.`;
|
|
491
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CMS module table.
|
|
3
|
+
*
|
|
4
|
+
* "Modules" were previously described in four separate places that had to agree
|
|
5
|
+
* by hand: the `SiteFeatureFlags` type, the defaults object, the label map on
|
|
6
|
+
* the Modules screen, and the sidebar definitions. Adding one meant editing all
|
|
7
|
+
* four and hoping none was missed.
|
|
8
|
+
*
|
|
9
|
+
* This is the single description each of those now derives from. It is
|
|
10
|
+
* deliberately NOT a plugin registry: the key space stays a closed, typed union
|
|
11
|
+
* so an unknown value in `site_settings` is still rejected rather than trusted.
|
|
12
|
+
* What it buys is that a new first-party module is one entry here plus one line
|
|
13
|
+
* in the flag type, instead of five edits across the package.
|
|
14
|
+
*/
|
|
15
|
+
import type { ComponentType } from "react";
|
|
16
|
+
import type { LucideProps } from "lucide-react";
|
|
17
|
+
import {
|
|
18
|
+
Blocks,
|
|
19
|
+
Calendar,
|
|
20
|
+
ClipboardList,
|
|
21
|
+
GraduationCap,
|
|
22
|
+
MessageSquare,
|
|
23
|
+
MessageSquareQuote,
|
|
24
|
+
} from "lucide-react";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every module a site can switch on.
|
|
28
|
+
*
|
|
29
|
+
* Adding a key here is the one type-level edit a new module needs; the compiler
|
|
30
|
+
* then requires the matching table entry below.
|
|
31
|
+
*/
|
|
32
|
+
export type SiteFeatureFlags = {
|
|
33
|
+
/** Trips / departures module (`/kontoret/trips`). */
|
|
34
|
+
trips: boolean;
|
|
35
|
+
/** Courses module (`/kontoret/kurser`). */
|
|
36
|
+
courses: boolean;
|
|
37
|
+
/** Reviews module (`/kontoret/recensioner`). */
|
|
38
|
+
reviews: boolean;
|
|
39
|
+
/** Transactional email + provider settings screens. */
|
|
40
|
+
email: boolean;
|
|
41
|
+
/** Head/body code injection and analytics pixels. */
|
|
42
|
+
tracking: boolean;
|
|
43
|
+
/** Backup & restore tooling (superadmin only). */
|
|
44
|
+
backup: boolean;
|
|
45
|
+
/** Custom domain management screen. */
|
|
46
|
+
domains: boolean;
|
|
47
|
+
/** Public forms module. */
|
|
48
|
+
forms: boolean;
|
|
49
|
+
/** Popups module: public popup runtime + backoffice administration. */
|
|
50
|
+
popups: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type CmsModuleKey = keyof SiteFeatureFlags;
|
|
54
|
+
|
|
55
|
+
/** A sidebar entry a module contributes when it is enabled. */
|
|
56
|
+
export type CmsModuleNav = {
|
|
57
|
+
/** Appended to the site's configured backoffice path. Never hardcode `/kontoret`. */
|
|
58
|
+
readonly path: string;
|
|
59
|
+
readonly label: string;
|
|
60
|
+
readonly icon: ComponentType<LucideProps>;
|
|
61
|
+
readonly exact?: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type CmsModuleDefinition = {
|
|
65
|
+
readonly key: CmsModuleKey;
|
|
66
|
+
/** Shown on the Modules screen. */
|
|
67
|
+
readonly label: string;
|
|
68
|
+
/** Whether a site that says nothing gets this module. */
|
|
69
|
+
readonly defaultEnabled: boolean;
|
|
70
|
+
/** One line of help on the Modules screen. */
|
|
71
|
+
readonly description?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Sidebar contribution. Absent for modules whose screen belongs under Admin
|
|
74
|
+
* rather than in the sidebar — `email`, `tracking`, `backup` and `domains`.
|
|
75
|
+
*/
|
|
76
|
+
readonly nav?: CmsModuleNav;
|
|
77
|
+
/**
|
|
78
|
+
* Minimum role for the module's own screens.
|
|
79
|
+
*
|
|
80
|
+
* This is not decoration: it has to name the same role the module's server
|
|
81
|
+
* functions actually demand. `admin` is used by modules whose writes call
|
|
82
|
+
* `has_role(user, 'admin')`, and `superadmin` by modules that can destroy or
|
|
83
|
+
* exfiltrate data. A module advertised to staff whose saves are refused sends
|
|
84
|
+
* an editor to a screen that cannot work.
|
|
85
|
+
*/
|
|
86
|
+
readonly requiresRole?: "staff" | "admin" | "superadmin";
|
|
87
|
+
/**
|
|
88
|
+
* True when the module contributes a component to the public site. The public
|
|
89
|
+
* runtime host renders only the modules that both declare this and are
|
|
90
|
+
* enabled, so a disabled module mounts nothing.
|
|
91
|
+
*/
|
|
92
|
+
readonly hasPublicRuntime?: boolean;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Every module, in sidebar order.
|
|
97
|
+
*
|
|
98
|
+
* `satisfies` pins the array to the flag union, so a key added to
|
|
99
|
+
* `SiteFeatureFlags` without an entry here — or an entry whose key is not a
|
|
100
|
+
* flag — is a compile error rather than a module that silently never appears.
|
|
101
|
+
*/
|
|
102
|
+
export const CMS_MODULES = [
|
|
103
|
+
{
|
|
104
|
+
key: "trips",
|
|
105
|
+
label: "Trips & departures",
|
|
106
|
+
defaultEnabled: false,
|
|
107
|
+
nav: { path: "/trips", label: "Trips", icon: Calendar },
|
|
108
|
+
requiresRole: "staff",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
key: "courses",
|
|
112
|
+
label: "Courses",
|
|
113
|
+
defaultEnabled: false,
|
|
114
|
+
nav: { path: "/kurser", label: "Courses", icon: GraduationCap },
|
|
115
|
+
requiresRole: "staff",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
key: "reviews",
|
|
119
|
+
label: "Reviews",
|
|
120
|
+
defaultEnabled: false,
|
|
121
|
+
nav: { path: "/recensioner", label: "Reviews", icon: MessageSquareQuote },
|
|
122
|
+
requiresRole: "staff",
|
|
123
|
+
},
|
|
124
|
+
{ key: "email", label: "Transactional email", defaultEnabled: true, requiresRole: "staff" },
|
|
125
|
+
{ key: "tracking", label: "Code & tracking", defaultEnabled: true, requiresRole: "admin" },
|
|
126
|
+
{ key: "backup", label: "Backup & restore", defaultEnabled: true, requiresRole: "superadmin" },
|
|
127
|
+
{
|
|
128
|
+
key: "domains",
|
|
129
|
+
label: "Languages & domains",
|
|
130
|
+
defaultEnabled: false,
|
|
131
|
+
requiresRole: "admin",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: "forms",
|
|
135
|
+
label: "Forms",
|
|
136
|
+
defaultEnabled: true,
|
|
137
|
+
nav: { path: "/forms", label: "Forms", icon: ClipboardList },
|
|
138
|
+
requiresRole: "staff",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
key: "popups",
|
|
142
|
+
label: "Popups",
|
|
143
|
+
defaultEnabled: false,
|
|
144
|
+
description: "Timed popups on the public site, with their own statistics.",
|
|
145
|
+
nav: { path: "/popups", label: "Popups", icon: MessageSquare },
|
|
146
|
+
requiresRole: "staff",
|
|
147
|
+
hasPublicRuntime: true,
|
|
148
|
+
},
|
|
149
|
+
] as const satisfies readonly CmsModuleDefinition[];
|
|
150
|
+
|
|
151
|
+
/** Compile-time proof that every flag has exactly one module entry. */
|
|
152
|
+
type DeclaredKeys = (typeof CMS_MODULES)[number]["key"];
|
|
153
|
+
type MissingModule = Exclude<CmsModuleKey, DeclaredKeys>;
|
|
154
|
+
type AssertNever<T extends never> = T;
|
|
155
|
+
type _EveryFlagIsDeclared = AssertNever<MissingModule>;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The table widened to the declaration type.
|
|
159
|
+
*
|
|
160
|
+
* `CMS_MODULES` is `as const` so the exhaustiveness check above can see the
|
|
161
|
+
* literal keys; consumers want the declared shape, where every optional field
|
|
162
|
+
* exists whether or not a particular entry uses it.
|
|
163
|
+
*/
|
|
164
|
+
export const CMS_MODULE_LIST: readonly CmsModuleDefinition[] = CMS_MODULES;
|
|
165
|
+
|
|
166
|
+
export const CMS_MODULE_KEYS: readonly CmsModuleKey[] = CMS_MODULES.map((m) => m.key);
|
|
167
|
+
|
|
168
|
+
export function findCmsModule(key: CmsModuleKey): CmsModuleDefinition | undefined {
|
|
169
|
+
return CMS_MODULES.find((module) => module.key === key);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether a viewer's platform roles satisfy a module's `requiresRole`.
|
|
174
|
+
*
|
|
175
|
+
* `staff` is not a row in `user_roles`: it is the host's own `is_staff` check,
|
|
176
|
+
* which anyone rendering the backoffice has already passed, so it is satisfied
|
|
177
|
+
* here. `admin` and `superadmin` are rows, and each has to be held in its own
|
|
178
|
+
* right — the server asks `has_role(user, 'admin')`, and that question is not
|
|
179
|
+
* answered by holding some other, higher-sounding role. Inferring one from the
|
|
180
|
+
* other here would put an editable screen in front of someone whose every save
|
|
181
|
+
* the server then refuses.
|
|
182
|
+
*/
|
|
183
|
+
export function moduleRoleSatisfied(
|
|
184
|
+
requiresRole: CmsModuleDefinition["requiresRole"],
|
|
185
|
+
roles: readonly string[],
|
|
186
|
+
): boolean {
|
|
187
|
+
if (requiresRole === "admin" || requiresRole === "superadmin") {
|
|
188
|
+
return roles.includes(requiresRole);
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The defaults a site starts from, derived so the two can never disagree. */
|
|
194
|
+
export const DEFAULT_FEATURE_FLAGS: SiteFeatureFlags = Object.fromEntries(
|
|
195
|
+
CMS_MODULES.map((module) => [module.key, module.defaultEnabled]),
|
|
196
|
+
) as SiteFeatureFlags;
|
|
197
|
+
|
|
198
|
+
/** Labels for the Modules screen, derived from the same table. */
|
|
199
|
+
export const CMS_MODULE_LABELS: Readonly<Record<CmsModuleKey, string>> = Object.fromEntries(
|
|
200
|
+
CMS_MODULES.map((module) => [module.key, module.label]),
|
|
201
|
+
) as Record<CmsModuleKey, string>;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Blocks that unnecessary icon imports are not the reason a public bundle grows:
|
|
205
|
+
* this module table is imported by the public runtime host as well, so keep it
|
|
206
|
+
* free of anything heavier than the icon components already used by the sidebar.
|
|
207
|
+
*/
|
|
208
|
+
export type { LucideProps };
|