@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.
- package/CHANGELOG.md +17 -0
- package/package.json +13 -3
- package/src/backoffice/AuditView.tsx +171 -0
- package/src/backoffice/ContactDetailsView.tsx +108 -0
- package/src/backoffice/DomainsView.tsx +264 -0
- package/src/backoffice/EmailLogsView.tsx +203 -0
- package/src/backoffice/LogsView.tsx +43 -0
- package/src/backoffice/NavigationView.tsx +133 -0
- package/src/backoffice/RolesView.tsx +50 -0
- package/src/backoffice/TrackingView.tsx +405 -0
- package/src/backoffice/UsersView.tsx +348 -0
- package/src/backoffice/VersionView.tsx +109 -0
|
@@ -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 & 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
|
+
}
|