@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +16 -2
  3. package/docs/EDIT-LOCKS.md +213 -0
  4. package/docs/POPUPS.md +102 -0
  5. package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
  6. package/package.json +31 -3
  7. package/scripts/create-cms-site.mjs +5 -4
  8. package/scripts/sync-migrations.mjs +27 -6
  9. package/src/backoffice/ContactDetailsView.tsx +19 -5
  10. package/src/backoffice/DomainsView.tsx +152 -25
  11. package/src/backoffice/EditLock.tsx +513 -0
  12. package/src/backoffice/EmailSettingsView.tsx +99 -0
  13. package/src/backoffice/FormEditorView.tsx +531 -512
  14. package/src/backoffice/FormSettingsView.tsx +16 -4
  15. package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
  16. package/src/backoffice/NavigationView.tsx +35 -11
  17. package/src/backoffice/PageBuilderView.tsx +51 -16
  18. package/src/backoffice/RichTextEditor.tsx +50 -14
  19. package/src/backoffice/SectionEditorView.tsx +18 -6
  20. package/src/backoffice/SectionFormView.tsx +184 -31
  21. package/src/backoffice/SiteSettingsView.tsx +14 -15
  22. package/src/backoffice/TrackingView.tsx +247 -208
  23. package/src/backoffice/forms/VersionHistory.tsx +4 -2
  24. package/src/backoffice/inspectors/blocks.tsx +22 -1
  25. package/src/backoffice/inspectors/fields.tsx +21 -0
  26. package/src/backoffice/routes/host.ts +58 -7
  27. package/src/backoffice/routes/index.tsx +117 -15
  28. package/src/backoffice/routes/pages.tsx +10 -6
  29. package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
  30. package/src/backoffice/shell/domain-guard.ts +27 -14
  31. package/src/backoffice/shell/index.ts +5 -0
  32. package/src/backoffice/shell/nav-model.ts +32 -23
  33. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  34. package/src/backoffice/tracking-drafts.ts +150 -0
  35. package/src/blocks/components/CtaBannerBlock.tsx +18 -12
  36. package/src/blocks/components/HeroFullBlock.tsx +25 -12
  37. package/src/blocks/components/MediaSplitBlock.tsx +91 -41
  38. package/src/blocks/components/PriceTableBlock.tsx +123 -89
  39. package/src/blocks/components/RichTextBlock.tsx +26 -4
  40. package/src/blocks/index.ts +8 -0
  41. package/src/blocks/optional-content.ts +82 -0
  42. package/src/blocks/registry.tsx +1 -0
  43. package/src/blocks/types.ts +6 -0
  44. package/src/cms/edit-lock.ts +491 -0
  45. package/src/config/modules.ts +208 -0
  46. package/src/config/site-config.ts +9 -31
  47. package/src/forms/FormRenderer.tsx +62 -3
  48. package/src/forms/submission-result.ts +35 -0
  49. package/src/forms/success-listeners.ts +46 -0
  50. package/src/host/index.tsx +27 -1
  51. package/src/modules/index.ts +28 -0
  52. package/src/modules/public-runtime.tsx +159 -0
  53. package/src/navigation/index.ts +29 -0
  54. package/src/popups/admin.tsx +123 -0
  55. package/src/popups/cms-bridges.ts +240 -0
  56. package/src/popups/destinations.tsx +147 -0
  57. package/src/popups/host.ts +91 -0
  58. package/src/popups/media-bridge.tsx +84 -0
  59. package/src/popups/register.ts +23 -0
  60. package/src/popups/runtime.tsx +128 -0
  61. package/src/runtime/server-entry.ts +8 -2
  62. package/src/server/content-sections-core.ts +22 -5
  63. package/src/server/domains-core.ts +124 -107
  64. package/src/server/edit-lock-adapter.ts +91 -0
  65. package/src/server/edit-lock-supabase-adapter.ts +135 -0
  66. package/src/server/edit-locks-core.ts +286 -0
  67. package/src/server/forms-core.ts +37 -2
  68. package/src/server/injections-core.ts +230 -49
  69. package/src/server/page-seo-save-core.ts +27 -0
  70. package/src/server/pages-core.ts +108 -11
  71. package/src/server/popup-optin-trigger.ts +111 -0
  72. package/src/server/popup-optin-webhook-core.ts +363 -0
  73. package/src/server/provider-secrets.ts +100 -4
  74. package/src/server/schemas.ts +74 -9
  75. package/src/server/webhook-url-guard.ts +199 -0
  76. package/src/site/SiteHeader.tsx +11 -5
  77. package/src/site/host.ts +172 -0
  78. package/src/site/locale-domains.ts +500 -0
  79. package/src/site/routes.ts +206 -38
  80. package/src/site/site-url.ts +80 -17
  81. package/src/tracking/injections.ts +52 -9
  82. package/src/types/css-modules.d.ts +6 -0
  83. package/src/ui/Reveal.tsx +74 -9
