@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.
- package/dist/api/contracts.d.ts +6 -1
- package/dist/api/gateway/auth.d.ts +19 -1
- package/dist/api/gateway/auth.js +20 -2
- package/dist/api/gateway/client-errors.d.ts +4 -0
- package/dist/api/gateway/client-errors.js +14 -0
- package/dist/api/gateway/errors.d.ts +2 -1
- package/dist/api/gateway/errors.js +34 -1
- package/dist/api/schema.d.ts +154 -0
- package/dist/api/transport/index.d.ts +2 -0
- package/dist/api/transport/index.js +2 -2
- package/dist/auth/auth-context.d.ts +21 -1
- package/dist/auth/auth-context.js +2 -1
- package/dist/auth/auth-provider.js +16 -2
- package/dist/components/app/app-shell.js +4 -1
- package/dist/components/app/error-state.js +1 -0
- package/dist/components/app/foundation-providers.d.ts +9 -1
- package/dist/components/app/foundation-providers.js +5 -2
- package/dist/components/app/route-error-boundary.js +11 -0
- package/dist/error-reporting/error-reporting-context.d.ts +6 -0
- package/dist/error-reporting/error-reporting-context.js +16 -0
- package/dist/error-reporting/error-reporting-provider.d.ts +14 -0
- package/dist/error-reporting/error-reporting-provider.js +28 -0
- package/dist/error-reporting/reporter.d.ts +30 -0
- package/dist/error-reporting/reporter.js +126 -0
- package/dist/index.d.ts +2 -1
- package/dist/mocks/auth.d.ts +3 -0
- package/dist/mocks/auth.js +39 -0
- package/dist/mocks/client-errors.d.ts +7 -0
- package/dist/mocks/client-errors.js +24 -0
- package/dist/mocks/e2e-hooks.js +2 -2
- package/dist/mocks/index.d.ts +2 -1
- package/dist/mocks/index.js +2 -1
- package/dist/mocks/override.d.ts +2 -0
- package/dist/routes/login-schema.d.ts +7 -0
- package/dist/routes/login-schema.js +8 -0
- package/dist/routes/login.js +95 -9
- package/openapi/foundation.yaml +114 -2
- package/package.json +1 -1
package/dist/api/contracts.d.ts
CHANGED
|
@@ -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
|
-
|
|
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>;
|
package/dist/api/gateway/auth.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
package/dist/api/schema.d.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|