@bygghammar/cms-platform 0.52.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/package.json +4 -1
- package/src/admin/describe-log.ts +229 -0
- package/src/admin/format-log.ts +39 -79
- package/src/backoffice/AuditView.tsx +4 -1
- package/src/backoffice/EmailLogsView.tsx +28 -2
- package/src/blocks/components/FormBlock.tsx +51 -0
- package/src/blocks/registry.tsx +15 -0
- package/src/blocks/types.ts +10 -0
- package/src/forms/FormRenderer.tsx +438 -0
- package/src/host/index.tsx +75 -0
- package/src/server/admin-core.ts +29 -13
- package/src/server/audit-core.ts +30 -5
- package/src/server/content-sections-core.ts +8 -0
- package/src/server/domains-core.ts +19 -0
- package/src/server/email-log-core.ts +69 -0
- package/src/server/forms-delivery-core.ts +7 -5
- package/src/server/forms-submissions-core.ts +19 -0
- package/src/server/media-focus-core.ts +25 -0
- package/src/server/media-meta-core.ts +13 -0
- package/src/server/redirects-core.ts +28 -0
- package/src/server/site-settings-core.ts +23 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { ArrowRight } from "lucide-react";
|
|
4
|
+
import { useT, useLocale } from "../i18n";
|
|
5
|
+
import type { LocalizedString } from "../i18n";
|
|
6
|
+
import { tripFieldOptions, resolveAutocomplete } from "./field-sets";
|
|
7
|
+
import type { FormFieldDef, PublicForm, TripSource } from "./field-sets";
|
|
8
|
+
import {
|
|
9
|
+
isFieldRequired,
|
|
10
|
+
isFieldVisible,
|
|
11
|
+
normalizeCheckbox,
|
|
12
|
+
validateAll,
|
|
13
|
+
validateField,
|
|
14
|
+
} from "./conditions";
|
|
15
|
+
import { renderLinkedText } from "./rich-text";
|
|
16
|
+
import { useHostPublicFormFns, useHostTripSource } from "../host";
|
|
17
|
+
|
|
18
|
+
const TOKEN_PREFIX = "form-draft-token:";
|
|
19
|
+
|
|
20
|
+
/** Stable per-visitor, per-form token so a draft updates instead of piling up. */
|
|
21
|
+
function draftToken(formKey: string): string {
|
|
22
|
+
if (typeof window === "undefined") return "";
|
|
23
|
+
const storageKey = TOKEN_PREFIX + formKey;
|
|
24
|
+
try {
|
|
25
|
+
const existing = window.localStorage.getItem(storageKey);
|
|
26
|
+
if (existing) return existing;
|
|
27
|
+
const token =
|
|
28
|
+
typeof crypto !== "undefined" && crypto.randomUUID
|
|
29
|
+
? crypto.randomUUID()
|
|
30
|
+
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
31
|
+
window.localStorage.setItem(storageKey, token);
|
|
32
|
+
return token;
|
|
33
|
+
} catch {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Renders a managed form from an already-resolved definition.
|
|
40
|
+
* `preview` runs every rule and validation but never contacts the server,
|
|
41
|
+
* which is what the backoffice preview uses.
|
|
42
|
+
*/
|
|
43
|
+
export function FormRenderer({
|
|
44
|
+
form,
|
|
45
|
+
dark = false,
|
|
46
|
+
className,
|
|
47
|
+
preview = false,
|
|
48
|
+
}: {
|
|
49
|
+
form: PublicForm;
|
|
50
|
+
dark?: boolean;
|
|
51
|
+
className?: string;
|
|
52
|
+
preview?: boolean;
|
|
53
|
+
}) {
|
|
54
|
+
const { t } = useT();
|
|
55
|
+
const locale = useLocale();
|
|
56
|
+
const { submitForm: send, savePartialSubmission: saveDraft } = useHostPublicFormFns();
|
|
57
|
+
|
|
58
|
+
const mountedAt = useRef(Date.now());
|
|
59
|
+
const [values, setValues] = useState<Record<string, string>>({});
|
|
60
|
+
const [consent, setConsent] = useState(false);
|
|
61
|
+
const [hp, setHp] = useState("");
|
|
62
|
+
const [busy, setBusy] = useState(false);
|
|
63
|
+
const [error, setError] = useState<string | null>(null);
|
|
64
|
+
const [done, setDone] = useState(false);
|
|
65
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
66
|
+
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
mountedAt.current = Date.now();
|
|
70
|
+
}, [form.key]);
|
|
71
|
+
|
|
72
|
+
const tripItems = useHostTripSource() as TripSource[] | undefined;
|
|
73
|
+
|
|
74
|
+
// "trip"/"course" fields are dropdowns whose choices come from the central
|
|
75
|
+
// trips source (single source of truth), resolved at render time.
|
|
76
|
+
const resolved = useMemo(() => {
|
|
77
|
+
const trips = (tripItems ?? []) as TripSource[];
|
|
78
|
+
return (form.fields ?? []).map((f) =>
|
|
79
|
+
f.type === "trip" || f.type === "course"
|
|
80
|
+
? {
|
|
81
|
+
...f,
|
|
82
|
+
type: "select" as const,
|
|
83
|
+
options: tripFieldOptions(trips, f.tripYear ?? f.courseYear),
|
|
84
|
+
}
|
|
85
|
+
: f,
|
|
86
|
+
);
|
|
87
|
+
}, [form, tripItems]);
|
|
88
|
+
|
|
89
|
+
const enabled = useMemo(() => resolved.filter((f) => f.enabled), [resolved]);
|
|
90
|
+
/** Fields whose show-conditions currently match. */
|
|
91
|
+
const fields = useMemo(() => enabled.filter((f) => isFieldVisible(f, values)), [enabled, values]);
|
|
92
|
+
|
|
93
|
+
// Save what the visitor typed even if they never submit (debounced).
|
|
94
|
+
const dirty = useRef(false);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (preview || !form.savePartials || done || !dirty.current) return;
|
|
97
|
+
const token = draftToken(form.key);
|
|
98
|
+
if (!token) return;
|
|
99
|
+
const snapshot = Object.fromEntries(fields.map((f) => [f.key, values[f.key] ?? ""]));
|
|
100
|
+
if (!Object.values(snapshot).some((v) => v.trim())) return;
|
|
101
|
+
const id = window.setTimeout(() => {
|
|
102
|
+
void Promise.resolve(
|
|
103
|
+
saveDraft({
|
|
104
|
+
key: form.key,
|
|
105
|
+
clientToken: token,
|
|
106
|
+
locale,
|
|
107
|
+
pagePath: window.location.pathname,
|
|
108
|
+
values: snapshot,
|
|
109
|
+
}),
|
|
110
|
+
).catch(() => {});
|
|
111
|
+
}, 1500);
|
|
112
|
+
return () => window.clearTimeout(id);
|
|
113
|
+
}, [values, fields, form.key, form.savePartials, locale, done, preview, saveDraft]);
|
|
114
|
+
|
|
115
|
+
const setValue = (f: FormFieldDef, value: string) => {
|
|
116
|
+
dirty.current = true;
|
|
117
|
+
setValues((v) => {
|
|
118
|
+
const next = { ...v, [f.key]: value };
|
|
119
|
+
// Answers to fields that just became hidden must not be submitted.
|
|
120
|
+
for (const other of enabled) {
|
|
121
|
+
if (other.key !== f.key && !isFieldVisible(other, next)) delete next[other.key];
|
|
122
|
+
}
|
|
123
|
+
return next;
|
|
124
|
+
});
|
|
125
|
+
if (touched[f.key] || errors[f.key]) {
|
|
126
|
+
setErrors((e) => {
|
|
127
|
+
const next = { ...e };
|
|
128
|
+
const msg = validateField(f, { ...values, [f.key]: value }, locale);
|
|
129
|
+
if (msg) next[f.key] = msg;
|
|
130
|
+
else delete next[f.key];
|
|
131
|
+
return next;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const blurField = (f: FormFieldDef) => {
|
|
137
|
+
setTouched((t) => ({ ...t, [f.key]: true }));
|
|
138
|
+
const msg = validateField(f, values, locale);
|
|
139
|
+
setErrors((e) => {
|
|
140
|
+
const next = { ...e };
|
|
141
|
+
if (msg) next[f.key] = msg;
|
|
142
|
+
else delete next[f.key];
|
|
143
|
+
return next;
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (done) {
|
|
148
|
+
return (
|
|
149
|
+
<div
|
|
150
|
+
className={[
|
|
151
|
+
className ?? "",
|
|
152
|
+
dark
|
|
153
|
+
? "bg-paper/5 backdrop-blur border border-paper/15"
|
|
154
|
+
: "border border-ink/10 bg-white",
|
|
155
|
+
"p-8 text-center",
|
|
156
|
+
].join(" ")}
|
|
157
|
+
>
|
|
158
|
+
<p className="font-serif text-2xl whitespace-pre-line">
|
|
159
|
+
{t(form.thankyouMessage as LocalizedString)}
|
|
160
|
+
</p>
|
|
161
|
+
{preview ? (
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
onClick={() => {
|
|
165
|
+
setDone(false);
|
|
166
|
+
setValues({});
|
|
167
|
+
setConsent(false);
|
|
168
|
+
setErrors({});
|
|
169
|
+
setTouched({});
|
|
170
|
+
}}
|
|
171
|
+
className="mt-6 border border-current/20 px-4 py-2 text-[0.65rem] uppercase tracking-[0.2em]"
|
|
172
|
+
>
|
|
173
|
+
Reset preview
|
|
174
|
+
</button>
|
|
175
|
+
) : null}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const inputCls = dark
|
|
181
|
+
? "w-full bg-paper/10 border border-paper/25 px-4 pt-5 pb-2 text-paper placeholder-transparent focus:border-sea focus:bg-paper/15 focus:outline-none transition-colors"
|
|
182
|
+
: "w-full bg-white border border-ink/20 px-4 pt-5 pb-2 text-ink placeholder-transparent focus:border-sea focus:outline-none transition-colors";
|
|
183
|
+
const labelCls = dark
|
|
184
|
+
? "pointer-events-none absolute left-4 top-1.5 text-[0.65rem] uppercase tracking-[0.2em] text-paper/75"
|
|
185
|
+
: "pointer-events-none absolute left-4 top-1.5 text-[0.65rem] uppercase tracking-[0.2em] text-ink/55";
|
|
186
|
+
|
|
187
|
+
const onSubmit = async (e: React.FormEvent) => {
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
setError(null);
|
|
190
|
+
|
|
191
|
+
// Validate everything that is currently visible before touching the server.
|
|
192
|
+
const found = validateAll(enabled, values, locale);
|
|
193
|
+
if (form.requireConsent && !consent) {
|
|
194
|
+
setError(
|
|
195
|
+
locale === "sv" ? "Du behöver godkänna villkoren." : "Please accept the terms to continue.",
|
|
196
|
+
);
|
|
197
|
+
if (!Object.keys(found).length) return;
|
|
198
|
+
}
|
|
199
|
+
if (Object.keys(found).length) {
|
|
200
|
+
setErrors(found);
|
|
201
|
+
setTouched(Object.fromEntries(Object.keys(found).map((k) => [k, true])));
|
|
202
|
+
const first = document.getElementById(`${form.key}-${Object.keys(found)[0]}`);
|
|
203
|
+
first?.focus();
|
|
204
|
+
first?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Preview mode stops here: rules and validation ran, nothing is stored.
|
|
209
|
+
if (preview) {
|
|
210
|
+
setDone(true);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
setBusy(true);
|
|
215
|
+
setError(null);
|
|
216
|
+
try {
|
|
217
|
+
// Send only the answers to fields the visitor actually saw.
|
|
218
|
+
const visible = Object.fromEntries(fields.map((f) => [f.key, values[f.key] ?? ""]));
|
|
219
|
+
await send({
|
|
220
|
+
key: form.key,
|
|
221
|
+
locale,
|
|
222
|
+
pagePath: typeof window !== "undefined" ? window.location.pathname : undefined,
|
|
223
|
+
consent,
|
|
224
|
+
elapsedMs: Date.now() - mountedAt.current,
|
|
225
|
+
hp,
|
|
226
|
+
clientToken: form.savePartials ? draftToken(form.key) : undefined,
|
|
227
|
+
values: visible,
|
|
228
|
+
});
|
|
229
|
+
if (form.thankyouMode === "redirect" && form.thankyouRedirect) {
|
|
230
|
+
window.location.href = form.thankyouRedirect;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
setDone(true);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
setError(err instanceof Error ? err.message : "Something went wrong");
|
|
236
|
+
} finally {
|
|
237
|
+
setBusy(false);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const renderField = (f: FormFieldDef) => {
|
|
242
|
+
const label = t(f.label as LocalizedString);
|
|
243
|
+
const help = f.help ? t(f.help as LocalizedString) : "";
|
|
244
|
+
const placeholder = f.placeholder ? t(f.placeholder as LocalizedString) : label;
|
|
245
|
+
const id = `${form.key}-${f.key}`;
|
|
246
|
+
const required = isFieldRequired(f, values);
|
|
247
|
+
const err = errors[f.key];
|
|
248
|
+
const errId = `${id}-error`;
|
|
249
|
+
const invalidCls = err ? " border-red-500" : "";
|
|
250
|
+
|
|
251
|
+
const Message = err ? (
|
|
252
|
+
<p id={errId} role="alert" className="mt-1 text-xs text-red-500 whitespace-pre-line">
|
|
253
|
+
{err}
|
|
254
|
+
</p>
|
|
255
|
+
) : help ? (
|
|
256
|
+
<p className="mt-1 text-xs opacity-60 whitespace-pre-line">{help}</p>
|
|
257
|
+
) : null;
|
|
258
|
+
|
|
259
|
+
if (f.type === "checkbox") {
|
|
260
|
+
return (
|
|
261
|
+
<div key={f.key} className="sm:col-span-2">
|
|
262
|
+
<label className="flex items-start gap-3 text-sm">
|
|
263
|
+
<input
|
|
264
|
+
id={id}
|
|
265
|
+
name={f.key}
|
|
266
|
+
type="checkbox"
|
|
267
|
+
autoComplete="off"
|
|
268
|
+
checked={normalizeCheckbox(values[f.key] ?? "") === "true"}
|
|
269
|
+
aria-invalid={err ? true : undefined}
|
|
270
|
+
aria-describedby={err ? errId : undefined}
|
|
271
|
+
onChange={(e) => setValue(f, e.target.checked ? "true" : "false")}
|
|
272
|
+
onBlur={() => blurField(f)}
|
|
273
|
+
className="mt-0.5 size-4 shrink-0 accent-sea"
|
|
274
|
+
/>
|
|
275
|
+
<span>
|
|
276
|
+
{label}
|
|
277
|
+
{required ? " *" : ""}
|
|
278
|
+
</span>
|
|
279
|
+
</label>
|
|
280
|
+
{Message}
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const auto = resolveAutocomplete(f);
|
|
286
|
+
const inputMode =
|
|
287
|
+
f.type === "tel"
|
|
288
|
+
? ("tel" as const)
|
|
289
|
+
: f.type === "email"
|
|
290
|
+
? ("email" as const)
|
|
291
|
+
: f.type === "number"
|
|
292
|
+
? ("numeric" as const)
|
|
293
|
+
: undefined;
|
|
294
|
+
|
|
295
|
+
const common = {
|
|
296
|
+
id,
|
|
297
|
+
name: f.key,
|
|
298
|
+
autoComplete: auto,
|
|
299
|
+
placeholder,
|
|
300
|
+
maxLength: f.maxLength,
|
|
301
|
+
value: values[f.key] ?? "",
|
|
302
|
+
"aria-invalid": err ? true : undefined,
|
|
303
|
+
"aria-describedby": err ? errId : undefined,
|
|
304
|
+
onChange: (
|
|
305
|
+
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
|
|
306
|
+
) => setValue(f, e.target.value),
|
|
307
|
+
onBlur: () => blurField(f),
|
|
308
|
+
className: inputCls + invalidCls,
|
|
309
|
+
};
|
|
310
|
+
return (
|
|
311
|
+
<div key={f.key} className={["relative", f.wide ? "sm:col-span-2" : ""].join(" ")}>
|
|
312
|
+
{f.type === "textarea" ? (
|
|
313
|
+
<textarea {...common} rows={4} />
|
|
314
|
+
) : f.type === "select" ? (
|
|
315
|
+
<select {...common}>
|
|
316
|
+
<option value="">—</option>
|
|
317
|
+
{(f.options ?? []).map((o) => (
|
|
318
|
+
<option key={o.value} value={o.value}>
|
|
319
|
+
{t(o.label as LocalizedString)}
|
|
320
|
+
</option>
|
|
321
|
+
))}
|
|
322
|
+
</select>
|
|
323
|
+
) : (
|
|
324
|
+
<input
|
|
325
|
+
{...common}
|
|
326
|
+
inputMode={inputMode}
|
|
327
|
+
type={f.type}
|
|
328
|
+
min={f.type === "date" ? f.minDate : f.min}
|
|
329
|
+
max={f.type === "date" ? f.maxDate : f.max}
|
|
330
|
+
/>
|
|
331
|
+
)}
|
|
332
|
+
<label htmlFor={id} className={labelCls}>
|
|
333
|
+
{label}
|
|
334
|
+
{required ? " *" : ""}
|
|
335
|
+
</label>
|
|
336
|
+
{Message}
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<form
|
|
343
|
+
id={`form-${form.key}`}
|
|
344
|
+
onSubmit={onSubmit}
|
|
345
|
+
noValidate
|
|
346
|
+
className={[
|
|
347
|
+
className ?? "",
|
|
348
|
+
dark ? "bg-paper/5 backdrop-blur border border-paper/15" : "border border-ink/10 bg-white",
|
|
349
|
+
"p-6 md:p-8 space-y-5",
|
|
350
|
+
].join(" ")}
|
|
351
|
+
>
|
|
352
|
+
<div className="grid gap-4 sm:grid-cols-2">{fields.map(renderField)}</div>
|
|
353
|
+
|
|
354
|
+
{form.honeypot ? (
|
|
355
|
+
<div className="hidden" aria-hidden="true">
|
|
356
|
+
<label>
|
|
357
|
+
Company
|
|
358
|
+
<input
|
|
359
|
+
tabIndex={-1}
|
|
360
|
+
autoComplete="off"
|
|
361
|
+
value={hp}
|
|
362
|
+
onChange={(e) => setHp(e.target.value)}
|
|
363
|
+
/>
|
|
364
|
+
</label>
|
|
365
|
+
</div>
|
|
366
|
+
) : null}
|
|
367
|
+
|
|
368
|
+
{form.requireConsent ? (
|
|
369
|
+
<label
|
|
370
|
+
className={[
|
|
371
|
+
"flex items-start gap-3 text-xs",
|
|
372
|
+
dark ? "text-paper/85" : "text-ink/70",
|
|
373
|
+
].join(" ")}
|
|
374
|
+
>
|
|
375
|
+
<input
|
|
376
|
+
type="checkbox"
|
|
377
|
+
required
|
|
378
|
+
checked={consent}
|
|
379
|
+
onChange={(e) => setConsent(e.target.checked)}
|
|
380
|
+
className={[
|
|
381
|
+
"mt-0.5 size-4 shrink-0 accent-sea bg-transparent border",
|
|
382
|
+
dark ? "border-paper/40" : "border-ink/30",
|
|
383
|
+
].join(" ")}
|
|
384
|
+
/>
|
|
385
|
+
<span>
|
|
386
|
+
{renderLinkedText(t(form.consentText as LocalizedString), {
|
|
387
|
+
privacyUrl: form.privacyPolicyUrl,
|
|
388
|
+
})}
|
|
389
|
+
</span>
|
|
390
|
+
</label>
|
|
391
|
+
) : null}
|
|
392
|
+
|
|
393
|
+
{error ? <p className="text-xs text-red-500 whitespace-pre-line">{error}</p> : null}
|
|
394
|
+
|
|
395
|
+
<button
|
|
396
|
+
type="submit"
|
|
397
|
+
disabled={busy}
|
|
398
|
+
className={[
|
|
399
|
+
"group inline-flex w-full items-center justify-center gap-2 px-6 py-4 text-xs uppercase tracking-[0.22em] transition-all disabled:opacity-60",
|
|
400
|
+
dark ? "bg-paper text-ink hover:bg-sea" : "bg-ink text-paper hover:bg-sea hover:text-ink",
|
|
401
|
+
].join(" ")}
|
|
402
|
+
>
|
|
403
|
+
{t(form.submitLabel as LocalizedString)}
|
|
404
|
+
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
|
405
|
+
</button>
|
|
406
|
+
</form>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Fetches a published form by key and renders it. */
|
|
411
|
+
export function ManagedForm({
|
|
412
|
+
formKey,
|
|
413
|
+
dark = false,
|
|
414
|
+
className,
|
|
415
|
+
}: {
|
|
416
|
+
formKey: string;
|
|
417
|
+
dark?: boolean;
|
|
418
|
+
className?: string;
|
|
419
|
+
}) {
|
|
420
|
+
const { getPublicForm } = useHostPublicFormFns();
|
|
421
|
+
const { data: form, isLoading } = useQuery({
|
|
422
|
+
queryKey: ["public-form", formKey],
|
|
423
|
+
queryFn: () => getPublicForm(formKey) as Promise<PublicForm | null>,
|
|
424
|
+
staleTime: 60_000,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
if (isLoading) {
|
|
428
|
+
return (
|
|
429
|
+
<div className={className}>
|
|
430
|
+
<div className="h-64 animate-pulse border border-current/15 bg-current/5" />
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
if (!form) return null;
|
|
435
|
+
return <FormRenderer form={form} dark={dark} className={className} />;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export type { PublicForm };
|
package/src/host/index.tsx
CHANGED
|
@@ -148,3 +148,78 @@ export function useHostMediaUpload(existing: HostMediaItem[] = []): HostMediaUpl
|
|
|
148
148
|
if (!hook) missing("useMediaUpload");
|
|
149
149
|
return hook(existing);
|
|
150
150
|
}
|
|
151
|
+
|
|
152
|
+
/* --------------------------- public form runtime --------------------------- */
|
|
153
|
+
|
|
154
|
+
/** Answers keyed by field key, exactly as the visitor typed them. */
|
|
155
|
+
export type HostFormValues = Record<string, string>;
|
|
156
|
+
|
|
157
|
+
export type HostSubmitFormInput = {
|
|
158
|
+
key: string;
|
|
159
|
+
locale: string;
|
|
160
|
+
pagePath?: string;
|
|
161
|
+
consent?: boolean;
|
|
162
|
+
elapsedMs?: number;
|
|
163
|
+
hp?: string;
|
|
164
|
+
clientToken?: string;
|
|
165
|
+
values: HostFormValues;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export type HostSavePartialInput = {
|
|
169
|
+
key: string;
|
|
170
|
+
clientToken: string;
|
|
171
|
+
locale: string;
|
|
172
|
+
pagePath?: string;
|
|
173
|
+
values: HostFormValues;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Server functions behind the public form runtime. Each site wraps its own
|
|
178
|
+
* `createServerFn` declarations (they must live in the site bundle) and hands
|
|
179
|
+
* plain callables to the package.
|
|
180
|
+
*/
|
|
181
|
+
export type HostPublicFormFns = {
|
|
182
|
+
getPublicForm: (key: string) => Promise<unknown>;
|
|
183
|
+
submitForm: (input: HostSubmitFormInput) => Promise<unknown>;
|
|
184
|
+
savePartialSubmission: (input: HostSavePartialInput) => Promise<unknown>;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export type HostSiteAdapters = {
|
|
188
|
+
/** Hook returning the site's public form server functions. */
|
|
189
|
+
usePublicFormFns: () => HostPublicFormFns;
|
|
190
|
+
/**
|
|
191
|
+
* Hook returning the site's central trip/course source, used to fill
|
|
192
|
+
* `trip`/`course` dropdowns from a single source of truth.
|
|
193
|
+
*/
|
|
194
|
+
useTripSource: () => unknown[];
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
let siteAdapters: Partial<HostSiteAdapters> = {};
|
|
198
|
+
|
|
199
|
+
export function registerSiteAdapters(adapters: Partial<HostSiteAdapters>): void {
|
|
200
|
+
siteAdapters = { ...siteAdapters, ...adapters };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Register public-site adapters while constructing a value the site exports. */
|
|
204
|
+
export function initializeSiteAdapters<T>(
|
|
205
|
+
adapters: Partial<HostSiteAdapters>,
|
|
206
|
+
initialize: () => T,
|
|
207
|
+
): T {
|
|
208
|
+
registerSiteAdapters(adapters);
|
|
209
|
+
return initialize();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function useHostPublicFormFns(): HostPublicFormFns {
|
|
213
|
+
const hook = siteAdapters.usePublicFormFns;
|
|
214
|
+
if (!hook) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
'@bygghammar/cms-platform: site adapter "usePublicFormFns" is not registered. Call registerSiteAdapters() during site startup.',
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return hook();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function useHostTripSource(): unknown[] {
|
|
223
|
+
const hook = siteAdapters.useTripSource;
|
|
224
|
+
return hook ? hook() : [];
|
|
225
|
+
}
|
package/src/server/admin-core.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { SupabaseClient } from "@supabase/supabase-js";
|
|
|
15
15
|
|
|
16
16
|
import { writeAudit } from "./audit-core";
|
|
17
17
|
import { actorEmail, assertRole, assertStaff, type PlatformAuthContext } from "./guards";
|
|
18
|
+
import { logEmail } from "./email-log-core";
|
|
18
19
|
|
|
19
20
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
20
21
|
|
|
@@ -111,6 +112,15 @@ export async function createUserCore(
|
|
|
111
112
|
.from("user_roles")
|
|
112
113
|
.insert(roles.map((role) => ({ user_id: uid, role })));
|
|
113
114
|
}
|
|
115
|
+
if (!input.password) {
|
|
116
|
+
await logEmail(anyClient(admin), {
|
|
117
|
+
to: input.email,
|
|
118
|
+
subject: "Your new account",
|
|
119
|
+
provider: "Authentication service",
|
|
120
|
+
status: "sent",
|
|
121
|
+
purpose: "user_invite",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
114
124
|
await audit(deps, "user.create", "auth.users", uid, { email: input.email, roles });
|
|
115
125
|
return { id: uid };
|
|
116
126
|
}
|
|
@@ -153,6 +163,13 @@ export async function sendPasswordResetEmailCore(
|
|
|
153
163
|
email: input.email,
|
|
154
164
|
});
|
|
155
165
|
if (error) throw new Error(error.message);
|
|
166
|
+
await logEmail(anyClient(admin), {
|
|
167
|
+
to: input.email,
|
|
168
|
+
subject: "Password reset",
|
|
169
|
+
provider: "Authentication service",
|
|
170
|
+
status: "sent",
|
|
171
|
+
purpose: "password_reset",
|
|
172
|
+
});
|
|
156
173
|
await audit(deps, "user.send_reset", "auth.users", undefined, { email: input.email });
|
|
157
174
|
return { ok: true };
|
|
158
175
|
}
|
|
@@ -359,19 +376,18 @@ export async function sendTestEmailCore(
|
|
|
359
376
|
)
|
|
360
377
|
: ({ ok: false, error: "No from address configured for this provider or in the email settings." } as const);
|
|
361
378
|
|
|
362
|
-
await anyClient(admin)
|
|
363
|
-
.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
});
|
|
379
|
+
await logEmail(anyClient(admin), {
|
|
380
|
+
to: input.to,
|
|
381
|
+
from: fromEmail,
|
|
382
|
+
replyTo: replyTo,
|
|
383
|
+
subject,
|
|
384
|
+
bodyPreview: text,
|
|
385
|
+
provider: provider.name,
|
|
386
|
+
status: result.ok ? "sent" : "failed",
|
|
387
|
+
error: result.ok ? null : result.error,
|
|
388
|
+
purpose: "provider_test",
|
|
389
|
+
meta: { kind: provider.kind, provider_id: provider.id, test: true },
|
|
390
|
+
});
|
|
375
391
|
|
|
376
392
|
await audit(deps, "email_provider.test_send", "email_providers", provider.id, {
|
|
377
393
|
to: input.to,
|
package/src/server/audit-core.ts
CHANGED
|
@@ -2,19 +2,20 @@
|
|
|
2
2
|
* Host-agnostic audit trail writer.
|
|
3
3
|
*
|
|
4
4
|
* Every platform core records what an editor did in the append-only
|
|
5
|
-
* `audit_logs` table.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `audit_logs` table. Entries always carry the acting user's id and email, and
|
|
6
|
+
* always carry a plain-English sentence in `meta.summary` describing what
|
|
7
|
+
* happened, so the Audit view never has to show raw codes.
|
|
8
8
|
*
|
|
9
9
|
* Auditing is deliberately best-effort: a failed log entry never fails the
|
|
10
10
|
* operation the editor asked for.
|
|
11
11
|
*/
|
|
12
|
+
import { describeAudit } from "../admin/describe-log";
|
|
12
13
|
import type { PlatformAuthContext } from "./guards";
|
|
13
14
|
import { actorEmail } from "./guards";
|
|
14
15
|
|
|
15
16
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
16
17
|
|
|
17
|
-
/** Minimal
|
|
18
|
+
/** Minimal client shape the audit writer needs. */
|
|
18
19
|
export type AuditClient = {
|
|
19
20
|
from: (table: string) => { insert: (row: any) => PromiseLike<{ error: any }> };
|
|
20
21
|
};
|
|
@@ -26,6 +27,9 @@ export type AuditEntry = {
|
|
|
26
27
|
meta?: Record<string, unknown>;
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
/** Writer shape hosts can pass into cores that need auditing. */
|
|
31
|
+
export type AuditWriter = (entry: AuditEntry) => Promise<void>;
|
|
32
|
+
|
|
29
33
|
/** Write one audit entry. Never throws — logging must not break the action. */
|
|
30
34
|
export async function writeAudit(
|
|
31
35
|
admin: AuditClient,
|
|
@@ -33,13 +37,20 @@ export async function writeAudit(
|
|
|
33
37
|
entry: AuditEntry,
|
|
34
38
|
): Promise<void> {
|
|
35
39
|
try {
|
|
40
|
+
const meta = { ...(entry.meta ?? {}) } as Record<string, unknown>;
|
|
41
|
+
meta["summary"] = describeAudit({
|
|
42
|
+
action: entry.action,
|
|
43
|
+
entity: entry.entity ?? null,
|
|
44
|
+
entityId: entry.entityId ?? null,
|
|
45
|
+
meta,
|
|
46
|
+
});
|
|
36
47
|
const { error } = await admin.from("audit_logs").insert({
|
|
37
48
|
actor_id: context.userId,
|
|
38
49
|
actor_email: actorEmail(context),
|
|
39
50
|
action: entry.action,
|
|
40
51
|
entity: entry.entity ?? null,
|
|
41
52
|
entity_id: entry.entityId ?? null,
|
|
42
|
-
meta:
|
|
53
|
+
meta: meta as unknown,
|
|
43
54
|
});
|
|
44
55
|
if (error) console.warn("audit failed", error.message);
|
|
45
56
|
} catch (err) {
|
|
@@ -52,3 +63,17 @@ export function auditWriter(admin: AuditClient, context: PlatformAuthContext, en
|
|
|
52
63
|
return (action: string, entityId: string, meta: Record<string, unknown> = {}) =>
|
|
53
64
|
writeAudit(admin, context, { action, entity, entityId, meta });
|
|
54
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Write one audit entry as the signed-in staff user.
|
|
69
|
+
*
|
|
70
|
+
* `audit_logs` accepts inserts from staff under row-level security, so cores
|
|
71
|
+
* that already ran a staff check can log through the ordinary request client
|
|
72
|
+
* without any privileged credential.
|
|
73
|
+
*/
|
|
74
|
+
export async function auditAs(
|
|
75
|
+
context: PlatformAuthContext & { supabase: unknown },
|
|
76
|
+
entry: AuditEntry,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
await writeAudit(context.supabase as unknown as AuditClient, context, entry);
|
|
79
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { Json } from "../i18n/json";
|
|
11
11
|
import { stampManualEdits } from "../i18n/translation-meta";
|
|
12
12
|
import { assertStaff, type PlatformAuthContext } from "./guards";
|
|
13
|
+
import { auditAs } from "./audit-core";
|
|
13
14
|
|
|
14
15
|
/** Structural subset of a Supabase-shaped client used by this core. */
|
|
15
16
|
export type ContentClient = {
|
|
@@ -64,5 +65,12 @@ export async function saveContentSectionCore(
|
|
|
64
65
|
.from("content_sections")
|
|
65
66
|
.upsert({ key: input.key, data: stamped as Json }, { onConflict: "key" });
|
|
66
67
|
if (error) throw new Error(error.message);
|
|
68
|
+
|
|
69
|
+
await auditAs(context as never, {
|
|
70
|
+
action: "content.section.save",
|
|
71
|
+
entity: "content_sections",
|
|
72
|
+
entityId: input.key,
|
|
73
|
+
meta: { key: input.key },
|
|
74
|
+
});
|
|
67
75
|
return { ok: true, key: input.key };
|
|
68
76
|
}
|