@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
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ import { useTerpBaseUrl, useTerpClient } from "./TerpProvider";
4
+
5
+ export type RealtimeTransport = "sse" | "websocket";
6
+ export type RealtimeStatus = "connecting" | "open" | "closed" | "error";
7
+
8
+ const INITIAL_RECONNECT_DELAY_MS = 1_000;
9
+ const MAX_RECONNECT_DELAY_MS = 30_000;
10
+
11
+ interface TicketResponse {
12
+ ticket: string;
13
+ expires_in: number;
14
+ channel: string;
15
+ transport: RealtimeTransport;
16
+ }
17
+
18
+ interface RealtimePaths {
19
+ "/api/v1/realtime/tickets": {
20
+ post: {
21
+ requestBody: {
22
+ content: {
23
+ "application/json": {
24
+ channel: string;
25
+ transport: RealtimeTransport;
26
+ };
27
+ };
28
+ };
29
+ responses: {
30
+ 201: { content: { "application/json": TicketResponse } };
31
+ };
32
+ };
33
+ };
34
+ }
35
+
36
+ export interface RealtimeChannelOptions<Message> {
37
+ channel: string;
38
+ transport?: RealtimeTransport;
39
+ /** Runtime validation for the channel's Pydantic JSON wire payload. */
40
+ validate(value: unknown): value is Message;
41
+ /** Receive every validated message (lastMessage is also retained). */
42
+ onMessage?(message: Message): void;
43
+ /** Default true; false closes/does not open the transport. */
44
+ enabled?: boolean;
45
+ }
46
+
47
+ export interface RealtimeChannelState<Message> {
48
+ status: RealtimeStatus;
49
+ lastMessage: Message | null;
50
+ error: Error | null;
51
+ /** WebSocket only; throws unless the socket is open. */
52
+ send(message: unknown): void;
53
+ close(): void;
54
+ }
55
+
56
+ function httpBase(baseUrl: string): URL {
57
+ const browserBase =
58
+ typeof window === "undefined" ? "http://localhost/" : window.location.href;
59
+ return new URL(baseUrl || "/", browserBase);
60
+ }
61
+
62
+ function transportUrl(
63
+ baseUrl: string,
64
+ transport: RealtimeTransport,
65
+ channel: string,
66
+ ticket: string,
67
+ ): string {
68
+ const base = httpBase(baseUrl);
69
+ const kind = transport === "sse" ? "sse" : "ws";
70
+ const url = new URL(
71
+ `/api/v1/realtime/${kind}/${encodeURIComponent(channel)}`,
72
+ base,
73
+ );
74
+ if (transport === "websocket") {
75
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
76
+ }
77
+ url.searchParams.set("ticket", ticket);
78
+ return url.toString();
79
+ }
80
+
81
+ function parseMessage<Message>(
82
+ raw: string,
83
+ validate: (value: unknown) => value is Message,
84
+ ): Message {
85
+ let value: unknown;
86
+ try {
87
+ value = JSON.parse(raw);
88
+ } catch {
89
+ throw new Error("Realtime channel received invalid JSON");
90
+ }
91
+ if (!validate(value)) {
92
+ throw new Error("Realtime channel received a payload outside its declared type");
93
+ }
94
+ return value;
95
+ }
96
+
97
+ /**
98
+ * Subscribe to one typed realtime channel through the sanctioned transport.
99
+ *
100
+ * The hook mints a 30-second, one-use ticket via the generated authenticated
101
+ * client, then opens EventSource or WebSocket internally. App modules never
102
+ * touch raw egress APIs, bearer tokens never enter URLs, and each inbound JSON
103
+ * payload passes the caller's runtime type guard before reaching state/code.
104
+ */
105
+ export function useRealtimeChannel<Message>({
106
+ channel,
107
+ transport = "sse",
108
+ validate,
109
+ onMessage,
110
+ enabled = true,
111
+ }: RealtimeChannelOptions<Message>): RealtimeChannelState<Message> {
112
+ const client = useTerpClient<RealtimePaths>();
113
+ const baseUrl = useTerpBaseUrl();
114
+ const socketRef = useRef<WebSocket | null>(null);
115
+ const eventSourceRef = useRef<EventSource | null>(null);
116
+ const validateRef = useRef(validate);
117
+ const onMessageRef = useRef(onMessage);
118
+ const closeRef = useRef<() => void>(() => {});
119
+ const terminalFailureRef = useRef<(cause: unknown) => void>(() => {});
120
+ validateRef.current = validate;
121
+ onMessageRef.current = onMessage;
122
+ const [status, setStatus] = useState<RealtimeStatus>(
123
+ enabled ? "connecting" : "closed",
124
+ );
125
+ const [lastMessage, setLastMessage] = useState<Message | null>(null);
126
+ const [error, setError] = useState<Error | null>(null);
127
+
128
+ const receiveRef = useRef((raw: string) => {});
129
+ receiveRef.current = (raw: string) => {
130
+ try {
131
+ const message = parseMessage(raw, validateRef.current);
132
+ setLastMessage(message);
133
+ onMessageRef.current?.(message);
134
+ } catch (cause) {
135
+ terminalFailureRef.current(cause);
136
+ }
137
+ };
138
+
139
+ const failRef = useRef((cause: unknown) => {});
140
+ failRef.current = (cause: unknown) => {
141
+ setError(cause instanceof Error ? cause : new Error("Realtime connection failed"));
142
+ setStatus("error");
143
+ };
144
+
145
+ useEffect(() => {
146
+ let cancelled = false;
147
+ let stopped = false;
148
+ let source: EventSource | null = null;
149
+ let socket: WebSocket | null = null;
150
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
151
+ let retryAttempt = 0;
152
+
153
+ const active = () => !cancelled && !stopped;
154
+
155
+ const releaseTransport = (reason: string) => {
156
+ source?.close();
157
+ socket?.close(1000, reason);
158
+ if (eventSourceRef.current === source) eventSourceRef.current = null;
159
+ if (socketRef.current === socket) socketRef.current = null;
160
+ source = null;
161
+ socket = null;
162
+ };
163
+
164
+ const cancelRetry = () => {
165
+ if (retryTimer !== null) clearTimeout(retryTimer);
166
+ retryTimer = null;
167
+ };
168
+
169
+ const stop = () => {
170
+ stopped = true;
171
+ cancelRetry();
172
+ releaseTransport("closed by caller");
173
+ setStatus("closed");
174
+ };
175
+ closeRef.current = stop;
176
+
177
+ const terminalFailure = (cause: unknown) => {
178
+ if (!active()) return;
179
+ stopped = true;
180
+ cancelRetry();
181
+ releaseTransport("invalid message payload");
182
+ failRef.current(cause);
183
+ };
184
+ terminalFailureRef.current = terminalFailure;
185
+
186
+ if (!enabled) {
187
+ stopped = true;
188
+ setStatus("closed");
189
+ return () => {
190
+ cancelled = true;
191
+ if (closeRef.current === stop) closeRef.current = () => {};
192
+ if (terminalFailureRef.current === terminalFailure) {
193
+ terminalFailureRef.current = () => {};
194
+ }
195
+ };
196
+ }
197
+ setStatus("connecting");
198
+ setError(null);
199
+ setLastMessage(null);
200
+
201
+ const connect = async (): Promise<void> => {
202
+ if (!active()) return;
203
+ setStatus("connecting");
204
+ try {
205
+ const { data, error: apiError } = await client.POST(
206
+ "/api/v1/realtime/tickets",
207
+ { body: { channel, transport } },
208
+ );
209
+ if (!active()) return;
210
+ if (apiError || !data) {
211
+ throw new Error("Realtime ticket mint failed");
212
+ }
213
+ const url = transportUrl(baseUrl, transport, channel, data.ticket);
214
+ if (transport === "sse") {
215
+ source = new EventSource(url);
216
+ eventSourceRef.current = source;
217
+ const connectedSource = source;
218
+ source.onopen = () => {
219
+ if (!active() || source !== connectedSource) return;
220
+ retryAttempt = 0;
221
+ setError(null);
222
+ setStatus("open");
223
+ };
224
+ source.onmessage = (event) => {
225
+ if (!active() || source !== connectedSource) return;
226
+ receiveRef.current(event.data);
227
+ };
228
+ source.onerror = () => {
229
+ if (!active() || source !== connectedSource) return;
230
+ scheduleReconnect(new Error("Realtime SSE connection failed"));
231
+ };
232
+ } else {
233
+ socket = new WebSocket(url);
234
+ socketRef.current = socket;
235
+ const connectedSocket = socket;
236
+ socket.onopen = () => {
237
+ if (!active() || socket !== connectedSocket) return;
238
+ retryAttempt = 0;
239
+ setError(null);
240
+ setStatus("open");
241
+ };
242
+ socket.onmessage = (event) => {
243
+ if (!active() || socket !== connectedSocket) return;
244
+ receiveRef.current(String(event.data));
245
+ };
246
+ socket.onerror = () => {
247
+ if (!active() || socket !== connectedSocket) return;
248
+ scheduleReconnect(new Error("Realtime WebSocket connection failed"));
249
+ };
250
+ socket.onclose = (event) => {
251
+ if (!active() || socket !== connectedSocket) return;
252
+ scheduleReconnect(
253
+ new Error(`Realtime WebSocket connection closed (${event.code})`),
254
+ );
255
+ };
256
+ }
257
+ } catch (cause) {
258
+ if (active()) scheduleReconnect(cause);
259
+ }
260
+ };
261
+
262
+ const scheduleReconnect = (cause: unknown) => {
263
+ if (!active() || retryTimer !== null) return;
264
+ releaseTransport("reconnecting");
265
+ failRef.current(cause);
266
+ const delay = Math.min(
267
+ INITIAL_RECONNECT_DELAY_MS * 2 ** retryAttempt,
268
+ MAX_RECONNECT_DELAY_MS,
269
+ );
270
+ retryAttempt += 1;
271
+ retryTimer = setTimeout(() => {
272
+ retryTimer = null;
273
+ if (active()) void connect();
274
+ }, delay);
275
+ };
276
+
277
+ void connect();
278
+
279
+ return () => {
280
+ cancelled = true;
281
+ cancelRetry();
282
+ releaseTransport("component unmounted");
283
+ if (closeRef.current === stop) closeRef.current = () => {};
284
+ if (terminalFailureRef.current === terminalFailure) {
285
+ terminalFailureRef.current = () => {};
286
+ }
287
+ };
288
+ }, [baseUrl, channel, client, enabled, transport]);
289
+
290
+ return {
291
+ status,
292
+ lastMessage,
293
+ error,
294
+ send(message: unknown) {
295
+ const socket = socketRef.current;
296
+ if (transport !== "websocket" || socket?.readyState !== WebSocket.OPEN) {
297
+ throw new Error("Realtime WebSocket is not open");
298
+ }
299
+ socket.send(JSON.stringify(message));
300
+ },
301
+ close() {
302
+ closeRef.current();
303
+ },
304
+ };
305
+ }
306
+
307
+ export { parseMessage, transportUrl };
@@ -0,0 +1,114 @@
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 { RequireAuth } from "./RequireAuth";
6
+ import { TerpProvider, useAuth, useTerpClient } from "./TerpProvider";
7
+
8
+ function json(body: unknown, status = 200): Response {
9
+ return new Response(JSON.stringify(body), {
10
+ status,
11
+ headers: { "content-type": "application/json" },
12
+ });
13
+ }
14
+
15
+ function me(token: string) {
16
+ return json({ id: "1", email: `${token}@acme.test`, role_rank: 20, role_name: "editor" });
17
+ }
18
+
19
+ afterEach(() => {
20
+ cleanup();
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ function CurrentEmail() {
25
+ const auth = useAuth();
26
+ return <p>{auth.currentUser()?.email ?? "signed out"}</p>;
27
+ }
28
+
29
+ function DoWork() {
30
+ const client = useTerpClient();
31
+ return (
32
+ <button type="button" onClick={() => void client.GET("/api/v1/me/", {})}>
33
+ Do work
34
+ </button>
35
+ );
36
+ }
37
+
38
+ describe("refresh-token sessions (ADR 0054)", () => {
39
+ it("silently restores the session on boot from the httpOnly refresh cookie", async () => {
40
+ vi.stubGlobal(
41
+ "fetch",
42
+ vi.fn<typeof fetch>(async (input) => {
43
+ const request = input as Request;
44
+ if (request.url.endsWith("/api/v1/auth/refresh")) {
45
+ return json({ access_token: "restored", token_type: "bearer" });
46
+ }
47
+ if (request.url.endsWith("/api/v1/me/")) {
48
+ return me("restored");
49
+ }
50
+ return json({}, 404);
51
+ }),
52
+ );
53
+
54
+ render(
55
+ <TerpProvider baseUrl="https://api.test">
56
+ <RequireAuth fallback={<p>Please sign in</p>}>
57
+ <CurrentEmail />
58
+ </RequireAuth>
59
+ </TerpProvider>,
60
+ );
61
+
62
+ await waitFor(() => expect(screen.getByText("restored@acme.test")).toBeInTheDocument());
63
+ expect(screen.queryByText("Please sign in")).not.toBeInTheDocument();
64
+ });
65
+
66
+ it("refreshes and retries an expired access token without logging out", async () => {
67
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
68
+ const request = input as Request;
69
+ if (request.url.endsWith("/api/v1/auth/refresh")) {
70
+ return json({ access_token: "fresh", token_type: "bearer" });
71
+ }
72
+ if (request.url.endsWith("/api/v1/auth/login")) {
73
+ return json({ access_token: "expired", token_type: "bearer" });
74
+ }
75
+ if (request.url.endsWith("/api/v1/me/")) {
76
+ const auth = request.headers.get("Authorization");
77
+ if (auth === "Bearer expired") return json({ detail: "expired" }, 401);
78
+ return me("fresh");
79
+ }
80
+ return json({}, 404);
81
+ });
82
+ vi.stubGlobal("fetch", fetchMock);
83
+
84
+ function LogIn() {
85
+ const auth = useAuth();
86
+ return (
87
+ <button
88
+ type="button"
89
+ onClick={() => void auth.login({ email: "e@acme.test", password: "pw" })}
90
+ >
91
+ Sign in
92
+ </button>
93
+ );
94
+ }
95
+
96
+ render(
97
+ <TerpProvider baseUrl="https://api.test">
98
+ <LogIn />
99
+ <RequireAuth fallback={<p>Please sign in</p>}>
100
+ <DoWork />
101
+ </RequireAuth>
102
+ </TerpProvider>,
103
+ );
104
+
105
+ fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
106
+ await waitFor(() => expect(screen.getByRole("button", { name: "Do work" })).toBeInTheDocument());
107
+
108
+ fireEvent.click(screen.getByRole("button", { name: "Do work" }));
109
+
110
+ await waitFor(() => expect(screen.getByRole("button", { name: "Do work" })).toBeInTheDocument());
111
+ expect(screen.queryByText("Please sign in")).not.toBeInTheDocument();
112
+ expect(fetchMock.mock.calls.some(([input]) => (input as Request).headers.get("Authorization") === "Bearer fresh")).toBe(true);
113
+ });
114
+ });
@@ -0,0 +1,81 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { useEffect } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { RequireAuth } from "./RequireAuth";
7
+ import { TerpProvider, useAuth, useTerpClient } from "./TerpProvider";
8
+
9
+ afterEach(() => {
10
+ cleanup();
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ function json(body: unknown, status = 200): Response {
15
+ return new Response(JSON.stringify(body), {
16
+ status,
17
+ headers: { "content-type": "application/json" },
18
+ });
19
+ }
20
+
21
+ /** Login + the first `/me` succeed; every later authenticated request 401s (a server-revoked session). */
22
+ function revokingFetch() {
23
+ let meCalls = 0;
24
+ return vi.fn<typeof fetch>(async (input) => {
25
+ const url = (input as Request).url;
26
+ if (url.endsWith("/api/v1/auth/login")) {
27
+ return json({ access_token: "t", token_type: "bearer" });
28
+ }
29
+ if (url.endsWith("/api/v1/me/")) {
30
+ meCalls += 1;
31
+ if (meCalls === 1) {
32
+ return json({ id: "1", email: "e@acme.test", role_rank: 20, role_name: "editor" });
33
+ }
34
+ }
35
+ return json({ code: "authentication_required", detail: "token revoked" }, 401);
36
+ });
37
+ }
38
+
39
+ function LogInOnMount() {
40
+ const auth = useAuth();
41
+ useEffect(() => {
42
+ void auth.login({ email: "e@acme.test", password: "pw" });
43
+ }, []);
44
+ return null;
45
+ }
46
+
47
+ function DoWork() {
48
+ const client = useTerpClient();
49
+ return (
50
+ <button type="button" onClick={() => void client.GET("/api/v1/me/", {})}>
51
+ Do work
52
+ </button>
53
+ );
54
+ }
55
+
56
+ describe("session revocation (ADR 0031)", () => {
57
+ it("clears the session and falls back to login when an authenticated request 401s", async () => {
58
+ vi.stubGlobal("fetch", revokingFetch());
59
+
60
+ render(
61
+ <TerpProvider baseUrl="https://api.test">
62
+ <LogInOnMount />
63
+ <RequireAuth fallback={<p>Please sign in</p>}>
64
+ <DoWork />
65
+ </RequireAuth>
66
+ </TerpProvider>,
67
+ );
68
+
69
+ // After login the authenticated app (its "Do work" action) is shown.
70
+ await waitFor(() =>
71
+ expect(screen.getByRole("button", { name: "Do work" })).toBeInTheDocument(),
72
+ );
73
+
74
+ // The token is revoked server-side: the next authenticated request 401s, which clears the
75
+ // session so the app falls back to the login screen — not a signed-in shell over empty data.
76
+ fireEvent.click(screen.getByRole("button", { name: "Do work" }));
77
+
78
+ await waitFor(() => expect(screen.getByText("Please sign in")).toBeInTheDocument());
79
+ expect(screen.queryByRole("button", { name: "Do work" })).not.toBeInTheDocument();
80
+ });
81
+ });