@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.
- package/README.md +190 -0
- package/package.json +44 -0
- package/src/AppShell.test.tsx +152 -0
- package/src/AppShell.tsx +554 -0
- package/src/Authorized.test.tsx +60 -0
- package/src/Authorized.tsx +21 -0
- package/src/Breadcrumbs.test.tsx +45 -0
- package/src/Breadcrumbs.tsx +110 -0
- package/src/ConfirmDialog.tsx +170 -0
- package/src/DetailPage.tsx +28 -0
- package/src/EmptyState.tsx +74 -0
- package/src/ErrorState.tsx +108 -0
- package/src/Field.test.tsx +53 -0
- package/src/Field.tsx +51 -0
- package/src/HubPage.test.tsx +108 -0
- package/src/HubPage.tsx +204 -0
- package/src/LoadingState.test.tsx +40 -0
- package/src/LoadingState.tsx +96 -0
- package/src/LoginView.test.tsx +57 -0
- package/src/LoginView.tsx +203 -0
- package/src/ModuleNav.test.tsx +96 -0
- package/src/ModuleNav.tsx +88 -0
- package/src/OverviewPage.tsx +26 -0
- package/src/Page.test.tsx +147 -0
- package/src/Page.tsx +158 -0
- package/src/PageActions.test.tsx +104 -0
- package/src/PageActions.tsx +72 -0
- package/src/ProfileView.test.tsx +112 -0
- package/src/ProfileView.tsx +84 -0
- package/src/RequireAuth.test.tsx +89 -0
- package/src/RequireAuth.tsx +22 -0
- package/src/ResourceList.test.tsx +176 -0
- package/src/ResourceList.tsx +123 -0
- package/src/TerpProvider.tsx +320 -0
- package/src/UserMenu.test.tsx +166 -0
- package/src/UserMenu.tsx +125 -0
- package/src/admin/AdminHub.tsx +108 -0
- package/src/admin/AuditLogAdmin.tsx +116 -0
- package/src/admin/GroupCreate.tsx +90 -0
- package/src/admin/GroupDetail.tsx +446 -0
- package/src/admin/GroupsAdmin.tsx +109 -0
- package/src/admin/UserCreate.tsx +115 -0
- package/src/admin/UserDetail.tsx +228 -0
- package/src/admin/UsersAdmin.tsx +111 -0
- package/src/admin/admin.test.tsx +537 -0
- package/src/admin/crumbs.tsx +14 -0
- package/src/admin/module.tsx +51 -0
- package/src/admin/roles.ts +19 -0
- package/src/bootstrap.test.tsx +67 -0
- package/src/bootstrap.tsx +270 -0
- package/src/capabilities.test.ts +24 -0
- package/src/capabilities.ts +26 -0
- package/src/createAuthClient.test.ts +176 -0
- package/src/createAuthClient.ts +105 -0
- package/src/dataview/DataView.test.tsx +392 -0
- package/src/dataview/DataView.tsx +467 -0
- package/src/dataview/DataViewCardList.tsx +189 -0
- package/src/dataview/DataViewColumnSettings.tsx +118 -0
- package/src/dataview/DataViewExpandableRow.tsx +67 -0
- package/src/dataview/DataViewPagination.tsx +113 -0
- package/src/dataview/DataViewRowActions.tsx +131 -0
- package/src/dataview/DataViewTable.tsx +359 -0
- package/src/dataview/DataViewToolbar.tsx +260 -0
- package/src/dataview/README.md +138 -0
- package/src/dataview/glyphs.tsx +175 -0
- package/src/dataview/hooks/hooks.test.tsx +240 -0
- package/src/dataview/hooks/useDataViewQuery.ts +72 -0
- package/src/dataview/hooks/useDataViewState.ts +310 -0
- package/src/dataview/hooks/useServerDataView.ts +154 -0
- package/src/dataview/hooks/useViewSearch.ts +68 -0
- package/src/dataview/index.ts +62 -0
- package/src/dataview/internal.tsx +96 -0
- package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
- package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
- package/src/dataview/repositories/repositories.test.ts +158 -0
- package/src/dataview/repositories/viewState.test.ts +90 -0
- package/src/dataview/repositories/viewState.ts +128 -0
- package/src/dataview/types.ts +249 -0
- package/src/errorMessages.test.tsx +83 -0
- package/src/errorMessages.tsx +79 -0
- package/src/feedback.test.tsx +167 -0
- package/src/files.test.tsx +142 -0
- package/src/files.tsx +174 -0
- package/src/icons.test.tsx +46 -0
- package/src/icons.tsx +533 -0
- package/src/index.ts +155 -0
- package/src/layout.test.tsx +72 -0
- package/src/layout.tsx +90 -0
- package/src/layoutContract.test.tsx +179 -0
- package/src/layoutContract.ts +137 -0
- package/src/locale.test.tsx +97 -0
- package/src/locale.tsx +246 -0
- package/src/nav.test.ts +21 -0
- package/src/nav.ts +13 -0
- package/src/pageMarker.ts +15 -0
- package/src/raw.d.ts +7 -0
- package/src/realtime-hook.test.tsx +226 -0
- package/src/realtime.test.ts +44 -0
- package/src/realtime.ts +307 -0
- package/src/refresh-session.test.tsx +114 -0
- package/src/revocation.test.tsx +81 -0
- package/src/router.test.tsx +307 -0
- package/src/router.tsx +222 -0
- package/src/sso.test.tsx +128 -0
- package/src/sso.ts +142 -0
- package/src/ssr.test.tsx +45 -0
- package/src/styles.test.ts +21 -0
- package/src/styles.ts +302 -0
- package/src/theme.test.tsx +74 -0
- package/src/theme.tsx +143 -0
- package/src/toast.test.tsx +94 -0
- package/src/toast.tsx +214 -0
- package/src/tokens.guard.test.ts +51 -0
- package/src/ui/Alert.test.tsx +19 -0
- package/src/ui/Alert.tsx +115 -0
- package/src/ui/Badge.test.tsx +14 -0
- package/src/ui/Badge.tsx +48 -0
- package/src/ui/Button.test.tsx +36 -0
- package/src/ui/Button.tsx +95 -0
- package/src/ui/Card.test.tsx +40 -0
- package/src/ui/Card.tsx +92 -0
- package/src/ui/Checkbox.test.tsx +17 -0
- package/src/ui/Checkbox.tsx +51 -0
- package/src/ui/Combobox.test.tsx +58 -0
- package/src/ui/Combobox.tsx +313 -0
- package/src/ui/DatePicker.test.tsx +60 -0
- package/src/ui/DatePicker.tsx +421 -0
- package/src/ui/Input.tsx +30 -0
- package/src/ui/Markdown.test.tsx +32 -0
- package/src/ui/Markdown.tsx +213 -0
- package/src/ui/Menu.test.tsx +85 -0
- package/src/ui/Menu.tsx +216 -0
- package/src/ui/Popover.tsx +218 -0
- package/src/ui/Radio.test.tsx +29 -0
- package/src/ui/Radio.tsx +127 -0
- package/src/ui/Select.tsx +40 -0
- package/src/ui/Switch.test.tsx +17 -0
- package/src/ui/Switch.tsx +53 -0
- package/src/ui/Tabs.test.tsx +29 -0
- package/src/ui/Tabs.tsx +128 -0
- package/src/ui/Textarea.tsx +27 -0
- package/src/ui/Tooltip.test.tsx +28 -0
- package/src/ui/Tooltip.tsx +67 -0
- package/src/ui/controlStyles.ts +9 -0
- package/src/uiText.test.tsx +93 -0
- package/src/uiText.tsx +342 -0
- package/src/unwrap.test.ts +67 -0
- package/src/unwrap.ts +101 -0
- package/src/useResource.test.tsx +118 -0
- package/src/useResource.ts +110 -0
- package/src/useTerpClient.test.ts +35 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +14 -0
- package/vitest.setup.ts +58 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
|
3
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { ModuleManifest } from "@terpjs/contract";
|
|
7
|
+
import type { ComponentType } from "react";
|
|
8
|
+
|
|
9
|
+
import { withAdminArea } from "../bootstrap";
|
|
10
|
+
import type { AdminAreaSections } from "../bootstrap";
|
|
11
|
+
import { buildAppRouter } from "../router";
|
|
12
|
+
import { Page } from "../Page";
|
|
13
|
+
import { TerpProvider, useAuth } from "../TerpProvider";
|
|
14
|
+
import { ToastProvider } from "../toast";
|
|
15
|
+
|
|
16
|
+
function jsonResponse(body: unknown): Response {
|
|
17
|
+
return new Response(JSON.stringify(body), {
|
|
18
|
+
status: 200,
|
|
19
|
+
headers: { "content-type": "application/json" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
cleanup();
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
window.localStorage.clear();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// --- withAdminArea: the injection rules ------------------------------------- //
|
|
30
|
+
|
|
31
|
+
describe("withAdminArea", () => {
|
|
32
|
+
const appManifests = (): ModuleManifest[] => [
|
|
33
|
+
{ name: "notes", routes: [{ path: "/", view: "NotesList" }], nav: [] },
|
|
34
|
+
];
|
|
35
|
+
const appViews = (): Record<string, ComponentType> => ({ NotesList: () => null });
|
|
36
|
+
|
|
37
|
+
it("appends the packaged admin module by default", () => {
|
|
38
|
+
const { manifests, views } = withAdminArea(appManifests(), appViews(), true);
|
|
39
|
+
const admin = manifests.find((manifest) => manifest.name === "terp-admin");
|
|
40
|
+
expect(admin).toBeDefined();
|
|
41
|
+
expect(admin?.routes.map((route) => route.path)).toContain("/admin");
|
|
42
|
+
expect(admin?.routes.map((route) => route.path)).toEqual(expect.arrayContaining([
|
|
43
|
+
"/admin/users/new",
|
|
44
|
+
"/admin/users/$userId",
|
|
45
|
+
"/admin/groups/new",
|
|
46
|
+
"/admin/groups/$groupId",
|
|
47
|
+
]));
|
|
48
|
+
expect(admin?.nav?.[0]?.label).toBe("Admin");
|
|
49
|
+
expect(views.TerpAdminHub).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns the inputs untouched when disabled", () => {
|
|
53
|
+
const manifests = appManifests();
|
|
54
|
+
const views = appViews();
|
|
55
|
+
const result = withAdminArea(manifests, views, false);
|
|
56
|
+
expect(result.manifests).toBe(manifests);
|
|
57
|
+
expect(result.views).toBe(views);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("lets an app route claim a packaged path (that screen is dropped, the rest stay)", () => {
|
|
61
|
+
const manifests = [
|
|
62
|
+
...appManifests(),
|
|
63
|
+
{
|
|
64
|
+
name: "custom",
|
|
65
|
+
routes: [{ path: "/admin/users", view: "MyUsers" }],
|
|
66
|
+
nav: [],
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
const views = { ...appViews(), MyUsers: (() => null) as ComponentType };
|
|
70
|
+
const merged = withAdminArea(manifests, views, true);
|
|
71
|
+
const admin = merged.manifests.find((manifest) => manifest.name === "terp-admin");
|
|
72
|
+
expect(admin?.routes.map((route) => route.path)).not.toContain("/admin/users");
|
|
73
|
+
expect(admin?.routes.map((route) => route.path)).toContain("/admin/groups");
|
|
74
|
+
expect(merged.views.TerpAdminUsers).toBeUndefined();
|
|
75
|
+
expect(merged.views.MyUsers).toBeDefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("drops the Admin nav entry when the app claims the hub itself", () => {
|
|
79
|
+
const manifests = [
|
|
80
|
+
...appManifests(),
|
|
81
|
+
{ name: "custom", routes: [{ path: "/admin", view: "MyHub" }], nav: [] },
|
|
82
|
+
];
|
|
83
|
+
const views = { ...appViews(), MyHub: (() => null) as ComponentType };
|
|
84
|
+
const merged = withAdminArea(manifests, views, true);
|
|
85
|
+
const admin = merged.manifests.find((manifest) => manifest.name === "terp-admin");
|
|
86
|
+
expect(admin?.nav).toEqual([]);
|
|
87
|
+
expect(admin?.routes.map((route) => route.path)).toContain("/admin/users");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("ships a capability-selective area from a sections object", () => {
|
|
91
|
+
const { manifests, views } = withAdminArea(appManifests(), appViews(), {
|
|
92
|
+
groups: false,
|
|
93
|
+
});
|
|
94
|
+
const admin = manifests.find((manifest) => manifest.name === "terp-admin");
|
|
95
|
+
const paths = admin?.routes.map((route) => route.path) ?? [];
|
|
96
|
+
expect(paths).toContain("/admin");
|
|
97
|
+
expect(paths).toContain("/admin/users");
|
|
98
|
+
expect(paths).toContain("/admin/audit");
|
|
99
|
+
expect(paths.some((path) => path.startsWith("/admin/groups"))).toBe(false);
|
|
100
|
+
expect(views.TerpAdminGroups).toBeUndefined();
|
|
101
|
+
expect(views.TerpAdminGroupCreate).toBeUndefined();
|
|
102
|
+
expect(views.TerpAdminHub).toBeDefined();
|
|
103
|
+
// Omitted flags default to true: an empty object is the full area.
|
|
104
|
+
const full = withAdminArea(appManifests(), appViews(), {});
|
|
105
|
+
const fullAdmin = full.manifests.find((manifest) => manifest.name === "terp-admin");
|
|
106
|
+
expect(fullAdmin?.routes).toHaveLength(8);
|
|
107
|
+
expect(full.views.TerpAdminHub).toBe(withAdminArea(appManifests(), appViews(), true).views.TerpAdminHub);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("keeps the Admin nav and hub when only some sections are dropped", () => {
|
|
111
|
+
const { manifests } = withAdminArea(appManifests(), appViews(), {
|
|
112
|
+
users: false,
|
|
113
|
+
groups: false,
|
|
114
|
+
});
|
|
115
|
+
const admin = manifests.find((manifest) => manifest.name === "terp-admin");
|
|
116
|
+
expect(admin?.nav?.[0]?.label).toBe("Admin");
|
|
117
|
+
expect(admin?.routes.map((route) => route.path)).toEqual(["/admin", "/admin/audit"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("refuses a view-id collision that claims no path (a silent drop would dead-link the hub)", () => {
|
|
121
|
+
const manifests = [
|
|
122
|
+
...appManifests(),
|
|
123
|
+
{ name: "custom", routes: [{ path: "/other", view: "TerpAdminUsers" }], nav: [] },
|
|
124
|
+
];
|
|
125
|
+
const views = { ...appViews(), TerpAdminUsers: (() => null) as ComponentType };
|
|
126
|
+
expect(() => withAdminArea(manifests, views, true)).toThrow(/TerpAdminUsers/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// --- the packaged screens, end to end through the router --------------------- //
|
|
131
|
+
|
|
132
|
+
function LogInOnMount() {
|
|
133
|
+
const auth = useAuth();
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
void auth.login({ email: "admin@example.com", password: "pw" });
|
|
136
|
+
}, []);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const emptyPage = { items: [], total: 0, skip: 0, limit: 1 };
|
|
141
|
+
|
|
142
|
+
function stubAdminFetch() {
|
|
143
|
+
const fetchMock = vi.fn<typeof fetch>(async (input) => {
|
|
144
|
+
const request = input as Request;
|
|
145
|
+
const url = new URL(request.url);
|
|
146
|
+
const path = url.pathname;
|
|
147
|
+
if (path.endsWith("/api/v1/auth/login")) {
|
|
148
|
+
return jsonResponse({ access_token: "t", token_type: "bearer" });
|
|
149
|
+
}
|
|
150
|
+
if (path.endsWith("/api/v1/me/")) {
|
|
151
|
+
return jsonResponse({
|
|
152
|
+
id: "a1",
|
|
153
|
+
email: "admin@example.com",
|
|
154
|
+
role_rank: 30,
|
|
155
|
+
role_name: "admin",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (path.endsWith("/api/v1/users/u1")) {
|
|
159
|
+
if (request.method === "PATCH") {
|
|
160
|
+
return jsonResponse({
|
|
161
|
+
id: "u1",
|
|
162
|
+
email: "jane.doe@example.com",
|
|
163
|
+
role: 10,
|
|
164
|
+
is_active: true,
|
|
165
|
+
created_at: "2026-07-01T10:00:00Z",
|
|
166
|
+
updated_at: "2026-07-02T10:00:00Z",
|
|
167
|
+
version: 2,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return jsonResponse({
|
|
171
|
+
id: "u1",
|
|
172
|
+
email: "jane.doe@example.com",
|
|
173
|
+
role: 20,
|
|
174
|
+
is_active: true,
|
|
175
|
+
created_at: "2026-07-01T10:00:00Z",
|
|
176
|
+
updated_at: "2026-07-01T10:00:00Z",
|
|
177
|
+
version: 1,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (path.endsWith("/api/v1/users/u2")) {
|
|
181
|
+
return jsonResponse({
|
|
182
|
+
id: "u2",
|
|
183
|
+
email: "new.account@example.com",
|
|
184
|
+
role: 10,
|
|
185
|
+
is_active: true,
|
|
186
|
+
created_at: "2026-07-02T10:00:00Z",
|
|
187
|
+
updated_at: "2026-07-02T10:00:00Z",
|
|
188
|
+
version: 1,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (path.endsWith("/api/v1/users/")) {
|
|
192
|
+
if (request.method === "POST") {
|
|
193
|
+
return jsonResponse({
|
|
194
|
+
id: "u2",
|
|
195
|
+
email: "new.account@example.com",
|
|
196
|
+
role: 10,
|
|
197
|
+
is_active: true,
|
|
198
|
+
created_at: "2026-07-02T10:00:00Z",
|
|
199
|
+
updated_at: "2026-07-02T10:00:00Z",
|
|
200
|
+
version: 1,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// The directory search behind the member picker filters by email substring.
|
|
204
|
+
if (url.searchParams.get("email") === "new.user") {
|
|
205
|
+
return jsonResponse({
|
|
206
|
+
items: [
|
|
207
|
+
{
|
|
208
|
+
id: "u9",
|
|
209
|
+
email: "new.user@example.com",
|
|
210
|
+
role: 10,
|
|
211
|
+
is_active: true,
|
|
212
|
+
created_at: "2026-07-01T10:00:00Z",
|
|
213
|
+
updated_at: "2026-07-01T10:00:00Z",
|
|
214
|
+
version: 1,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
total: 1,
|
|
218
|
+
skip: 0,
|
|
219
|
+
limit: 20,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return jsonResponse({
|
|
223
|
+
items: [
|
|
224
|
+
{
|
|
225
|
+
id: "u1",
|
|
226
|
+
email: "jane.doe@example.com",
|
|
227
|
+
role: 20,
|
|
228
|
+
is_active: true,
|
|
229
|
+
created_at: "2026-07-01T10:00:00Z",
|
|
230
|
+
updated_at: "2026-07-01T10:00:00Z",
|
|
231
|
+
version: 1,
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
total: 7,
|
|
235
|
+
skip: 0,
|
|
236
|
+
limit: 10,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (path.endsWith("/members") && request.method === "POST") {
|
|
240
|
+
return jsonResponse({
|
|
241
|
+
id: "m2",
|
|
242
|
+
group_id: "g1",
|
|
243
|
+
user_id: "u9",
|
|
244
|
+
email: "new.user@example.com",
|
|
245
|
+
created_at: "2026-07-02T10:00:00Z",
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (path.endsWith("/members")) {
|
|
249
|
+
return jsonResponse({
|
|
250
|
+
items: [
|
|
251
|
+
{
|
|
252
|
+
id: "m1",
|
|
253
|
+
group_id: "g1",
|
|
254
|
+
user_id: "u1",
|
|
255
|
+
email: "jane.doe@example.com",
|
|
256
|
+
created_at: "2026-07-01T10:00:00Z",
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
total: 1,
|
|
260
|
+
skip: 0,
|
|
261
|
+
limit: 200,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (path.endsWith("/api/v1/groups/g1")) {
|
|
265
|
+
return jsonResponse({
|
|
266
|
+
id: "g1",
|
|
267
|
+
name: "Finance",
|
|
268
|
+
description: "money",
|
|
269
|
+
member_count: 1,
|
|
270
|
+
version: 1,
|
|
271
|
+
created_at: "2026-07-01T10:00:00Z",
|
|
272
|
+
updated_at: "2026-07-01T10:00:00Z",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (path.endsWith("/api/v1/groups/g2")) {
|
|
276
|
+
return jsonResponse({
|
|
277
|
+
id: "g2",
|
|
278
|
+
name: "Operations",
|
|
279
|
+
description: "Daily operations",
|
|
280
|
+
member_count: 0,
|
|
281
|
+
version: 1,
|
|
282
|
+
created_at: "2026-07-02T10:00:00Z",
|
|
283
|
+
updated_at: "2026-07-02T10:00:00Z",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
if (path.endsWith("/api/v1/groups/")) {
|
|
287
|
+
if (request.method === "POST") {
|
|
288
|
+
return jsonResponse({
|
|
289
|
+
id: "g2",
|
|
290
|
+
name: "Operations",
|
|
291
|
+
description: "Daily operations",
|
|
292
|
+
member_count: 0,
|
|
293
|
+
version: 1,
|
|
294
|
+
created_at: "2026-07-02T10:00:00Z",
|
|
295
|
+
updated_at: "2026-07-02T10:00:00Z",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return jsonResponse({
|
|
299
|
+
items: [
|
|
300
|
+
{
|
|
301
|
+
id: "g1",
|
|
302
|
+
name: "Finance",
|
|
303
|
+
description: "money",
|
|
304
|
+
member_count: 1,
|
|
305
|
+
version: 1,
|
|
306
|
+
created_at: "2026-07-01T10:00:00Z",
|
|
307
|
+
updated_at: "2026-07-01T10:00:00Z",
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
total: 3,
|
|
311
|
+
skip: 0,
|
|
312
|
+
limit: 10,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (path.endsWith("/api/v1/audit/")) {
|
|
316
|
+
return jsonResponse(emptyPage);
|
|
317
|
+
}
|
|
318
|
+
return jsonResponse(emptyPage);
|
|
319
|
+
});
|
|
320
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
321
|
+
return fetchMock;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function renderAdminApp(
|
|
325
|
+
initialPath: string,
|
|
326
|
+
roleRank = 30,
|
|
327
|
+
adminArea: boolean | AdminAreaSections = true,
|
|
328
|
+
) {
|
|
329
|
+
const manifests: ModuleManifest[] = [
|
|
330
|
+
{ name: "notes", routes: [{ path: "/", view: "NotesList" }], nav: [] },
|
|
331
|
+
];
|
|
332
|
+
const views: Record<string, ComponentType> = {
|
|
333
|
+
NotesList: () => <Page title="Notes">notes</Page>,
|
|
334
|
+
};
|
|
335
|
+
const merged = withAdminArea(manifests, views, adminArea);
|
|
336
|
+
const router = buildAppRouter(merged.manifests, {
|
|
337
|
+
views: merged.views,
|
|
338
|
+
title: "Terp",
|
|
339
|
+
history: createMemoryHistory({ initialEntries: [initialPath] }),
|
|
340
|
+
});
|
|
341
|
+
const fetchMock = stubAdminFetch();
|
|
342
|
+
if (roleRank !== 30) {
|
|
343
|
+
fetchMock.mockImplementation(async (input) => {
|
|
344
|
+
const url = (input as Request).url;
|
|
345
|
+
if (url.endsWith("/api/v1/auth/login")) {
|
|
346
|
+
return jsonResponse({ access_token: "t", token_type: "bearer" });
|
|
347
|
+
}
|
|
348
|
+
return jsonResponse({
|
|
349
|
+
id: "v1",
|
|
350
|
+
email: "viewer@example.com",
|
|
351
|
+
role_rank: roleRank,
|
|
352
|
+
role_name: "viewer",
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
render(
|
|
357
|
+
<TerpProvider baseUrl="https://api.test">
|
|
358
|
+
<ToastProvider>
|
|
359
|
+
<LogInOnMount />
|
|
360
|
+
<RouterProvider router={router} />
|
|
361
|
+
</ToastProvider>
|
|
362
|
+
</TerpProvider>,
|
|
363
|
+
);
|
|
364
|
+
return { fetchMock, router };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
describe("the packaged admin area", () => {
|
|
368
|
+
it("serves the hub at /admin with cards into users, groups and audit", async () => {
|
|
369
|
+
renderAdminApp("/admin");
|
|
370
|
+
await waitFor(() =>
|
|
371
|
+
expect(screen.getByRole("heading", { level: 1, name: "Admin" })).toBeInTheDocument(),
|
|
372
|
+
);
|
|
373
|
+
expect(screen.getByRole("link", { name: /Users/ })).toBeInTheDocument();
|
|
374
|
+
expect(screen.getByRole("link", { name: /Groups/ })).toBeInTheDocument();
|
|
375
|
+
expect(screen.getByRole("link", { name: /Audit log/ })).toBeInTheDocument();
|
|
376
|
+
// Live totals from the limit=1 probes reach the cards.
|
|
377
|
+
await waitFor(() => expect(screen.getByText("7")).toBeInTheDocument());
|
|
378
|
+
expect(screen.getByText("3")).toBeInTheDocument();
|
|
379
|
+
// The sidebar carries the single admin-gated entry.
|
|
380
|
+
expect(screen.getByRole("link", { name: "Admin" })).toBeInTheDocument();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("serves a capability-selective hub: dropped sections lose card, route and stat call", async () => {
|
|
384
|
+
const { fetchMock } = renderAdminApp("/admin", 30, { groups: false });
|
|
385
|
+
await waitFor(() =>
|
|
386
|
+
expect(screen.getByRole("heading", { level: 1, name: "Admin" })).toBeInTheDocument(),
|
|
387
|
+
);
|
|
388
|
+
expect(screen.getByRole("link", { name: /Users/ })).toBeInTheDocument();
|
|
389
|
+
expect(screen.getByRole("link", { name: /Audit log/ })).toBeInTheDocument();
|
|
390
|
+
expect(screen.queryByRole("link", { name: /Groups/ })).not.toBeInTheDocument();
|
|
391
|
+
// The users stat still arrives; the groups endpoint is never probed (its
|
|
392
|
+
// capability may not be mounted at all).
|
|
393
|
+
await waitFor(() => expect(screen.getByText("7")).toBeInTheDocument());
|
|
394
|
+
const probed = fetchMock.mock.calls.map((call) => (call[0] as Request).url);
|
|
395
|
+
expect(probed.some((url) => url.includes("/api/v1/groups/"))).toBe(false);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("uses the users overview action and clickable rows for dedicated pages", async () => {
|
|
399
|
+
renderAdminApp("/admin/users");
|
|
400
|
+
await waitFor(() =>
|
|
401
|
+
expect(screen.getByRole("heading", { level: 1, name: "Users" })).toBeInTheDocument(),
|
|
402
|
+
);
|
|
403
|
+
expect(screen.getByRole("button", { name: "Provision user" })).toBeInTheDocument();
|
|
404
|
+
await waitFor(() => expect(screen.getByText("jane.doe@example.com")).toBeInTheDocument());
|
|
405
|
+
expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toHaveTextContent("Admin");
|
|
406
|
+
|
|
407
|
+
fireEvent.click(screen.getByText("jane.doe@example.com"));
|
|
408
|
+
await waitFor(() =>
|
|
409
|
+
expect(screen.getByRole("heading", { level: 1, name: "jane.doe@example.com" })).toBeInTheDocument(),
|
|
410
|
+
);
|
|
411
|
+
expect(screen.getByRole("button", { name: "Reset password" })).toBeInTheDocument();
|
|
412
|
+
expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toHaveTextContent("Users");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("provisions a user on a dedicated create page and redirects to its detail", async () => {
|
|
416
|
+
const { fetchMock } = renderAdminApp("/admin/users");
|
|
417
|
+
await screen.findByRole("heading", { level: 1, name: "Users" });
|
|
418
|
+
fireEvent.click(screen.getByRole("button", { name: "Provision user" }));
|
|
419
|
+
await screen.findByRole("heading", { level: 1, name: "Provision user" });
|
|
420
|
+
|
|
421
|
+
fireEvent.change(screen.getByLabelText("Email"), {
|
|
422
|
+
target: { value: "new.account@example.com" },
|
|
423
|
+
});
|
|
424
|
+
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "strong-password" } });
|
|
425
|
+
fireEvent.click(screen.getByRole("button", { name: "Provision user" }));
|
|
426
|
+
|
|
427
|
+
await screen.findByRole("heading", { level: 1, name: "new.account@example.com" });
|
|
428
|
+
expect(fetchMock.mock.calls.some(([input]) => {
|
|
429
|
+
const request = input as Request;
|
|
430
|
+
return request.method === "POST" && request.url.endsWith("/api/v1/users/");
|
|
431
|
+
})).toBe(true);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("confirms lifecycle mutations from the user detail action slot", async () => {
|
|
435
|
+
const { fetchMock } = renderAdminApp("/admin/users/u1");
|
|
436
|
+
await screen.findByRole("heading", { level: 1, name: "jane.doe@example.com" });
|
|
437
|
+
fireEvent.click(screen.getByRole("button", { name: "More actions" }));
|
|
438
|
+
fireEvent.click(screen.getByRole("menuitem", { name: "Make viewer" }));
|
|
439
|
+
expect(screen.getByRole("dialog", { name: "Make viewer" })).toBeInTheDocument();
|
|
440
|
+
expect(fetchMock.mock.calls.some(([input]) => (input as Request).method === "PATCH")).toBe(false);
|
|
441
|
+
|
|
442
|
+
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
|
443
|
+
await waitFor(() =>
|
|
444
|
+
expect(fetchMock.mock.calls.some(([input]) => (input as Request).method === "PATCH")).toBe(true),
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("clears user mutation state when navigating between detail records in place", async () => {
|
|
449
|
+
const { router } = renderAdminApp("/admin/users/u1");
|
|
450
|
+
await screen.findByRole("heading", { level: 1, name: "jane.doe@example.com" });
|
|
451
|
+
fireEvent.click(screen.getByRole("button", { name: "Reset password" }));
|
|
452
|
+
fireEvent.change(screen.getByLabelText("New password"), {
|
|
453
|
+
target: { value: "must-not-cross-records" },
|
|
454
|
+
});
|
|
455
|
+
expect(screen.getByRole("dialog", { name: /Reset password/ })).toBeInTheDocument();
|
|
456
|
+
|
|
457
|
+
await act(async () => {
|
|
458
|
+
await router.navigate({
|
|
459
|
+
to: "/admin/users/$userId",
|
|
460
|
+
params: { userId: "u2" },
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
await screen.findByRole("heading", { level: 1, name: "new.account@example.com" });
|
|
464
|
+
expect(screen.queryByRole("dialog", { name: /Reset password/ })).not.toBeInTheDocument();
|
|
465
|
+
expect(screen.queryByDisplayValue("must-not-cross-records")).not.toBeInTheDocument();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("uses the groups overview action and rows for create/detail navigation", async () => {
|
|
469
|
+
renderAdminApp("/admin/groups");
|
|
470
|
+
await screen.findByRole("heading", { level: 1, name: "Groups" });
|
|
471
|
+
await screen.findByText("Finance");
|
|
472
|
+
|
|
473
|
+
fireEvent.click(screen.getByRole("button", { name: "Create group" }));
|
|
474
|
+
await screen.findByRole("heading", { level: 1, name: "Create group" });
|
|
475
|
+
expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toHaveTextContent("Groups");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("serves the group detail: API-resolved member emails and a searched member picker", async () => {
|
|
479
|
+
const { fetchMock } = renderAdminApp("/admin/groups/g1");
|
|
480
|
+
await waitFor(() =>
|
|
481
|
+
expect(screen.getByRole("heading", { level: 1, name: "Finance" })).toBeInTheDocument(),
|
|
482
|
+
);
|
|
483
|
+
// The member row shows the email the backend resolved (no client-side directory).
|
|
484
|
+
await waitFor(() => expect(screen.getByText("jane.doe@example.com")).toBeInTheDocument());
|
|
485
|
+
|
|
486
|
+
// Typing searches the directory server-side (debounced) and suggests matches…
|
|
487
|
+
fireEvent.change(screen.getByPlaceholderText("Email"), {
|
|
488
|
+
target: { value: "new.user" },
|
|
489
|
+
});
|
|
490
|
+
await waitFor(() =>
|
|
491
|
+
expect(
|
|
492
|
+
fetchMock.mock.calls.some(([input]) =>
|
|
493
|
+
(input as Request).url.includes("email=new.user"),
|
|
494
|
+
),
|
|
495
|
+
).toBe(true),
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
// …and submitting the full address resolves it to the account id for the POST.
|
|
499
|
+
fireEvent.change(screen.getByPlaceholderText("Email"), {
|
|
500
|
+
target: { value: "new.user@example.com" },
|
|
501
|
+
});
|
|
502
|
+
fireEvent.submit(screen.getByRole("button", { name: "Add member" }).closest("form")!);
|
|
503
|
+
await waitFor(() =>
|
|
504
|
+
expect(
|
|
505
|
+
fetchMock.mock.calls.some(([input]) => {
|
|
506
|
+
const request = input as Request;
|
|
507
|
+
return request.method === "POST" && request.url.endsWith("/groups/g1/members");
|
|
508
|
+
}),
|
|
509
|
+
).toBe(true),
|
|
510
|
+
);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("clears group destructive state when navigating between detail records in place", async () => {
|
|
514
|
+
const { router } = renderAdminApp("/admin/groups/g1");
|
|
515
|
+
await screen.findByRole("heading", { level: 1, name: "Finance" });
|
|
516
|
+
fireEvent.click(screen.getAllByRole("button", { name: "More actions" })[0]!);
|
|
517
|
+
fireEvent.click(screen.getByRole("menuitem", { name: "Delete group" }));
|
|
518
|
+
expect(screen.getByRole("dialog", { name: /Delete group/ })).toBeInTheDocument();
|
|
519
|
+
|
|
520
|
+
await act(async () => {
|
|
521
|
+
await router.navigate({
|
|
522
|
+
to: "/admin/groups/$groupId",
|
|
523
|
+
params: { groupId: "g2" },
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
await screen.findByRole("heading", { level: 1, name: "Operations" });
|
|
527
|
+
expect(screen.queryByRole("dialog", { name: /Delete group/ })).not.toBeInTheDocument();
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("denies the area to a non-admin (nav hidden, route refused)", async () => {
|
|
531
|
+
renderAdminApp("/admin", 10);
|
|
532
|
+
await waitFor(() =>
|
|
533
|
+
expect(screen.getByText("You do not have access to this page.")).toBeInTheDocument(),
|
|
534
|
+
);
|
|
535
|
+
expect(screen.queryByRole("link", { name: "Admin" })).not.toBeInTheDocument();
|
|
536
|
+
});
|
|
537
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
|
|
3
|
+
import type { BreadcrumbItem, RenderBreadcrumbLink } from "../Breadcrumbs";
|
|
4
|
+
import type { TerpStrings } from "../uiText";
|
|
5
|
+
|
|
6
|
+
/** The admin hub's crumb — every admin overview breadcrumbs back to `/admin`. */
|
|
7
|
+
export function adminCrumb(strings: TerpStrings): BreadcrumbItem {
|
|
8
|
+
return { label: strings.admin, to: "/admin" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Ancestor crumbs link through the TanStack router (no raw same-app anchors). */
|
|
12
|
+
export const renderAdminCrumb: RenderBreadcrumbLink = (item) => (
|
|
13
|
+
<Link to={item.to}>{item.label}</Link>
|
|
14
|
+
);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { defineModuleManifest } from "@terpjs/contract";
|
|
2
|
+
|
|
3
|
+
import type { TerpModule } from "../bootstrap";
|
|
4
|
+
|
|
5
|
+
import { AdminHub } from "./AdminHub";
|
|
6
|
+
import { AuditLogAdmin } from "./AuditLogAdmin";
|
|
7
|
+
import { GroupDetail } from "./GroupDetail";
|
|
8
|
+
import { GroupCreate } from "./GroupCreate";
|
|
9
|
+
import { GroupsAdmin } from "./GroupsAdmin";
|
|
10
|
+
import { UserCreate } from "./UserCreate";
|
|
11
|
+
import { UserDetail } from "./UserDetail";
|
|
12
|
+
import { UsersAdmin } from "./UsersAdmin";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The packaged administration area every Terp app ships: one admin-gated "Admin"
|
|
16
|
+
* sidebar entry opening a hub (`/admin`) whose cards lead to the users, groups and
|
|
17
|
+
* audit-log overviews — the UI over the base-profile capabilities the backend
|
|
18
|
+
* mounts in every app (users, groups + access, audit; ADR 0074). The whole area is
|
|
19
|
+
* `role: "admin"` end to end; the backend re-checks every call regardless.
|
|
20
|
+
*
|
|
21
|
+
* `renderTerpApp` injects it by default (`adminArea: false` opts out; an app
|
|
22
|
+
* manifest claiming one of its paths overrides that screen). An L2 composition
|
|
23
|
+
* (`buildAppRouter`) spreads it in explicitly: append `adminModule.manifest` to the
|
|
24
|
+
* manifests and merge `adminModule.views` into the views map.
|
|
25
|
+
*/
|
|
26
|
+
export const adminModule: TerpModule = {
|
|
27
|
+
manifest: defineModuleManifest({
|
|
28
|
+
name: "terp-admin",
|
|
29
|
+
routes: [
|
|
30
|
+
{ path: "/admin", view: "TerpAdminHub", role: "admin" },
|
|
31
|
+
{ path: "/admin/users", view: "TerpAdminUsers", role: "admin" },
|
|
32
|
+
{ path: "/admin/users/new", view: "TerpAdminUserCreate", role: "admin" },
|
|
33
|
+
{ path: "/admin/users/$userId", view: "TerpAdminUserDetail", role: "admin" },
|
|
34
|
+
{ path: "/admin/groups", view: "TerpAdminGroups", role: "admin" },
|
|
35
|
+
{ path: "/admin/groups/new", view: "TerpAdminGroupCreate", role: "admin" },
|
|
36
|
+
{ path: "/admin/groups/$groupId", view: "TerpAdminGroupDetail", role: "admin" },
|
|
37
|
+
{ path: "/admin/audit", view: "TerpAdminAudit", role: "admin" },
|
|
38
|
+
],
|
|
39
|
+
nav: [{ label: "Admin", to: "/admin", icon: "shield", role: "admin" }],
|
|
40
|
+
}),
|
|
41
|
+
views: {
|
|
42
|
+
TerpAdminHub: AdminHub,
|
|
43
|
+
TerpAdminUsers: UsersAdmin,
|
|
44
|
+
TerpAdminUserCreate: UserCreate,
|
|
45
|
+
TerpAdminUserDetail: UserDetail,
|
|
46
|
+
TerpAdminGroups: GroupsAdmin,
|
|
47
|
+
TerpAdminGroupCreate: GroupCreate,
|
|
48
|
+
TerpAdminGroupDetail: GroupDetail,
|
|
49
|
+
TerpAdminAudit: AuditLogAdmin,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TerpStrings } from "../uiText";
|
|
2
|
+
|
|
3
|
+
export interface AdminRoleOption {
|
|
4
|
+
rank: number;
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** The packaged role ladder, localized through the active framework catalog. */
|
|
9
|
+
export function adminRoleOptions(strings: TerpStrings): AdminRoleOption[] {
|
|
10
|
+
return [
|
|
11
|
+
{ rank: 10, label: strings.roleViewer },
|
|
12
|
+
{ rank: 20, label: strings.roleEditor },
|
|
13
|
+
{ rank: 30, label: strings.roleAdmin },
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function adminRoleLabel(strings: TerpStrings, rank: number): string {
|
|
18
|
+
return adminRoleOptions(strings).find((option) => option.rank === rank)?.label ?? `rank ${rank}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { defineModuleManifest } from "@terpjs/contract";
|
|
5
|
+
|
|
6
|
+
import { collectModules, renderTerpApp } from "./bootstrap";
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
cleanup();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const notesModule = {
|
|
13
|
+
manifest: defineModuleManifest({
|
|
14
|
+
name: "notes",
|
|
15
|
+
routes: [{ path: "/", view: "NotesList" }],
|
|
16
|
+
nav: [{ label: "Notes", to: "/" }],
|
|
17
|
+
}),
|
|
18
|
+
views: { NotesList: () => <h1>Notes</h1> },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("collectModules", () => {
|
|
22
|
+
it("merges manifests and views from module files", () => {
|
|
23
|
+
const { manifests, views } = collectModules({
|
|
24
|
+
"./modules/notes/module.tsx": notesModule,
|
|
25
|
+
});
|
|
26
|
+
expect(manifests.map((manifest) => manifest.name)).toEqual(["notes"]);
|
|
27
|
+
expect(Object.keys(views)).toEqual(["NotesList"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects a module that does not export manifest/views", () => {
|
|
31
|
+
expect(() => collectModules({ "./modules/bad/module.tsx": {} })).toThrow(/must export/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects duplicate view ids instead of silently overwriting a module", () => {
|
|
35
|
+
expect(() =>
|
|
36
|
+
collectModules({
|
|
37
|
+
"./modules/notes/module.tsx": notesModule,
|
|
38
|
+
"./modules/tasks/module.tsx": {
|
|
39
|
+
manifest: defineModuleManifest({
|
|
40
|
+
name: "tasks",
|
|
41
|
+
routes: [{ path: "/tasks", view: "NotesList" }],
|
|
42
|
+
nav: [{ label: "Tasks", to: "/tasks" }],
|
|
43
|
+
}),
|
|
44
|
+
views: { NotesList: () => <h1>Tasks</h1> },
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
).toThrow(/exported by more than one module/);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("renderTerpApp", () => {
|
|
52
|
+
it("mounts the app and shows the login view while signed out", async () => {
|
|
53
|
+
const root = document.createElement("div");
|
|
54
|
+
document.body.appendChild(root);
|
|
55
|
+
|
|
56
|
+
renderTerpApp({
|
|
57
|
+
title: "Test",
|
|
58
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
59
|
+
rootElement: root,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await waitFor(() =>
|
|
63
|
+
expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument(),
|
|
64
|
+
);
|
|
65
|
+
root.remove();
|
|
66
|
+
});
|
|
67
|
+
});
|