@tristan2828/ui-foundation 3.30.1 → 4.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 (38) hide show
  1. package/dist/api/contracts.d.ts +6 -1
  2. package/dist/api/gateway/auth.d.ts +19 -1
  3. package/dist/api/gateway/auth.js +20 -2
  4. package/dist/api/gateway/client-errors.d.ts +4 -0
  5. package/dist/api/gateway/client-errors.js +14 -0
  6. package/dist/api/gateway/errors.d.ts +2 -1
  7. package/dist/api/gateway/errors.js +34 -1
  8. package/dist/api/schema.d.ts +154 -0
  9. package/dist/api/transport/index.d.ts +2 -0
  10. package/dist/api/transport/index.js +2 -2
  11. package/dist/auth/auth-context.d.ts +21 -1
  12. package/dist/auth/auth-context.js +2 -1
  13. package/dist/auth/auth-provider.js +16 -2
  14. package/dist/components/app/app-shell.js +4 -1
  15. package/dist/components/app/error-state.js +1 -0
  16. package/dist/components/app/foundation-providers.d.ts +9 -1
  17. package/dist/components/app/foundation-providers.js +5 -2
  18. package/dist/components/app/route-error-boundary.js +11 -0
  19. package/dist/error-reporting/error-reporting-context.d.ts +6 -0
  20. package/dist/error-reporting/error-reporting-context.js +16 -0
  21. package/dist/error-reporting/error-reporting-provider.d.ts +14 -0
  22. package/dist/error-reporting/error-reporting-provider.js +28 -0
  23. package/dist/error-reporting/reporter.d.ts +30 -0
  24. package/dist/error-reporting/reporter.js +126 -0
  25. package/dist/index.d.ts +2 -1
  26. package/dist/mocks/auth.d.ts +3 -0
  27. package/dist/mocks/auth.js +39 -0
  28. package/dist/mocks/client-errors.d.ts +7 -0
  29. package/dist/mocks/client-errors.js +24 -0
  30. package/dist/mocks/e2e-hooks.js +2 -2
  31. package/dist/mocks/index.d.ts +2 -1
  32. package/dist/mocks/index.js +2 -1
  33. package/dist/mocks/override.d.ts +2 -0
  34. package/dist/routes/login-schema.d.ts +7 -0
  35. package/dist/routes/login-schema.js +8 -0
  36. package/dist/routes/login.js +95 -9
  37. package/openapi/foundation.yaml +114 -2
  38. package/package.json +1 -1
@@ -5,9 +5,14 @@ export type Page<T> = {
5
5
  pageSize: number;
6
6
  };
7
7
  export type AppError = {
8
- kind: "validation" | "notfound" | "auth" | "server" | "network";
8
+ kind: "validation" | "notfound" | "auth" | "rate_limited" | "server" | "network";
9
9
  message: string;
10
10
  fieldErrors?: Record<string, string[]>;
11
+ /**
12
+ * kind "rate_limited" (a 429): seconds until another attempt is allowed,
13
+ * from the response's Retry-After. Absent when the server didn't say.
14
+ */
15
+ retryAfterSeconds?: number;
11
16
  };
