@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,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">
|