@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 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.23.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?: Record<string, unknown>;
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: Record<string, unknown>;
106
+ extra: SiteExtra;
104
107
  };
105
108
 
106
109
  function fail(message: string): never {