@bygghammar/cms-platform 0.23.1 → 0.25.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.
@@ -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
+ }
@@ -0,0 +1,109 @@
1
+ import { Package } from "lucide-react";
2
+
3
+ /** Build metadata injected at compile time by the host. */
4
+ export type PlatformBuildInfo = {
5
+ appVersion?: string | null;
6
+ buildTime?: string | null;
7
+ platformVersion?: string | null;
8
+ platformRange?: string | null;
9
+ };
10
+
11
+ /** One published platform release. */
12
+ export type PlatformRelease = {
13
+ version: string;
14
+ publishedAt: string | null;
15
+ current?: boolean;
16
+ };
17
+
18
+ /** Host-provided data for the version screen. */
19
+ export type VersionDeps = {
20
+ build: PlatformBuildInfo | undefined;
21
+ releases: PlatformRelease[];
22
+ loading?: boolean;
23
+ /** Package name shown in the release history copy. */
24
+ packageName?: string;
25
+ };
26
+
27
+ function fmt(value: string | null | undefined) {
28
+ if (!value) return "—";
29
+ const d = new Date(value);
30
+ if (Number.isNaN(d.getTime())) return value;
31
+ return d.toLocaleString("sv-SE", { timeZone: "UTC", hour12: false }) + " UTC";
32
+ }
33
+
34
+ /** Host-agnostic CMS & platform version screen. */
35
+ export function VersionView({ deps }: { deps: VersionDeps }) {
36
+ const { build, releases, loading, packageName = "@bygghammar/cms-platform" } = deps;
37
+
38
+ return (
39
+ <div className="p-8 md:p-12 w-full max-w-4xl">
40
+ <div className="flex items-center gap-3">
41
+ <Package className="size-5 text-ink/70" />
42
+ <h2 className="font-serif text-xl text-ink">CMS &amp; platform version</h2>
43
+ </div>
44
+ <p className="mt-2 text-sm text-ink/60">
45
+ Which build of this site is live and which release of the shared CMS platform package it
46
+ runs on.
47
+ </p>
48
+
49
+ {loading ? (
50
+ <p className="mt-8 text-sm text-ink/50">Loading…</p>
51
+ ) : (
52
+ <>
53
+ <dl className="mt-8 grid gap-4 sm:grid-cols-2">
54
+ {[
55
+ { label: "CMS build", value: build?.appVersion ?? "—" },
56
+ { label: "Built at", value: fmt(build?.buildTime) },
57
+ { label: "Platform package", value: build?.platformVersion ?? "—" },
58
+ { label: "Declared range", value: build?.platformRange ?? "—" },
59
+ ].map((row) => (
60
+ <div key={row.label} className="border border-ink/10 bg-white p-5">
61
+ <dt className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/50">
62
+ {row.label}
63
+ </dt>
64
+ <dd className="mt-2 font-mono text-sm text-ink">{row.value}</dd>
65
+ </div>
66
+ ))}
67
+ </dl>
68
+
69
+ <h3 className="mt-12 font-serif text-lg text-ink">Latest platform releases</h3>
70
+ <p className="mt-1 text-sm text-ink/60">
71
+ The three most recent published versions of {packageName} with their publish dates.
72
+ </p>
73
+ <div className="mt-4 border border-ink/10 bg-white">
74
+ {releases.length === 0 ? (
75
+ <p className="p-5 text-sm text-ink/50">Release history unavailable right now.</p>
76
+ ) : (
77
+ <table className="w-full text-sm">
78
+ <thead>
79
+ <tr className="border-b border-ink/10 text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/50">
80
+ <th className="px-5 py-3">Version</th>
81
+ <th className="px-5 py-3">Published</th>
82
+ <th className="px-5 py-3">Status</th>
83
+ </tr>
84
+ </thead>
85
+ <tbody>
86
+ {releases.map((r) => (
87
+ <tr key={r.version} className="border-b border-ink/5 last:border-0">
88
+ <td className="px-5 py-3 font-mono text-ink">{r.version}</td>
89
+ <td className="px-5 py-3 text-ink/70">{fmt(r.publishedAt)}</td>
90
+ <td className="px-5 py-3">
91
+ {r.current ? (
92
+ <span className="inline-flex items-center rounded-full bg-sea/10 px-3 py-1 text-[0.7rem] uppercase tracking-[0.16em] text-sea">
93
+ In use
94
+ </span>
95
+ ) : (
96
+ <span className="text-ink/40">—</span>
97
+ )}
98
+ </td>
99
+ </tr>
100
+ ))}
101
+ </tbody>
102
+ </table>
103
+ )}
104
+ </div>
105
+ </>
106
+ )}
107
+ </div>
108
+ );
109
+ }