@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,307 @@
1
+ // @vitest-environment jsdom
2
+ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
3
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import { useEffect, useState } from "react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { ModuleManifest } from "@terpjs/contract";
7
+
8
+ import { buildAppRouter } from "./router";
9
+ import { Page } from "./Page";
10
+ import { TerpProvider, useAuth } from "./TerpProvider";
11
+
12
+ function jsonResponse(body: unknown): Response {
13
+ return new Response(JSON.stringify(body), {
14
+ status: 200,
15
+ headers: { "content-type": "application/json" },
16
+ });
17
+ }
18
+
19
+ afterEach(() => {
20
+ cleanup();
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ function LogInOnMount() {
25
+ const auth = useAuth();
26
+ useEffect(() => {
27
+ void auth.login({ email: "editor@example.com", password: "pw" });
28
+ }, []);
29
+ return null;
30
+ }
31
+
32
+ const manifests: ModuleManifest[] = [
33
+ {
34
+ name: "home",
35
+ routes: [{ path: "/", view: "Home" }],
36
+ nav: [],
37
+ },
38
+ {
39
+ name: "notes",
40
+ routes: [{ path: "/notes", view: "NotesList" }],
41
+ nav: [{ label: "Notes", to: "/notes" }],
42
+ },
43
+ {
44
+ name: "users",
45
+ routes: [{ path: "/users", view: "UsersList", role: "admin" }],
46
+ nav: [{ label: "Users", to: "/users", role: "admin" }],
47
+ },
48
+ ];
49
+
50
+ const views = {
51
+ Home: () => <Page title="Home view">home body</Page>,
52
+ NotesList: () => <Page title="Notes view">notes body</Page>,
53
+ UsersList: () => <Page title="Users view">users body</Page>,
54
+ };
55
+
56
+ describe("buildAppRouter", () => {
57
+ it("renders the matched view in the shell with a role-filtered nav", async () => {
58
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
59
+ const url = (input as Request).url;
60
+ if (url.endsWith("/api/v1/auth/login")) {
61
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
62
+ }
63
+ return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
64
+ });
65
+ vi.stubGlobal("fetch", fetchMock);
66
+
67
+ const router = buildAppRouter(manifests, {
68
+ views,
69
+ title: "Terp",
70
+ history: createMemoryHistory({ initialEntries: ["/notes"] }),
71
+ });
72
+
73
+ render(
74
+ <TerpProvider baseUrl="https://api.test">
75
+ <LogInOnMount />
76
+ <RouterProvider router={router} />
77
+ </TerpProvider>,
78
+ );
79
+
80
+ await waitFor(() =>
81
+ expect(screen.getByRole("heading", { name: "Notes view" })).toBeInTheDocument(),
82
+ );
83
+ // Editor (rank 20) sees the Notes nav but not the admin-only Users nav.
84
+ expect(screen.getByRole("link", { name: "Notes" })).toBeInTheDocument();
85
+ expect(screen.queryByRole("link", { name: "Users" })).not.toBeInTheDocument();
86
+ });
87
+
88
+ it("navigates home through the product brand", async () => {
89
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
90
+ const url = (input as Request).url;
91
+ if (url.endsWith("/api/v1/auth/login")) {
92
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
93
+ }
94
+ return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
95
+ });
96
+ vi.stubGlobal("fetch", fetchMock);
97
+ const router = buildAppRouter(manifests, {
98
+ views,
99
+ title: "Terp",
100
+ history: createMemoryHistory({ initialEntries: ["/notes"] }),
101
+ });
102
+
103
+ render(
104
+ <TerpProvider baseUrl="https://api.test">
105
+ <LogInOnMount />
106
+ <RouterProvider router={router} />
107
+ </TerpProvider>,
108
+ );
109
+ await screen.findByRole("heading", { name: "Notes view" });
110
+ const brand = screen.getByRole("link", { name: "Terp" });
111
+ expect(brand).toHaveAttribute("data-terp", "appshell-brand");
112
+ fireEvent.click(brand);
113
+ await screen.findByRole("heading", { name: "Home view" });
114
+ });
115
+
116
+ it("shows a sign-out control for the signed-in user and revokes the token on click", async () => {
117
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
118
+ const url = (input as Request).url;
119
+ if (url.endsWith("/api/v1/auth/login")) {
120
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
121
+ }
122
+ if (url.endsWith("/api/v1/auth/logout")) {
123
+ return new Response(null, { status: 204 });
124
+ }
125
+ return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
126
+ });
127
+ vi.stubGlobal("fetch", fetchMock);
128
+
129
+ const router = buildAppRouter(manifests, {
130
+ views,
131
+ title: "Terp",
132
+ history: createMemoryHistory({ initialEntries: ["/notes"] }),
133
+ });
134
+
135
+ render(
136
+ <TerpProvider baseUrl="https://api.test">
137
+ <LogInOnMount />
138
+ <RouterProvider router={router} />
139
+ </TerpProvider>,
140
+ );
141
+
142
+ // The sidebar's user menu shows the signed-in user's email; sign-out lives inside it.
143
+ await waitFor(() => expect(screen.getByText("editor@example.com")).toBeInTheDocument());
144
+ fireEvent.click(screen.getByRole("button", { name: "Account menu" }));
145
+ // Clicking sign-out revokes the token server-side (ADR 0031).
146
+ fireEvent.click(screen.getByRole("menuitem", { name: "Sign out" }));
147
+ await waitFor(() =>
148
+ expect(
149
+ fetchMock.mock.calls.some(([input]) =>
150
+ (input as Request).url.endsWith("/api/v1/auth/logout"),
151
+ ),
152
+ ).toBe(true),
153
+ );
154
+ });
155
+
156
+ it("rejects a route whose view id was not collected", () => {
157
+ expect(() =>
158
+ buildAppRouter(
159
+ [{ name: "missing", routes: [{ path: "/missing", view: "MissingView" }] }],
160
+ {
161
+ views: {},
162
+ title: "Terp",
163
+ history: createMemoryHistory({ initialEntries: ["/missing"] }),
164
+ },
165
+ ),
166
+ ).toThrow(/missing view/);
167
+ });
168
+
169
+ it("refuses a routed view that skips the page archetypes (fail closed)", async () => {
170
+ // The runtime half of the page-archetype control: a view rendering bare markup (no Page /
171
+ // OverviewPage / DetailPage / HubPage in its tree) is refused after mount, not shown.
172
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
173
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
174
+ const url = (input as Request).url;
175
+ if (url.endsWith("/api/v1/auth/login")) {
176
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
177
+ }
178
+ return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
179
+ });
180
+ vi.stubGlobal("fetch", fetchMock);
181
+
182
+ const router = buildAppRouter(
183
+ [{ name: "bare", routes: [{ path: "/bare", view: "BareView" }], nav: [] }],
184
+ {
185
+ views: { BareView: () => <h1>Bare view</h1> },
186
+ title: "Terp",
187
+ history: createMemoryHistory({ initialEntries: ["/bare"] }),
188
+ },
189
+ );
190
+
191
+ render(
192
+ <TerpProvider baseUrl="https://api.test">
193
+ <LogInOnMount />
194
+ <RouterProvider router={router} />
195
+ </TerpProvider>,
196
+ );
197
+
198
+ // The bare view mounts, the post-mount check bites, and the screen is torn down.
199
+ await waitFor(() =>
200
+ expect(screen.queryByRole("heading", { name: "Bare view" })).not.toBeInTheDocument(),
201
+ );
202
+ });
203
+
204
+ it("accepts a view whose page archetype lands on a follow-up commit", async () => {
205
+ // A view may frame one commit late (e.g. a lazy inner component resolving); the grace
206
+ // window tolerates it while a view that never frames is still refused.
207
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
208
+ const url = (input as Request).url;
209
+ if (url.endsWith("/api/v1/auth/login")) {
210
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
211
+ }
212
+ return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
213
+ });
214
+ vi.stubGlobal("fetch", fetchMock);
215
+
216
+ function LateFramedView() {
217
+ const [ready, setReady] = useState(false);
218
+ useEffect(() => setReady(true), []);
219
+ return ready ? <Page title="Late view">late body</Page> : null;
220
+ }
221
+
222
+ const router = buildAppRouter(
223
+ [{ name: "late", routes: [{ path: "/late", view: "LateView" }], nav: [] }],
224
+ {
225
+ views: { LateView: LateFramedView },
226
+ title: "Terp",
227
+ history: createMemoryHistory({ initialEntries: ["/late"] }),
228
+ },
229
+ );
230
+
231
+ render(
232
+ <TerpProvider baseUrl="https://api.test">
233
+ <LogInOnMount />
234
+ <RouterProvider router={router} />
235
+ </TerpProvider>,
236
+ );
237
+
238
+ await waitFor(() =>
239
+ expect(screen.getByRole("heading", { name: "Late view" })).toBeInTheDocument(),
240
+ );
241
+ });
242
+
243
+ it("mounts the built-in profile page; the user menu's Settings opens it", async () => {
244
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
245
+ const url = (input as Request).url;
246
+ if (url.endsWith("/api/v1/auth/login")) {
247
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
248
+ }
249
+ return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
250
+ });
251
+ vi.stubGlobal("fetch", fetchMock);
252
+
253
+ const router = buildAppRouter(manifests, {
254
+ views,
255
+ title: "Terp",
256
+ history: createMemoryHistory({ initialEntries: ["/notes"] }),
257
+ });
258
+
259
+ render(
260
+ <TerpProvider baseUrl="https://api.test">
261
+ <LogInOnMount />
262
+ <RouterProvider router={router} />
263
+ </TerpProvider>,
264
+ );
265
+
266
+ await waitFor(() => expect(screen.getByText("editor@example.com")).toBeInTheDocument());
267
+ fireEvent.click(screen.getByRole("button", { name: "Account menu" }));
268
+ fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
269
+
270
+ await waitFor(() =>
271
+ expect(screen.getByRole("heading", { level: 1, name: "Profile" })).toBeInTheDocument(),
272
+ );
273
+ // The identity comes from the server-validated session (email + role), framed by a Page.
274
+ expect(screen.getByText("editor (20)")).toBeInTheDocument();
275
+ });
276
+
277
+ it("lets an app manifest claim /profile over the built-in page", async () => {
278
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
279
+ const url = (input as Request).url;
280
+ if (url.endsWith("/api/v1/auth/login")) {
281
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
282
+ }
283
+ return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
284
+ });
285
+ vi.stubGlobal("fetch", fetchMock);
286
+
287
+ const router = buildAppRouter(
288
+ [{ name: "custom", routes: [{ path: "/profile", view: "CustomProfile" }], nav: [] }],
289
+ {
290
+ views: { CustomProfile: () => <Page title="Custom profile">custom body</Page> },
291
+ title: "Terp",
292
+ history: createMemoryHistory({ initialEntries: ["/profile"] }),
293
+ },
294
+ );
295
+
296
+ render(
297
+ <TerpProvider baseUrl="https://api.test">
298
+ <LogInOnMount />
299
+ <RouterProvider router={router} />
300
+ </TerpProvider>,
301
+ );
302
+
303
+ await waitFor(() =>
304
+ expect(screen.getByRole("heading", { name: "Custom profile" })).toBeInTheDocument(),
305
+ );
306
+ });
307
+ });
package/src/router.tsx ADDED
@@ -0,0 +1,222 @@
1
+ import {
2
+ createRootRoute,
3
+ createRoute,
4
+ createRouter,
5
+ Link,
6
+ Outlet,
7
+ useRouter,
8
+ type AnyRoute,
9
+ type RouterHistory,
10
+ } from "@tanstack/react-router";
11
+ import type { ComponentType, ReactNode } from "react";
12
+ import { useEffect, useRef, useState } from "react";
13
+ import type { ModuleManifest } from "@terpjs/contract";
14
+
15
+ import { AppShell } from "./AppShell";
16
+ import { ProfileView } from "./ProfileView";
17
+ import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
18
+ import { visibleNav } from "./nav";
19
+ import { PageMarkerContext } from "./pageMarker";
20
+ import { useAuth } from "./TerpProvider";
21
+ import { UserMenu } from "./UserMenu";
22
+ import { useStrings } from "./uiText";
23
+
24
+ /** Default role-name -> minimum rank map (the bundled viewer/editor/admin ladder). */
25
+ export const DEFAULT_ROLE_RANKS: Record<string, number> = {
26
+ viewer: 10,
27
+ editor: 20,
28
+ admin: 30,
29
+ };
30
+
31
+ /** The built-in profile / settings route (an app manifest claiming the path wins). */
32
+ export const PROFILE_PATH = "/profile";
33
+
34
+ export interface BuildAppRouterOptions {
35
+ /** Maps a manifest route's `view` id to the component that renders it. */
36
+ views: Record<string, ComponentType>;
37
+ /** App title shown in the shell's sidebar brand. */
38
+ title: string;
39
+ /** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
40
+ logo?: ReactNode;
41
+ /** Footer line under the content; default: a muted line with the app title. */
42
+ footer?: ReactNode;
43
+ /** Role-name -> minimum rank; an unknown role is denied (fail closed). */
44
+ roleRanks?: Record<string, number>;
45
+ /** Rendered when the current user may not access a route (default: a simple message). */
46
+ unauthorized?: ComponentType;
47
+ /**
48
+ * Opt into a slot-typed layout contract (ADR 0079), e.g. `"standard"`: every routed
49
+ * archetype's body slot then accepts only the components the contract allows there,
50
+ * verified at runtime (fail closed) with the same directive message the
51
+ * `terp/layout-contract` lint rule phrases. Keep it in sync with the app's checked-in
52
+ * `layout-contract.json` (the lint half). Omit for today's archetype-only behavior.
53
+ */
54
+ layoutContract?: string;
55
+ /** Router history (e.g. `createMemoryHistory`); omit for the browser history. */
56
+ history?: RouterHistory;
57
+ }
58
+
59
+ /** Whether a user with `roleRank` (null = signed out) may access a route requiring `role`. */
60
+ function allows(
61
+ roleRanks: Record<string, number>,
62
+ roleRank: number | null,
63
+ role: string | undefined,
64
+ ): boolean {
65
+ if (roleRank === null) {
66
+ return false;
67
+ }
68
+ if (role === undefined) {
69
+ return true; // any authenticated user
70
+ }
71
+ const required = roleRanks[role];
72
+ return required !== undefined && roleRank >= required;
73
+ }
74
+
75
+ function DefaultUnauthorized() {
76
+ return <p>{useStrings().unauthorized}</p>;
77
+ }
78
+
79
+ /**
80
+ * Build a TanStack Router from module manifests. The root route renders the {@link AppShell}
81
+ * (logo + title brand, a role-filtered icon/label sidebar of TanStack `Link`s, the
82
+ * {@link UserMenu} pinned at the sidebar's bottom, the sticky header with the sidebar
83
+ * toggle and theme/language controls, and the footer) around an `<Outlet/>`; each manifest
84
+ * route mounts its `view`, gated by the route's `role` (an unknown role is denied). The
85
+ * built-in {@link ProfileView} mounts at {@link PROFILE_PATH} unless a manifest claims that
86
+ * path. Wrap the returned router in `<TerpProvider><RouterProvider router={router}/></TerpProvider>`.
87
+ */
88
+ export function buildAppRouter(
89
+ manifests: readonly ModuleManifest[],
90
+ options: BuildAppRouterOptions,
91
+ ) {
92
+ const roleRanks = options.roleRanks ?? DEFAULT_ROLE_RANKS;
93
+ const Unauthorized = options.unauthorized ?? DefaultUnauthorized;
94
+ const layoutContract = options.layoutContract ?? null;
95
+ if (layoutContract !== null && LAYOUT_CONTRACTS[layoutContract] === undefined) {
96
+ throw new Error(
97
+ `Unknown layout contract "${layoutContract}"; known contracts: ` +
98
+ Object.keys(LAYOUT_CONTRACTS).join(", ") +
99
+ ".",
100
+ );
101
+ }
102
+ const missingViews = manifests.flatMap((manifest) =>
103
+ manifest.routes
104
+ .filter((route) => options.views[route.view] === undefined)
105
+ .map((route) => `${manifest.name}:${route.path} -> ${route.view}`),
106
+ );
107
+ if (missingViews.length > 0) {
108
+ throw new Error(
109
+ "Terp route(s) reference missing view(s): " + missingViews.join(", "),
110
+ );
111
+ }
112
+
113
+ function Shell() {
114
+ const router = useRouter();
115
+ const rank = useAuth().currentUser()?.role_rank ?? null;
116
+ const nav = visibleNav(manifests, (role) => allows(roleRanks, rank, role));
117
+ return (
118
+ <AppShell
119
+ title={options.title}
120
+ logo={options.logo}
121
+ footer={options.footer}
122
+ nav={nav}
123
+ renderBrandLink={({ to, children, style }) => (
124
+ <Link to={to} data-terp="appshell-brand" style={style}>
125
+ {children}
126
+ </Link>
127
+ )}
128
+ renderLink={(item, children, context) => (
129
+ <Link
130
+ to={item.to}
131
+ style={context.style}
132
+ activeProps={{ style: { ...context.style, ...context.activeStyle } }}
133
+ activeOptions={{ exact: item.to === "/" }}
134
+ >
135
+ {children}
136
+ </Link>
137
+ )}
138
+ navFooter={({ collapsed }) => (
139
+ <UserMenu
140
+ collapsed={collapsed}
141
+ onSettings={() => void router.navigate({ to: PROFILE_PATH })}
142
+ />
143
+ )}
144
+ >
145
+ <Outlet />
146
+ </AppShell>
147
+ );
148
+ }
149
+
150
+ const rootRoute = createRootRoute({ component: Shell });
151
+
152
+ function guardedRoute(
153
+ path: string,
154
+ View: ComponentType,
155
+ role: string | undefined,
156
+ viewName: string,
157
+ ): AnyRoute {
158
+ function RouteComponent() {
159
+ const rank = useAuth().currentUser()?.role_rank ?? null;
160
+ const allowed = allows(roleRanks, rank, role);
161
+ // The runtime half of the "every routed view is a page archetype" control: Page
162
+ // (composed by OverviewPage / DetailPage / HubPage) marks the render; a routed view
163
+ // that mounted without any archetype in its tree is refused, fail closed. The check
164
+ // waits one macrotask so a view whose archetype lands on a follow-up commit (e.g. a
165
+ // lazy inner component resolving) is not refused spuriously.
166
+ const marked = useRef(false);
167
+ const [unframed, setUnframed] = useState(false);
168
+ useEffect(() => {
169
+ if (!allowed || marked.current) {
170
+ return;
171
+ }
172
+ const timer = setTimeout(() => {
173
+ if (!marked.current) {
174
+ setUnframed(true);
175
+ }
176
+ }, 0);
177
+ return () => clearTimeout(timer);
178
+ }, [allowed]);
179
+ if (unframed && !marked.current) {
180
+ throw new Error(
181
+ `Terp routed view "${viewName}" must render a react-core page archetype ` +
182
+ "(Page, OverviewPage, DetailPage or HubPage) so every screen keeps the " +
183
+ "breadcrumb/title/error frame.",
184
+ );
185
+ }
186
+ if (!allowed) {
187
+ return <Unauthorized />;
188
+ }
189
+ return (
190
+ <LayoutContractContext.Provider value={layoutContract}>
191
+ <PageMarkerContext.Provider
192
+ value={() => {
193
+ marked.current = true;
194
+ }}
195
+ >
196
+ <View />
197
+ </PageMarkerContext.Provider>
198
+ </LayoutContractContext.Provider>
199
+ );
200
+ }
201
+ return createRoute({
202
+ getParentRoute: () => rootRoute,
203
+ path,
204
+ component: RouteComponent,
205
+ });
206
+ }
207
+
208
+ const childRoutes: AnyRoute[] = manifests.flatMap((manifest) =>
209
+ manifest.routes.map((route) =>
210
+ guardedRoute(route.path, options.views[route.view]!, route.role, route.view),
211
+ ),
212
+ );
213
+ const profileClaimed = manifests.some((manifest) =>
214
+ manifest.routes.some((route) => route.path === PROFILE_PATH),
215
+ );
216
+ if (!profileClaimed) {
217
+ childRoutes.push(guardedRoute(PROFILE_PATH, ProfileView, undefined, "profile"));
218
+ }
219
+
220
+ const routeTree = rootRoute.addChildren(childRoutes);
221
+ return createRouter({ routeTree, history: options.history });
222
+ }
@@ -0,0 +1,128 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { LoginView } from "./LoginView";
6
+ import { TerpProvider, useAuth } from "./TerpProvider";
7
+ import { parseSsoCallback } from "./sso";
8
+
9
+ function json(body: unknown, status = 200): Response {
10
+ return new Response(JSON.stringify(body), {
11
+ status,
12
+ headers: { "content-type": "application/json" },
13
+ });
14
+ }
15
+
16
+ afterEach(() => {
17
+ cleanup();
18
+ vi.restoreAllMocks();
19
+ window.history.replaceState({}, "", "/");
20
+ });
21
+
22
+ function CurrentEmail() {
23
+ const auth = useAuth();
24
+ return <p>{auth.currentUser()?.email ?? "signed out"}</p>;
25
+ }
26
+
27
+ describe("parseSsoCallback", () => {
28
+ it("parses a provider callback landing", () => {
29
+ expect(
30
+ parseSsoCallback({ pathname: "/auth/callback/dex", search: "?code=c1&state=s1" }),
31
+ ).toEqual({ provider: "dex", code: "c1", state: "s1" });
32
+ });
33
+
34
+ it("returns null for a normal boot or a code-less landing", () => {
35
+ expect(parseSsoCallback({ pathname: "/", search: "" })).toBeNull();
36
+ expect(parseSsoCallback({ pathname: "/auth/callback/dex", search: "?error=denied" })).toBeNull();
37
+ expect(parseSsoCallback({ pathname: "/auth/callback/", search: "?code=c&state=s" })).toBeNull();
38
+ expect(
39
+ parseSsoCallback({ pathname: "/auth/callback/a/b", search: "?code=c&state=s" }),
40
+ ).toBeNull();
41
+ });
42
+ });
43
+
44
+ describe("SSO login (ADR 0058)", () => {
45
+ it("renders provider buttons and navigates to the IdP authorize URL", async () => {
46
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
47
+ const request = input as Request;
48
+ if (request.url.endsWith("/api/v1/auth/refresh")) return json({}, 401);
49
+ if (request.url.endsWith("/api/v1/oidc/dex/authorize")) {
50
+ return json({ provider: "dex", authorization_url: "https://idp.test/authorize?x=1" });
51
+ }
52
+ return json({}, 404);
53
+ });
54
+ vi.stubGlobal("fetch", fetchMock);
55
+ const assign = vi.fn();
56
+ vi.spyOn(window, "location", "get").mockReturnValue({
57
+ ...window.location,
58
+ assign,
59
+ pathname: "/",
60
+ search: "",
61
+ } as unknown as Location);
62
+
63
+ render(
64
+ <TerpProvider baseUrl="https://api.test">
65
+ <LoginView ssoProviders={[{ name: "dex", label: "Dex" }]} />
66
+ </TerpProvider>,
67
+ );
68
+
69
+ fireEvent.click(await screen.findByRole("button", { name: "Continue with Dex" }));
70
+ await waitFor(() => expect(assign).toHaveBeenCalledWith("https://idp.test/authorize?x=1"));
71
+ });
72
+
73
+ it("completes the callback landing into a signed-in session and cleans the URL", async () => {
74
+ window.history.replaceState({}, "", "/auth/callback/dex?code=c1&state=s1");
75
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
76
+ const request = input as Request;
77
+ if (request.url.endsWith("/api/v1/oidc/dex/callback")) {
78
+ expect(await request.clone().json()).toEqual({ code: "c1", state: "s1" });
79
+ return json({ access_token: "sso-token", token_type: "bearer" });
80
+ }
81
+ if (request.url.endsWith("/api/v1/me/")) {
82
+ expect(request.headers.get("authorization")).toContain("sso-token");
83
+ return json({ id: "1", email: "sso@acme.test", role_rank: 10, role_name: "viewer" });
84
+ }
85
+ return json({}, 404);
86
+ });
87
+ vi.stubGlobal("fetch", fetchMock);
88
+
89
+ render(
90
+ <TerpProvider baseUrl="https://api.test">
91
+ <CurrentEmail />
92
+ </TerpProvider>,
93
+ );
94
+
95
+ await waitFor(() => expect(screen.getByText("sso@acme.test")).toBeInTheDocument());
96
+ expect(window.location.pathname).toBe("/");
97
+ // The single-use code/state must not be replayed by a second boot pass.
98
+ const callbackCalls = fetchMock.mock.calls.filter((call) =>
99
+ (call[0] as Request).url.endsWith("/api/v1/oidc/dex/callback"),
100
+ );
101
+ expect(callbackCalls).toHaveLength(1);
102
+ });
103
+
104
+ it("surfaces a failed callback on the login screen instead of signing in", async () => {
105
+ window.history.replaceState({}, "", "/auth/callback/dex?code=bad&state=bad");
106
+ vi.stubGlobal(
107
+ "fetch",
108
+ vi.fn<typeof fetch>(async (input) => {
109
+ const request = input as Request;
110
+ if (request.url.endsWith("/api/v1/oidc/dex/callback")) {
111
+ return json({ detail: "Authentication failed", code: "authentication_failed" }, 401);
112
+ }
113
+ return json({}, 404);
114
+ }),
115
+ );
116
+
117
+ render(
118
+ <TerpProvider baseUrl="https://api.test">
119
+ <LoginView ssoProviders={[{ name: "dex" }]} />
120
+ </TerpProvider>,
121
+ );
122
+
123
+ await waitFor(() =>
124
+ expect(screen.getByText("Single sign-on failed. Try again.")).toBeInTheDocument(),
125
+ );
126
+ expect(window.location.pathname).toBe("/");
127
+ });
128
+ });