@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,176 @@
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 { ResourceList } from "./ResourceList";
7
+ import { ApiError } from "./unwrap";
8
+ import { TerpProvider, useAuth } from "./TerpProvider";
9
+ import type { Resource } from "./useResource";
10
+
11
+ function jsonResponse(body: unknown): Response {
12
+ return new Response(JSON.stringify(body), {
13
+ status: 200,
14
+ headers: { "content-type": "application/json" },
15
+ });
16
+ }
17
+
18
+ afterEach(() => {
19
+ cleanup();
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ function LogInOnMount() {
24
+ const auth = useAuth();
25
+ useEffect(() => {
26
+ void auth.login({ email: "editor@example.com", password: "pw" });
27
+ }, []);
28
+ return null;
29
+ }
30
+
31
+ function editorFetch() {
32
+ return vi.fn<typeof fetch>(async (input) => {
33
+ const url = (input as Request).url;
34
+ if (url.endsWith("/api/v1/auth/login")) {
35
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
36
+ }
37
+ return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
38
+ });
39
+ }
40
+
41
+ type Row = { id: string; label: string };
42
+
43
+ function fakeResource(overrides: Partial<Resource<Row, string>> = {}): Resource<Row, string> {
44
+ return {
45
+ items: [],
46
+ loading: false,
47
+ error: null,
48
+ cause: null,
49
+ reload: async () => {},
50
+ create: async () => {},
51
+ mutate: async (operation) => {
52
+ await operation();
53
+ },
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ describe("ResourceList", () => {
59
+ it("renders rows and a write-gated create form for a writer, and creates on submit", async () => {
60
+ vi.stubGlobal("fetch", editorFetch());
61
+ const create = vi.fn(async () => {});
62
+ const resource = fakeResource({ items: [{ id: "a", label: "Alpha" }], create });
63
+
64
+ render(
65
+ <TerpProvider baseUrl="https://api.test">
66
+ <LogInOnMount />
67
+ <ResourceList
68
+ title="Things"
69
+ resource={resource}
70
+ createPlaceholder="New thing"
71
+ renderItem={(row) => <strong>{row.label}</strong>}
72
+ />
73
+ </TerpProvider>,
74
+ );
75
+
76
+ expect(screen.getByRole("heading", { name: "Things" })).toBeInTheDocument();
77
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
78
+ // The create form appears once the editor session loads (it is write-gated by ResourceList).
79
+ await waitFor(() => expect(screen.getByPlaceholderText("New thing")).toBeInTheDocument());
80
+
81
+ fireEvent.change(screen.getByPlaceholderText("New thing"), { target: { value: "Beta" } });
82
+ fireEvent.click(screen.getByRole("button", { name: "Add" }));
83
+ await waitFor(() => expect(create).toHaveBeenCalledWith("Beta"));
84
+ });
85
+
86
+ it("hides the create form when the resource is read-only (no createPlaceholder)", () => {
87
+ const resource = fakeResource({ items: [], loading: false });
88
+ render(
89
+ <TerpProvider baseUrl="https://api.test">
90
+ <ResourceList title="Empty" resource={resource} renderItem={(row) => <span>{row.label}</span>} />
91
+ </TerpProvider>,
92
+ );
93
+ expect(screen.getByText("Nothing here yet.")).toBeInTheDocument();
94
+ expect(screen.queryByRole("button", { name: "Add" })).not.toBeInTheDocument();
95
+ });
96
+
97
+ it("surfaces the resource error as an alert", () => {
98
+ const resource = fakeResource({ error: "boom" });
99
+ render(
100
+ <TerpProvider baseUrl="https://api.test">
101
+ <ResourceList title="Errored" resource={resource} renderItem={(row) => <span>{row.label}</span>} />
102
+ </TerpProvider>,
103
+ );
104
+ expect(screen.getByRole("alert")).toHaveTextContent("boom");
105
+ });
106
+
107
+ it("prefers the mapped copy for the failure's stable code over the raw message", () => {
108
+ const resource = fakeResource({
109
+ error: "permission denied for tenant",
110
+ cause: new ApiError("permission denied for tenant", {
111
+ code: "permission_denied",
112
+ status: 403,
113
+ }),
114
+ });
115
+ render(
116
+ <TerpProvider baseUrl="https://api.test">
117
+ <ResourceList title="Errored" resource={resource} renderItem={(row) => <span>{row.label}</span>} />
118
+ </TerpProvider>,
119
+ );
120
+ expect(screen.getByRole("alert")).toHaveTextContent(
121
+ "You do not have permission to do this.",
122
+ );
123
+ expect(screen.queryByText("permission denied for tenant")).not.toBeInTheDocument();
124
+ });
125
+
126
+ it("keeps the typed draft when a create fails, so the user can retry", async () => {
127
+ vi.stubGlobal("fetch", editorFetch());
128
+ const create = vi.fn(async () => {
129
+ throw new Error("Nope.");
130
+ });
131
+ const resource = fakeResource({ error: "Nope.", create });
132
+
133
+ render(
134
+ <TerpProvider baseUrl="https://api.test">
135
+ <LogInOnMount />
136
+ <ResourceList
137
+ title="Things"
138
+ resource={resource}
139
+ createPlaceholder="New thing"
140
+ renderItem={(row) => <strong>{row.label}</strong>}
141
+ />
142
+ </TerpProvider>,
143
+ );
144
+
145
+ await waitFor(() => expect(screen.getByPlaceholderText("New thing")).toBeInTheDocument());
146
+ fireEvent.change(screen.getByPlaceholderText("New thing"), { target: { value: "Beta" } });
147
+ fireEvent.click(screen.getByRole("button", { name: "Add" }));
148
+ await waitFor(() => expect(create).toHaveBeenCalledWith("Beta"));
149
+
150
+ // A failed create is not swallowed: the error shows and the draft is kept for a retry.
151
+ expect(screen.getByText("Nope.")).toBeInTheDocument();
152
+ expect(screen.getByPlaceholderText("New thing")).toHaveValue("Beta");
153
+ });
154
+
155
+ it("renders a custom create form via renderCreate (write-gated), not the default single-field form", async () => {
156
+ vi.stubGlobal("fetch", editorFetch());
157
+ const resource = fakeResource({ items: [] });
158
+
159
+ render(
160
+ <TerpProvider baseUrl="https://api.test">
161
+ <LogInOnMount />
162
+ <ResourceList
163
+ title="Things"
164
+ resource={resource}
165
+ renderCreate={() => <span>custom-create-form</span>}
166
+ renderItem={(row) => <span>{row.label}</span>}
167
+ />
168
+ </TerpProvider>,
169
+ );
170
+
171
+ // The custom form appears once the writer session loads (ResourceList still applies the gate).
172
+ await waitFor(() => expect(screen.getByText("custom-create-form")).toBeInTheDocument());
173
+ // The default single-field "Add" form is not rendered when renderCreate is supplied.
174
+ expect(screen.queryByRole("button", { name: "Add" })).not.toBeInTheDocument();
175
+ });
176
+ });
@@ -0,0 +1,123 @@
1
+ import { useState } from "react";
2
+ import type { CSSProperties, FormEvent, ReactNode } from "react";
3
+
4
+ import { Authorized } from "./Authorized";
5
+ import { useErrorMessage } from "./errorMessages";
6
+ import { useStrings, useUiText } from "./uiText";
7
+ import type { UiText } from "./uiText";
8
+ import { Button } from "./ui/Button";
9
+ import { Input } from "./ui/Input";
10
+ import type { Resource } from "./useResource";
11
+
12
+ const rowStyle: CSSProperties = {
13
+ display: "flex",
14
+ alignItems: "center",
15
+ justifyContent: "space-between",
16
+ gap: "var(--space-3)",
17
+ padding: "var(--space-3)",
18
+ border: "1px solid var(--color-neutral-200)",
19
+ borderRadius: "var(--radius-md)",
20
+ background: "var(--color-neutral-0)",
21
+ };
22
+
23
+ const mutedStyle: CSSProperties = { color: "var(--color-neutral-600)" };
24
+
25
+ export interface ResourceListProps<T extends { id: string }> {
26
+ /** Section heading; omit when composed under a `Page` (whose title is the `h1`). */
27
+ title?: UiText;
28
+ /** The module's data hook result (items / loading / error / create — see {@link useResource}). */
29
+ resource: Resource<T, string>;
30
+ /** Render the content of one row. */
31
+ renderItem: (item: T) => ReactNode;
32
+ /** Placeholder for the single-field create input; omit to hide creating. */
33
+ createPlaceholder?: UiText;
34
+ /**
35
+ * A custom (e.g. multi-field) create form, for creates that need more than one field. Supply a
36
+ * component built from `Field` + the input primitives; ResourceList still applies the write-gate
37
+ * around it. Overrides `createPlaceholder`.
38
+ */
39
+ renderCreate?: () => ReactNode;
40
+ /** Optional per-row actions (e.g. a delete button); rendered only for writers. */
41
+ renderActions?: (item: T) => ReactNode;
42
+ /** Message shown when there are no rows (default: the `emptyList` string). */
43
+ emptyMessage?: UiText;
44
+ }
45
+
46
+ /**
47
+ * The standard list screen every CRUD module needs, centralized: a titled section, a write-gated
48
+ * single-field create form, loading / error / empty states, and a token-styled list. A module
49
+ * composes it with its typed data hook and a row renderer, so every module lists and creates the
50
+ * same way — and the {@link Authorized} write-gate (create form + row actions) is applied for you,
51
+ * not re-implemented per module.
52
+ *
53
+ * It is a composable component, not a hidden CRUD DSL: a screen that needs more just renders its own
54
+ * React and ignores this.
55
+ */
56
+ export function ResourceList<T extends { id: string }>({
57
+ title,
58
+ resource,
59
+ renderItem,
60
+ createPlaceholder,
61
+ renderCreate,
62
+ renderActions,
63
+ emptyMessage,
64
+ }: ResourceListProps<T>) {
65
+ const strings = useStrings();
66
+ const resolve = useUiText();
67
+ const messageForCode = useErrorMessage();
68
+ const [draft, setDraft] = useState("");
69
+
70
+ async function onCreate(event: FormEvent) {
71
+ event.preventDefault();
72
+ if (!draft.trim()) {
73
+ return;
74
+ }
75
+ try {
76
+ await resource.create(draft);
77
+ setDraft(""); // clear only on success; a failed create surfaces resource.error and keeps the draft
78
+ } catch {
79
+ // The failure is already surfaced via resource.error (rendered below); keep the draft to retry.
80
+ }
81
+ }
82
+
83
+ return (
84
+ <section
85
+ data-terp="resource-list"
86
+ style={{ display: "grid", gap: "var(--space-4)", maxWidth: "40rem" }}
87
+ >
88
+ {title !== undefined && <h1>{resolve(title)}</h1>}
89
+ {renderCreate !== undefined ? (
90
+ <Authorized action="write">{renderCreate()}</Authorized>
91
+ ) : createPlaceholder !== undefined ? (
92
+ <Authorized action="write">
93
+ <form onSubmit={onCreate} style={{ display: "flex", gap: "var(--space-2)" }}>
94
+ <Input
95
+ placeholder={resolve(createPlaceholder ?? "")}
96
+ value={draft}
97
+ onChange={(event) => setDraft(event.target.value)}
98
+ style={{ flex: 1 }}
99
+ />
100
+ <Button type="submit">{strings.add}</Button>
101
+ </form>
102
+ </Authorized>
103
+ ) : null}
104
+ {resource.error !== null && (
105
+ <p role="alert" style={{ color: "var(--color-status-danger)" }}>
106
+ {messageForCode(resource.cause) ?? resource.error}
107
+ </p>
108
+ )}
109
+ {resource.items.length === 0 ? (
110
+ <p style={mutedStyle}>{resource.loading ? strings.loading : resolve(emptyMessage ?? strings.emptyList)}</p>
111
+ ) : (
112
+ <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: "var(--space-2)" }}>
113
+ {resource.items.map((item) => (
114
+ <li key={item.id} style={rowStyle}>
115
+ <div>{renderItem(item)}</div>
116
+ {renderActions && <Authorized action="write">{renderActions(item)}</Authorized>}
117
+ </li>
118
+ ))}
119
+ </ul>
120
+ )}
121
+ </section>
122
+ );
123
+ }
@@ -0,0 +1,320 @@
1
+ import {
2
+ createContext,
3
+ useEffect,
4
+ useCallback,
5
+ useContext,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import type { ReactNode } from "react";
11
+ import type {
12
+ Action,
13
+ AuthSession,
14
+ Credentials,
15
+ CurrentUser,
16
+ paths as ContractPaths,
17
+ TerpClient,
18
+ TerpClientFor,
19
+ } from "@terpjs/contract";
20
+ import { createTerpClient } from "@terpjs/contract";
21
+
22
+ import {
23
+ canPerform,
24
+ DEFAULT_RANK_THRESHOLDS,
25
+ type RankThresholds,
26
+ } from "./capabilities";
27
+ import { createAuthClient } from "./createAuthClient";
28
+ import {
29
+ completeSsoCallback,
30
+ DEFAULT_SSO_CALLBACK_PATH,
31
+ fetchSsoAuthorizationUrl,
32
+ isSsoCallbackLocation,
33
+ parseSsoCallback,
34
+ type SsoCallbackParams,
35
+ } from "./sso";
36
+
37
+ /**
38
+ * The SSO login session (ADR 0058): `begin` opens a provider flow (navigates the
39
+ * browser to the IdP), and `error` carries a failed callback completion so the
40
+ * login screen can surface it. Only meaningful in apps that mount the OIDC
41
+ * capability; a password-only app simply never calls `begin`.
42
+ */
43
+ export interface SsoSession {
44
+ /** Start an SSO login: fetch the IdP authorize URL and navigate to it. */
45
+ begin(provider: string): Promise<void>;
46
+ /** The failure of the last SSO attempt (callback completion), or null. */
47
+ error: unknown;
48
+ }
49
+
50
+ interface TerpContextValue {
51
+ baseUrl: string;
52
+ client: TerpClient;
53
+ auth: AuthSession;
54
+ sso: SsoSession;
55
+ }
56
+
57
+ const TerpContext = createContext<TerpContextValue | null>(null);
58
+
59
+ export interface TerpProviderProps {
60
+ /** Backend API origin, e.g. "https://api.example.com". */
61
+ baseUrl: string;
62
+ /** Role-rank thresholds for {@link AuthSession.can}; defaults to the bundled ladder. */
63
+ thresholds?: RankThresholds;
64
+ /**
65
+ * SPA path prefix the IdP redirects back to after an SSO login (ADR 0058); the
66
+ * provider completes a `{ssoCallbackPath}/{provider}?code&state` landing on boot.
67
+ * Must match the `redirect_uri` configured on the backend's OIDC providers.
68
+ */
69
+ ssoCallbackPath?: string;
70
+ children: ReactNode;
71
+ }
72
+
73
+ async function loadCurrentUser(client: TerpClient): Promise<CurrentUser> {
74
+ const { data, error } = await client.GET("/api/v1/me/", {});
75
+ if (error || !data) {
76
+ throw new Error("failed to load the current user");
77
+ }
78
+ return data;
79
+ }
80
+
81
+ /**
82
+ * Provides a typed `@terpjs/contract` client and an {@link AuthSession} to the tree. The
83
+ * session implements the contract over the generated client: login exchanges credentials
84
+ * for a token and loads `/me`, logout revokes it (ADR 0031), and `can` gates the UI on
85
+ * the server-validated role rank. The bearer token lives in memory for the provider's
86
+ * lifetime.
87
+ */
88
+ export function TerpProvider({
89
+ baseUrl,
90
+ thresholds = DEFAULT_RANK_THRESHOLDS,
91
+ ssoCallbackPath = DEFAULT_SSO_CALLBACK_PATH,
92
+ children,
93
+ }: TerpProviderProps) {
94
+ const tokenRef = useRef<string | null>(null);
95
+ const [user, setUser] = useState<CurrentUser | null>(null);
96
+ const [loading, setLoading] = useState(true);
97
+ const [ssoError, setSsoError] = useState<unknown>(null);
98
+
99
+ // Captured once per provider lifetime (before the URL is cleaned), so a StrictMode
100
+ // double boot effect cannot replay the single-use OIDC state against the backend.
101
+ const ssoPendingRef = useRef<
102
+ { params: SsoCallbackParams | null; atCallback: boolean } | undefined
103
+ >(undefined);
104
+ if (ssoPendingRef.current === undefined) {
105
+ ssoPendingRef.current =
106
+ typeof window === "undefined"
107
+ ? { params: null, atCallback: false }
108
+ : {
109
+ params: parseSsoCallback(window.location, ssoCallbackPath),
110
+ atCallback: isSsoCallbackLocation(window.location, ssoCallbackPath),
111
+ };
112
+ }
113
+
114
+ const clearSession = useCallback(() => {
115
+ tokenRef.current = null;
116
+ setUser(null);
117
+ }, []);
118
+
119
+ const refreshClient = useMemo(
120
+ () => createTerpClient({ baseUrl, credentials: "include" }),
121
+ [baseUrl],
122
+ );
123
+
124
+ // Single-flight: concurrent callers (a StrictMode double boot effect, the 401 middleware
125
+ // racing the boot refresh) share one /refresh round-trip instead of racing the rotation.
126
+ const refreshInFlightRef = useRef<Promise<string | null> | null>(null);
127
+
128
+ const refreshAccessToken = useCallback((): Promise<string | null> => {
129
+ const existing = refreshInFlightRef.current;
130
+ if (existing) return existing;
131
+ const attempt = refreshClient
132
+ .POST("/api/v1/auth/refresh", {})
133
+ .then(({ data, error }) => {
134
+ if (error || !data) {
135
+ return null;
136
+ }
137
+ tokenRef.current = data.access_token;
138
+ return data.access_token;
139
+ })
140
+ .catch(() => null)
141
+ .finally(() => {
142
+ refreshInFlightRef.current = null;
143
+ });
144
+ refreshInFlightRef.current = attempt;
145
+ return attempt;
146
+ }, [refreshClient]);
147
+
148
+ // One client per provider; its middleware reads the live token from the ref, sends refresh
149
+ // cookies, and on a 401 tries one refresh+replay before clearing the session (ADR 0054/0031).
150
+ const client = useMemo(
151
+ () =>
152
+ createAuthClient(baseUrl, () => tokenRef.current, {
153
+ refreshAccessToken,
154
+ onUnauthorized: clearSession,
155
+ }),
156
+ [baseUrl, clearSession, refreshAccessToken],
157
+ );
158
+
159
+ const login = useCallback(
160
+ async (credentials: Credentials): Promise<CurrentUser> => {
161
+ const { data, error } = await client.POST("/api/v1/auth/login", {
162
+ body: credentials,
163
+ });
164
+ if (error || !data) {
165
+ throw new Error("login failed");
166
+ }
167
+ tokenRef.current = data.access_token;
168
+ const me = await loadCurrentUser(client);
169
+ setUser(me);
170
+ return me;
171
+ },
172
+ [client],
173
+ );
174
+
175
+ const logout = useCallback(async (): Promise<void> => {
176
+ try {
177
+ await client.POST("/api/v1/auth/logout", {});
178
+ } finally {
179
+ clearSession();
180
+ }
181
+ }, [client, clearSession]);
182
+
183
+ const refresh = useCallback(async (): Promise<CurrentUser | null> => {
184
+ const startingToken = tokenRef.current;
185
+ const token = await refreshAccessToken();
186
+ if (!token) {
187
+ if (tokenRef.current === startingToken) clearSession();
188
+ return null;
189
+ }
190
+ const me = await loadCurrentUser(client).catch(() => null);
191
+ if (!me) {
192
+ if (tokenRef.current === token) clearSession();
193
+ return null;
194
+ }
195
+ setUser(me);
196
+ return me;
197
+ }, [client, clearSession, refreshAccessToken]);
198
+
199
+ // Finish an in-flight SSO redirect: clean the URL first (the code/state are single-use
200
+ // and must not survive a reload), then exchange them for a normal Terp session.
201
+ const completePendingSso = useCallback(
202
+ async (pending: SsoCallbackParams): Promise<void> => {
203
+ try {
204
+ const token = await completeSsoCallback(client, pending.provider, pending);
205
+ tokenRef.current = token;
206
+ const me = await loadCurrentUser(client);
207
+ setUser(me);
208
+ } catch (error) {
209
+ tokenRef.current = null;
210
+ setSsoError(error);
211
+ }
212
+ },
213
+ [client],
214
+ );
215
+
216
+ useEffect(() => {
217
+ let cancelled = false;
218
+ setLoading(true);
219
+ const boot = async (): Promise<void> => {
220
+ const landing = ssoPendingRef.current;
221
+ ssoPendingRef.current = { params: null, atCallback: false };
222
+ if (landing?.atCallback && typeof window !== "undefined") {
223
+ window.history.replaceState(window.history.state, "", "/");
224
+ }
225
+ if (landing?.params) {
226
+ await completePendingSso(landing.params);
227
+ return;
228
+ }
229
+ if (landing?.atCallback) {
230
+ // The IdP redirected back without a usable code (e.g. the user denied
231
+ // consent): a failed SSO attempt, not a normal boot.
232
+ setSsoError(new Error("SSO sign-in was not completed"));
233
+ return;
234
+ }
235
+ await refresh();
236
+ };
237
+ void boot().finally(() => {
238
+ if (!cancelled) setLoading(false);
239
+ });
240
+ return () => {
241
+ cancelled = true;
242
+ };
243
+ }, [completePendingSso, refresh]);
244
+
245
+ const auth = useMemo<AuthSession>(
246
+ () => ({
247
+ login,
248
+ logout,
249
+ refresh,
250
+ currentUser: () => user,
251
+ loading: () => loading,
252
+ can: (action: Action) =>
253
+ user ? canPerform(user.role_rank, action, thresholds) : false,
254
+ }),
255
+ [login, logout, refresh, user, loading, thresholds],
256
+ );
257
+
258
+ const beginSso = useCallback(
259
+ async (provider: string): Promise<void> => {
260
+ setSsoError(null);
261
+ const url = await fetchSsoAuthorizationUrl(client, provider);
262
+ window.location.assign(url);
263
+ },
264
+ [client],
265
+ );
266
+
267
+ const sso = useMemo<SsoSession>(
268
+ () => ({ begin: beginSso, error: ssoError }),
269
+ [beginSso, ssoError],
270
+ );
271
+
272
+ const value = useMemo<TerpContextValue>(
273
+ () => ({ baseUrl, client, auth, sso }),
274
+ [baseUrl, client, auth, sso],
275
+ );
276
+
277
+ return <TerpContext.Provider value={value}>{children}</TerpContext.Provider>;
278
+ }
279
+
280
+ function useTerp(): TerpContextValue {
281
+ const ctx = useContext(TerpContext);
282
+ if (!ctx) {
283
+ throw new Error("useTerpClient / useAuth must be used within a <TerpProvider>");
284
+ }
285
+ return ctx;
286
+ }
287
+
288
+ /**
289
+ * The typed API client, authenticated with the current session token.
290
+ *
291
+ * Pass your app's generated `paths` to type calls to your OWN endpoints. It is the one
292
+ * shared client (the base-profile login / me / logout calls in {@link TerpProvider} use the
293
+ * same instance), re-typed to your contract:
294
+ *
295
+ * ```ts
296
+ * import type { paths } from "./api/schema"; // openapi-typescript output of your backend
297
+ * const client = useTerpClient<paths>();
298
+ * const { data } = await client.GET("/api/v1/invoices/", {});
299
+ * ```
300
+ *
301
+ * The default types the base-profile endpoints bundled in `@terpjs/contract`.
302
+ */
303
+ export function useTerpClient<AppPaths extends {} = ContractPaths>(): TerpClientFor<AppPaths> {
304
+ return useTerp().client as unknown as TerpClientFor<AppPaths>;
305
+ }
306
+
307
+ /** @internal The configured backend origin for sanctioned transport hooks. */
308
+ export function useTerpBaseUrl(): string {
309
+ return useTerp().baseUrl;
310
+ }
311
+
312
+ /** The current {@link AuthSession}: login / logout / refresh / currentUser / can. */
313
+ export function useAuth(): AuthSession {
314
+ return useTerp().auth;
315
+ }
316
+
317
+ /** The current {@link SsoSession}: begin an SSO login, or read the last SSO failure. */
318
+ export function useSso(): SsoSession {
319
+ return useTerp().sso;
320
+ }