@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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { assertStaff, type PlatformAuthContext } from "./guards";
|
|
10
|
+
import { auditAs } from "./audit-core";
|
|
10
11
|
|
|
11
12
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
12
13
|
/** Structural subset of a Supabase-shaped client used by this core. */
|
|
@@ -165,6 +166,18 @@ export async function saveLocaleDomainCore(
|
|
|
165
166
|
{ onConflict: "locale" },
|
|
166
167
|
);
|
|
167
168
|
if (error) throw new Error(error.message);
|
|
169
|
+
|
|
170
|
+
await auditAs(context as never, {
|
|
171
|
+
action: "domain.save",
|
|
172
|
+
entity: "locale_domains",
|
|
173
|
+
entityId: input.locale,
|
|
174
|
+
meta: {
|
|
175
|
+
locale: input.locale,
|
|
176
|
+
domain: normalized,
|
|
177
|
+
is_default: input.isDefault,
|
|
178
|
+
is_admin_domain: input.isAdminDomain,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
168
181
|
return { ok: true };
|
|
169
182
|
}
|
|
170
183
|
|
|
@@ -176,5 +189,11 @@ export async function deleteLocaleDomainCore(
|
|
|
176
189
|
await assertStaff(context);
|
|
177
190
|
const { error } = await context.supabase.from("locale_domains").delete().eq("locale", locale);
|
|
178
191
|
if (error) throw new Error(error.message);
|
|
192
|
+
await auditAs(context as never, {
|
|
193
|
+
action: "domain.delete",
|
|
194
|
+
entity: "locale_domains",
|
|
195
|
+
entityId: locale,
|
|
196
|
+
meta: { locale },
|
|
197
|
+
});
|
|
179
198
|
return { ok: true };
|
|
180
199
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central email log writer.
|
|
3
|
+
*
|
|
4
|
+
* Every message the platform attempts to send — form notifications, auto-replies,
|
|
5
|
+
* provider tests and account emails sent by the authentication service — is
|
|
6
|
+
* recorded in `email_logs`, successful or not, with a plain-English sentence in
|
|
7
|
+
* `meta.summary`. Logging is best-effort and never fails a send.
|
|
8
|
+
*/
|
|
9
|
+
import { describeEmail } from "../admin/describe-log";
|
|
10
|
+
|
|
11
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
12
|
+
|
|
13
|
+
export type EmailLogClient = {
|
|
14
|
+
from: (table: string) => { insert: (row: any) => PromiseLike<{ error: any }> };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Why the message was sent, e.g. `form_notification` or `password_reset`. */
|
|
18
|
+
export type EmailPurpose =
|
|
19
|
+
| "form_notification"
|
|
20
|
+
| "form_autoreply"
|
|
21
|
+
| "form_test"
|
|
22
|
+
| "provider_test"
|
|
23
|
+
| "password_reset"
|
|
24
|
+
| "user_invite"
|
|
25
|
+
| (string & {});
|
|
26
|
+
|
|
27
|
+
export type EmailLogEntry = {
|
|
28
|
+
to: string;
|
|
29
|
+
from?: string | null;
|
|
30
|
+
replyTo?: string | null;
|
|
31
|
+
subject?: string | null;
|
|
32
|
+
bodyPreview?: string | null;
|
|
33
|
+
/** Provider name as shown in the backoffice, or the transport used. */
|
|
34
|
+
provider?: string | null;
|
|
35
|
+
status: "sent" | "failed" | "skipped" | string;
|
|
36
|
+
error?: string | null;
|
|
37
|
+
purpose?: EmailPurpose | null;
|
|
38
|
+
meta?: Record<string, unknown>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Record one send attempt. Never throws. */
|
|
42
|
+
export async function logEmail(db: EmailLogClient, entry: EmailLogEntry): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
const meta = { ...(entry.meta ?? {}) } as Record<string, unknown>;
|
|
45
|
+
if (entry.purpose) meta["purpose"] = entry.purpose;
|
|
46
|
+
meta["summary"] = describeEmail({
|
|
47
|
+
status: entry.status,
|
|
48
|
+
to: entry.to,
|
|
49
|
+
subject: entry.subject ?? null,
|
|
50
|
+
provider: entry.provider ?? null,
|
|
51
|
+
purpose: entry.purpose ?? null,
|
|
52
|
+
error: entry.error ?? null,
|
|
53
|
+
});
|
|
54
|
+
const { error } = await db.from("email_logs").insert({
|
|
55
|
+
to_address: entry.to,
|
|
56
|
+
from_address: entry.from ?? null,
|
|
57
|
+
reply_to: entry.replyTo ?? null,
|
|
58
|
+
subject: entry.subject ?? null,
|
|
59
|
+
body_preview: (entry.bodyPreview ?? "").slice(0, 200) || null,
|
|
60
|
+
provider: entry.provider ?? null,
|
|
61
|
+
status: entry.status,
|
|
62
|
+
error: entry.error ?? null,
|
|
63
|
+
meta: meta as unknown,
|
|
64
|
+
});
|
|
65
|
+
if (error) console.warn("email log failed", error.message);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.warn("email log failed", err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import type { FormFieldDef } from "../forms/field-sets";
|
|
16
16
|
import { sendViaProvider } from "./email-send-core";
|
|
17
|
+
import { logEmail } from "./email-log-core";
|
|
17
18
|
|
|
18
19
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
19
20
|
/** Structural subset of a Supabase-shaped privileged client. */
|
|
@@ -132,15 +133,16 @@ async function dispatch(
|
|
|
132
133
|
},
|
|
133
134
|
);
|
|
134
135
|
|
|
135
|
-
await db
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
await logEmail(db as never, {
|
|
137
|
+
to: msg.to,
|
|
138
|
+
from: fromEmail,
|
|
139
|
+
replyTo: msg.replyTo,
|
|
139
140
|
subject: msg.subject,
|
|
140
|
-
|
|
141
|
+
bodyPreview: msg.text,
|
|
141
142
|
provider: provider.name,
|
|
142
143
|
status: result.ok ? "sent" : "failed",
|
|
143
144
|
error: result.ok ? null : result.error,
|
|
145
|
+
purpose: typeof meta["purpose"] === "string" ? (meta["purpose"] as string) : null,
|
|
144
146
|
meta: { ...meta, kind: provider.kind },
|
|
145
147
|
});
|
|
146
148
|
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type FormsContext,
|
|
34
34
|
type FormsDb,
|
|
35
35
|
} from "./forms-core";
|
|
36
|
+
import { auditAs } from "./audit-core";
|
|
36
37
|
import {
|
|
37
38
|
evaluateRule,
|
|
38
39
|
isFieldVisible,
|
|
@@ -461,6 +462,12 @@ export async function setSubmissionStatusCore(
|
|
|
461
462
|
.update({ status: data.status })
|
|
462
463
|
.in("id", data.ids);
|
|
463
464
|
if (error) throw new Error(error.message);
|
|
465
|
+
await auditAs(context as never, {
|
|
466
|
+
action: "submission.status",
|
|
467
|
+
entity: "form_submissions",
|
|
468
|
+
entityId: data.ids.join(", "),
|
|
469
|
+
meta: { status: data.status, count: data.ids.length },
|
|
470
|
+
});
|
|
464
471
|
return { ok: true as const, updated: data.ids.length };
|
|
465
472
|
}
|
|
466
473
|
|
|
@@ -800,6 +807,12 @@ export async function deletePartialsCore(context: FormsContext, ids: string[]) {
|
|
|
800
807
|
await assertStaff(context);
|
|
801
808
|
const { error } = await context.supabase.from("form_partials").delete().in("id", ids);
|
|
802
809
|
if (error) throw new Error(error.message);
|
|
810
|
+
await auditAs(context as never, {
|
|
811
|
+
action: "submission.partial.delete",
|
|
812
|
+
entity: "form_partials",
|
|
813
|
+
entityId: ids.join(", "),
|
|
814
|
+
meta: { count: ids.length },
|
|
815
|
+
});
|
|
803
816
|
return { ok: true as const, deleted: ids.length };
|
|
804
817
|
}
|
|
805
818
|
|
|
@@ -1326,6 +1339,12 @@ export async function resolveDeliveryAttemptsCore(context: FormsContext, ids: st
|
|
|
1326
1339
|
.update({ resolved_at: new Date().toISOString() })
|
|
1327
1340
|
.in("id", ids);
|
|
1328
1341
|
if (error) throw new Error(error.message);
|
|
1342
|
+
await auditAs(context as never, {
|
|
1343
|
+
action: "delivery.resolve",
|
|
1344
|
+
entity: "form_delivery_attempts",
|
|
1345
|
+
entityId: ids.join(", "),
|
|
1346
|
+
meta: { count: ids.length },
|
|
1347
|
+
});
|
|
1329
1348
|
return { ok: true as const, resolved: ids.length };
|
|
1330
1349
|
}
|
|
1331
1350
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { assertStaff, type PlatformAuthContext } from "./guards";
|
|
12
12
|
import { clampFocal, focusKey, type FocusDevice, type FocusMap } from "../media/focus";
|
|
13
|
+
import { auditAs } from "./audit-core";
|
|
13
14
|
|
|
14
15
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
15
16
|
|
|
@@ -95,6 +96,12 @@ export async function setMediaFocusCore(
|
|
|
95
96
|
{ onConflict: "path" },
|
|
96
97
|
);
|
|
97
98
|
if (error) throw new Error(error.message);
|
|
99
|
+
await auditAs(context as never, {
|
|
100
|
+
action: "media.focus.set",
|
|
101
|
+
entity: "media_focus",
|
|
102
|
+
entityId: path,
|
|
103
|
+
meta: { path, device, x: point.x, y: point.y },
|
|
104
|
+
});
|
|
98
105
|
return { ok: true, path, device, ...point };
|
|
99
106
|
}
|
|
100
107
|
|
|
@@ -105,6 +112,12 @@ export async function setMediaFocusCore(
|
|
|
105
112
|
{ onConflict: "path" },
|
|
106
113
|
);
|
|
107
114
|
if (error) throw new Error(error.message);
|
|
115
|
+
await auditAs(context as never, {
|
|
116
|
+
action: "media.focus.set",
|
|
117
|
+
entity: "media_focus",
|
|
118
|
+
entityId: path,
|
|
119
|
+
meta: { path, device, x: point.x, y: point.y },
|
|
120
|
+
});
|
|
108
121
|
return { ok: true, path, device, ...point };
|
|
109
122
|
}
|
|
110
123
|
|
|
@@ -127,10 +140,22 @@ export async function clearMediaFocusCore(
|
|
|
127
140
|
.update({ mobile_x: null, mobile_y: null, updated_by: context.userId })
|
|
128
141
|
.eq("path", path);
|
|
129
142
|
if (error) throw new Error(error.message);
|
|
143
|
+
await auditAs(context as never, {
|
|
144
|
+
action: "media.focus.clear",
|
|
145
|
+
entity: "media_focus",
|
|
146
|
+
entityId: path,
|
|
147
|
+
meta: { path, device: "mobile" },
|
|
148
|
+
});
|
|
130
149
|
return { ok: true };
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
const { error } = await context.supabase.from("media_focus").delete().eq("path", path);
|
|
134
153
|
if (error) throw new Error(error.message);
|
|
154
|
+
await auditAs(context as never, {
|
|
155
|
+
action: "media.focus.clear",
|
|
156
|
+
entity: "media_focus",
|
|
157
|
+
entityId: path,
|
|
158
|
+
meta: { path, device: "desktop" },
|
|
159
|
+
});
|
|
135
160
|
return { ok: true };
|
|
136
161
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* public site; writes run under the host's authenticated context.
|
|
10
10
|
*/
|
|
11
11
|
import { assertStaff, type PlatformAuthContext } from "./guards";
|
|
12
|
+
import { auditAs } from "./audit-core";
|
|
12
13
|
import {
|
|
13
14
|
cleanLocalized,
|
|
14
15
|
metaKey,
|
|
@@ -77,6 +78,12 @@ export async function setMediaMetaCore(
|
|
|
77
78
|
{ onConflict: "path" },
|
|
78
79
|
);
|
|
79
80
|
if (error) throw new Error(error.message);
|
|
81
|
+
await auditAs(context as never, {
|
|
82
|
+
action: "media.meta.set",
|
|
83
|
+
entity: "media_meta",
|
|
84
|
+
entityId: path,
|
|
85
|
+
meta: { path },
|
|
86
|
+
});
|
|
80
87
|
return { ok: true, path };
|
|
81
88
|
}
|
|
82
89
|
|
|
@@ -91,5 +98,11 @@ export async function clearMediaMetaCore(
|
|
|
91
98
|
if (!path) return { ok: true };
|
|
92
99
|
const { error } = await context.supabase.from("media_meta").delete().eq("path", path);
|
|
93
100
|
if (error) throw new Error(error.message);
|
|
101
|
+
await auditAs(context as never, {
|
|
102
|
+
action: "media.meta.clear",
|
|
103
|
+
entity: "media_meta",
|
|
104
|
+
entityId: path,
|
|
105
|
+
meta: { path },
|
|
106
|
+
});
|
|
94
107
|
return { ok: true };
|
|
95
108
|
}
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* so every site behaves identically.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { auditAs } from "./audit-core";
|
|
10
|
+
|
|
9
11
|
/** Structural subset of a Supabase-shaped client used by these cores. */
|
|
10
12
|
export type RedirectClient = {
|
|
11
13
|
from: (table: string) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
@@ -50,6 +52,7 @@ export async function saveRedirectCore(
|
|
|
50
52
|
client: RedirectClient,
|
|
51
53
|
input: { id?: string; from_path: string; to_path: string; status: number },
|
|
52
54
|
userId: string,
|
|
55
|
+
context?: Parameters<typeof auditAs>[0],
|
|
53
56
|
): Promise<{ ok: true }> {
|
|
54
57
|
const row = {
|
|
55
58
|
from_path: normalizePath(input.from_path),
|
|
@@ -61,15 +64,40 @@ export async function saveRedirectCore(
|
|
|
61
64
|
? await client.from("redirects").update(row).eq("id", input.id)
|
|
62
65
|
: await client.from("redirects").insert({ ...row, created_by: userId });
|
|
63
66
|
if (error) throw new Error(error.message);
|
|
67
|
+
if (context) {
|
|
68
|
+
await auditAs(context, {
|
|
69
|
+
action: "redirect.save",
|
|
70
|
+
entity: "redirects",
|
|
71
|
+
entityId: input.id ?? row.from_path,
|
|
72
|
+
meta: { from_path: row.from_path, to_path: row.to_path, status: row.status },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
64
75
|
return { ok: true };
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
export async function deleteRedirectCore(
|
|
68
79
|
client: RedirectClient,
|
|
69
80
|
id: string,
|
|
81
|
+
context?: Parameters<typeof auditAs>[0],
|
|
70
82
|
): Promise<{ ok: true }> {
|
|
83
|
+
const { data: existing } = await client
|
|
84
|
+
.from("redirects")
|
|
85
|
+
.select("from_path, to_path")
|
|
86
|
+
.eq("id", id)
|
|
87
|
+
.maybeSingle();
|
|
71
88
|
const { error } = await client.from("redirects").delete().eq("id", id);
|
|
72
89
|
if (error) throw new Error(error.message);
|
|
90
|
+
if (context) {
|
|
91
|
+
await auditAs(context, {
|
|
92
|
+
action: "redirect.delete",
|
|
93
|
+
entity: "redirects",
|
|
94
|
+
entityId: id,
|
|
95
|
+
meta: {
|
|
96
|
+
from_path: existing?.from_path ?? null,
|
|
97
|
+
to_path: existing?.to_path ?? null,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
73
101
|
return { ok: true };
|
|
74
102
|
}
|
|
75
103
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
+
import { auditAs } from "./audit-core";
|
|
2
3
|
* Host-agnostic core for the `site_settings` key/value store.
|
|
3
4
|
*
|
|
4
5
|
* Rows are `{ key, value jsonb }` where `key` is one of
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
type OverridableSettingKey,
|
|
14
15
|
type SiteSettingOverrides,
|
|
15
16
|
} from "../config/site-config";
|
|
17
|
+
import { auditAs } from "./audit-core";
|
|
16
18
|
|
|
17
19
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
18
20
|
type Client = {
|
|
@@ -20,6 +22,9 @@ type Client = {
|
|
|
20
22
|
};
|
|
21
23
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
22
24
|
|
|
25
|
+
/** Optional auth context, supplied when the caller wants the change audited. */
|
|
26
|
+
type AuditContext = Parameters<typeof auditAs>[0];
|
|
27
|
+
|
|
23
28
|
export const SITE_SETTINGS_TABLE = "site_settings";
|
|
24
29
|
|
|
25
30
|
/** Load every stored override, narrowed to the overridable subset. */
|
|
@@ -41,6 +46,7 @@ export async function saveSiteSettingsCore(
|
|
|
41
46
|
client: Client,
|
|
42
47
|
patch: SiteSettingOverrides,
|
|
43
48
|
actorId?: string | null,
|
|
49
|
+
context?: AuditContext,
|
|
44
50
|
): Promise<SiteSettingOverrides> {
|
|
45
51
|
const clean = parseSiteSettingOverrides(patch);
|
|
46
52
|
const rows = (Object.keys(clean) as OverridableSettingKey[]).map((key) => ({
|
|
@@ -52,6 +58,14 @@ export async function saveSiteSettingsCore(
|
|
|
52
58
|
if (rows.length) {
|
|
53
59
|
const { error } = await client.from(SITE_SETTINGS_TABLE).upsert(rows, { onConflict: "key" });
|
|
54
60
|
if (error) throw new Error(error.message);
|
|
61
|
+
if (context) {
|
|
62
|
+
await auditAs(context, {
|
|
63
|
+
action: "site_settings.update",
|
|
64
|
+
entity: "site_settings",
|
|
65
|
+
entityId: rows.map((r) => r.key).join(", "),
|
|
66
|
+
meta: { keys: rows.map((r) => r.key) },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
55
69
|
}
|
|
56
70
|
return loadSiteSettingsCore(client);
|
|
57
71
|
}
|
|
@@ -60,7 +74,16 @@ export async function saveSiteSettingsCore(
|
|
|
60
74
|
export async function clearSiteSettingCore(
|
|
61
75
|
client: Client,
|
|
62
76
|
key: OverridableSettingKey,
|
|
77
|
+
context?: AuditContext,
|
|
63
78
|
): Promise<void> {
|
|
64
79
|
const { error } = await client.from(SITE_SETTINGS_TABLE).delete().eq("key", key);
|
|
65
80
|
if (error) throw new Error(error.message);
|
|
81
|
+
if (context) {
|
|
82
|
+
await auditAs(context, {
|
|
83
|
+
action: "site_settings.clear",
|
|
84
|
+
entity: "site_settings",
|
|
85
|
+
entityId: key,
|
|
86
|
+
meta: { key },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
66
89
|
}
|