@terpjs/react-core 0.1.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.
Files changed (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
@@ -0,0 +1,446 @@
1
+ import { useNavigate, useParams } from "@tanstack/react-router";
2
+ import { useEffect, useId, useMemo, useState } from "react";
3
+ import type { FormEvent } from "react";
4
+ import type { components } from "@terpjs/contract";
5
+
6
+ import { ConfirmDialog } from "../ConfirmDialog";
7
+ import { DetailPage } from "../DetailPage";
8
+ import { useTerpClient } from "../TerpProvider";
9
+ import { Field } from "../Field";
10
+ import { Icon } from "../icons";
11
+ import { DataView, HttpDataViewRepository } from "../dataview";
12
+ import type { DataViewColumn } from "../dataview";
13
+ import { DetailList, Stack } from "../layout";
14
+ import { PageActions } from "../PageActions";
15
+ import { useResource } from "../useResource";
16
+ import { useToast } from "../toast";
17
+ import { Button } from "../ui/Button";
18
+ import { Input } from "../ui/Input";
19
+ import { useStrings } from "../uiText";
20
+ import { unwrap } from "../unwrap";
21
+
22
+ import { adminCrumb, renderAdminCrumb } from "./crumbs";
23
+
24
+ type GroupRead = components["schemas"]["GroupRead"];
25
+ type GroupMemberRead = components["schemas"]["GroupMemberRead"];
26
+ type GrantRead = components["schemas"]["GrantRead"];
27
+ type UserRead = components["schemas"]["UserRead"];
28
+
29
+ /** How long the member picker waits after a keystroke before searching accounts. */
30
+ const SEARCH_DEBOUNCE_MS = 250;
31
+
32
+ /**
33
+ * The packaged group detail page (`/admin/groups/$groupId`): the group's members
34
+ * (add / remove — idempotent on the backend) and the permissions granted to the
35
+ * group (ordinary access grants whose subject is the group's id, effective for
36
+ * every member through the subject-expansion seam, ADR 0074). Members are picked
37
+ * by email through the directory's server-side `?email=` search — no client-side
38
+ * user cache, so any directory size works — and member rows carry the email the
39
+ * backend resolved for them.
40
+ */
41
+ export function GroupDetail() {
42
+ const params = useParams({ strict: false }) as { groupId?: string };
43
+ const groupId = params.groupId ?? "";
44
+ const client = useTerpClient();
45
+ const navigate = useNavigate();
46
+ const toast = useToast();
47
+ const strings = useStrings();
48
+ const suggestionsId = useId();
49
+
50
+ const [membersVersion, setMembersVersion] = useState(0);
51
+ const [grantsVersion, setGrantsVersion] = useState(0);
52
+ const [memberQuery, setMemberQuery] = useState("");
53
+ const [suggestions, setSuggestions] = useState<UserRead[]>([]);
54
+ const [adding, setAdding] = useState(false);
55
+ const [permission, setPermission] = useState("");
56
+ const [granting, setGranting] = useState(false);
57
+ const [deleteOpen, setDeleteOpen] = useState(false);
58
+ const [deleting, setDeleting] = useState(false);
59
+ const [pendingMember, setPendingMember] = useState<GroupMemberRead | null>(null);
60
+ const [removingMember, setRemovingMember] = useState(false);
61
+ const [pendingGrant, setPendingGrant] = useState<GrantRead | null>(null);
62
+ const [revoking, setRevoking] = useState(false);
63
+
64
+ useEffect(() => {
65
+ setMemberQuery("");
66
+ setSuggestions([]);
67
+ setPermission("");
68
+ setDeleteOpen(false);
69
+ setPendingMember(null);
70
+ setPendingGrant(null);
71
+ setAdding(false);
72
+ setGranting(false);
73
+ setDeleting(false);
74
+ setRemovingMember(false);
75
+ setRevoking(false);
76
+ }, [groupId]);
77
+
78
+ const group = useResource<GroupRead>(
79
+ {
80
+ list: async () => {
81
+ const row = unwrap(
82
+ await client.GET("/api/v1/groups/{group_id}", {
83
+ params: { path: { group_id: groupId } },
84
+ }),
85
+ );
86
+ return [row];
87
+ },
88
+ },
89
+ // Reload when navigating between group detail pages in place.
90
+ [groupId],
91
+ );
92
+
93
+ // Debounced directory lookup feeding the picker's suggestions (best-effort).
94
+ useEffect(() => {
95
+ const needle = memberQuery.trim();
96
+ if (needle === "") {
97
+ setSuggestions([]);
98
+ return;
99
+ }
100
+ const timer = setTimeout(() => {
101
+ void (async () => {
102
+ try {
103
+ const page = unwrap(
104
+ await client.GET("/api/v1/users/", {
105
+ params: { query: { email: needle, limit: 20 } },
106
+ }),
107
+ );
108
+ setSuggestions(page.items);
109
+ } catch {
110
+ // Suggestions are a convenience; submitting still resolves the email.
111
+ }
112
+ })();
113
+ }, SEARCH_DEBOUNCE_MS);
114
+ return () => clearTimeout(timer);
115
+ }, [client, memberQuery]);
116
+
117
+ const memberColumns: DataViewColumn<GroupMemberRead>[] = useMemo(
118
+ () => [
119
+ {
120
+ id: "user",
121
+ header: strings.userField,
122
+ accessor: (m) => m.email ?? m.user_id,
123
+ meta: { mobileSlot: "title" },
124
+ },
125
+ {
126
+ id: "created_at",
127
+ header: strings.createdColumn,
128
+ accessor: (m) => m.created_at,
129
+ cell: (m) => new Date(m.created_at).toLocaleDateString(),
130
+ meta: { mobileSlot: "date", width: 120 },
131
+ },
132
+ ],
133
+ [strings],
134
+ );
135
+
136
+ const grantColumns: DataViewColumn<GrantRead>[] = useMemo(
137
+ () => [
138
+ {
139
+ id: "permission",
140
+ header: strings.permission,
141
+ accessor: (g) => g.permission,
142
+ meta: { mobileSlot: "title" },
143
+ },
144
+ {
145
+ id: "created_at",
146
+ header: strings.createdColumn,
147
+ accessor: (g) => g.created_at,
148
+ cell: (g) => new Date(g.created_at).toLocaleDateString(),
149
+ meta: { mobileSlot: "date", width: 120 },
150
+ },
151
+ ],
152
+ [strings],
153
+ );
154
+
155
+ const membersRepository = useMemo(
156
+ () =>
157
+ new HttpDataViewRepository<GroupMemberRead>({
158
+ getRowId: (m) => m.id,
159
+ search: false,
160
+ request: async ({ skip, limit }, signal) => {
161
+ const page = unwrap(
162
+ await client.GET("/api/v1/groups/{group_id}/members", {
163
+ params: { path: { group_id: groupId }, query: { skip, limit } },
164
+ signal,
165
+ }),
166
+ );
167
+ return { items: page.items, total: page.total };
168
+ },
169
+ }),
170
+ [client, groupId, membersVersion],
171
+ );
172
+
173
+ const grantsRepository = useMemo(
174
+ () =>
175
+ new HttpDataViewRepository<GrantRead>({
176
+ getRowId: (g) => g.id,
177
+ search: false,
178
+ request: async ({ skip, limit }, signal) => {
179
+ const page = unwrap(
180
+ await client.GET("/api/v1/access/grants", {
181
+ params: { query: { subject_id: groupId, skip, limit } },
182
+ signal,
183
+ }),
184
+ );
185
+ return { items: page.items, total: page.total };
186
+ },
187
+ }),
188
+ [client, groupId, grantsVersion],
189
+ );
190
+
191
+ function failed(error: unknown): void {
192
+ toast.warning(error instanceof Error ? error.message : strings.requestFailed);
193
+ }
194
+
195
+ async function onAddMember(event: FormEvent) {
196
+ event.preventDefault();
197
+ const needle = memberQuery.trim();
198
+ if (needle === "") return;
199
+ setAdding(true);
200
+ try {
201
+ // Resolve the typed email to an account: exact match among the current
202
+ // suggestions first, else one direct directory query.
203
+ let match = suggestions.find((user) => user.email === needle);
204
+ if (match === undefined) {
205
+ const page = unwrap(
206
+ await client.GET("/api/v1/users/", {
207
+ params: { query: { email: needle, limit: 20 } },
208
+ }),
209
+ );
210
+ match = page.items.find((user) => user.email === needle);
211
+ }
212
+ if (match === undefined) {
213
+ toast.warning(strings.userNotFound);
214
+ return;
215
+ }
216
+ unwrap(
217
+ await client.POST("/api/v1/groups/{group_id}/members", {
218
+ params: { path: { group_id: groupId } },
219
+ body: { user_id: match.id },
220
+ }),
221
+ );
222
+ toast.success(strings.saved);
223
+ setMemberQuery("");
224
+ setSuggestions([]);
225
+ setMembersVersion((v) => v + 1);
226
+ void group.reload();
227
+ } catch (error) {
228
+ failed(error);
229
+ } finally {
230
+ setAdding(false);
231
+ }
232
+ }
233
+
234
+ async function onConfirmRemoveMember() {
235
+ if (pendingMember === null) return;
236
+ setRemovingMember(true);
237
+ try {
238
+ unwrap(
239
+ await client.DELETE("/api/v1/groups/{group_id}/members/{user_id}", {
240
+ params: { path: { group_id: groupId, user_id: pendingMember.user_id } },
241
+ }),
242
+ );
243
+ toast.success(strings.saved);
244
+ setPendingMember(null);
245
+ setMembersVersion((v) => v + 1);
246
+ void group.reload();
247
+ } catch (error) {
248
+ failed(error);
249
+ } finally {
250
+ setRemovingMember(false);
251
+ }
252
+ }
253
+
254
+ async function onGrant(event: FormEvent) {
255
+ event.preventDefault();
256
+ setGranting(true);
257
+ try {
258
+ unwrap(
259
+ await client.POST("/api/v1/access/grants", {
260
+ body: { subject_id: groupId, permission },
261
+ }),
262
+ );
263
+ toast.success(strings.saved);
264
+ setPermission("");
265
+ setGrantsVersion((v) => v + 1);
266
+ } catch (error) {
267
+ failed(error);
268
+ } finally {
269
+ setGranting(false);
270
+ }
271
+ }
272
+
273
+ async function onConfirmRevoke() {
274
+ if (pendingGrant === null) return;
275
+ setRevoking(true);
276
+ try {
277
+ unwrap(
278
+ await client.DELETE("/api/v1/access/grants/{grant_id}", {
279
+ params: { path: { grant_id: pendingGrant.id } },
280
+ }),
281
+ );
282
+ toast.success(strings.saved);
283
+ setPendingGrant(null);
284
+ setGrantsVersion((v) => v + 1);
285
+ } catch (error) {
286
+ failed(error);
287
+ } finally {
288
+ setRevoking(false);
289
+ }
290
+ }
291
+
292
+ async function onConfirmDelete() {
293
+ setDeleting(true);
294
+ try {
295
+ unwrap(
296
+ await client.DELETE("/api/v1/groups/{group_id}", {
297
+ params: { path: { group_id: groupId } },
298
+ }),
299
+ );
300
+ toast.success(strings.saved);
301
+ await navigate({ to: "/admin/groups" });
302
+ } catch (error) {
303
+ failed(error);
304
+ } finally {
305
+ setDeleting(false);
306
+ }
307
+ }
308
+
309
+ const record = group.items[0];
310
+ return (
311
+ <DetailPage
312
+ title={record?.name ?? strings.adminGroups}
313
+ parents={[
314
+ { ...adminCrumb(strings), to: "/admin" },
315
+ { label: strings.adminGroups, to: "/admin/groups" },
316
+ ]}
317
+ renderLink={renderAdminCrumb}
318
+ isLoading={group.loading}
319
+ error={group.cause ?? group.error ?? undefined}
320
+ actions={record !== undefined ? (
321
+ <PageActions
322
+ overflow={[
323
+ {
324
+ label: strings.deleteGroup,
325
+ icon: <Icon name="trash" />,
326
+ variant: "destructive",
327
+ onSelect: () => setDeleteOpen(true),
328
+ },
329
+ ]}
330
+ />
331
+ ) : undefined}
332
+ >
333
+ <Stack gap={6}>
334
+ {record !== undefined && (
335
+ <DetailList
336
+ items={[
337
+ { label: strings.description, value: record.description || "-" },
338
+ { label: strings.members, value: record.member_count },
339
+ { label: strings.createdColumn, value: new Date(record.created_at).toLocaleString() },
340
+ ]}
341
+ />
342
+ )}
343
+ <Stack gap={3}>
344
+ <h2 style={{ margin: 0, fontSize: "var(--font-size-base)" }}>
345
+ {strings.members}
346
+ </h2>
347
+ <Stack as="form" direction="row" gap={2} align="end" wrap onSubmit={onAddMember}>
348
+ <Field label={strings.userField}>
349
+ <Input
350
+ type="email"
351
+ value={memberQuery}
352
+ list={suggestionsId}
353
+ placeholder={strings.email}
354
+ onChange={(event) => setMemberQuery(event.target.value)}
355
+ required
356
+ />
357
+ </Field>
358
+ <datalist id={suggestionsId}>
359
+ {suggestions.map((user) => (
360
+ <option key={user.id} value={user.email} />
361
+ ))}
362
+ </datalist>
363
+ <Button type="submit" disabled={adding || memberQuery.trim() === ""}>
364
+ {adding ? strings.working : strings.addMember}
365
+ </Button>
366
+ </Stack>
367
+ <DataView<GroupMemberRead>
368
+ variant="embedded"
369
+ repository={membersRepository}
370
+ columns={memberColumns}
371
+ rowActions={(member) => [
372
+ {
373
+ label: strings.removeMember,
374
+ variant: "destructive",
375
+ onClick: () => setPendingMember(member),
376
+ },
377
+ ]}
378
+ />
379
+ </Stack>
380
+ <Stack gap={3}>
381
+ <h2 style={{ margin: 0, fontSize: "var(--font-size-base)" }}>
382
+ {strings.permissions}
383
+ </h2>
384
+ <Stack as="form" direction="row" gap={2} align="end" wrap onSubmit={onGrant}>
385
+ <Field label={strings.permission}>
386
+ <Input
387
+ value={permission}
388
+ onChange={(event) => setPermission(event.target.value)}
389
+ required
390
+ />
391
+ </Field>
392
+ <Button type="submit" disabled={granting}>
393
+ {granting ? strings.working : strings.grantPermission}
394
+ </Button>
395
+ </Stack>
396
+ <DataView<GrantRead>
397
+ variant="embedded"
398
+ repository={grantsRepository}
399
+ columns={grantColumns}
400
+ rowActions={(grant) => [
401
+ {
402
+ label: strings.revoke,
403
+ variant: "destructive",
404
+ onClick: () => setPendingGrant(grant),
405
+ },
406
+ ]}
407
+ />
408
+ </Stack>
409
+ </Stack>
410
+ <ConfirmDialog
411
+ open={deleteOpen}
412
+ onOpenChange={setDeleteOpen}
413
+ onConfirm={() => void onConfirmDelete()}
414
+ title={`${strings.deleteGroup}: ${record?.name ?? ""}`}
415
+ description={strings.deleteGroupConfirm}
416
+ confirmLabel={strings.deleteGroup}
417
+ destructive
418
+ isPending={deleting}
419
+ />
420
+ <ConfirmDialog
421
+ open={pendingMember !== null}
422
+ onOpenChange={(open) => {
423
+ if (!open) setPendingMember(null);
424
+ }}
425
+ onConfirm={() => void onConfirmRemoveMember()}
426
+ title={strings.removeMember}
427
+ description={strings.removeMemberConfirm}
428
+ confirmLabel={strings.removeMember}
429
+ destructive
430
+ isPending={removingMember}
431
+ />
432
+ <ConfirmDialog
433
+ open={pendingGrant !== null}
434
+ onOpenChange={(open) => {
435
+ if (!open) setPendingGrant(null);
436
+ }}
437
+ onConfirm={() => void onConfirmRevoke()}
438
+ title={strings.revoke}
439
+ description={strings.revokeConfirm}
440
+ confirmLabel={strings.revoke}
441
+ destructive
442
+ isPending={revoking}
443
+ />
444
+ </DetailPage>
445
+ );
446
+ }
@@ -0,0 +1,109 @@
1
+ import { useNavigate } from "@tanstack/react-router";
2
+ import { useMemo } from "react";
3
+ import type { components } from "@terpjs/contract";
4
+
5
+ import { Icon } from "../icons";
6
+ import { OverviewPage } from "../OverviewPage";
7
+ import { PageActions } from "../PageActions";
8
+ import { useTerpClient } from "../TerpProvider";
9
+ import { DataView, HttpDataViewRepository, useServerDataView } from "../dataview";
10
+ import type { DataViewColumn } from "../dataview";
11
+ import { Button } from "../ui/Button";
12
+ import { useStrings } from "../uiText";
13
+ import type { TerpStrings } from "../uiText";
14
+ import { unwrap } from "../unwrap";
15
+
16
+ import { adminCrumb, renderAdminCrumb } from "./crumbs";
17
+
18
+ type GroupRead = components["schemas"]["GroupRead"];
19
+
20
+ function buildColumns(strings: TerpStrings): DataViewColumn<GroupRead>[] {
21
+ return [
22
+ { id: "name", header: strings.groupName, accessor: (g) => g.name, meta: { mobileSlot: "title" } },
23
+ {
24
+ id: "description",
25
+ header: strings.description,
26
+ accessor: (g) => g.description,
27
+ meta: { mobileSlot: "subtitle" },
28
+ },
29
+ {
30
+ id: "member_count",
31
+ header: strings.members,
32
+ accessor: (g) => g.member_count,
33
+ meta: { mobileSlot: "status", width: 110 },
34
+ },
35
+ {
36
+ id: "created_at",
37
+ header: strings.createdColumn,
38
+ accessor: (g) => g.created_at,
39
+ cell: (g) => new Date(g.created_at).toLocaleDateString(),
40
+ meta: { mobileSlot: "date", width: 120 },
41
+ },
42
+ ];
43
+ }
44
+
45
+ /**
46
+ * The packaged groups overview (`/admin/groups`): a server-paged table with live
47
+ * member counts. Rows open dedicated details; creation and destructive operations
48
+ * live on routed pages instead of competing with list navigation.
49
+ */
50
+ export function GroupsAdmin() {
51
+ const client = useTerpClient();
52
+ const strings = useStrings();
53
+ const navigate = useNavigate();
54
+ const serverQuery = useServerDataView({ initialPageSize: 10 });
55
+
56
+ const columns = useMemo(() => buildColumns(strings), [strings]);
57
+ const repository = useMemo(
58
+ () =>
59
+ new HttpDataViewRepository<GroupRead>({
60
+ getRowId: (g) => g.id,
61
+ search: false,
62
+ request: async ({ skip, limit }, signal) => {
63
+ const page = unwrap(
64
+ await client.GET("/api/v1/groups/", {
65
+ params: { query: { skip, limit } },
66
+ signal,
67
+ }),
68
+ );
69
+ return { items: page.items, total: page.total };
70
+ },
71
+ }),
72
+ [client],
73
+ );
74
+
75
+ return (
76
+ <OverviewPage
77
+ title={strings.adminGroups}
78
+ parents={[{ ...adminCrumb(strings), to: "/admin" }]}
79
+ renderLink={renderAdminCrumb}
80
+ actions={
81
+ <PageActions
82
+ primary={
83
+ <Button
84
+ icon={<Icon name="plus" />}
85
+ onClick={() => void navigate({ to: "/admin/groups/new" })}
86
+ >
87
+ {strings.createGroup}
88
+ </Button>
89
+ }
90
+ />
91
+ }
92
+ >
93
+ <DataView<GroupRead>
94
+ viewId="terp.admin.groups"
95
+ repository={repository}
96
+ columns={columns}
97
+ serverQuery={serverQuery}
98
+ pageSizeOptions={[10, 25, 50]}
99
+ getRowLabel={(group) => group.name}
100
+ onRowClick={(group) =>
101
+ void navigate({
102
+ to: "/admin/groups/$groupId",
103
+ params: { groupId: group.id },
104
+ })
105
+ }
106
+ />
107
+ </OverviewPage>
108
+ );
109
+ }
@@ -0,0 +1,115 @@
1
+ import { useNavigate } from "@tanstack/react-router";
2
+ import { useState } from "react";
3
+ import type { FormEvent } from "react";
4
+
5
+ import { Field } from "../Field";
6
+ import { Icon } from "../icons";
7
+ import { Stack } from "../layout";
8
+ import { Page } from "../Page";
9
+ import { PageActions } from "../PageActions";
10
+ import { useTerpClient } from "../TerpProvider";
11
+ import { useToast } from "../toast";
12
+ import { Button } from "../ui/Button";
13
+ import { Input } from "../ui/Input";
14
+ import { Select } from "../ui/Select";
15
+ import { useStrings } from "../uiText";
16
+ import { unwrap } from "../unwrap";
17
+
18
+ import { adminCrumb, renderAdminCrumb } from "./crumbs";
19
+ import { adminRoleOptions } from "./roles";
20
+
21
+ const FORM_ID = "terp-admin-user-create";
22
+
23
+ /** Dedicated account-provisioning page (`/admin/users/new`). */
24
+ export function UserCreate() {
25
+ const client = useTerpClient();
26
+ const navigate = useNavigate();
27
+ const strings = useStrings();
28
+ const toast = useToast();
29
+ const [email, setEmail] = useState("");
30
+ const [password, setPassword] = useState("");
31
+ const [role, setRole] = useState("10");
32
+ const [creating, setCreating] = useState(false);
33
+ const roles = adminRoleOptions(strings);
34
+
35
+ async function onSubmit(event: FormEvent) {
36
+ event.preventDefault();
37
+ setCreating(true);
38
+ try {
39
+ const user = unwrap(
40
+ await client.POST("/api/v1/users/", {
41
+ body: { email, password, role: Number(role) },
42
+ }),
43
+ );
44
+ toast.success(strings.saved);
45
+ await navigate({
46
+ to: "/admin/users/$userId",
47
+ params: { userId: user.id },
48
+ });
49
+ } catch (error) {
50
+ toast.warning(error instanceof Error ? error.message : strings.requestFailed);
51
+ } finally {
52
+ setCreating(false);
53
+ }
54
+ }
55
+
56
+ return (
57
+ <Page
58
+ title={strings.provisionUser}
59
+ breadcrumbs={[
60
+ { ...adminCrumb(strings), to: "/admin" },
61
+ { label: strings.adminUsers, to: "/admin/users" },
62
+ ]}
63
+ renderLink={renderAdminCrumb}
64
+ actions={
65
+ <PageActions
66
+ secondary={
67
+ <Button variant="secondary" onClick={() => void navigate({ to: "/admin/users" })}>
68
+ {strings.cancel}
69
+ </Button>
70
+ }
71
+ primary={
72
+ <Button
73
+ type="submit"
74
+ form={FORM_ID}
75
+ icon={<Icon name="plus" />}
76
+ disabled={creating}
77
+ >
78
+ {creating ? strings.working : strings.provisionUser}
79
+ </Button>
80
+ }
81
+ />
82
+ }
83
+ >
84
+ <div style={{ maxWidth: "32rem" }}>
85
+ <Stack id={FORM_ID} as="form" gap={4} onSubmit={onSubmit}>
86
+ <Field label={strings.email}>
87
+ <Input
88
+ type="email"
89
+ value={email}
90
+ onChange={(event) => setEmail(event.target.value)}
91
+ required
92
+ />
93
+ </Field>
94
+ <Field label={strings.password}>
95
+ <Input
96
+ type="password"
97
+ value={password}
98
+ onChange={(event) => setPassword(event.target.value)}
99
+ required
100
+ />
101
+ </Field>
102
+ <Field label={strings.role}>
103
+ <Select value={role} onChange={(event) => setRole(event.target.value)}>
104
+ {roles.map((option) => (
105
+ <option key={option.rank} value={option.rank}>
106
+ {option.label}
107
+ </option>
108
+ ))}
109
+ </Select>
110
+ </Field>
111
+ </Stack>
112
+ </div>
113
+ </Page>
114
+ );
115
+ }