@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,228 @@
1
+ import { useParams } from "@tanstack/react-router";
2
+ import { useEffect, useState } from "react";
3
+ import type { components } from "@terpjs/contract";
4
+
5
+ import { ConfirmDialog } from "../ConfirmDialog";
6
+ import { DetailPage } from "../DetailPage";
7
+ import { Field } from "../Field";
8
+ import { Icon } from "../icons";
9
+ import { DetailList } from "../layout";
10
+ import { PageActions } from "../PageActions";
11
+ import { useTerpClient } from "../TerpProvider";
12
+ import { useResource } from "../useResource";
13
+ import { useToast } from "../toast";
14
+ import { Button } from "../ui/Button";
15
+ import { Input } from "../ui/Input";
16
+ import { useStrings } from "../uiText";
17
+ import { unwrap } from "../unwrap";
18
+
19
+ import { adminCrumb, renderAdminCrumb } from "./crumbs";
20
+ import { adminRoleLabel, adminRoleOptions } from "./roles";
21
+
22
+ type UserRead = components["schemas"]["UserRead"];
23
+
24
+ type PendingLifecycle =
25
+ | { kind: "role"; rank: number }
26
+ | { kind: "active"; active: boolean };
27
+
28
+ /** Dedicated account detail and lifecycle page (`/admin/users/$userId`). */
29
+ export function UserDetail() {
30
+ const params = useParams({ strict: false }) as { userId?: string };
31
+ const userId = params.userId ?? "";
32
+ const client = useTerpClient();
33
+ const strings = useStrings();
34
+ const toast = useToast();
35
+ const [pendingLifecycle, setPendingLifecycle] = useState<PendingLifecycle | null>(null);
36
+ const [mutating, setMutating] = useState(false);
37
+ const [resetOpen, setResetOpen] = useState(false);
38
+ const [resetPassword, setResetPassword] = useState("");
39
+ const [resetting, setResetting] = useState(false);
40
+ const roles = adminRoleOptions(strings);
41
+
42
+ useEffect(() => {
43
+ setPendingLifecycle(null);
44
+ setResetOpen(false);
45
+ setResetPassword("");
46
+ setMutating(false);
47
+ setResetting(false);
48
+ }, [userId]);
49
+
50
+ const user = useResource<UserRead>(
51
+ {
52
+ list: async () => [
53
+ unwrap(
54
+ await client.GET("/api/v1/users/{user_id}", {
55
+ params: { path: { user_id: userId } },
56
+ }),
57
+ ),
58
+ ],
59
+ },
60
+ [userId],
61
+ );
62
+ const record = user.items[0];
63
+
64
+ function failed(error: unknown): void {
65
+ toast.warning(error instanceof Error ? error.message : strings.requestFailed);
66
+ }
67
+
68
+ async function onConfirmLifecycle() {
69
+ if (record === undefined || pendingLifecycle === null) return;
70
+ setMutating(true);
71
+ try {
72
+ if (pendingLifecycle.kind === "role") {
73
+ unwrap(
74
+ await client.PATCH("/api/v1/users/{user_id}", {
75
+ params: { path: { user_id: record.id } },
76
+ body: { role: pendingLifecycle.rank, version: record.version },
77
+ }),
78
+ );
79
+ } else {
80
+ const path = pendingLifecycle.active
81
+ ? "/api/v1/users/{user_id}/reactivate"
82
+ : "/api/v1/users/{user_id}/deactivate";
83
+ unwrap(await client.POST(path, { params: { path: { user_id: record.id } } }));
84
+ }
85
+ toast.success(strings.saved);
86
+ setPendingLifecycle(null);
87
+ await user.reload();
88
+ } catch (error) {
89
+ failed(error);
90
+ } finally {
91
+ setMutating(false);
92
+ }
93
+ }
94
+
95
+ async function onConfirmReset() {
96
+ if (record === undefined || resetPassword.trim() === "") return;
97
+ setResetting(true);
98
+ try {
99
+ unwrap(
100
+ await client.POST("/api/v1/users/{user_id}/reset-password", {
101
+ params: { path: { user_id: record.id } },
102
+ body: { password: resetPassword },
103
+ }),
104
+ );
105
+ toast.success(strings.saved);
106
+ setResetOpen(false);
107
+ setResetPassword("");
108
+ await user.reload();
109
+ } catch (error) {
110
+ failed(error);
111
+ } finally {
112
+ setResetting(false);
113
+ }
114
+ }
115
+
116
+ const pendingRole = pendingLifecycle?.kind === "role"
117
+ ? adminRoleLabel(strings, pendingLifecycle.rank)
118
+ : "";
119
+ const lifecycleDescription = pendingLifecycle?.kind === "role"
120
+ ? strings.changeRoleConfirm.replace("{role}", pendingRole)
121
+ : pendingLifecycle?.active
122
+ ? strings.reactivateUserConfirm
123
+ : strings.deactivateUserConfirm;
124
+
125
+ return (
126
+ <DetailPage
127
+ title={record?.email ?? strings.adminUsers}
128
+ parents={[
129
+ { ...adminCrumb(strings), to: "/admin" },
130
+ { label: strings.adminUsers, to: "/admin/users" },
131
+ ]}
132
+ renderLink={renderAdminCrumb}
133
+ isLoading={user.loading}
134
+ error={user.cause ?? user.error ?? undefined}
135
+ actions={record !== undefined ? (
136
+ <PageActions
137
+ secondary={
138
+ <Button
139
+ variant="secondary"
140
+ icon={<Icon name="key" />}
141
+ onClick={() => setResetOpen(true)}
142
+ >
143
+ {strings.resetPassword}
144
+ </Button>
145
+ }
146
+ overflow={[
147
+ ...roles
148
+ .filter((option) => option.rank !== record.role)
149
+ .map((option) => ({
150
+ label: strings.makeRole.replace("{role}", option.label.toLowerCase()),
151
+ onSelect: () => setPendingLifecycle({ kind: "role", rank: option.rank }),
152
+ })),
153
+ record.is_active
154
+ ? {
155
+ label: strings.deactivate,
156
+ icon: <Icon name="lock" />,
157
+ variant: "destructive" as const,
158
+ onSelect: () => setPendingLifecycle({ kind: "active", active: false }),
159
+ }
160
+ : {
161
+ label: strings.reactivate,
162
+ icon: <Icon name="refresh" />,
163
+ onSelect: () => setPendingLifecycle({ kind: "active", active: true }),
164
+ },
165
+ ]}
166
+ />
167
+ ) : undefined}
168
+ >
169
+ {record !== undefined && (
170
+ <DetailList
171
+ items={[
172
+ { label: strings.email, value: record.email },
173
+ { label: strings.role, value: adminRoleLabel(strings, record.role) },
174
+ {
175
+ label: strings.statusColumn,
176
+ value: record.is_active ? strings.statusActive : strings.statusDeactivated,
177
+ },
178
+ { label: strings.createdColumn, value: new Date(record.created_at).toLocaleString() },
179
+ ]}
180
+ />
181
+ )}
182
+ <ConfirmDialog
183
+ open={pendingLifecycle !== null}
184
+ onOpenChange={(open) => {
185
+ if (!open) setPendingLifecycle(null);
186
+ }}
187
+ onConfirm={() => void onConfirmLifecycle()}
188
+ title={pendingLifecycle?.kind === "role"
189
+ ? strings.makeRole.replace("{role}", pendingRole.toLowerCase())
190
+ : pendingLifecycle?.active
191
+ ? strings.reactivate
192
+ : strings.deactivate}
193
+ description={lifecycleDescription}
194
+ confirmLabel={pendingLifecycle?.kind === "role"
195
+ ? strings.confirm
196
+ : pendingLifecycle?.active
197
+ ? strings.reactivate
198
+ : strings.deactivate}
199
+ destructive={pendingLifecycle?.kind === "active" && !pendingLifecycle.active}
200
+ isPending={mutating}
201
+ />
202
+ <ConfirmDialog
203
+ open={resetOpen}
204
+ onOpenChange={(open) => {
205
+ if (!open) {
206
+ setResetOpen(false);
207
+ setResetPassword("");
208
+ }
209
+ }}
210
+ onConfirm={() => void onConfirmReset()}
211
+ title={`${strings.resetPassword}: ${record?.email ?? ""}`}
212
+ description={
213
+ <Field label={strings.newPassword}>
214
+ <Input
215
+ type="password"
216
+ value={resetPassword}
217
+ onChange={(event) => setResetPassword(event.target.value)}
218
+ required
219
+ />
220
+ </Field>
221
+ }
222
+ confirmLabel={strings.resetPassword}
223
+ confirmDisabled={resetPassword.trim() === ""}
224
+ isPending={resetting}
225
+ />
226
+ </DetailPage>
227
+ );
228
+ }
@@ -0,0 +1,111 @@
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
+ import { adminRoleLabel } from "./roles";
18
+
19
+ type UserRead = components["schemas"]["UserRead"];
20
+
21
+ function buildColumns(strings: TerpStrings): DataViewColumn<UserRead>[] {
22
+ return [
23
+ { id: "email", header: strings.email, accessor: (u) => u.email, meta: { mobileSlot: "title" } },
24
+ {
25
+ id: "role",
26
+ header: strings.role,
27
+ accessor: (u) => u.role,
28
+ cell: (u) => adminRoleLabel(strings, u.role),
29
+ meta: { mobileSlot: "subtitle", width: 100 },
30
+ },
31
+ {
32
+ id: "is_active",
33
+ header: strings.statusColumn,
34
+ accessor: (u) => (u.is_active ? strings.statusActive : strings.statusDeactivated),
35
+ meta: { mobileSlot: "status", width: 110 },
36
+ },
37
+ {
38
+ id: "created_at",
39
+ header: strings.createdColumn,
40
+ accessor: (u) => u.created_at,
41
+ cell: (u) => new Date(u.created_at).toLocaleDateString(),
42
+ meta: { mobileSlot: "date", width: 120 },
43
+ },
44
+ ];
45
+ }
46
+
47
+ /**
48
+ * The packaged users overview (`/admin/users`): a server-paged directory whose rows
49
+ * open dedicated detail pages. Creation and lifecycle mutations live on routed pages,
50
+ * keeping this screen focused on finding and comparing accounts.
51
+ */
52
+ export function UsersAdmin() {
53
+ const client = useTerpClient();
54
+ const strings = useStrings();
55
+ const navigate = useNavigate();
56
+ const serverQuery = useServerDataView({ initialPageSize: 10 });
57
+
58
+ const columns = useMemo(() => buildColumns(strings), [strings]);
59
+ const repository = useMemo(
60
+ () =>
61
+ new HttpDataViewRepository<UserRead>({
62
+ getRowId: (u) => u.id,
63
+ search: false,
64
+ request: async ({ skip, limit }, signal) => {
65
+ const page = unwrap(
66
+ await client.GET("/api/v1/users/", {
67
+ params: { query: { skip, limit } },
68
+ signal,
69
+ }),
70
+ );
71
+ return { items: page.items, total: page.total };
72
+ },
73
+ }),
74
+ [client],
75
+ );
76
+
77
+ return (
78
+ <OverviewPage
79
+ title={strings.adminUsers}
80
+ parents={[{ ...adminCrumb(strings), to: "/admin" }]}
81
+ renderLink={renderAdminCrumb}
82
+ actions={
83
+ <PageActions
84
+ primary={
85
+ <Button
86
+ icon={<Icon name="plus" />}
87
+ onClick={() => void navigate({ to: "/admin/users/new" })}
88
+ >
89
+ {strings.provisionUser}
90
+ </Button>
91
+ }
92
+ />
93
+ }
94
+ >
95
+ <DataView<UserRead>
96
+ viewId="terp.admin.users"
97
+ repository={repository}
98
+ columns={columns}
99
+ serverQuery={serverQuery}
100
+ pageSizeOptions={[10, 25, 50]}
101
+ getRowLabel={(user) => user.email}
102
+ onRowClick={(user) =>
103
+ void navigate({
104
+ to: "/admin/users/$userId",
105
+ params: { userId: user.id },
106
+ })
107
+ }
108
+ />
109
+ </OverviewPage>
110
+ );
111
+ }