12
17
  export type QuerySpec = {
13
18
  page: number;
@@ -2,7 +2,25 @@ import type { components } from "../schema.js";
2
2
  type User = components["schemas"]["User"];
3
3
  type LoginRequest = components["schemas"]["LoginRequest"];
4
4
  type RegisterRequest = components["schemas"]["RegisterRequest"];
5
- export declare function login(input: LoginRequest): Promise<User>;
5
+ type TotpLoginRequest = components["schemas"]["TotpLoginRequest"];
6
+ /** Signed in (200), or the password was right and a TOTP code is next (202). */
7
+ export type LoginStep = {
8
+ user: User;
9
+ } | {
10
+ next: "totp";
11
+ };
12
+ export declare function login(input: LoginRequest): Promise<LoginStep>;
13
+ /**
14
+ * Signed in, or the pending sign-in is gone (410: expired, or out of tries)
15
+ * and it starts again from the password, with the server's reason. A wrong
16
+ * code is an AppError of kind 'auth' (401).
17
+ */
18
+ export type TotpStep = {
19
+ user: User;
20
+ } | {
21
+ restart: string;
22
+ };
23
+ export declare function loginTotp(input: TotpLoginRequest): Promise<TotpStep>;
6
24
  export declare function register(input: RegisterRequest): Promise<User>;
7
25
  export declare function logout(): Promise<void>;
8
26
  export declare function getCurrentUser(): Promise<User>;
@@ -1,13 +1,31 @@
1
1
  import { safeFetch, toAppError } from "./errors.js";
2
+ // A 429 (too many failed attempts) is an AppError of kind 'rate_limited'
3
+ // with the Retry-After seconds, as on every endpoint (toAppError).
2
4
  export async function login(input) {
3
5
  const res = await safeFetch("/auth/login", {
4
6
  method: "POST",
5
7
  headers: { "content-type": "application/json" },
6
8
  body: JSON.stringify(input),
7
9
  });
10
+ if (res.status === 202)
11
+ return { next: "totp" };
8
12
  if (res.status !== 200)
9
- throw toAppError(res.status, res.body);
10
- return res.body;
13
+ throw toAppError(res.status, res.body, res.headers);
14
+ return { user: res.body };
15
+ }
16
+ export async function loginTotp(input) {
17
+ const res = await safeFetch("/auth/login/totp", {
18
+ method: "POST",
19
+ headers: { "content-type": "application/json" },
20
+ body: JSON.stringify(input),
21
+ });
22
+ if (res.status === 410) {
23
+ const detail = res.body?.detail;
24
+ return { restart: detail ?? "Your sign-in timed out. Enter your email and password again." };
25
+ }
26
+ if (res.status !== 200)
27
+ throw toAppError(res.status, res.body, res.headers);
28
+ return { user: res.body };
11
29
  }
12
30
  // Auto-logs in on success — the backend sets the same session cookie
13
31
  // /auth/login does (see backend/app/routers/auth.py's _start_session), so
@@ -0,0 +1,4 @@
1
+ import type { components } from "../schema.js";
2
+ export type ClientErrorReport = components["schemas"]["ClientErrorReport"];
3
+ /** Resolves whatever happens: a report that can't be sent is dropped. */
4
+ export declare function reportClientError(report: ClientErrorReport): Promise<void>;
@@ -0,0 +1,14 @@
1
+ import { safeFetch } from "./errors.js";
2
+ /** Resolves whatever happens: a report that can't be sent is dropped. */
3
+ export async function reportClientError(report) {
4
+ try {
5
+ await safeFetch("/client-errors", {
6
+ method: "POST",
7
+ headers: { "content-type": "application/json" },
8
+ body: JSON.stringify(report),
9
+ });
10
+ }
11
+ catch {
12
+ // Network down: nothing to do, and nothing to report it to.
13
+ }
14
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ApiResponse } from "../transport/index.js";
2
2
  import type { AppError } from "../contracts.js";
3
3
  export declare function safeFetch(path: string, init?: RequestInit): Promise<ApiResponse>;
4
- export declare function toAppError(status: number, body: unknown): AppError;
4
+ export declare function httpStatusOf(error: AppError): number | undefined;
5
+ export declare function toAppError(status: number, body: unknown, headers?: Headers): AppError;
5
6
  export declare function networkError(): AppError;
@@ -11,7 +11,23 @@ export async function safeFetch(path, init) {
11
11
  throw networkError();
12
12
  }
13
13
  }