@@ -0,0 +1,513 @@
1
+ /**
2
+ * Editing locks in the browser.
3
+ *
4
+ * Three pieces, deliberately small:
5
+ *
6
+ * - `EditLockProvider` — the host mounts it once, around the backoffice, with
7
+ * functions that call its own server endpoints. Nothing else needs wiring.
8
+ * - `useEditLock` — every editing screen calls it with the resource it has
9
+ * open. It takes the lock, keeps it alive, and gives it back.
10
+ * - `EditLockBanner` — what the blocked editor sees.
11
+ *
12
+ * With no provider mounted the hook reports "not enforced" and every editor
13
+ * behaves exactly as it did before locks existed, which is what keeps an
14
+ * existing installation working while its host catches up.
15
+ *
16
+ * None of this is the protection itself. The server refuses the write; this is
17
+ * how a person finds out before typing for ten minutes.
18
+ */
19
+ import {
20
+ createContext,
21
+ useCallback,
22
+ useContext,
23
+ useEffect,
24
+ useMemo,
25
+ useRef,
26
+ useState,
27
+ type ReactNode,
28
+ } from "react";
29
+
30
+ import {
31
+ asEditLockState,
32
+ claimEditSessionId,
33
+ DEFAULT_LOCK_TTL_SECONDS,
34
+ describeLock,
35
+ editSessionId,
36
+ heartbeatIntervalMs,
37
+ isBlocked,
38
+ lockHolderLabel,
39
+ secondsRemaining,
40
+ type EditLockRef,
41
+ type EditLockState,
42
+ } from "../cms/edit-lock";
43
+
44
+ export type { EditLockRef, EditLockState } from "../cms/edit-lock";
45
+
46
+ /** What the host must provide, each call reaching its own server function. */
47
+ export type EditLockApi = {
48
+ acquire: (
49
+ input: EditLockRef & { sessionId: string; ttlSeconds?: number },
50
+ ) => Promise<EditLockState>;
51
+ /** Defaults to `acquire`, which the server treats as a renewal. */
52
+ renew?: (
53
+ input: EditLockRef & { sessionId: string; ttlSeconds?: number },
54
+ ) => Promise<EditLockState>;
55
+ release: (input: EditLockRef & { sessionId: string }) => Promise<unknown>;
56
+ status?: (
57
+ input: EditLockRef & { sessionId?: string },
58
+ ) => Promise<EditLockState>;
59
+ /** Superadmin recovery for a lock whose owner is unreachable. */
60
+ steal?: (
61
+ input: EditLockRef & { sessionId: string; ttlSeconds?: number },
62
+ ) => Promise<EditLockState>;
63
+ /** Whether to offer the take-over button at all. */
64
+ canSteal?: boolean;
65
+ /** Lifetime to ask for. The server clamps it. */
66
+ ttlSeconds?: number;
67
+ };
68
+
69
+ /** The five things a host's lock endpoint has to answer. */
70
+ export type EditLockAction = "acquire" | "renew" | "release" | "status" | "steal";
71
+
72
+ /**
73
+ * Build the whole api from one call into the host's own transport.
74
+ *
75
+ * The five functions differ only in the word they send, so a host that writes
76
+ * them by hand writes the same thing five times. Give this a function that
77
+ * reaches its server however it already does — a server function, `fetch`, a
78
+ * react-query mutation — and it returns the api the provider wants:
79
+ *
80
+ * Keep the returned object stable (for example with `useMemo`) because its
81
+ * identity controls each editor's acquire/release effect.
82
+ *
83
+ * On the server, `editLockActionCore` is the matching half. Nothing here knows
84
+ * what the transport is or which database is behind it.
85
+ */
86
+ export function createEditLockApi(
87
+ call: (
88
+ action: EditLockAction,
89
+ input: EditLockRef & { sessionId: string; ttlSeconds?: number },
90
+ ) => Promise<unknown>,
91
+ options: { canSteal?: boolean; ttlSeconds?: number } = {},
92
+ ): EditLockApi {
93
+ const send =
94
+ (action: EditLockAction) =>
95
+ async (input: EditLockRef & { sessionId?: string; ttlSeconds?: number }) =>
96
+ asEditLockState(
97
+ await call(action, { ...input, sessionId: input.sessionId ?? "" }),
98
+ );
99
+ return {
100
+ acquire: send("acquire"),
101
+ renew: send("renew"),
102
+ release: send("release"),
103
+ status: send("status"),
104
+ // Offered only when the host says so: the database refuses anyone but a
105
+ // superadmin, and a button that always fails is worse than no button.
106
+ ...(options.canSteal ? { steal: send("steal") } : {}),
107
+ canSteal: options.canSteal ?? false,
108
+ ...(options.ttlSeconds == null ? {} : { ttlSeconds: options.ttlSeconds }),
109
+ };
110
+ }
111
+
112
+ const ApiContext = createContext<EditLockApi | null>(null);
113
+
114
+ export function EditLockProvider({
115
+ api,
116
+ children,
117
+ }: {
118
+ api: EditLockApi | null;
119
+ children: ReactNode;
120
+ }) {
121
+ return <ApiContext.Provider value={api}>{children}</ApiContext.Provider>;
122
+ }
123
+
124
+ export function useEditLockApi(): EditLockApi | null {
125
+ return useContext(ApiContext);
126
+ }
127
+
128
+ export type EditLockHandle = {
129
+ /** The editor may change things. False while identity is being checked or another holder blocks it. */
130
+ editable: boolean;
131
+ /** The editor must remain read-only, including during the identity handshake. */
132
+ blocked: boolean;
133
+ /** A lock is actually being kept. False with no provider, or on an installation without the migration. */
134
+ enforced: boolean;
135
+ state: EditLockState | null;
136
+ /** Pass to save calls as `editSessionId` so the server can tell it is us. */
137
+ sessionId: string;
138
+ /** One sentence for the person who is blocked. Empty when they are not. */
139
+ message: string;
140
+ holder: string;
141
+ /** Seconds until the blocking lock lapses on its own. */
142
+ expiresInSeconds: number | null;
143
+ busy: boolean;
144
+ error: string | null;
145
+ refresh: () => Promise<void>;
146
+ release: () => Promise<void>;
147
+ takeOver: () => Promise<void>;
148
+ canTakeOver: boolean;
149
+ };
150
+
151
+ export type UseEditLockOptions = {
152
+ /** Skip while the resource id is still loading, or in a read-only screen. */
153
+ enabled?: boolean;
154
+ ttlSeconds?: number;
155
+ /** Told when the lock is lost mid-edit, e.g. to warn before saving. */
156
+ onLost?: (state: EditLockState) => void;
157
+ };
158
+
159
+ const IDLE: EditLockState = { locked: false, held: false };
160
+
161
+ /**
162
+ * One queue per resource and session, shared by every hook instance.
163
+ *
164
+ * Closing an editor and opening it again — a route change, or React's own
165
+ * double-invoked effects in development — releases from the old screen and
166
+ * acquires from the new one with the *same* session id. Those are two
167
+ * independent requests, and a release that happens to land second deletes a
168
+ * lock the editor believes it holds; nothing notices until the next heartbeat,
169
+ * a minute or so later, by which time somebody else may have taken it. The
170
+ * queue makes the server see the order the browser issued them in.
171
+ */
172
+ const queues = new Map<string, Promise<unknown>>();
173
+
174
+ function lockQueueKey(resourceType: string, resourceId: string, sessionId: string): string {
175
+ return [resourceType, resourceId, sessionId].join("\u0000");
176
+ }
177
+
178
+ function serialize<T>(key: string, work: () => Promise<T>): Promise<T> {
179
+ const previous = queues.get(key) ?? Promise.resolve();
180
+ // Run whether the one before succeeded or failed: a failed release must not
181
+ // strand every later call for this resource.
182
+ const result = previous.then(work, work);
183
+ const tail = result.then(
184
+ () => undefined,
185
+ () => undefined,
186
+ );
187
+ queues.set(key, tail);
188
+ void tail.then(() => {
189
+ if (queues.get(key) === tail) queues.delete(key);
190
+ });
191
+ return result;
192
+ }
193
+
194
+ /**
195
+ * Hold one resource for as long as this screen is open.
196
+ *
197
+ * The session id lives in `sessionStorage`, so reloading the page keeps the
198
+ * lock the editor already had instead of locking them out of their own work,
199
+ * while a second tab gets its own id and is told about the first.
200
+ */
201
+ export function useEditLock(
202
+ ref: { resourceType: string; resourceId?: string | null },
203
+ options: UseEditLockOptions = {},
204
+ ): EditLockHandle {
205
+ const api = useEditLockApi();
206
+ const { enabled = true, onLost } = options;
207
+ const ttlSeconds =
208
+ options.ttlSeconds ?? api?.ttlSeconds ?? DEFAULT_LOCK_TTL_SECONDS;
209
+
210
+ // Keep a valid id available for backward-compatible, provider-free screens.
211
+ // When enforcement is eligible the screen remains fenced until the live-
212
+ // document handshake has checked that sessionStorage was not copied.
213
+ const initialSessionId = useMemo(() => editSessionId(), []);
214
+ const [sessionId, setSessionId] = useState(initialSessionId);
215
+ const [sessionReady, setSessionReady] = useState(false);
216
+ const [stateResult, setStateResult] = useState<{
217
+ activation: object;
218
+ state: EditLockState;
219
+ } | null>(null);
220
+ const [busy, setBusy] = useState(false);
221
+ const [errorResult, setErrorResult] = useState<{
222
+ activation: object;
223
+ message: string;
224
+ } | null>(null);
225
+
226
+ const resourceType = ref.resourceType;
227
+ const resourceId = ref.resourceId ?? "";
228
+ const eligible =
229
+ Boolean(api) && enabled && resourceType !== "" && resourceId !== "";
230
+ const active = eligible && sessionReady;
231
+ const queueKey = lockQueueKey(resourceType, resourceId, sessionId);
232
+ // A fresh token for every activation prevents A -> B -> A navigation from
233
+ // reviving A's old answer after its cleanup has already released that lock.
234
+ // It also fences provider, eligibility and TTL changes before effects run.
235
+ const activation = useMemo(
236
+ () => ({}),
237
+ [api, eligible, queueKey, ttlSeconds],
238
+ );
239
+ const state =
240
+ stateResult?.activation === activation ? stateResult.state : null;
241
+ const error =
242
+ errorResult?.activation === activation ? errorResult.message : null;
243
+
244
+ // Only the newest request may write state: a heartbeat that overtakes a
245
+ // take-over must not resurrect the answer it was already given.
246
+ const turn = useRef(0);
247
+ const lost = useRef(false);
248
+ const onLostRef = useRef(onLost);
249
+ onLostRef.current = onLost;
250
+
251
+ useEffect(() => {
252
+ let stopped = false;
253
+ void claimEditSessionId().then((claimed) => {
254
+ if (stopped) return;
255
+ setSessionId(claimed || initialSessionId);
256
+ setSessionReady(true);
257
+ });
258
+ return () => {
259
+ stopped = true;
260
+ };
261
+ }, [initialSessionId]);
262
+
263
+ const run = useCallback(
264
+ async (
265
+ call: (a: EditLockApi) => Promise<EditLockState>,
266
+ { showBusy = true }: { showBusy?: boolean } = {},
267
+ ) => {
268
+ if (!api || !active) return;
269
+ const mine = ++turn.current;
270
+ if (showBusy) setBusy(true);
271
+ try {
272
+ const next = await serialize(queueKey, () => call(api));
273
+ if (turn.current !== mine) return;
274
+ setStateResult({ activation, state: next });
275
+ setErrorResult(null);
276
+ if (isBlocked(next) && !lost.current) {
277
+ lost.current = true;
278
+ onLostRef.current?.(next);
279
+ } else if (!isBlocked(next)) {
280
+ lost.current = false;
281
+ }
282
+ } catch (e) {
283
+ if (turn.current !== mine) return;
284
+ // A lock we cannot reach must not stop anyone working. The server is
285
+ // still the authority on whether the save lands.
286
+ setErrorResult({
287
+ activation,
288
+ message: e instanceof Error ? e.message : String(e),
289
+ });
290
+ } finally {
291
+ // A newer non-busy heartbeat may supersede a manual request. Whichever
292
+ // turn wins must clear the older spinner as well as its own.
293
+ if (turn.current === mine) setBusy(false);
294
+ }
295
+ },
296
+ [api, active, queueKey, activation],
297
+ );
298
+
299
+ /* Take it, keep it, give it back. */
300
+ useEffect(() => {
301
+ if (!api || !active) {
302
+ setStateResult(null);
303
+ return;
304
+ }
305
+ let stopped = false;
306
+ const input = { resourceType, resourceId, sessionId, ttlSeconds };
307
+
308
+ const beat = (showBusy: boolean) => {
309
+ if (stopped) return;
310
+ void run((a) => (a.renew ?? a.acquire)(input), { showBusy });
311
+ };
312
+
313
+ void run((a) => a.acquire(input));
314
+ const timer = setInterval(
315
+ () => beat(false),
316
+ heartbeatIntervalMs(ttlSeconds),
317
+ );
318
+
319
+ // A backgrounded/bfcached tab may have lost the lock while its timers were
320
+ // suspended. Fence its stale answer before asking the server again.
321
+ const revalidate = () => {
322
+ setStateResult(null);
323
+ setErrorResult(null);
324
+ beat(false);
325
+ };
326
+ const onVisible = () => {
327
+ if (document.visibilityState === "visible") revalidate();
328
+ };
329
+ const onPageShow = (event: PageTransitionEvent) => {
330
+ if (event.persisted) revalidate();
331
+ };
332
+ let fullDocumentLeaving = false;
333
+ const onPageHide = (event: PageTransitionEvent) => {
334
+ fullDocumentLeaving = !event.persisted;
335
+ };
336
+ document.addEventListener("visibilitychange", onVisible);
337
+ window.addEventListener("pageshow", onPageShow);
338
+ window.addEventListener("pagehide", onPageHide);
339
+
340
+ return () => {
341
+ stopped = true;
342
+ turn.current++;
343
+ clearInterval(timer);
344
+ document.removeEventListener("visibilitychange", onVisible);
345
+ window.removeEventListener("pageshow", onPageShow);
346
+ window.removeEventListener("pagehide", onPageHide);
347
+ // Leaving the editor frees the resource immediately rather than after
348
+ // the TTL. Full-document teardown deliberately relies on expiry instead:
349
+ // an old pagehide release can race a reload's acquire in another JS realm.
350
+ if (!fullDocumentLeaving) {
351
+ void serialize(queueKey, async () => api.release(input)).catch(() => {});
352
+ }
353
+ };
354
+ }, [api, active, resourceType, resourceId, sessionId, ttlSeconds, queueKey, run]);
355
+
356
+ const refresh = useCallback(async () => {
357
+ const input = { resourceType, resourceId, sessionId, ttlSeconds };
358
+ // A status reply saying "free" does not reserve anything. Acquire first,
359
+ // then enable editing only from the authoritative result of that attempt.
360
+ await run((a) => a.acquire(input));
361
+ }, [run, resourceType, resourceId, sessionId, ttlSeconds]);
362
+
363
+ const release = useCallback(async () => {
364
+ if (!api || !active) return;
365
+ turn.current++;
366
+ await serialize(queueKey, async () =>
367
+ api.release({ resourceType, resourceId, sessionId }),
368
+ ).catch(() => {});
369
+ setStateResult({ activation, state: IDLE });
370
+ }, [api, active, resourceType, resourceId, sessionId, queueKey, activation]);
371
+
372
+ const takeOver = useCallback(async () => {
373
+ await run((a) => {
374
+ if (!a.steal)
375
+ throw new Error("Taking over an editing lock is not available here.");
376
+ return a.steal({ resourceType, resourceId, sessionId, ttlSeconds });
377
+ });
378
+ }, [run, resourceType, resourceId, sessionId, ttlSeconds]);
379
+
380
+ const identityPending = eligible && !sessionReady;
381
+ const acquisitionPending = active && state === null && error === null;
382
+ const blocked =
383
+ identityPending || acquisitionPending || (active && isBlocked(state));
384
+ return {
385
+ editable: !blocked,
386
+ blocked,
387
+ enforced: active && state !== null && state.locked,
388
+ state,
389
+ sessionId,
390
+ message: blocked ? describeLock(state) : "",
391
+ holder: lockHolderLabel(state),
392
+ expiresInSeconds: blocked ? secondsRemaining(state) : null,
393
+ busy: busy || identityPending || acquisitionPending,
394
+ error,
395
+ refresh,
396
+ release,
397
+ takeOver,
398
+ canTakeOver:
399
+ active &&
400
+ isBlocked(state) &&
401
+ Boolean(api?.steal) &&
402
+ api?.canSteal !== false,
403
+ };
404
+ }
405
+
406
+ const BlockedContext = createContext(false);
407
+
408
+ /**
409
+ * True when this part of the screen is inside a fence somebody else holds.
410
+ *
411
+ * A disabled `fieldset` stops form controls and buttons, but the browser
412
+ * happily goes on editing a `contenteditable` region inside one — which is
413
+ * every rich-text field in the page builder. Anything that edits outside a
414
+ * form control asks this and turns itself off.
415
+ */
416
+ export function useEditLockBlocked(): boolean {
417
+ return useContext(BlockedContext);
418
+ }
419
+
420
+ /**
421
+ * Makes everything inside read-only while somebody else holds the lock.
422
+ *
423
+ * A disabled `fieldset` rather than an overlay: the content stays visible,
424
+ * selectable and scrollable — a blocked editor is meant to be able to *read* —
425
+ * while every control inside stops responding, including the drag handles,
426
+ * which are buttons. The server is what actually refuses the write; this only
427
+ * spares someone ten minutes of typing.
428
+ */
429
+ export function EditLockFence({
430
+ lock,
431
+ children,
432
+ className = "",
433
+ }: {
434
+ lock: EditLockHandle;
435
+ children: ReactNode;
436
+ className?: string;
437
+ }) {
438
+ return (
439
+ <fieldset
440
+ disabled={lock.blocked}
441
+ aria-busy={lock.busy || undefined}
442
+ className={`min-w-0 border-0 p-0 m-0 ${lock.blocked ? "opacity-70" : ""} ${className}`.trim()}
443
+ >
444
+ <BlockedContext.Provider value={lock.blocked}>{children}</BlockedContext.Provider>
445
+ </fieldset>
446
+ );
447
+ }
448
+
449
+ /**
450
+ * What a blocked editor sees. Renders nothing when they are not blocked, so a
451
+ * screen can mount it unconditionally.
452
+ */
453
+ export function EditLockBanner({
454
+ lock,
455
+ className = "",
456
+ }: {
457
+ lock: EditLockHandle;
458
+ className?: string;
459
+ }) {
460
+ // The fence also blocks during the short tab-identity handshake, but that is
461
+ // progress rather than another editor holding the resource and needs no
462
+ // read-only warning.
463
+ if (!lock.blocked || !isBlocked(lock.state)) return null;
464
+ const seconds = lock.expiresInSeconds;
465
+ return (
466
+ <div
467
+ role="status"
468
+ className={`border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 ${className}`.trim()}
469
+ >
470
+ <div className="text-[0.65rem] uppercase tracking-[0.22em] text-amber-700">
471
+ Read only
472
+ </div>
473
+ <p className="mt-2">{lock.message}</p>
474
+ {seconds !== null ? (
475
+ <p className="mt-1 text-xs text-amber-700">
476
+ {seconds > 0
477
+ ? `The lock lapses on its own in about ${Math.max(1, Math.round(seconds / 60))} min if it is not renewed.`
478
+ : "The lock has lapsed — check again to start editing."}
479
+ </p>
480
+ ) : null}
481
+ <div className="mt-3 flex flex-wrap gap-3">
482
+ <button
483
+ type="button"
484
+ onClick={() => void lock.refresh()}
485
+ disabled={lock.busy}
486
+ className="border border-amber-400 bg-white px-3 py-1 text-xs text-ink disabled:opacity-50"
487
+ >
488
+ {lock.busy ? "Checking…" : "Check again"}
489
+ </button>
490
+ {lock.canTakeOver ? (
491
+ <button
492
+ type="button"
493
+ onClick={() => {
494
+ const who = lock.holder;
495
+ if (
496
+ typeof window !== "undefined" &&
497
+ !window.confirm(
498
+ `Take over editing from ${who}? Any unsaved work of theirs will be lost.`,
499
+ )
500
+ )
501
+ return;
502
+ void lock.takeOver();
503
+ }}
504
+ disabled={lock.busy}
505
+ className="border border-amber-400 bg-white px-3 py-1 text-xs text-ink disabled:opacity-50"
506
+ >
507
+ Take over
508
+ </button>
509
+ ) : null}
510
+ </div>
511
+ </div>
512
+ );
513
+ }
@@ -1,6 +1,11 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
2
  import { toast } from "sonner";
