@bygghammar/cms-platform 0.51.0 → 0.53.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 +22 -0
- package/package.json +5 -2
- 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/backoffice/IndexingStatusCard.tsx +43 -0
- package/src/backoffice/seo/SeoListView.tsx +23 -2
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.53.0
|
|
4
|
+
|
|
5
|
+
- `admin/describe-log`: one place that turns action codes and JSON meta into
|
|
6
|
+
ordinary English sentences for the Audit, Activity and Email log views.
|
|
7
|
+
- `server/audit-core`: `auditAs` lets any staff-guarded core log through the
|
|
8
|
+
caller's own client, and every entry now stores its sentence in
|
|
9
|
+
`meta.summary` at write time.
|
|
10
|
+
- `server/email-log-core`: `logEmail` records every send attempt — form
|
|
11
|
+
notifications, auto-replies, provider tests and account emails — with a
|
|
12
|
+
purpose and a sentence. Form delivery and the backoffice test send both go
|
|
13
|
+
through it.
|
|
14
|
+
- Content records, language domains, redirects, site settings, image focus
|
|
15
|
+
points, image text and submission/delivery actions are now audited.
|
|
16
|
+
- `AuditView`/`EmailLogsView`: rows lead with the sentence; codes stay in the
|
|
17
|
+
expanded detail only.
|
|
18
|
+
|
|
19
|
+
## 0.52.0
|
|
20
|
+
|
|
21
|
+
- `IndexingStatusCard`: read-only search-engine indexing status (from
|
|
22
|
+
`site/indexing`), shown on the SEO list and SEO detail views via a new
|
|
23
|
+
optional `indexing` dep — the same box as Admin → Site settings.
|
|
24
|
+
|
|
3
25
|
## 0.51.0
|
|
4
26
|
|
|
5
27
|
- `site/indexing`: environment-derived search-engine policy (`SITE_INDEXING`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -141,7 +141,9 @@
|
|
|
141
141
|
"./backoffice/BlocksLibraryView": "./src/backoffice/BlocksLibraryView.tsx",
|
|
142
142
|
"./backoffice/SharedContentListView": "./src/backoffice/SharedContentListView.tsx",
|
|
143
143
|
"./backoffice/seo/SeoListView": "./src/backoffice/seo/SeoListView.tsx",
|
|
144
|
+
"./admin/describe-log": "./src/admin/describe-log.ts",
|
|
144
145
|
"./server/audit-core": "./src/server/audit-core.ts",
|
|
146
|
+
"./server/email-log-core": "./src/server/email-log-core.ts",
|
|
145
147
|
"./server/review-image-validate": "./src/server/review-image-validate.ts",
|
|
146
148
|
"./server/media-library-core": "./src/server/media-library-core.ts",
|
|
147
149
|
"./server/admin-core": "./src/server/admin-core.ts",
|
|
@@ -197,7 +199,8 @@
|
|
|
197
199
|
"./runtime/error-reporting": "./src/runtime/error-reporting.ts",
|
|
198
200
|
"./server/media-usage-host": "./src/server/media-usage-host.ts",
|
|
199
201
|
"./runtime/server-entry": "./src/runtime/server-entry.ts",
|
|
200
|
-
"./site/indexing": "./src/site/indexing.ts"
|
|
202
|
+
"./site/indexing": "./src/site/indexing.ts",
|
|
203
|
+
"./backoffice/IndexingStatusCard": "./src/backoffice/IndexingStatusCard.tsx"
|
|
201
204
|
},
|
|
202
205
|
"dependencies": {
|
|
203
206
|
"fflate": "^0.8.3"
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain-English descriptions for audit and email log rows.
|
|
3
|
+
*
|
|
4
|
+
* Everything an editor sees in the Activity, Audit and Email log views is
|
|
5
|
+
* written here in ordinary sentences — never raw action codes, table names or
|
|
6
|
+
* JSON. The same module runs on the server, where each log row gets its sentence
|
|
7
|
+
* stored in `meta.summary` at write time, so the wording survives even if the
|
|
8
|
+
* label tables later change.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const ACTION_LABELS: Record<string, string> = {
|
|
12
|
+
"user.create": "Created user",
|
|
13
|
+
"user.delete": "Deleted user",
|
|
14
|
+
"user.password_reset": "Set a new password",
|
|
15
|
+
"user.send_reset": "Sent a password reset email",
|
|
16
|
+
"user.set_roles": "Changed user roles",
|
|
17
|
+
"user.invite": "Invited user",
|
|
18
|
+
"role.grant": "Granted role",
|
|
19
|
+
"role.revoke": "Revoked role",
|
|
20
|
+
"role.update": "Updated role",
|
|
21
|
+
"email.settings.update": "Updated email settings",
|
|
22
|
+
"email_settings.update": "Updated email settings",
|
|
23
|
+
"email.settings.test": "Sent test email",
|
|
24
|
+
"email_provider.create": "Added email provider",
|
|
25
|
+
"email_provider.update": "Updated email provider",
|
|
26
|
+
"email_provider.delete": "Deleted email provider",
|
|
27
|
+
"email_provider.test_send": "Sent a test email",
|
|
28
|
+
"content.section.save": "Saved content",
|
|
29
|
+
"content.section.delete": "Deleted content",
|
|
30
|
+
"site_settings.update": "Updated site settings",
|
|
31
|
+
"site_settings.clear": "Reset a site setting to the code default",
|
|
32
|
+
"page.create": "Created page",
|
|
33
|
+
"page.update": "Updated page",
|
|
34
|
+
"page.delete": "Deleted page",
|
|
35
|
+
"page.publish": "Published page",
|
|
36
|
+
"page.unpublish": "Unpublished page",
|
|
37
|
+
"page.version.create": "Saved page version",
|
|
38
|
+
"page.version.restore": "Restored page version",
|
|
39
|
+
"template.create": "Created block template",
|
|
40
|
+
"template.update": "Updated block template",
|
|
41
|
+
"template.delete": "Deleted block template",
|
|
42
|
+
"global.section.save": "Saved global block",
|
|
43
|
+
"global.section.delete": "Deleted global block",
|
|
44
|
+
"domain.create": "Added language domain",
|
|
45
|
+
"domain.update": "Updated language domain",
|
|
46
|
+
"domain.save": "Saved language domain",
|
|
47
|
+
"domain.delete": "Removed language domain",
|
|
48
|
+
"seo.update": "Updated SEO settings",
|
|
49
|
+
"seo.delete": "Removed SEO settings",
|
|
50
|
+
"redirect.save": "Saved redirect",
|
|
51
|
+
"redirect.delete": "Deleted redirect",
|
|
52
|
+
"media.upload": "Uploaded media",
|
|
53
|
+
"media.delete": "Deleted media",
|
|
54
|
+
"media.import": "Imported external media",
|
|
55
|
+
"media.replace": "Replaced an image",
|
|
56
|
+
"media.focus.set": "Set an image focus point",
|
|
57
|
+
"media.focus.clear": "Cleared an image focus point",
|
|
58
|
+
"media.meta.set": "Updated image text",
|
|
59
|
+
"media.meta.clear": "Cleared image text",
|
|
60
|
+
"submission.partial.delete": "Deleted saved partial submissions",
|
|
61
|
+
"delivery.resolve": "Marked delivery attempts as resolved",
|
|
62
|
+
"translate.run": "Ran auto-translation",
|
|
63
|
+
"form.create": "Created form",
|
|
64
|
+
"form.update": "Updated form",
|
|
65
|
+
"form.duplicate": "Duplicated form",
|
|
66
|
+
"form.soft_delete": "Moved form to bin",
|
|
67
|
+
"form.restore": "Restored form",
|
|
68
|
+
"form.purge": "Permanently deleted form",
|
|
69
|
+
"form.defaults.update": "Updated shared form defaults",
|
|
70
|
+
"submission.status": "Changed a submission status",
|
|
71
|
+
"submission.purge": "Removed old submissions",
|
|
72
|
+
"submission.sheet_push": "Sent submissions to the spreadsheet",
|
|
73
|
+
"submission.delivery_retry": "Retried a failed delivery",
|
|
74
|
+
"submission.delivery_resolve": "Marked a delivery problem as resolved",
|
|
75
|
+
"submission.partials_delete": "Deleted saved drafts",
|
|
76
|
+
"backup.export": "Exported a backup",
|
|
77
|
+
"backup.import": "Restored from a backup",
|
|
78
|
+
"auth.login": "Signed in",
|
|
79
|
+
"auth.logout": "Signed out",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const ENTITY_LABELS: Record<string, string> = {
|
|
83
|
+
user: "user",
|
|
84
|
+
"auth.users": "user",
|
|
85
|
+
user_role: "role",
|
|
86
|
+
user_roles: "roles",
|
|
87
|
+
content_section: "content record",
|
|
88
|
+
content_sections: "content record",
|
|
89
|
+
site_settings: "site settings",
|
|
90
|
+
page: "page",
|
|
91
|
+
page_version: "page version",
|
|
92
|
+
page_publication: "publication",
|
|
93
|
+
block_template: "template",
|
|
94
|
+
global_section: "global block",
|
|
95
|
+
locale_domain: "language domain",
|
|
96
|
+
locale_domains: "language domain",
|
|
97
|
+
page_seo: "SEO settings",
|
|
98
|
+
email_settings: "email settings",
|
|
99
|
+
email_providers: "email provider",
|
|
100
|
+
media: "media file",
|
|
101
|
+
media_focus: "image focus point",
|
|
102
|
+
media_meta: "image text",
|
|
103
|
+
redirect: "redirect",
|
|
104
|
+
redirects: "redirect",
|
|
105
|
+
forms: "form",
|
|
106
|
+
form_submissions: "submission",
|
|
107
|
+
form_partials: "saved draft",
|
|
108
|
+
form_delivery_attempts: "delivery attempt",
|
|
109
|
+
translations: "translations",
|
|
110
|
+
backup: "backup",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function humanizeAction(action: string | null | undefined): string {
|
|
114
|
+
if (!action) return "—";
|
|
115
|
+
const label = ACTION_LABELS[action];
|
|
116
|
+
if (label) return label;
|
|
117
|
+
return action
|
|
118
|
+
.replace(/[._-]+/g, " ")
|
|
119
|
+
.replace(/\s+/g, " ")
|
|
120
|
+
.trim()
|
|
121
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function humanizeEntity(entity: string | null | undefined): string {
|
|
125
|
+
if (!entity) return "—";
|
|
126
|
+
const label = ENTITY_LABELS[entity];
|
|
127
|
+
if (label) return label.replace(/^./, (c) => c.toUpperCase());
|
|
128
|
+
return entity.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Lower-case noun for use inside a sentence, e.g. "the page". */
|
|
132
|
+
export function entityNoun(entity: string | null | undefined): string {
|
|
133
|
+
if (!entity) return "record";
|
|
134
|
+
return ENTITY_LABELS[entity] ?? entity.replace(/[._-]+/g, " ").toLowerCase();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const isUuid = (v: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(v);
|
|
138
|
+
|
|
139
|
+
function metaString(meta: Record<string, unknown>, keys: string[]): string | null {
|
|
140
|
+
for (const key of keys) {
|
|
141
|
+
const value = meta[key];
|
|
142
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
143
|
+
if (typeof value === "number") return String(value);
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One sentence describing what happened, in ordinary English.
|
|
150
|
+
*
|
|
151
|
+
* Uses whatever human-friendly handle the meta payload carries (name, key,
|
|
152
|
+
* email, path) and only falls back to an identifier when nothing better exists.
|
|
153
|
+
*/
|
|
154
|
+
export function describeAudit(input: {
|
|
155
|
+
action: string;
|
|
156
|
+
entity?: string | null;
|
|
157
|
+
entityId?: string | null;
|
|
158
|
+
meta?: Record<string, unknown> | null;
|
|
159
|
+
}): string {
|
|
160
|
+
const meta = (input.meta ?? {}) as Record<string, unknown>;
|
|
161
|
+
const existing = metaString(meta, ["summary"]);
|
|
162
|
+
if (existing) return existing;
|
|
163
|
+
|
|
164
|
+
const verb = humanizeAction(input.action);
|
|
165
|
+
const noun = entityNoun(input.entity);
|
|
166
|
+
const handle =
|
|
167
|
+
metaString(meta, ["name", "title", "email", "key", "path", "from_path", "domain", "locale"]) ??
|
|
168
|
+
(input.entityId && !isUuid(input.entityId) ? input.entityId : null);
|
|
169
|
+
|
|
170
|
+
const firstWord = noun.split(" ")[0] ?? "";
|
|
171
|
+
const alreadyNamesEntity = firstWord ? verb.toLowerCase().includes(firstWord) : false;
|
|
172
|
+
const subject = alreadyNamesEntity ? "" : ` the ${noun}`;
|
|
173
|
+
const named = handle ? ` “${handle}”` : "";
|
|
174
|
+
|
|
175
|
+
let sentence = `${verb}${subject}${named}`.replace(/\s+/g, " ").trim();
|
|
176
|
+
|
|
177
|
+
const extras: string[] = [];
|
|
178
|
+
const roles = meta["roles"];
|
|
179
|
+
if (Array.isArray(roles) && roles.length) extras.push(`roles: ${roles.join(", ")}`);
|
|
180
|
+
const to = metaString(meta, ["to_path"]);
|
|
181
|
+
if (to && input.action.startsWith("redirect")) extras.push(`now points to ${to}`);
|
|
182
|
+
const count = meta["count"] ?? meta["deleted"] ?? meta["updated"];
|
|
183
|
+
if (typeof count === "number") extras.push(`${count} item${count === 1 ? "" : "s"}`);
|
|
184
|
+
const status = metaString(meta, ["status"]);
|
|
185
|
+
if (status && input.action.startsWith("submission.status")) extras.push(`set to ${status}`);
|
|
186
|
+
const reason = metaString(meta, ["reason", "note"]);
|
|
187
|
+
if (reason) extras.push(reason);
|
|
188
|
+
|
|
189
|
+
if (extras.length) sentence += ` — ${extras.join("; ")}`;
|
|
190
|
+
if (!/[.!?]$/.test(sentence)) sentence += ".";
|
|
191
|
+
return sentence;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One sentence describing an email attempt. */
|
|
195
|
+
export function describeEmail(input: {
|
|
196
|
+
status: string;
|
|
197
|
+
to: string;
|
|
198
|
+
subject?: string | null;
|
|
199
|
+
provider?: string | null;
|
|
200
|
+
purpose?: string | null;
|
|
201
|
+
error?: string | null;
|
|
202
|
+
}): string {
|
|
203
|
+
const purposeLabel = humanizeEmailPurpose(input.purpose);
|
|
204
|
+
const lead = purposeLabel ? `${purposeLabel} email` : "Email";
|
|
205
|
+
const subject = input.subject ? ` “${input.subject}”` : "";
|
|
206
|
+
const via = input.provider ? ` via ${input.provider}` : "";
|
|
207
|
+
const ok = input.status === "sent" || input.status === "delivered";
|
|
208
|
+
const verb = ok ? "sent to" : "could not be sent to";
|
|
209
|
+
const tail = !ok && input.error ? ` — ${input.error}` : "";
|
|
210
|
+
return `${lead}${subject} ${verb} ${input.to}${via}${tail}.`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Friendly names for the purposes app emails are sent for. */
|
|
214
|
+
export const EMAIL_PURPOSE_LABELS: Record<string, string> = {
|
|
215
|
+
form_notification: "Form notification",
|
|
216
|
+
form_autoreply: "Form auto-reply",
|
|
217
|
+
form_test: "Form test",
|
|
218
|
+
provider_test: "Provider test",
|
|
219
|
+
password_reset: "Password reset",
|
|
220
|
+
user_invite: "New account",
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export function humanizeEmailPurpose(purpose: string | null | undefined): string | null {
|
|
224
|
+
if (!purpose) return null;
|
|
225
|
+
return (
|
|
226
|
+
EMAIL_PURPOSE_LABELS[purpose] ??
|
|
227
|
+
purpose.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase())
|
|
228
|
+
);
|
|
229
|
+
}
|
package/src/admin/format-log.ts
CHANGED
|
@@ -1,83 +1,18 @@
|
|
|
1
1
|
// Turn machine action codes and JSON meta into human-readable strings for the
|
|
2
|
-
// backoffice Audit and Email log views.
|
|
2
|
+
// backoffice Audit and Email log views. All wording lives in `describe-log`.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"content.section.save": "Saved section",
|
|
15
|
-
"content.section.delete": "Deleted section",
|
|
16
|
-
"page.create": "Created page",
|
|
17
|
-
"page.update": "Updated page",
|
|
18
|
-
"page.delete": "Deleted page",
|
|
19
|
-
"page.publish": "Published page",
|
|
20
|
-
"page.unpublish": "Unpublished page",
|
|
21
|
-
"page.version.create": "Saved page version",
|
|
22
|
-
"page.version.restore": "Restored page version",
|
|
23
|
-
"template.create": "Created block template",
|
|
24
|
-
"template.update": "Updated block template",
|
|
25
|
-
"template.delete": "Deleted block template",
|
|
26
|
-
"global.section.save": "Saved global block",
|
|
27
|
-
"global.section.delete": "Deleted global block",
|
|
28
|
-
"domain.create": "Added locale domain",
|
|
29
|
-
"domain.update": "Updated locale domain",
|
|
30
|
-
"domain.delete": "Removed locale domain",
|
|
31
|
-
"seo.update": "Updated SEO",
|
|
32
|
-
"media.upload": "Uploaded media",
|
|
33
|
-
"media.delete": "Deleted media",
|
|
34
|
-
"media.import": "Imported external media",
|
|
35
|
-
"translate.run": "Ran auto-translation",
|
|
36
|
-
"form.create": "Created form",
|
|
37
|
-
"form.update": "Updated form",
|
|
38
|
-
"form.duplicate": "Duplicated form",
|
|
39
|
-
"form.soft_delete": "Moved form to bin",
|
|
40
|
-
"form.restore": "Restored form",
|
|
41
|
-
"form.purge": "Permanently deleted form",
|
|
42
|
-
"form.defaults.update": "Updated shared form defaults",
|
|
43
|
-
"auth.login": "Signed in",
|
|
44
|
-
"auth.logout": "Signed out",
|
|
45
|
-
};
|
|
4
|
+
export {
|
|
5
|
+
ACTION_LABELS,
|
|
6
|
+
ENTITY_LABELS,
|
|
7
|
+
describeAudit,
|
|
8
|
+
describeEmail,
|
|
9
|
+
entityNoun,
|
|
10
|
+
humanizeAction,
|
|
11
|
+
humanizeEmailPurpose,
|
|
12
|
+
humanizeEntity,
|
|
13
|
+
} from "./describe-log";
|
|
46
14
|
|
|
47
|
-
|
|
48
|
-
user: "User",
|
|
49
|
-
user_role: "Role",
|
|
50
|
-
content_section: "Section",
|
|
51
|
-
page: "Page",
|
|
52
|
-
page_version: "Page version",
|
|
53
|
-
page_publication: "Publication",
|
|
54
|
-
block_template: "Template",
|
|
55
|
-
global_section: "Global block",
|
|
56
|
-
locale_domain: "Domain",
|
|
57
|
-
page_seo: "SEO",
|
|
58
|
-
email_settings: "Email settings",
|
|
59
|
-
media: "Media",
|
|
60
|
-
redirect: "Redirect",
|
|
61
|
-
forms: "Form",
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
export function humanizeAction(action: string | null | undefined): string {
|
|
65
|
-
if (!action) return "—";
|
|
66
|
-
if (ACTION_LABELS[action]) return ACTION_LABELS[action];
|
|
67
|
-
// Fallback: turn "some.snake_case.action" into "Some snake case action".
|
|
68
|
-
return action
|
|
69
|
-
.replace(/[._-]+/g, " ")
|
|
70
|
-
.replace(/\s+/g, " ")
|
|
71
|
-
.trim()
|
|
72
|
-
.replace(/^./, (c) => c.toUpperCase());
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function humanizeEntity(entity: string | null | undefined): string {
|
|
76
|
-
if (!entity) return "—";
|
|
77
|
-
return (
|
|
78
|
-
ENTITY_LABELS[entity] ?? entity.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase())
|
|
79
|
-
);
|
|
80
|
-
}
|
|
15
|
+
import { describeAudit, humanizeEntity } from "./describe-log";
|
|
81
16
|
|
|
82
17
|
function shortId(id: string): string {
|
|
83
18
|
if (id.length <= 12) return id;
|
|
@@ -105,6 +40,7 @@ export function summarizeMeta(meta: unknown): string {
|
|
|
105
40
|
if (!Object.keys(m).length) return "";
|
|
106
41
|
|
|
107
42
|
const parts: string[] = [];
|
|
43
|
+
// `summary` is rendered on its own as the row's sentence — never repeat it here.
|
|
108
44
|
const push = (label: string, value: unknown) => {
|
|
109
45
|
if (value == null || value === "") return;
|
|
110
46
|
if (typeof value === "object") {
|
|
@@ -120,7 +56,6 @@ export function summarizeMeta(meta: unknown): string {
|
|
|
120
56
|
|
|
121
57
|
// Common well-known fields, in a nice order
|
|
122
58
|
const known = [
|
|
123
|
-
["summary", "Summary"],
|
|
124
59
|
["email", "Email"],
|
|
125
60
|
["role", "Role"],
|
|
126
61
|
["name", "Name"],
|
|
@@ -146,7 +81,7 @@ export function summarizeMeta(meta: unknown): string {
|
|
|
146
81
|
}
|
|
147
82
|
// Any remaining keys, up to 4 total extra fields
|
|
148
83
|
const extras = Object.keys(m)
|
|
149
|
-
.filter((k) => !used.has(k))
|
|
84
|
+
.filter((k) => !used.has(k) && k !== "summary")
|
|
150
85
|
.slice(0, 4);
|
|
151
86
|
for (const k of extras) {
|
|
152
87
|
push(
|
|
@@ -215,3 +150,28 @@ export function emailStatusTone(status: string | null | undefined): {
|
|
|
215
150
|
}
|
|
216
151
|
);
|
|
217
152
|
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The plain-English sentence for one audit row: the stored `meta.summary`
|
|
157
|
+
* written at the time of the action, or a sentence derived from the row.
|
|
158
|
+
*/
|
|
159
|
+
export function auditSentence(row: {
|
|
160
|
+
action: string;
|
|
161
|
+
entity?: string | null;
|
|
162
|
+
entity_id?: string | null;
|
|
163
|
+
meta?: Record<string, unknown> | null;
|
|
164
|
+
}): string {
|
|
165
|
+
return describeAudit({
|
|
166
|
+
action: row.action,
|
|
167
|
+
entity: row.entity ?? null,
|
|
168
|
+
entityId: row.entity_id ?? null,
|
|
169
|
+
meta: row.meta ?? null,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The plain-English sentence stored with an email log row, when present. */
|
|
174
|
+
export function emailSentence(row: { meta?: Record<string, unknown> | null }): string {
|
|
175
|
+
const summary = row.meta?.["summary"];
|
|
176
|
+
return typeof summary === "string" ? summary : "";
|
|
177
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Fragment, useMemo, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
auditSentence,
|
|
4
5
|
formatAbsoluteTime,
|
|
5
6
|
formatEntityRef,
|
|
6
7
|
formatRelativeTime,
|
|
@@ -35,6 +36,7 @@ export function AuditView({ rows }: { rows: AuditLogRow[] }) {
|
|
|
35
36
|
r.actor_email,
|
|
36
37
|
r.action,
|
|
37
38
|
humanizeAction(r.action),
|
|
39
|
+
auditSentence(r),
|
|
38
40
|
r.entity,
|
|
39
41
|
r.entity_id,
|
|
40
42
|
JSON.stringify(r.meta ?? {}),
|
|
@@ -77,6 +79,7 @@ export function AuditView({ rows }: { rows: AuditLogRow[] }) {
|
|
|
77
79
|
</thead>
|
|
78
80
|
<tbody>
|
|
79
81
|
{filtered.map((r) => {
|
|
82
|
+
const sentence = auditSentence(r);
|
|
80
83
|
const summary = summarizeMeta(r.meta as never);
|
|
81
84
|
const isOpen = expanded === r.id;
|
|
82
85
|
return (
|
|
@@ -100,7 +103,7 @@ export function AuditView({ rows }: { rows: AuditLogRow[] }) {
|
|
|
100
103
|
)}
|
|
101
104
|
</td>
|
|
102
105
|
<td className="p-3">
|
|
103
|
-
<div className="text-ink">{
|
|
106
|
+
<div className="text-ink">{sentence}</div>
|
|
104
107
|
{summary && (
|
|
105
108
|
<div className="text-[0.7rem] text-ink/60 mt-1 line-clamp-2">{summary}</div>
|
|
106
109
|
)}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Fragment, useMemo, useState } from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
emailSentence,
|
|
5
|
+
emailStatusTone,
|
|
6
|
+
formatAbsoluteTime,
|
|
7
|
+
formatRelativeTime,
|
|
8
|
+
humanizeEmailPurpose,
|
|
9
|
+
} from "../admin/format-log";
|
|
4
10
|
|
|
5
11
|
/** One outgoing email attempt. */
|
|
6
12
|
export type EmailLogRow = {
|
|
@@ -33,7 +39,17 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
|
|
|
33
39
|
return rows.filter((r) => {
|
|
34
40
|
if (status !== "all" && r.status !== status) return false;
|
|
35
41
|
if (!s) return true;
|
|
36
|
-
const hay = [
|
|
42
|
+
const hay = [
|
|
43
|
+
r.to_address,
|
|
44
|
+
r.from_address,
|
|
45
|
+
r.subject,
|
|
46
|
+
r.provider,
|
|
47
|
+
r.error,
|
|
48
|
+
emailSentence(r),
|
|
49
|
+
humanizeEmailPurpose(
|
|
50
|
+
typeof r.meta?.["purpose"] === "string" ? (r.meta["purpose"] as string) : null,
|
|
51
|
+
),
|
|
52
|
+
]
|
|
37
53
|
.filter(Boolean)
|
|
38
54
|
.join(" ")
|
|
39
55
|
.toLowerCase();
|
|
@@ -88,6 +104,10 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
|
|
|
88
104
|
<tbody>
|
|
89
105
|
{filtered.map((r) => {
|
|
90
106
|
const tone = emailStatusTone(r.status);
|
|
107
|
+
const purpose = humanizeEmailPurpose(
|
|
108
|
+
typeof r.meta?.["purpose"] === "string" ? (r.meta["purpose"] as string) : null,
|
|
109
|
+
);
|
|
110
|
+
const sentence = emailSentence(r);
|
|
91
111
|
const isOpen = expanded === r.id;
|
|
92
112
|
return (
|
|
93
113
|
<Fragment key={r.id}>
|
|
@@ -109,6 +129,11 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
|
|
|
109
129
|
</td>
|
|
110
130
|
<td className="p-3">
|
|
111
131
|
<div className="text-ink">{r.subject ?? "(no subject)"}</div>
|
|
132
|
+
{purpose && (
|
|
133
|
+
<div className="text-[0.65rem] uppercase tracking-[0.15em] text-ink/50 mt-1">
|
|
134
|
+
{purpose}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
112
137
|
{r.body_preview && (
|
|
113
138
|
<div className="text-[0.7rem] text-ink/50 mt-1 line-clamp-1">
|
|
114
139
|
{r.body_preview}
|
|
@@ -133,6 +158,7 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
|
|
|
133
158
|
{isOpen && (
|
|
134
159
|
<tr className="bg-ink/[0.02] border-t border-ink/10">
|
|
135
160
|
<td colSpan={6} className="p-4 space-y-3 text-xs">
|
|
161
|
+
{sentence && <p className="text-ink/80">{sentence}</p>}
|
|
136
162
|
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
|
|
137
163
|
<DetailRow label="To" value={r.to_address} />
|
|
138
164
|
<DetailRow label="From" value={r.from_address ?? "—"} />
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { IndexingPolicy } from "../site/indexing";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read-only search-engine indexing status, resolved server-side from the
|
|
7
|
+
* environment (`SITE_INDEXING` / `SITE_ENV` — see `site/indexing`). Shown on
|
|
8
|
+
* Site settings and the SEO screens so a blocked staging deployment is
|
|
9
|
+
* visible wherever metadata is edited. Never a database toggle: indexing is
|
|
10
|
+
* an environment fact so a content restore cannot flip it.
|
|
11
|
+
*/
|
|
12
|
+
export function IndexingStatusCard({
|
|
13
|
+
indexing,
|
|
14
|
+
}: {
|
|
15
|
+
indexing?: IndexingPolicy;
|
|
16
|
+
}): ReactNode {
|
|
17
|
+
const status = indexing
|
|
18
|
+
? indexing.mode === "block"
|
|
19
|
+
? "Blocked — not indexed"
|
|
20
|
+
: "Indexed"
|
|
21
|
+
: "Unknown";
|
|
22
|
+
const source = indexing
|
|
23
|
+
? indexing.source === "SITE_INDEXING"
|
|
24
|
+
? `From SITE_INDEXING=${indexing.value}`
|
|
25
|
+
: indexing.source === "SITE_ENV"
|
|
26
|
+
? `From SITE_ENV=${indexing.value}`
|
|
27
|
+
: "Safe default — SITE_INDEXING unset"
|
|
28
|
+
: "Set SITE_INDEXING per environment";
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="max-w-sm border border-ink/10 bg-white p-4">
|
|
32
|
+
<div className="text-xs uppercase tracking-wider text-ink/50">Search engines</div>
|
|
33
|
+
<div className="mt-1 text-sm text-ink">{status}</div>
|
|
34
|
+
<div className="mt-2 text-[11px] uppercase tracking-wider text-ink/40">{source}</div>
|
|
35
|
+
<p className="mt-3 text-xs text-ink/50">
|
|
36
|
+
Indexing is an environment fact, not a setting here: set <code>SITE_INDEXING=off</code> on
|
|
37
|
+
staging and <code>SITE_INDEXING=on</code> in production. A blocked deployment serves{" "}
|
|
38
|
+
<code>Disallow: /</code> in robots.txt and a <code>noindex, nofollow</code> header on every
|
|
39
|
+
response.
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
import type { ReactNode } from "react";
|
|
8
8
|
import { Check, ChevronLeft, ChevronRight } from "lucide-react";
|
|
9
9
|
|
|
10
|
+
import type { IndexingPolicy } from "../../site/indexing";
|
|
11
|
+
import { IndexingStatusCard } from "../IndexingStatusCard";
|
|
12
|
+
|
|
10
13
|
export type SeoListRow = {
|
|
11
14
|
/** Page key, e.g. `index` or a CMS-only page key. */
|
|
12
15
|
key: string;
|
|
@@ -21,10 +24,15 @@ export type SeoListDeps = {
|
|
|
21
24
|
rows: readonly SeoListRow[];
|
|
22
25
|
/** Navigate to the SEO editor for one page key. */
|
|
23
26
|
openEditor: (key: string) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Indexing policy for the running deployment, resolved from the environment
|
|
29
|
+
* server-side (see `site/indexing`). Shown read-only, same as Site settings.
|
|
30
|
+
*/
|
|
31
|
+
indexing?: IndexingPolicy;
|
|
24
32
|
};
|
|
25
33
|
|
|
26
34
|
export function SeoListView({ deps }: { deps: SeoListDeps }): ReactNode {
|
|
27
|
-
const { rows, openEditor } = deps;
|
|
35
|
+
const { rows, openEditor, indexing } = deps;
|
|
28
36
|
|
|
29
37
|
return (
|
|
30
38
|
<div className="p-8 md:p-12 w-full">
|
|
@@ -36,6 +44,10 @@ export function SeoListView({ deps }: { deps: SeoListDeps }): ReactNode {
|
|
|
36
44
|
defaults from the codebase and are used directly by canonical, hreflang and sitemap.
|
|
37
45
|
</p>
|
|
38
46
|
|
|
47
|
+
<div className="mt-6">
|
|
48
|
+
<IndexingStatusCard indexing={indexing} />
|
|
49
|
+
</div>
|
|
50
|
+
|
|
39
51
|
<ul className="mt-10 divide-y divide-ink/10 border-y border-ink/10">
|
|
40
52
|
{rows.map((row) => (
|
|
41
53
|
<li key={row.key}>
|
|
@@ -73,10 +85,15 @@ export type SeoDetailDeps = {
|
|
|
73
85
|
onBack: () => void;
|
|
74
86
|
/** The host's page SEO panel for this page key. */
|
|
75
87
|
children: ReactNode;
|
|
88
|
+
/**
|
|
89
|
+
* Indexing policy for the running deployment, resolved from the environment
|
|
90
|
+
* server-side (see `site/indexing`). Shown read-only, same as Site settings.
|
|
91
|
+
*/
|
|
92
|
+
indexing?: IndexingPolicy;
|
|
76
93
|
};
|
|
77
94
|
|
|
78
95
|
export function SeoDetailView({ deps }: { deps: SeoDetailDeps }): ReactNode {
|
|
79
|
-
const { pageKey, onBack, children } = deps;
|
|
96
|
+
const { pageKey, onBack, children, indexing } = deps;
|
|
80
97
|
|
|
81
98
|
return (
|
|
82
99
|
<div className="p-8 md:p-12 w-full">
|
|
@@ -98,6 +115,10 @@ export function SeoDetailView({ deps }: { deps: SeoDetailDeps }): ReactNode {
|
|
|
98
115
|
hreflang, Open Graph and Twitter cards.
|
|
99
116
|
</p>
|
|
100
117
|
|
|
118
|
+
<div className="mt-6">
|
|
119
|
+
<IndexingStatusCard indexing={indexing} />
|
|
120
|
+
</div>
|
|
121
|
+
|
|
101
122
|
<div className="mt-8">{children}</div>
|
|
102
123
|
</div>
|
|
103
124
|
);
|
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
|
}
|
|
@@ -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
|
}
|