14
- export function toAppError(status, body) {
14
+ /**
15
+ * Pass the response's `headers` so a 429 carries its Retry-After
16
+ * (`retryAfterSeconds`); without them it's still `rate_limited`.
17
+ */
18
+ // The HTTP status each AppError came from, kept beside it rather than on
19
+ // it, so AppError's shape (and every test comparing one) doesn't change.
20
+ // Error reporting reads it (src/error-reporting/reporter.ts).
21
+ const STATUS = new WeakMap();
22
+ export function httpStatusOf(error) {
23
+ return STATUS.get(error);
24
+ }
25
+ export function toAppError(status, body, headers) {
26
+ const error = translate(status, body, headers);
27
+ STATUS.set(error, status);
28
+ return error;
29
+ }
30
+ function translate(status, body, headers) {
15
31
  if (status === 422) {
16
32
  // A rule the server enforces (a cap, a status the change needs) is
17
33
  // often refused with FastAPI's HTTPException shape: a sentence, not a
@@ -32,8 +48,25 @@ export function toAppError(status, body) {
32
48
  return { kind: "notfound", message };
33
49
  if (status === 401)
34
50
  return { kind: "auth", message };
51
+ if (status === 429) {
52
+ const retryAfterSeconds = parseRetryAfter(headers?.get("retry-after") ?? null);
53
+ return retryAfterSeconds === undefined
54
+ ? { kind: "rate_limited", message }
55
+ : { kind: "rate_limited", message, retryAfterSeconds };
56
+ }
35
57
  return { kind: "server", message };
36
58
  }
59
+ // Retry-After is either whole seconds or an HTTP date (RFC 9110 10.2.3).
60
+ function parseRetryAfter(value) {
61
+ if (value === null || value.trim() === "")
62
+ return undefined;
63
+ if (/^\d+$/.test(value.trim()))
64
+ return Number(value.trim());
65
+ const at = Date.parse(value);
66
+ if (Number.isNaN(at))
67
+ return undefined;
68
+ return Math.max(0, Math.ceil((at - Date.now()) / 1000));
69
+ }
37
70
  // The key a form binds a 422 to (react-hook-form's field path):
38
71
  // - ["body", "price"] → "price"
39
72
  // - ["body", "tags", 0] → "tags": an item of a list of values is the list's
@@ -20,6 +20,26 @@ export interface paths {
20
20
  patch?: never;
21
21
  trace?: never;
22
22
  };
23
+ "/auth/login/totp": {
24
+ parameters: {
25
+ query?: never;
26
+ header?: never;
27
+ path?: never;
28
+ cookie?: never;
29
+ };
30
+ get?: never;
31
+ put?: never;
32
+ /**
33
+ * The second step of a sign-in, a code from an authenticator app or a recovery code
34
+ * @description Needs the pending_login cookie a 202 from POST /auth/login set. A right code starts the session as a 200 from /auth/login does.
35
+ */
36
+ post: operations["loginTotp"];
37
+ delete?: never;
38
+ options?: never;
39
+ head?: never;
40
+ patch?: never;
41
+ trace?: never;
42
+ };
23
43
  "/auth/register": {
24
44
  parameters: {
25
45
  query?: never;
@@ -91,6 +111,26 @@ export interface paths {
91
111
  patch?: never;
92
112
  trace?: never;
93
113
  };
114
+ "/client-errors": {
115
+ parameters: {
116
+ query?: never;
117
+ header?: never;
118
+ path?: never;
119
+ cookie?: never;
120
+ };
121
+ get?: never;
122
+ put?: never;
123
+ /**
124
+ * A failure in the browser, for the backend to forward to error reporting
125
+ * @description Sent by the package when an app turns on FoundationProviders' errorReporting. Needs a session, so the public can't post into it. The backend may drop a report (a duplicate, too many) and still answer 204. The user is the session's; the report never carries request or response bodies, cookies, headers, form values or query strings.
126
+ */
127
+ post: operations["reportClientError"];
128
+ delete?: never;
129
+ options?: never;
130
+ head?: never;
131
+ patch?: never;
132
+ trace?: never;
133
+ };
94
134
  }
95
135
  export type webhooks = Record<string, never>;
96
136
  export interface components {
@@ -110,11 +150,41 @@ export interface components {
110
150
  email: string;
111
151
  password: string;
112
152
  };
153
+ LoginNextStep: {
154
+ /**
155
+ * @description The step the sign-in still needs.
156
+ * @enum {string}
157
+ */
158
+ next: "totp";
159
+ };
160
+ TotpLoginRequest: {
161
+ /** @description The 6-digit code from the authenticator app, or a recovery code. */
162
+ code: string;
163
+ };
113
164
  RegisterRequest: {
114
165
  email: components["schemas"]["User"]["email"];
115
166
  name: components["schemas"]["User"]["name"];
116
167
  password: components["schemas"]["LoginRequest"]["password"];
117
168
  };
169
+ ClientErrorReport: {
170
+ /**
171
+ * @description Where the package caught it.
172
+ * @enum {string}
173
+ */
174
+ source: "route" | "window" | "unhandledrejection" | "query" | "mutation";
175
+ /** @description The error's type (TypeError, ServerError). */
176
+ type: string;
177
+ message: string;
178
+ stack?: string;
179
+ /** @description The route pattern (/items/:id), never the URL. */
180
+ route?: string;
181
+ /** @description The HTTP method of a failed request. */
182
+ method?: string;
183
+ /** @description The HTTP status of a failed request. */
184
+ status?: number;
185
+ /** @description The frontend build (its commit). */
186
+ release?: string;
187
+ };
118
188
  /** @description FastAPI's default 422 shape (RequestValidationError). */
119
189
  ValidationErrorBody: {
120
190
  detail: {
@@ -156,6 +226,26 @@ export interface components {
156
226
  "application/json": components["schemas"]["HTTPErrorBody"];
157
227
  };
158
228
  };
229
+ /** @description Too many failed attempts. Try again after Retry-After seconds. */
230
+ RateLimitedError: {
231
+ headers: {
232
+ /** @description Seconds until another attempt is allowed. */
233
+ "Retry-After"?: number;
234
+ [name: string]: unknown;
235
+ };
236
+ content: {
237
+ "application/json": components["schemas"]["HTTPErrorBody"];
238
+ };
239
+ };
240
+ /** @description The pending sign-in is missing, expired or out of tries. Start again with POST /auth/login. */
241
+ SignInExpiredError: {
242
+ headers: {
243
+ [name: string]: unknown;
244
+ };
245
+ content: {
246
+ "application/json": components["schemas"]["HTTPErrorBody"];
247
+ };
248
+ };
159
249
  /** @description Unhandled server error. */
160
250
  ServerError: {
161
251
  headers: {
@@ -185,6 +275,43 @@ export interface operations {
185
275
  "application/json": components["schemas"]["LoginRequest"];
186
276
  };
187
277
  };
278
+ responses: {
279
+ /** @description Authenticated. Sets the session_id cookie. */
280
+ 200: {
281
+ headers: {
282
+ [name: string]: unknown;
283
+ };
284
+ content: {
285
+ "application/json": components["schemas"]["User"];
286
+ };
287
+ };
288
+ /** @description The password is right and the account has a second step. No session yet: sets a short-lived, single-use pending_login cookie for POST /auth/login/totp. */
289
+ 202: {
290
+ headers: {
291
+ [name: string]: unknown;
292
+ };
293
+ content: {
294
+ "application/json": components["schemas"]["LoginNextStep"];
295
+ };
296
+ };
297
+ 401: components["responses"]["UnauthorizedError"];
298
+ 422: components["responses"]["ValidationError"];
299
+ 429: components["responses"]["RateLimitedError"];
300
+ 500: components["responses"]["ServerError"];
301
+ };
302
+ };
303
+ loginTotp: {
304
+ parameters: {
305
+ query?: never;
306
+ header?: never;
307
+ path?: never;
308
+ cookie?: never;
309
+ };
310
+ requestBody: {
311
+ content: {
312
+ "application/json": components["schemas"]["TotpLoginRequest"];
313
+ };
314
+ };
188
315
  responses: {
189
316
  /** @description Authenticated. Sets the session_id cookie. */
190
317
  200: {
@@ -196,7 +323,9 @@ export interface operations {
196
323
  };
197
324
  };
198
325
  401: components["responses"]["UnauthorizedError"];
326
+ 410: components["responses"]["SignInExpiredError"];
199
327
  422: components["responses"]["ValidationError"];
328
+ 429: components["responses"]["RateLimitedError"];
200
329
  500: components["responses"]["ServerError"];
201
330
  };
202
331
  };
@@ -288,4 +417,29 @@ export interface operations {
288
417
  500: components["responses"]["ServerError"];
289
418
  };
290
419
  };
420
+ reportClientError: {
421
+ parameters: {
422
+ query?: never;
423
+ header?: never;
424
+ path?: never;
425
+ cookie?: never;
426
+ };
427
+ requestBody: {
428
+ content: {
429
+ "application/json": components["schemas"]["ClientErrorReport"];
430
+ };
431
+ };
432
+ responses: {
433
+ /** @description Received (forwarded, or dropped as a duplicate). */
434
+ 204: {
435
+ headers: {
436
+ [name: string]: unknown;
437
+ };
438
+ content?: never;
439
+ };
440
+ 401: components["responses"]["UnauthorizedError"];
441
+ 422: components["responses"]["ValidationError"];
442
+ 500: components["responses"]["ServerError"];
443
+ };
444
+ };
291
445
  }
@@ -1,5 +1,7 @@
1
1
  export type ApiResponse = {
2
2
  status: number;
3
3
  body: unknown;
4
+ /** The response's headers (a 429's Retry-After, for one). */
5
+ headers: Headers;
4
6
  };
5
7
  export declare function apiFetch(path: string, init?: RequestInit): Promise<ApiResponse>;
@@ -7,8 +7,8 @@ const BASE_URL = "/api";
7
7
  export async function apiFetch(path, init) {
8
8
  const response = await fetch(`${BASE_URL}${path}`, init);
9
9
  if (response.status === 204) {
10
- return { status: response.status, body: undefined };
10
+ return { status: response.status, body: undefined, headers: response.headers };
11
11
  }
12
12
  const body = await response.json().catch(() => undefined);
13
- return { status: response.status, body };
13
+ return { status: response.status, body, headers: response.headers };
14
14
  }
@@ -5,6 +5,24 @@ export type AuthUser = {
5
5
  email: string;
6
6
  };
7
7
  export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' | 'unavailable';
8
+ /**
9
+ * Where a sign-in stands after `login` or `submitTotp`:
10
+ * - `signed-in`: the session started.
11
+ * - `totp`: the password was right; the account wants a code next
12
+ * (`submitTotp`), and no session exists yet.
13
+ * - `restart`: the sign-in waiting for its code is gone (it expired, or
14
+ * ran out of tries); start again from the password. `message` says why.
15
+ * Anything else (a wrong password or code, too many attempts, the server
16
+ * down) rejects with an AppError.
17
+ */
18
+ export type SignInResult = {
19
+ status: 'signed-in';
20
+ } | {
21
+ status: 'totp';
22
+ } | {
23
+ status: 'restart';
24
+ message: string;
25
+ };
8
26
  export type AuthContextValue = {
9
27
  user: AuthUser | null;
10
28
  status: AuthStatus;
@@ -12,7 +30,9 @@ export type AuthContextValue = {
12
30
  error: AppError | null;
13
31
  /** Re-runs the session check (the 'unavailable' state's retry). */
14
32
  retry: () => void;
15
- login: (email: string, password: string) => Promise<void>;
33
+ login: (email: string, password: string) => Promise<SignInResult>;
34
+ /** The second step, after `login` answered `totp`: an authenticator code or a recovery code. */
35
+ submitTotp: (code: string) => Promise<SignInResult>;
16
36
  logout: () => Promise<void>;
17
37
  register: (email: string, name: string, password: string) => Promise<void>;
18
38
  };
@@ -6,7 +6,8 @@ export const AuthContext = createContext({
6
6
  status: 'loading',
7
7
  error: null,
8
8
  retry: () => { },
9
- login: async () => { },
9
+ login: async () => ({ status: 'signed-in' }),
10
+ submitTotp: async () => ({ status: 'signed-in' }),
10
11
  logout: async () => { },
11
12
  register: async () => { },
12
13
  });
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect } from 'react';
3
3
  import { useQuery, useQueryClient } from '@tanstack/react-query';
4
- import { getCurrentUser, login as loginRequest, logout as logoutRequest, register as registerRequest, } from '../api/gateway/auth.js';
4
+ import { getCurrentUser, login as loginRequest, loginTotp as loginTotpRequest, logout as logoutRequest, register as registerRequest, } from '../api/gateway/auth.js';
5
5
  import { DATA_LABEL_QUERY_KEY } from '../hooks/use-data-label.js';
6
6
  import { AuthContext } from './auth-context.js';
7
7
  // TanStack Query, not useEffect — AGENTS.md's "NEVER fetch in useEffect"
@@ -83,8 +83,21 @@ export function AuthProvider({ children }) {
83
83
  : error
84
84
  ? 'unavailable'
85
85
  : 'unauthenticated';
86
+ // With TOTP on, the password alone starts nothing: the server holds a
87
+ // pending sign-in (its own short-lived cookie) until the code arrives.
86
88
  async function login(email, password) {
87
- startSession(queryClient, await loginRequest({ email, password }));
89
+ const step = await loginRequest({ email, password });
90
+ if ('next' in step)
91
+ return { status: 'totp' };
92
+ startSession(queryClient, step.user);
93
+ return { status: 'signed-in' };
94
+ }
95
+ async function submitTotp(code) {
96
+ const step = await loginTotpRequest({ code });
97
+ if ('restart' in step)
98
+ return { status: 'restart', message: step.restart };
99
+ startSession(queryClient, step.user);
100
+ return { status: 'signed-in' };
88
101
  }
89
102
  // Logged out on screen even when the request fails (network down): the
90
103
  // person asked to leave, and a shell that still shows them signed in is
@@ -106,6 +119,7 @@ export function AuthProvider({ children }) {
106
119
  error: status === 'unavailable' ? error : null,
107
120
  retry: () => void refetch(),
108
121
  login,
122
+ submitTotp,
109
123
  logout,
110
124
  register,
111
125
  }, children: children }));
@@ -6,6 +6,8 @@ import { Link, Navigate, Outlet, useLocation } from 'react-router';
6
6
  import { useAuth } from '../../auth/use-auth.js';
7
7
  import { activeNavTo, navGroups } from './app-shell-nav.js';
8
8
  import { ErrorState } from './error-state.js';
9
+ import { useErrorReporter } from '../../error-reporting/error-reporting-context.js';
10
+ import { ReportRoutePattern } from '../../error-reporting/error-reporting-provider.js';
9
11
  import { Button } from '../ui/button.js';
10
12
  import { Skeleton } from '../ui/skeleton.js';
11
13
  import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, useSidebar, } from '../ui/sidebar.js';
@@ -41,6 +43,7 @@ function AppShellSkeleton() {
41
43
  export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true }) {
42
44
  const { user, status, error, retry, logout } = useAuth();
43
45
  const location = useLocation();
46
+ const reporter = useErrorReporter();
44
47
  // Set before logout starts, so the redirect below knows this was a
45
48
  // deliberate logout. It can't be a follow-up navigate() after logout
46
49
  // resolves: that runs before React re-renders, and the <Navigate> this
@@ -66,7 +69,7 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true }
66
69
  return _jsx(Navigate, { to: "/login", replace: true, state: isLoggingOut ? undefined : { from } });