3
3
 
4
+ import {
5
+ describeSecretConfiguration,
6
+ type SecretsLocation,
7
+ } from "../server/provider-secrets";
8
+
4
9
  export const PROVIDER_KINDS = ["smtp", "ses", "lovable", "mailgun", "http"] as const;
5
10
  export type ProviderKind = (typeof PROVIDER_KINDS)[number];
6
11
 
@@ -59,6 +64,12 @@ export type EmailSettingsDeps = {
59
64
  refreshLogs?: () => Promise<unknown> | void;
60
65
  /** Example addresses shown as placeholders — site specific. */
61
66
  placeholders?: { fromEmail?: string; notifyTo?: string };
67
+ /**
68
+ * Where this installation keeps its email credentials. Omitted, the screen
69
+ * describes the package's documented convention and says the deployment root
70
+ * differs per installation.
71
+ */
72
+ secretsLocation?: SecretsLocation;
62
73
  };
63
74
 
64
75
  const KIND_LABEL: Record<ProviderKind, string> = {
@@ -73,6 +84,85 @@ const input =
73
84
  "mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm focus:border-sea focus:outline-none disabled:bg-ink/5";
74
85
  const lbl = "text-[0.65rem] uppercase tracking-[0.22em] text-ink/55";
75
86
 
87
+ /**
88
+ * Where email credentials live on the server.
89
+ *
90
+ * Administrators keep asking where to put a password once they learn the CMS
91
+ * will not hold one, so the answer sits next to the question. It is deliberately
92
+ * value-free: variable *names* and file *locations* only. The names shown are
93
+ * the ones this screen already loads with each provider row, so nothing new is
94
+ * sent to the browser, and no value is read anywhere in this component.
95
+ */
96
+ function SecretsLocationNote({
97
+ secrets,
98
+ }: {
99
+ secrets: ReturnType<typeof describeSecretConfiguration>;
100
+ }) {
101
+ const code =
102
+ "border border-ink/15 bg-white px-1.5 py-0.5 font-mono text-[0.7rem] text-ink";
103
+ return (
104
+ <section className="border border-ink/10 bg-ice/20 px-6 py-5">
105
+ <div className={lbl}>Where credentials are configured</div>
106
+
107
+ <p className="mt-2 text-sm text-ink/70">
108
+ {secrets.usesEnvFiles ? (
109
+ <>
110
+ Provider passwords and API keys are read from the server's environment, configured in{" "}
111
+ <code className={code}>{secrets.providerSecretsPath}</code>. System mail — account and
112
+ password messages — uses <code className={code}>{secrets.systemSecretName}</code>,
113
+ configured separately in <code className={code}>{secrets.systemEnvPath}</code>.
114
+ </>
115
+ ) : (
116
+ <>
117
+ This installation does not use environment files: provider passwords and API keys are
118
+ set as environment variables on the hosting platform. System mail — account and password
119
+ messages — uses <code className={code}>{secrets.systemSecretName}</code>.
120
+ </>
121
+ )}
122
+ </p>
123
+
124
+ {secrets.usesEnvFiles && !secrets.rootIsKnown ? (
125
+ <p className="mt-2 text-sm text-ink/60">
126
+ Those paths are relative to the deployment root, which differs per installation — the
127
+ directory the server runs from, the working directory of its service unit, or the root of
128
+ its container image. Your operator knows the absolute location.
129
+ </p>
130
+ ) : null}
131
+
132
+ {secrets.note ? <p className="mt-2 text-sm text-ink/60">{secrets.note}</p> : null}
133
+
134
+ {secrets.requiredNames.length > 0 ? (
135
+ <div className="mt-4">
136
+ <div className={lbl}>Variables these providers expect</div>
137
+ <ul className="mt-2 flex flex-wrap gap-2">
138
+ {secrets.requiredNames.map((name) => (
139
+ <li key={name}>
140
+ <code className={code}>{name}</code>
141
+ </li>
142
+ ))}
143
+ </ul>
144
+ </div>
145
+ ) : null}
146
+
147
+ {secrets.problems.length > 0 ? (
148
+ <ul className="mt-4 space-y-1 border border-amber-300 bg-amber-50 p-4 text-xs text-amber-800">
149
+ {secrets.problems.map((problem) => (
150
+ <li key={problem}>{problem}</li>
151
+ ))}
152
+ </ul>
153
+ ) : null}
154
+
155
+ <p className="mt-4 text-sm text-ink/70">
156
+ Enter only the <em>name</em> of a variable below — never the password or API key itself. A
157
+ credential saved as CMS content is stored in the database, travels in backups and page
158
+ exports, and is readable by anyone with CMS access. Secret names must match{" "}
159
+ <code className={code}>{secrets.namePattern}</code>. Values are never shown here, and this
160
+ screen has no way to read one.
161
+ </p>
162
+ </section>
163
+ );
164
+ }
165
+
76
166
  /** Shared Email + providers screen. Data and actions come from the host. */
77
167
  export function EmailSettingsView({ deps }: { deps: EmailSettingsDeps }) {
78
168
  const { providers, isSuper } = deps;
@@ -125,6 +215,13 @@ export function EmailSettingsView({ deps }: { deps: EmailSettingsDeps }) {
125
215
  }
126
216
  };
127
217
 
218
+ // Names and locations only. No value is read here, and the browser's own
219
+ // environment is not the sending server's environment in any case.
220
+ const secrets = useMemo(
221
+ () => describeSecretConfiguration(providers, deps.secretsLocation),
222
+ [providers, deps.secretsLocation],
223
+ );
224
+
128
225
  const [editing, setEditing] = useState<EmailProviderLike | null>(null);
129
226
  const [creating, setCreating] = useState(false);
130
227
  const [duplicating, setDuplicating] = useState<EmailProviderLike | null>(null);
@@ -140,6 +237,8 @@ export function EmailSettingsView({ deps }: { deps: EmailSettingsDeps }) {
140
237
  </p>
141
238
  </div>
142
239
 
240
+ <SecretsLocationNote secrets={secrets} />
241
+
143
242
  {/* Providers list */}
144
243
  <section className="border border-ink/10 bg-white">
145
244
  <header className="flex items-center justify-between border-b border-ink/10 px-6 py-4">