@bygghammar/cms-platform 0.23.0 → 0.24.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 +10 -0
- package/package.json +7 -2
- package/src/backoffice/AuditView.tsx +171 -0
- package/src/backoffice/EmailLogsView.tsx +203 -0
- package/src/backoffice/LogsView.tsx +43 -0
- package/src/backoffice/RolesView.tsx +50 -0
- package/src/backoffice/UsersView.tsx +348 -0
- package/src/config/site-config.ts +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,16 @@ always compatible. In addition, `PLATFORM_CONTRACT_VERSION` in
|
|
|
9
9
|
or adapter capabilities an older site does not have. Sites assert that constant
|
|
10
10
|
in a test and upgrade deliberately.
|
|
11
11
|
|
|
12
|
+
## 0.24.0
|
|
13
|
+
|
|
14
|
+
- Added `backoffice/UsersView`: shared account administration (create users,
|
|
15
|
+
toggle roles, set or reset passwords, delete), superadmin-gated.
|
|
16
|
+
- Added `backoffice/RolesView`: shared role reference list with default
|
|
17
|
+
descriptions that hosts can override.
|
|
18
|
+
- Added `backoffice/AuditView` and `backoffice/EmailLogsView`: shared log
|
|
19
|
+
browsers with search, status filtering and per-row detail.
|
|
20
|
+
- Added `backoffice/LogsView`: shared index over the two log surfaces.
|
|
21
|
+
|
|
12
22
|
## 0.23.0
|
|
13
23
|
|
|
14
24
|
- Added `config/site-config`: the typed `SiteConfig` contract, `defineSiteConfig`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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": false,
|
|
@@ -115,7 +115,12 @@
|
|
|
115
115
|
"./server/forms-core": "./src/server/forms-core.ts",
|
|
116
116
|
"./config/site-config": "./src/config/site-config.ts",
|
|
117
117
|
"./server/site-settings-core": "./src/server/site-settings-core.ts",
|
|
118
|
-
"./backoffice/SiteSettingsView": "./src/backoffice/SiteSettingsView.tsx"
|
|
118
|
+
"./backoffice/SiteSettingsView": "./src/backoffice/SiteSettingsView.tsx",
|
|
119
|
+
"./backoffice/UsersView": "./src/backoffice/UsersView.tsx",
|
|
120
|
+
"./backoffice/RolesView": "./src/backoffice/RolesView.tsx",
|
|
121
|
+
"./backoffice/AuditView": "./src/backoffice/AuditView.tsx",
|
|
122
|
+
"./backoffice/EmailLogsView": "./src/backoffice/EmailLogsView.tsx",
|
|
123
|
+
"./backoffice/LogsView": "./src/backoffice/LogsView.tsx"
|
|
119
124
|
},
|
|
120
125
|
"dependencies": {
|
|
121
126
|
"fflate": "^0.8.3"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Fragment, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatAbsoluteTime,
|
|
5
|
+
formatEntityRef,
|
|
6
|
+
formatRelativeTime,
|
|
7
|
+
humanizeAction,
|
|
8
|
+
summarizeMeta,
|
|
9
|
+
} from "../admin/format-log";
|
|
10
|
+
|
|
11
|
+
/** One audit trail row. */
|
|
12
|
+
export type AuditLogRow = {
|
|
13
|
+
id: string;
|
|
14
|
+
created_at: string;
|
|
15
|
+
actor_id?: string | null;
|
|
16
|
+
actor_email?: string | null;
|
|
17
|
+
action: string;
|
|
18
|
+
entity?: string | null;
|
|
19
|
+
entity_id?: string | null;
|
|
20
|
+
ip?: string | null;
|
|
21
|
+
user_agent?: string | null;
|
|
22
|
+
meta?: Record<string, unknown> | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Host-agnostic audit log browser with search and per-row detail. */
|
|
26
|
+
export function AuditView({ rows }: { rows: AuditLogRow[] }) {
|
|
27
|
+
const [search, setSearch] = useState("");
|
|
28
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
const filtered = useMemo(() => {
|
|
31
|
+
const s = search.trim().toLowerCase();
|
|
32
|
+
if (!s) return rows;
|
|
33
|
+
return rows.filter((r) => {
|
|
34
|
+
const hay = [
|
|
35
|
+
r.actor_email,
|
|
36
|
+
r.action,
|
|
37
|
+
humanizeAction(r.action),
|
|
38
|
+
r.entity,
|
|
39
|
+
r.entity_id,
|
|
40
|
+
JSON.stringify(r.meta ?? {}),
|
|
41
|
+
]
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.join(" ")
|
|
44
|
+
.toLowerCase();
|
|
45
|
+
return hay.includes(s);
|
|
46
|
+
});
|
|
47
|
+
}, [rows, search]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="p-8 md:p-12">
|
|
51
|
+
<div className="flex items-start justify-between gap-6 flex-wrap">
|
|
52
|
+
<div>
|
|
53
|
+
<h2 className="font-serif text-xl">Audit log</h2>
|
|
54
|
+
<p className="mt-2 text-sm text-ink/60 max-w-2xl">
|
|
55
|
+
A human-readable trail of every sensitive action performed in the backoffice: who did
|
|
56
|
+
what, when, and to which record.
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
<input
|
|
60
|
+
value={search}
|
|
61
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
62
|
+
placeholder="Search actor, action, entity…"
|
|
63
|
+
className="border border-ink/15 bg-white px-3 py-2 text-sm w-72 focus:outline-none focus:border-sea"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div className="mt-6 border border-ink/10 bg-white overflow-hidden">
|
|
68
|
+
<table className="w-full text-sm">
|
|
69
|
+
<thead className="bg-ink/5 text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">
|
|
70
|
+
<tr>
|
|
71
|
+
<th className="text-left p-3 w-40">When</th>
|
|
72
|
+
<th className="text-left p-3 w-56">Who</th>
|
|
73
|
+
<th className="text-left p-3">What happened</th>
|
|
74
|
+
<th className="text-left p-3 w-56">Target</th>
|
|
75
|
+
<th className="w-10 p-3" />
|
|
76
|
+
</tr>
|
|
77
|
+
</thead>
|
|
78
|
+
<tbody>
|
|
79
|
+
{filtered.map((r) => {
|
|
80
|
+
const summary = summarizeMeta(r.meta as never);
|
|
81
|
+
const isOpen = expanded === r.id;
|
|
82
|
+
return (
|
|
83
|
+
<Fragment key={r.id}>
|
|
84
|
+
<tr
|
|
85
|
+
className="border-t border-ink/10 align-top hover:bg-ink/[0.02] cursor-pointer"
|
|
86
|
+
onClick={() => setExpanded(isOpen ? null : r.id)}
|
|
87
|
+
>
|
|
88
|
+
<td className="p-3 whitespace-nowrap">
|
|
89
|
+
<div className="text-ink">{formatRelativeTime(r.created_at)}</div>
|
|
90
|
+
<div className="text-[0.65rem] text-ink/50">
|
|
91
|
+
{formatAbsoluteTime(r.created_at)}
|
|
92
|
+
</div>
|
|
93
|
+
</td>
|
|
94
|
+
<td className="p-3">
|
|
95
|
+
<div className="text-ink">{r.actor_email ?? "System"}</div>
|
|
96
|
+
{r.actor_id && (
|
|
97
|
+
<div className="text-[0.65rem] text-ink/40 font-mono">
|
|
98
|
+
{r.actor_id.slice(0, 8)}…
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</td>
|
|
102
|
+
<td className="p-3">
|
|
103
|
+
<div className="text-ink">{humanizeAction(r.action)}</div>
|
|
104
|
+
{summary && (
|
|
105
|
+
<div className="text-[0.7rem] text-ink/60 mt-1 line-clamp-2">{summary}</div>
|
|
106
|
+
)}
|
|
107
|
+
</td>
|
|
108
|
+
<td className="p-3 text-ink/70">{formatEntityRef(r.entity, r.entity_id)}</td>
|
|
109
|
+
<td className="p-3 text-ink/40 text-xs">{isOpen ? "▾" : "▸"}</td>
|
|
110
|
+
</tr>
|
|
111
|
+
{isOpen && (
|
|
112
|
+
<tr className="bg-ink/[0.02] border-t border-ink/10">
|
|
113
|
+
<td colSpan={5} className="p-4">
|
|
114
|
+
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-xs">
|
|
115
|
+
<DetailRow label="Action code" value={r.action} mono />
|
|
116
|
+
<DetailRow label="Entity" value={r.entity ?? "—"} />
|
|
117
|
+
<DetailRow label="Entity ID" value={r.entity_id ?? "—"} mono />
|
|
118
|
+
<DetailRow label="Actor ID" value={r.actor_id ?? "—"} mono />
|
|
119
|
+
<DetailRow label="IP" value={r.ip ?? "—"} mono />
|
|
120
|
+
<DetailRow label="User agent" value={r.user_agent ?? "—"} />
|
|
121
|
+
</dl>
|
|
122
|
+
{r.meta && Object.keys(r.meta).length > 0 && (
|
|
123
|
+
<div className="mt-4">
|
|
124
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50 mb-1">
|
|
125
|
+
Details
|
|
126
|
+
</div>
|
|
127
|
+
<pre className="text-[0.7rem] whitespace-pre-wrap break-all text-ink/70 bg-white border border-ink/10 p-3">
|
|
128
|
+
{JSON.stringify(r.meta, null, 2)}
|
|
129
|
+
</pre>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</td>
|
|
133
|
+
</tr>
|
|
134
|
+
)}
|
|
135
|
+
</Fragment>
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
138
|
+
{filtered.length === 0 && (
|
|
139
|
+
<tr>
|
|
140
|
+
<td colSpan={5} className="p-6 text-center text-ink/50">
|
|
141
|
+
{rows.length === 0 ? "No audit events yet." : "No events match your search."}
|
|
142
|
+
</td>
|
|
143
|
+
</tr>
|
|
144
|
+
)}
|
|
145
|
+
</tbody>
|
|
146
|
+
</table>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function DetailRow({
|
|
153
|
+
label,
|
|
154
|
+
value,
|
|
155
|
+
mono,
|
|
156
|
+
}: {
|
|
157
|
+
label: string;
|
|
158
|
+
value: string;
|
|
159
|
+
mono?: boolean;
|
|
160
|
+
}) {
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex gap-3">
|
|
163
|
+
<dt className="text-ink/50 uppercase tracking-[0.18em] text-[0.6rem] w-28 shrink-0 pt-0.5">
|
|
164
|
+
{label}
|
|
165
|
+
</dt>
|
|
166
|
+
<dd className={mono ? "font-mono text-ink/80 break-all" : "text-ink/80 break-all"}>
|
|
167
|
+
{value}
|
|
168
|
+
</dd>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Fragment, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { emailStatusTone, formatAbsoluteTime, formatRelativeTime } from "../admin/format-log";
|
|
4
|
+
|
|
5
|
+
/** One outgoing email attempt. */
|
|
6
|
+
export type EmailLogRow = {
|
|
7
|
+
id: string;
|
|
8
|
+
created_at: string;
|
|
9
|
+
to_address: string;
|
|
10
|
+
from_address?: string | null;
|
|
11
|
+
reply_to?: string | null;
|
|
12
|
+
subject?: string | null;
|
|
13
|
+
body_preview?: string | null;
|
|
14
|
+
provider?: string | null;
|
|
15
|
+
status: string;
|
|
16
|
+
error?: string | null;
|
|
17
|
+
meta?: Record<string, unknown> | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Host-agnostic email delivery log with status filter, search and detail. */
|
|
21
|
+
export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
|
|
22
|
+
const [search, setSearch] = useState("");
|
|
23
|
+
const [status, setStatus] = useState("all");
|
|
24
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
25
|
+
|
|
26
|
+
const statuses = useMemo(
|
|
27
|
+
() => Array.from(new Set(rows.map((r) => r.status).filter(Boolean))),
|
|
28
|
+
[rows],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const filtered = useMemo(() => {
|
|
32
|
+
const s = search.trim().toLowerCase();
|
|
33
|
+
return rows.filter((r) => {
|
|
34
|
+
if (status !== "all" && r.status !== status) return false;
|
|
35
|
+
if (!s) return true;
|
|
36
|
+
const hay = [r.to_address, r.from_address, r.subject, r.provider, r.error]
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.join(" ")
|
|
39
|
+
.toLowerCase();
|
|
40
|
+
return hay.includes(s);
|
|
41
|
+
});
|
|
42
|
+
}, [rows, search, status]);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="p-8 md:p-12">
|
|
46
|
+
<div className="flex items-start justify-between gap-6 flex-wrap">
|
|
47
|
+
<div>
|
|
48
|
+
<h2 className="font-serif text-xl">Email log</h2>
|
|
49
|
+
<p className="mt-2 text-sm text-ink/60 max-w-2xl">
|
|
50
|
+
Every outgoing email — contact form replies, password resets and system notifications —
|
|
51
|
+
with delivery status and any error returned by the provider.
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="flex items-center gap-2">
|
|
55
|
+
<select
|
|
56
|
+
value={status}
|
|
57
|
+
onChange={(e) => setStatus(e.target.value)}
|
|
58
|
+
className="border border-ink/15 bg-white px-3 py-2 text-sm"
|
|
59
|
+
>
|
|
60
|
+
<option value="all">All statuses</option>
|
|
61
|
+
{statuses.map((s) => (
|
|
62
|
+
<option key={s} value={s}>
|
|
63
|
+
{s}
|
|
64
|
+
</option>
|
|
65
|
+
))}
|
|
66
|
+
</select>
|
|
67
|
+
<input
|
|
68
|
+
value={search}
|
|
69
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
70
|
+
placeholder="Search recipient, subject…"
|
|
71
|
+
className="border border-ink/15 bg-white px-3 py-2 text-sm w-72 focus:outline-none focus:border-sea"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="mt-6 border border-ink/10 bg-white overflow-hidden">
|
|
77
|
+
<table className="w-full text-sm">
|
|
78
|
+
<thead className="bg-ink/5 text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">
|
|
79
|
+
<tr>
|
|
80
|
+
<th className="text-left p-3 w-40">When</th>
|
|
81
|
+
<th className="text-left p-3">Recipient</th>
|
|
82
|
+
<th className="text-left p-3">Subject</th>
|
|
83
|
+
<th className="text-left p-3 w-32">Provider</th>
|
|
84
|
+
<th className="text-left p-3 w-32">Status</th>
|
|
85
|
+
<th className="w-10 p-3" />
|
|
86
|
+
</tr>
|
|
87
|
+
</thead>
|
|
88
|
+
<tbody>
|
|
89
|
+
{filtered.map((r) => {
|
|
90
|
+
const tone = emailStatusTone(r.status);
|
|
91
|
+
const isOpen = expanded === r.id;
|
|
92
|
+
return (
|
|
93
|
+
<Fragment key={r.id}>
|
|
94
|
+
<tr
|
|
95
|
+
className="border-t border-ink/10 align-top hover:bg-ink/[0.02] cursor-pointer"
|
|
96
|
+
onClick={() => setExpanded(isOpen ? null : r.id)}
|
|
97
|
+
>
|
|
98
|
+
<td className="p-3 whitespace-nowrap">
|
|
99
|
+
<div className="text-ink">{formatRelativeTime(r.created_at)}</div>
|
|
100
|
+
<div className="text-[0.65rem] text-ink/50">
|
|
101
|
+
{formatAbsoluteTime(r.created_at)}
|
|
102
|
+
</div>
|
|
103
|
+
</td>
|
|
104
|
+
<td className="p-3">
|
|
105
|
+
<div className="text-ink">{r.to_address}</div>
|
|
106
|
+
{r.from_address && (
|
|
107
|
+
<div className="text-[0.65rem] text-ink/50">from {r.from_address}</div>
|
|
108
|
+
)}
|
|
109
|
+
</td>
|
|
110
|
+
<td className="p-3">
|
|
111
|
+
<div className="text-ink">{r.subject ?? "(no subject)"}</div>
|
|
112
|
+
{r.body_preview && (
|
|
113
|
+
<div className="text-[0.7rem] text-ink/50 mt-1 line-clamp-1">
|
|
114
|
+
{r.body_preview}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</td>
|
|
118
|
+
<td className="p-3 text-ink/70">{r.provider ?? "—"}</td>
|
|
119
|
+
<td className="p-3">
|
|
120
|
+
<span
|
|
121
|
+
className={`inline-block px-2 py-0.5 text-[0.65rem] uppercase tracking-[0.15em] border ${tone.className}`}
|
|
122
|
+
>
|
|
123
|
+
{tone.label}
|
|
124
|
+
</span>
|
|
125
|
+
{r.error && (
|
|
126
|
+
<div className="mt-1 text-[0.65rem] text-red-600 line-clamp-1">
|
|
127
|
+
{r.error}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</td>
|
|
131
|
+
<td className="p-3 text-ink/40 text-xs">{isOpen ? "▾" : "▸"}</td>
|
|
132
|
+
</tr>
|
|
133
|
+
{isOpen && (
|
|
134
|
+
<tr className="bg-ink/[0.02] border-t border-ink/10">
|
|
135
|
+
<td colSpan={6} className="p-4 space-y-3 text-xs">
|
|
136
|
+
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
|
|
137
|
+
<DetailRow label="To" value={r.to_address} />
|
|
138
|
+
<DetailRow label="From" value={r.from_address ?? "—"} />
|
|
139
|
+
<DetailRow label="Reply-to" value={r.reply_to ?? "—"} />
|
|
140
|
+
<DetailRow label="Provider" value={r.provider ?? "—"} />
|
|
141
|
+
<DetailRow label="Status" value={r.status} />
|
|
142
|
+
<DetailRow label="Sent" value={formatAbsoluteTime(r.created_at)} />
|
|
143
|
+
</dl>
|
|
144
|
+
{r.body_preview && (
|
|
145
|
+
<div>
|
|
146
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50 mb-1">
|
|
147
|
+
Body preview
|
|
148
|
+
</div>
|
|
149
|
+
<div className="text-ink/80 bg-white border border-ink/10 p-3 whitespace-pre-wrap">
|
|
150
|
+
{r.body_preview}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
{r.error && (
|
|
155
|
+
<div>
|
|
156
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-red-600 mb-1">
|
|
157
|
+
Error
|
|
158
|
+
</div>
|
|
159
|
+
<div className="text-red-700 bg-red-50 border border-red-200 p-3 whitespace-pre-wrap">
|
|
160
|
+
{r.error}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
{r.meta && Object.keys(r.meta).length > 0 && (
|
|
165
|
+
<div>
|
|
166
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50 mb-1">
|
|
167
|
+
Metadata
|
|
168
|
+
</div>
|
|
169
|
+
<pre className="text-[0.7rem] whitespace-pre-wrap break-all text-ink/70 bg-white border border-ink/10 p-3">
|
|
170
|
+
{JSON.stringify(r.meta, null, 2)}
|
|
171
|
+
</pre>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
</td>
|
|
175
|
+
</tr>
|
|
176
|
+
)}
|
|
177
|
+
</Fragment>
|
|
178
|
+
);
|
|
179
|
+
})}
|
|
180
|
+
{filtered.length === 0 && (
|
|
181
|
+
<tr>
|
|
182
|
+
<td colSpan={6} className="p-6 text-center text-ink/50">
|
|
183
|
+
{rows.length === 0 ? "No email activity yet." : "No emails match your filters."}
|
|
184
|
+
</td>
|
|
185
|
+
</tr>
|
|
186
|
+
)}
|
|
187
|
+
</tbody>
|
|
188
|
+
</table>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function DetailRow({ label, value }: { label: string; value: string }) {
|
|
195
|
+
return (
|
|
196
|
+
<div className="flex gap-3">
|
|
197
|
+
<dt className="text-ink/50 uppercase tracking-[0.18em] text-[0.6rem] w-24 shrink-0 pt-0.5">
|
|
198
|
+
{label}
|
|
199
|
+
</dt>
|
|
200
|
+
<dd className="text-ink/80 break-all">{value}</dd>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type LogsViewProps = {
|
|
2
|
+
/** Path to the audit log screen, e.g. `/kontoret/admin/audit`. */
|
|
3
|
+
auditPath: string;
|
|
4
|
+
/** Path to the email log screen, e.g. `/kontoret/admin/email-logs`. */
|
|
5
|
+
emailLogsPath: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Host-agnostic index over the two log surfaces. */
|
|
9
|
+
export function LogsView({ auditPath, emailLogsPath }: LogsViewProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="p-8 md:p-12 w-full">
|
|
12
|
+
<h2 className="font-serif text-xl">Logs</h2>
|
|
13
|
+
<p className="mt-2 text-sm text-ink/60">Application logs live in two places:</p>
|
|
14
|
+
<ul className="mt-6 space-y-4">
|
|
15
|
+
<li className="border border-ink/10 bg-white p-5">
|
|
16
|
+
<div className="font-serif text-lg">Audit logs</div>
|
|
17
|
+
<p className="mt-1 text-sm text-ink/60">
|
|
18
|
+
Actions performed in the backoffice (user changes, role changes, email settings updates,
|
|
19
|
+
content saves).
|
|
20
|
+
</p>
|
|
21
|
+
<a
|
|
22
|
+
href={auditPath}
|
|
23
|
+
className="mt-3 inline-block text-xs uppercase tracking-[0.22em] text-sea"
|
|
24
|
+
>
|
|
25
|
+
Open audit logs →
|
|
26
|
+
</a>
|
|
27
|
+
</li>
|
|
28
|
+
<li className="border border-ink/10 bg-white p-5">
|
|
29
|
+
<div className="font-serif text-lg">Email logs</div>
|
|
30
|
+
<p className="mt-1 text-sm text-ink/60">
|
|
31
|
+
Every outgoing email attempt (recipient, subject, provider, status).
|
|
32
|
+
</p>
|
|
33
|
+
<a
|
|
34
|
+
href={emailLogsPath}
|
|
35
|
+
className="mt-3 inline-block text-xs uppercase tracking-[0.22em] text-sea"
|
|
36
|
+
>
|
|
37
|
+
Open email logs →
|
|
38
|
+
</a>
|
|
39
|
+
</li>
|
|
40
|
+
</ul>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Default descriptions for the canonical platform roles. */
|
|
2
|
+
export const DEFAULT_ROLE_DESCRIPTIONS: Record<string, string> = {
|
|
3
|
+
superadmin: "Full control. Manage users, roles, email and everything else. Reserved.",
|
|
4
|
+
admin: "Manage content, pages, media, view logs and audit.",
|
|
5
|
+
editor: "Legacy editor role — behaves like admin for CMS. Prefer admin.",
|
|
6
|
+
seo: "Access SEO tools and page metadata.",
|
|
7
|
+
user: "Signed-in but no backoffice access beyond their own account.",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RolesViewProps = {
|
|
11
|
+
allRoles: readonly string[];
|
|
12
|
+
/** Overrides merged over `DEFAULT_ROLE_DESCRIPTIONS`. */
|
|
13
|
+
descriptions?: Record<string, string>;
|
|
14
|
+
/** Path to the users screen, e.g. `/kontoret/admin/users`. */
|
|
15
|
+
usersPath: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Host-agnostic reference list of the roles that exist in the system. */
|
|
19
|
+
export function RolesView({ allRoles, descriptions, usersPath }: RolesViewProps) {
|
|
20
|
+
const text = { ...DEFAULT_ROLE_DESCRIPTIONS, ...(descriptions ?? {}) };
|
|
21
|
+
return (
|
|
22
|
+
<div className="p-8 md:p-12 w-full">
|
|
23
|
+
<h2 className="font-serif text-xl">User roles</h2>
|
|
24
|
+
<p className="mt-2 text-sm text-ink/60">
|
|
25
|
+
These are the roles that exist in the system. Assign them to users on the{" "}
|
|
26
|
+
<a href={usersPath} className="underline underline-offset-2">
|
|
27
|
+
Users
|
|
28
|
+
</a>{" "}
|
|
29
|
+
page.
|
|
30
|
+
</p>
|
|
31
|
+
<div className="mt-8 border border-ink/10 bg-white divide-y divide-ink/10">
|
|
32
|
+
{allRoles.map((r) => (
|
|
33
|
+
<div key={r} className="p-5">
|
|
34
|
+
<div className="flex items-center gap-3">
|
|
35
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] bg-ink text-white px-2 py-1">
|
|
36
|
+
{r}
|
|
37
|
+
</div>
|
|
38
|
+
{r === "superadmin" && (
|
|
39
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-sea">
|
|
40
|
+
Highest privilege
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
<p className="mt-2 text-sm text-ink/70">{text[r] ?? ""}</p>
|
|
45
|
+
</div>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
|
|
4
|
+
/** One account row as shown in the users table. */
|
|
5
|
+
export type PlatformUserRow = {
|
|
6
|
+
id: string;
|
|
7
|
+
email: string;
|
|
8
|
+
roles: string[];
|
|
9
|
+
email_confirmed_at?: string | null;
|
|
10
|
+
last_sign_in_at?: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Host-provided account administration. */
|
|
14
|
+
export type UsersDeps = {
|
|
15
|
+
users: PlatformUserRow[];
|
|
16
|
+
myRoles: string[];
|
|
17
|
+
allRoles: readonly string[];
|
|
18
|
+
createUser: (input: {
|
|
19
|
+
data: { email: string; password?: string; roles: string[] };
|
|
20
|
+
}) => Promise<unknown>;
|
|
21
|
+
deleteUser: (input: { data: { userId: string } }) => Promise<unknown>;
|
|
22
|
+
setUserPassword: (input: { data: { userId: string; password: string } }) => Promise<unknown>;
|
|
23
|
+
sendPasswordResetEmail: (input: { data: { email: string } }) => Promise<unknown>;
|
|
24
|
+
setUserRoles: (input: { data: { userId: string; roles: string[] } }) => Promise<unknown>;
|
|
25
|
+
refresh: () => Promise<unknown> | void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Host-agnostic user administration screen: create accounts, assign roles,
|
|
30
|
+
* set or reset passwords and delete accounts. Only superadmins get write
|
|
31
|
+
* controls; everyone else sees a read-only table.
|
|
32
|
+
*/
|
|
33
|
+
export function UsersView({ deps }: { deps: UsersDeps }) {
|
|
34
|
+
const {
|
|
35
|
+
users,
|
|
36
|
+
myRoles,
|
|
37
|
+
allRoles,
|
|
38
|
+
createUser,
|
|
39
|
+
deleteUser,
|
|
40
|
+
setUserPassword,
|
|
41
|
+
sendPasswordResetEmail,
|
|
42
|
+
setUserRoles,
|
|
43
|
+
refresh,
|
|
44
|
+
} = deps;
|
|
45
|
+
const isSuper = myRoles.includes("superadmin");
|
|
46
|
+
|
|
47
|
+
const [showNew, setShowNew] = useState(false);
|
|
48
|
+
const [newEmail, setNewEmail] = useState("");
|
|
49
|
+
const [newPassword, setNewPassword] = useState("");
|
|
50
|
+
const [newRoles, setNewRoles] = useState<string[]>(["user"]);
|
|
51
|
+
const [busy, setBusy] = useState(false);
|
|
52
|
+
|
|
53
|
+
// `prompt()` is unavailable in sandboxed preview iframes, so the password
|
|
54
|
+
// flow lives in the page itself.
|
|
55
|
+
const [pwTarget, setPwTarget] = useState<{ id: string; email: string } | null>(null);
|
|
56
|
+
const [pwValue, setPwValue] = useState("");
|
|
57
|
+
const [pwConfirm, setPwConfirm] = useState("");
|
|
58
|
+
const [pwBusy, setPwBusy] = useState(false);
|
|
59
|
+
|
|
60
|
+
const onCreate = async () => {
|
|
61
|
+
setBusy(true);
|
|
62
|
+
try {
|
|
63
|
+
await createUser({
|
|
64
|
+
data: { email: newEmail, password: newPassword || undefined, roles: newRoles },
|
|
65
|
+
});
|
|
66
|
+
toast.success("User created");
|
|
67
|
+
setShowNew(false);
|
|
68
|
+
setNewEmail("");
|
|
69
|
+
setNewPassword("");
|
|
70
|
+
setNewRoles(["user"]);
|
|
71
|
+
await refresh();
|
|
72
|
+
} catch (e) {
|
|
73
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
74
|
+
} finally {
|
|
75
|
+
setBusy(false);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const onDelete = async (id: string, email: string) => {
|
|
80
|
+
if (!confirm(`Delete ${email}? This cannot be undone.`)) return;
|
|
81
|
+
try {
|
|
82
|
+
await deleteUser({ data: { userId: id } });
|
|
83
|
+
toast.success("User deleted");
|
|
84
|
+
await refresh();
|
|
85
|
+
} catch (e) {
|
|
86
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const submitPassword = async () => {
|
|
91
|
+
if (!pwTarget) return;
|
|
92
|
+
if (pwValue.length < 8) {
|
|
93
|
+
toast.error("Password must be at least 8 characters");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (pwValue !== pwConfirm) {
|
|
97
|
+
toast.error("Passwords do not match");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setPwBusy(true);
|
|
101
|
+
try {
|
|
102
|
+
await setUserPassword({ data: { userId: pwTarget.id, password: pwValue } });
|
|
103
|
+
toast.success(`Password updated for ${pwTarget.email}`);
|
|
104
|
+
setPwTarget(null);
|
|
105
|
+
setPwValue("");
|
|
106
|
+
setPwConfirm("");
|
|
107
|
+
} catch (e) {
|
|
108
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
109
|
+
} finally {
|
|
110
|
+
setPwBusy(false);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const onSendReset = async (email: string) => {
|
|
115
|
+
try {
|
|
116
|
+
await sendPasswordResetEmail({ data: { email } });
|
|
117
|
+
toast.success("Reset link generated");
|
|
118
|
+
} catch (e) {
|
|
119
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const onToggleRole = async (userId: string, current: string[], role: string, on: boolean) => {
|
|
124
|
+
const next = on ? Array.from(new Set([...current, role])) : current.filter((r) => r !== role);
|
|
125
|
+
try {
|
|
126
|
+
await setUserRoles({ data: { userId, roles: next } });
|
|
127
|
+
toast.success("Roles updated");
|
|
128
|
+
await refresh();
|
|
129
|
+
} catch (e) {
|
|
130
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="p-8 md:p-12">
|
|
136
|
+
<div className="flex items-center justify-between">
|
|
137
|
+
<h2 className="font-serif text-xl">Users</h2>
|
|
138
|
+
{isSuper && (
|
|
139
|
+
<button
|
|
140
|
+
onClick={() => setShowNew((v) => !v)}
|
|
141
|
+
className="border border-ink bg-ink px-4 py-2 text-xs uppercase tracking-[0.22em] text-white hover:bg-sea hover:border-sea"
|
|
142
|
+
>
|
|
143
|
+
{showNew ? "Cancel" : "New user"}
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{showNew && isSuper && (
|
|
149
|
+
<div className="mt-6 border border-ink/10 bg-white p-6 max-w-2xl">
|
|
150
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
151
|
+
<label className="block">
|
|
152
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Email</span>
|
|
153
|
+
<input
|
|
154
|
+
type="email"
|
|
155
|
+
value={newEmail}
|
|
156
|
+
onChange={(e) => setNewEmail(e.target.value)}
|
|
157
|
+
className="mt-1 w-full border border-ink/20 px-3 py-2 text-sm"
|
|
158
|
+
/>
|
|
159
|
+
</label>
|
|
160
|
+
<label className="block">
|
|
161
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
|
|
162
|
+
Password (optional; leave blank to send reset)
|
|
163
|
+
</span>
|
|
164
|
+
<input
|
|
165
|
+
type="text"
|
|
166
|
+
value={newPassword}
|
|
167
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
168
|
+
className="mt-1 w-full border border-ink/20 px-3 py-2 text-sm"
|
|
169
|
+
/>
|
|
170
|
+
</label>
|
|
171
|
+
</div>
|
|
172
|
+
<div className="mt-4">
|
|
173
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Roles</div>
|
|
174
|
+
<div className="mt-2 flex flex-wrap gap-2">
|
|
175
|
+
{allRoles.map((r) => (
|
|
176
|
+
<label
|
|
177
|
+
key={r}
|
|
178
|
+
className="inline-flex items-center gap-2 border border-ink/15 px-3 py-1.5 text-xs"
|
|
179
|
+
>
|
|
180
|
+
<input
|
|
181
|
+
type="checkbox"
|
|
182
|
+
checked={newRoles.includes(r)}
|
|
183
|
+
onChange={(e) =>
|
|
184
|
+
setNewRoles((prev) =>
|
|
185
|
+
e.target.checked ? [...prev, r] : prev.filter((x) => x !== r),
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
/>
|
|
189
|
+
{r}
|
|
190
|
+
</label>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<button
|
|
195
|
+
disabled={busy || !newEmail}
|
|
196
|
+
onClick={() => void onCreate()}
|
|
197
|
+
className="mt-6 border border-ink bg-ink px-4 py-2 text-xs uppercase tracking-[0.22em] text-white hover:bg-sea hover:border-sea disabled:opacity-50"
|
|
198
|
+
>
|
|
199
|
+
{busy ? "Creating…" : "Create user"}
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
<div className="mt-8 border border-ink/10 bg-white overflow-x-auto">
|
|
205
|
+
<table className="w-full text-sm">
|
|
206
|
+
<thead className="bg-ink/5 text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">
|
|
207
|
+
<tr>
|
|
208
|
+
<th className="text-left p-3">Email</th>
|
|
209
|
+
<th className="text-left p-3">Roles</th>
|
|
210
|
+
<th className="text-left p-3">Last sign-in</th>
|
|
211
|
+
<th className="text-right p-3">Actions</th>
|
|
212
|
+
</tr>
|
|
213
|
+
</thead>
|
|
214
|
+
<tbody>
|
|
215
|
+
{users.map((u) => (
|
|
216
|
+
<tr key={u.id} className="border-t border-ink/10">
|
|
217
|
+
<td className="p-3">
|
|
218
|
+
<div className="text-ink">{u.email}</div>
|
|
219
|
+
{!u.email_confirmed_at && (
|
|
220
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-amber-600">
|
|
221
|
+
Unverified
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
</td>
|
|
225
|
+
<td className="p-3">
|
|
226
|
+
<div className="flex flex-wrap gap-1">
|
|
227
|
+
{allRoles.map((r) => {
|
|
228
|
+
const has = u.roles.includes(r);
|
|
229
|
+
return (
|
|
230
|
+
<button
|
|
231
|
+
key={r}
|
|
232
|
+
disabled={!isSuper}
|
|
233
|
+
onClick={() => void onToggleRole(u.id, u.roles, r, !has)}
|
|
234
|
+
className={[
|
|
235
|
+
"px-2 py-1 text-[0.65rem] uppercase tracking-[0.15em] border",
|
|
236
|
+
has
|
|
237
|
+
? "bg-ink text-white border-ink"
|
|
238
|
+
: "border-ink/20 text-ink/50 hover:text-ink",
|
|
239
|
+
!isSuper ? "opacity-50 cursor-not-allowed" : "",
|
|
240
|
+
].join(" ")}
|
|
241
|
+
>
|
|
242
|
+
{r}
|
|
243
|
+
</button>
|
|
244
|
+
);
|
|
245
|
+
})}
|
|
246
|
+
</div>
|
|
247
|
+
</td>
|
|
248
|
+
<td className="p-3 text-ink/60">
|
|
249
|
+
{u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleString() : "—"}
|
|
250
|
+
</td>
|
|
251
|
+
<td className="p-3 text-right space-x-2">
|
|
252
|
+
{isSuper && (
|
|
253
|
+
<>
|
|
254
|
+
<button
|
|
255
|
+
onClick={() => {
|
|
256
|
+
setPwTarget({ id: u.id, email: u.email });
|
|
257
|
+
setPwValue("");
|
|
258
|
+
setPwConfirm("");
|
|
259
|
+
}}
|
|
260
|
+
className="text-xs uppercase tracking-[0.18em] text-ink/70 hover:text-ink"
|
|
261
|
+
>
|
|
262
|
+
Set password
|
|
263
|
+
</button>
|
|
264
|
+
<button
|
|
265
|
+
onClick={() => void onSendReset(u.email)}
|
|
266
|
+
className="text-xs uppercase tracking-[0.18em] text-ink/70 hover:text-ink"
|
|
267
|
+
>
|
|
268
|
+
Send reset
|
|
269
|
+
</button>
|
|
270
|
+
<button
|
|
271
|
+
onClick={() => void onDelete(u.id, u.email)}
|
|
272
|
+
className="text-xs uppercase tracking-[0.18em] text-red-600 hover:text-red-800"
|
|
273
|
+
>
|
|
274
|
+
Delete
|
|
275
|
+
</button>
|
|
276
|
+
</>
|
|
277
|
+
)}
|
|
278
|
+
</td>
|
|
279
|
+
</tr>
|
|
280
|
+
))}
|
|
281
|
+
{users.length === 0 && (
|
|
282
|
+
<tr>
|
|
283
|
+
<td colSpan={4} className="p-6 text-center text-ink/50">
|
|
284
|
+
No users
|
|
285
|
+
</td>
|
|
286
|
+
</tr>
|
|
287
|
+
)}
|
|
288
|
+
</tbody>
|
|
289
|
+
</table>
|
|
290
|
+
</div>
|
|
291
|
+
{!isSuper && (
|
|
292
|
+
<p className="mt-4 text-xs text-ink/55">
|
|
293
|
+
Read-only view. Only superadministrators can create, delete or change roles.
|
|
294
|
+
</p>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{pwTarget && (
|
|
298
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/50 p-4">
|
|
299
|
+
<div className="w-full max-w-md border border-ink/10 bg-white p-6">
|
|
300
|
+
<h3 className="font-serif text-lg">Change password</h3>
|
|
301
|
+
<p className="mt-1 text-xs text-ink/60">{pwTarget.email}</p>
|
|
302
|
+
<label className="mt-5 block">
|
|
303
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
|
|
304
|
+
New password (min 8 characters)
|
|
305
|
+
</span>
|
|
306
|
+
<input
|
|
307
|
+
type="text"
|
|
308
|
+
autoFocus
|
|
309
|
+
value={pwValue}
|
|
310
|
+
onChange={(e) => setPwValue(e.target.value)}
|
|
311
|
+
className="mt-1 w-full border border-ink/20 px-3 py-2 text-sm"
|
|
312
|
+
/>
|
|
313
|
+
</label>
|
|
314
|
+
<label className="mt-4 block">
|
|
315
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
|
|
316
|
+
Repeat password
|
|
317
|
+
</span>
|
|
318
|
+
<input
|
|
319
|
+
type="text"
|
|
320
|
+
value={pwConfirm}
|
|
321
|
+
onChange={(e) => setPwConfirm(e.target.value)}
|
|
322
|
+
onKeyDown={(e) => {
|
|
323
|
+
if (e.key === "Enter") void submitPassword();
|
|
324
|
+
}}
|
|
325
|
+
className="mt-1 w-full border border-ink/20 px-3 py-2 text-sm"
|
|
326
|
+
/>
|
|
327
|
+
</label>
|
|
328
|
+
<div className="mt-6 flex justify-end gap-3">
|
|
329
|
+
<button
|
|
330
|
+
onClick={() => setPwTarget(null)}
|
|
331
|
+
className="border border-ink/20 px-4 py-2 text-xs uppercase tracking-[0.22em] text-ink/70 hover:text-ink"
|
|
332
|
+
>
|
|
333
|
+
Cancel
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
disabled={pwBusy || pwValue.length < 8 || pwValue !== pwConfirm}
|
|
337
|
+
onClick={() => void submitPassword()}
|
|
338
|
+
className="border border-ink bg-ink px-4 py-2 text-xs uppercase tracking-[0.22em] text-white hover:bg-sea hover:border-sea disabled:opacity-50"
|
|
339
|
+
>
|
|
340
|
+
{pwBusy ? "Saving…" : "Save password"}
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
@@ -67,6 +67,9 @@ export const EMPTY_COMPANY: SiteCompany = {
|
|
|
67
67
|
/** Brand design tokens. Values are CSS colors written into the theme contract. */
|
|
68
68
|
export type SiteBrandTokens = Record<string, string>;
|
|
69
69
|
|
|
70
|
+
/** Serializable per-site extras. Kept primitive so the config can cross RPC. */
|
|
71
|
+
export type SiteExtra = Record<string, string | number | boolean | null>;
|
|
72
|
+
|
|
70
73
|
export type SiteConfigInput = {
|
|
71
74
|
/** Machine key, e.g. "voyara". Lowercase, no spaces. */
|
|
72
75
|
key: string;
|
|
@@ -86,7 +89,7 @@ export type SiteConfigInput = {
|
|
|
86
89
|
brand?: SiteBrandTokens;
|
|
87
90
|
features?: Partial<SiteFeatureFlags>;
|
|
88
91
|
/** Optional free-form per-site extras shared code never reads. */
|
|
89
|
-
extra?:
|
|
92
|
+
extra?: SiteExtra;
|
|
90
93
|
};
|
|
91
94
|
|
|
92
95
|
export type SiteConfig = {
|
|
@@ -100,7 +103,7 @@ export type SiteConfig = {
|
|
|
100
103
|
company: SiteCompany;
|
|
101
104
|
brand: SiteBrandTokens;
|
|
102
105
|
features: SiteFeatureFlags;
|
|
103
|
-
extra:
|
|
106
|
+
extra: SiteExtra;
|
|
104
107
|
};
|
|
105
108
|
|
|
106
109
|
function fail(message: string): never {
|