67
70
  }
68
71
  const activeTo = activeNavTo(nav, location.pathname);
69
- return (_jsxs(TooltipProvider, { children: [_jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: group.items.map(({ to, label, icon: Icon }) => {
72
+ return (_jsxs(TooltipProvider, { children: [reporter && _jsx(ReportRoutePattern, { reporter: reporter }), _jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: group.items.map(({ to, label, icon: Icon }) => {
70
73
  // A plain Link, not NavLink: NavLink marks every
71
74
  // matching entry current, and only the most specific
72
75
  // one should be (activeNavTo).
@@ -18,6 +18,7 @@ const TITLE_BY_KIND = {
18
18
  server: 'Something went wrong',
19
19
  notfound: 'Not found',
20
20
  auth: 'Not authorized',
21
+ rate_limited: 'Too many attempts',
21
22
  validation: 'Check the form',
22
23
  };
23
24
  export function ErrorState({ error, onRetry, size = 'default', className, }) {
@@ -1,9 +1,17 @@
1
1
  import { type QueryClient } from '@tanstack/react-query';
2
2
  import { type ReactNode } from 'react';
3
- export declare function FoundationProviders({ mockMode, queryClient, children, }: {
3
+ import { type ErrorReportingOptions } from '../../error-reporting/reporter.js';
4
+ export declare function FoundationProviders({ mockMode, queryClient, errorReporting, children, }: {
4
5
  /** Whether the API is served by MSW — the app's own IS_MOCK_MODE. */
5
6
  mockMode: boolean;
6
7
  /** Defaults to createQueryClient(); pass one to share it with code outside React. */
7
8
  queryClient?: QueryClient;
9
+ /**
10
+ * Off by default. On, crashes, uncaught errors and server failures are
11
+ * posted to the app's own POST /api/client-errors (while signed in),
12
+ * which forwards them; see docs/deploy.md "Error reporting" for what a
13
+ * report holds and never holds. `{ release }` names the build.
14
+ */
15
+ errorReporting?: boolean | ErrorReportingOptions;
8
16
  children: ReactNode;
9
17
  }): import("react").JSX.Element;
@@ -5,13 +5,16 @@ import { createQueryClient } from '../../api/query-client.js';
5
5
  import { AuthProvider } from '../../auth/use-auth.js';
6
6
  import { MockModeBanner } from './mock-mode-banner.js';
7
7
  import { ThemeProvider } from '../theme-provider.js';
8
+ import { ErrorReportingProvider } from '../../error-reporting/error-reporting-provider.js';
9
+ import { resolveErrorReporting } from '../../error-reporting/reporter.js';
8
10
  // Everything an app's root needs around its router, in the order that
9
11
  // works: the theme outermost, the mock-mode banner above the router so it
10
12
  // shows on every route (/login included), and AuthProvider inside
11
13
  // QueryClientProvider because it queries /auth/me through TanStack Query.
12
14
  // An app's DataEnvironmentBanner goes first among the children, before its
13
15
  // router: it queries too, and shows on every route like the mock banner.
14
- export function FoundationProviders({ mockMode, queryClient, children, }) {
16
+ export function FoundationProviders({ mockMode, queryClient, errorReporting, children, }) {
15
17
  const [client] = useState(() => queryClient ?? createQueryClient());
16
- return (_jsxs(ThemeProvider, { attribute: "class", defaultTheme: "system", enableSystem: true, children: [_jsx(MockModeBanner, { active: mockMode }), _jsx(QueryClientProvider, { client: client, children: _jsx(AuthProvider, { children: children }) })] }));
18
+ const reporting = resolveErrorReporting(errorReporting);
19
+ return (_jsxs(ThemeProvider, { attribute: "class", defaultTheme: "system", enableSystem: true, children: [_jsx(MockModeBanner, { active: mockMode }), _jsx(QueryClientProvider, { client: client, children: _jsx(AuthProvider, { children: reporting ? _jsx(ErrorReportingProvider, { options: reporting, children: children }) : children }) })] }));
17
20
  }
@@ -1,9 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { TriangleAlertIcon } from 'lucide-react';
3
+ import { useEffect } from 'react';
3
4
  import { isRouteErrorResponse, useRouteError } from 'react-router';
5
+ import { useErrorReporter, useRoutePattern } from '../../error-reporting/error-reporting-context.js';
4
6
  import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
5
7
  export function RouteErrorBoundary() {
6
8
  const error = useRouteError();
9
+ const reporter = useErrorReporter();
10
+ const route = useRoutePattern();
11
+ useEffect(() => {
12
+ // A crash, or a loader's 5xx; never a 404 (no such page) or other 4xx.
13
+ if (!reporter || (isRouteErrorResponse(error) && error.status < 500))
14
+ return;
15
+ reporter.setRoute(route);
16
+ reporter.report('route', error, isRouteErrorResponse(error) ? { status: error.status } : undefined);
17
+ }, [reporter, error, route]);
7
18
  const message = isRouteErrorResponse(error)
8
19
  ? `${error.status} ${error.statusText}`
9
20
  : error instanceof Error
@@ -0,0 +1,6 @@
1
+ import { type ErrorReporter } from './reporter.js';
2
+ export declare const ErrorReportingContext: import("react").Context<ErrorReporter | null>;
3
+ /** The reporter, or null when the app hasn't turned reporting on. */
4
+ export declare function useErrorReporter(): ErrorReporter | null;
5
+ /** The current route's pattern (`/widgets/:id`), from a data router's matches. */
6
+ export declare function useRoutePattern(): string | null;
@@ -0,0 +1,16 @@
1
+ import { createContext, useContext } from 'react';
2
+ import { useMatches } from 'react-router';
3
+ import { routePattern } from './reporter.js';
4
+ // Null unless FoundationProviders' errorReporting is on
5
+ // (./error-reporting-provider.tsx), so every use of it does nothing then.
6
+ export const ErrorReportingContext = createContext(null);
7
+ /** The reporter, or null when the app hasn't turned reporting on. */
8
+ export function useErrorReporter() {
9
+ return useContext(ErrorReportingContext);
10
+ }
11
+ /** The current route's pattern (`/widgets/:id`), from a data router's matches. */
12
+ export function useRoutePattern() {
13
+ const matches = useMatches();
14
+ const last = matches[matches.length - 1];
15
+ return last ? routePattern(last.pathname, last.params) : null;
16
+ }
@@ -0,0 +1,14 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type ErrorReporter, type ErrorReportingOptions } from './reporter.js';
3
+ export declare function ErrorReportingProvider({ options, children }: {
4
+ options: ErrorReportingOptions;
5
+ children: ReactNode;
6
+ }): import("react").JSX.Element;
7
+ /**
8
+ * Keeps the reporter's route current, for reports that don't know theirs.
9
+ * AppShell renders it only while reporting is on: the router's matches
10
+ * need a data router (createBrowserRouter), which reporting then needs too.
11
+ */
12
+ export declare function ReportRoutePattern({ reporter }: {
13
+ reporter: ErrorReporter;
14
+ }): null;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // The React side of error reporting (./reporter.ts): FoundationProviders
3
+ // mounts this inside AuthProvider when `errorReporting` is on. Off, there's
4
+ // no provider, the context is null, and nothing here runs.
5
+ import { useQueryClient } from '@tanstack/react-query';
6
+ import { useEffect, useState } from 'react';
7
+ import { reportClientError } from '../api/gateway/client-errors.js';
8
+ import { useAuth } from '../auth/use-auth.js';
9
+ import { ErrorReportingContext, useRoutePattern } from './error-reporting-context.js';
10
+ import { createErrorReporter } from './reporter.js';
11
+ export function ErrorReportingProvider({ options, children }) {
12
+ const queryClient = useQueryClient();
13
+ const { status } = useAuth();
14
+ const [reporter] = useState(() => createErrorReporter({ options, send: (report) => void reportClientError(report) }));
15
+ useEffect(() => reporter.setSignedIn(status === 'authenticated'), [reporter, status]);
16
+ useEffect(() => reporter.install(queryClient, window), [reporter, queryClient]);
17
+ return _jsx(ErrorReportingContext.Provider, { value: reporter, children: children });
18
+ }
19
+ /**
20
+ * Keeps the reporter's route current, for reports that don't know theirs.
21
+ * AppShell renders it only while reporting is on: the router's matches
22
+ * need a data router (createBrowserRouter), which reporting then needs too.
23
+ */
24
+ export function ReportRoutePattern({ reporter }) {
25
+ const pattern = useRoutePattern();
26
+ useEffect(() => reporter.setRoute(pattern), [reporter, pattern]);
27
+ return null;
28
+ }