@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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { ClientErrorReport } from "../api/gateway/client-errors.js";
|
|
3
|
+
export type ErrorReportingOptions = {
|
|
4
|
+
/** The frontend build a report names, e.g. the commit (`import.meta.env.VITE_RELEASE`). */
|
|
5
|
+
release?: string;
|
|
6
|
+
};
|
|
7
|
+
export type ReportSource = ClientErrorReport["source"];
|
|
8
|
+
/** FoundationProviders' prop, as options, or null when reporting is off. */
|
|
9
|
+
export declare function resolveErrorReporting(option: boolean | ErrorReportingOptions | undefined): ErrorReportingOptions | null;
|
|
10
|
+
export declare function scrub(text: string, limit: number): string;
|
|
11
|
+
/** `/widgets/7/edit` with `{ id: '7' }` → `/widgets/:id/edit`; a splat → `*`. */
|
|
12
|
+
export declare function routePattern(pathname: string, params: Readonly<Record<string, string | undefined>>): string;
|
|
13
|
+
export type ErrorReporter = {
|
|
14
|
+
/** Reports `error` from `source`, unless it's a duplicate or signed out. */
|
|
15
|
+
report: (source: ReportSource, error: unknown, request?: {
|
|
16
|
+
method?: string;
|
|
17
|
+
status?: number;
|
|
18
|
+
}) => void;
|
|
19
|
+
/** The screen's route pattern, for reports that don't know theirs. */
|
|
20
|
+
setRoute: (pattern: string | null) => void;
|
|
21
|
+
/** Whether there's a session: the endpoint refuses without one, so nothing is sent until then. */
|
|
22
|
+
setSignedIn: (signedIn: boolean) => void;
|
|
23
|
+
/** Listens on the window and the query client; returns the undo. */
|
|
24
|
+
install: (queryClient: QueryClient, target: Pick<EventTarget, "addEventListener" | "removeEventListener">) => () => void;
|
|
25
|
+
};
|
|
26
|
+
export declare function createErrorReporter({ options, send, now, }: {
|
|
27
|
+
options: ErrorReportingOptions;
|
|
28
|
+
send: (report: ClientErrorReport) => void;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
}): ErrorReporter;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { httpStatusOf } from "../api/gateway/errors.js";
|
|
2
|
+
const DEDUPE_MS = 60_000;
|
|
3
|
+
const MAX_MESSAGE = 2000;
|
|
4
|
+
const MAX_STACK = 16000;
|
|
5
|
+
const REPORTED_KINDS = new Set(["server"]);
|
|
6
|
+
/** FoundationProviders' prop, as options, or null when reporting is off. */
|
|
7
|
+
export function resolveErrorReporting(option) {
|
|
8
|
+
if (!option)
|
|
9
|
+
return null;
|
|
10
|
+
return option === true ? {} : option;
|
|
11
|
+
}
|
|
12
|
+
// A query string or fragment in a URL or path, up to a stack frame's
|
|
13
|
+
// trailing :line:col (the backend's scrub, in app/error_reporting.py, is
|
|
14
|
+
// the same rule).
|
|
15
|
+
const URL_QUERY = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#"'()]+|(?<![\w/])\/[^\s?#"'()]*)[?#][^\s"'()]*?(?=(?::\d+){1,2}(?=[\s"'()]|$)|[\s"'()]|$)/gi;
|
|
16
|
+
export function scrub(text, limit) {
|
|
17
|
+
const cut = text.replace(URL_QUERY, "$1");
|
|
18
|
+
return cut.length <= limit ? cut : `${cut.slice(0, limit - 1)}…`;
|
|
19
|
+
}
|
|
20
|
+
/** `/widgets/7/edit` with `{ id: '7' }` → `/widgets/:id/edit`; a splat → `*`. */
|
|
21
|
+
export function routePattern(pathname, params) {
|
|
22
|
+
const named = Object.entries(params).filter(([name, value]) => name !== "*" && value);
|
|
23
|
+
const splat = params["*"];
|
|
24
|
+
let path = pathname;
|
|
25
|
+
if (splat && path.endsWith(`/${splat}`))
|
|
26
|
+
path = `${path.slice(0, -splat.length)}*`;
|
|
27
|
+
return path
|
|
28
|
+
.split("/")
|
|
29
|
+
.map((segment) => {
|
|
30
|
+
const match = named.find(([, value]) => value === decodeSegment(segment));
|
|
31
|
+
return match ? `:${match[0]}` : segment;
|
|
32
|
+
})
|
|
33
|
+
.join("/");
|
|
34
|
+
}
|
|
35
|
+
function decodeSegment(segment) {
|
|
36
|
+
try {
|
|
37
|
+
return decodeURIComponent(segment);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return segment;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function isAppError(value) {
|
|
44
|
+
return typeof value === "object" && value !== null && "kind" in value && "message" in value;
|
|
45
|
+
}
|
|
46
|
+
function describe(error) {
|
|
47
|
+
if (error instanceof Error) {
|
|
48
|
+
return { type: error.name || "Error", message: error.message, stack: error.stack };
|
|
49
|
+
}
|
|
50
|
+
if (isAppError(error))
|
|
51
|
+
return { type: "ServerError", message: error.message };
|
|
52
|
+
return { type: "Error", message: typeof error === "string" ? error : "A value that isn't an Error was thrown." };
|
|
53
|
+
}
|
|
54
|
+
export function createErrorReporter({ options, send, now = Date.now, }) {
|
|
55
|
+
const lastSent = new Map();
|
|
56
|
+
let route = null;
|
|
57
|
+
let signedIn = false;
|
|
58
|
+
const report = (source, error, request = {}) => {
|
|
59
|
+
if (!signedIn)
|
|
60
|
+
return;
|
|
61
|
+
const described = describe(error);
|
|
62
|
+
const message = scrub(described.message, MAX_MESSAGE);
|
|
63
|
+
const signature = [source, described.type, message, route ?? ""].join("\u001f");
|
|
64
|
+
const at = now();
|
|
65
|
+
for (const [key, sentAt] of lastSent)
|
|
66
|
+
if (at - sentAt >= DEDUPE_MS)
|
|
67
|
+
lastSent.delete(key);
|
|
68
|
+
if (lastSent.has(signature))
|
|
69
|
+
return;
|
|
70
|
+
lastSent.set(signature, at);
|
|
71
|
+
send({
|
|
72
|
+
source,
|
|
73
|
+
type: described.type.slice(0, 200),
|
|
74
|
+
message,
|
|
75
|
+
...(described.stack ? { stack: scrub(described.stack, MAX_STACK) } : {}),
|
|
76
|
+
...(route ? { route } : {}),
|
|
77
|
+
...(request.method ? { method: request.method } : {}),
|
|
78
|
+
...(request.status ? { status: request.status } : {}),
|
|
79
|
+
...(options.release ? { release: options.release.slice(0, 100) } : {}),
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
const reportAppError = (source, error, method) => {
|
|
83
|
+
if (!isAppError(error) || !REPORTED_KINDS.has(error.kind))
|
|
84
|
+
return;
|
|
85
|
+
report(source, error, { method, status: httpStatusOf(error) });
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
report,
|
|
89
|
+
setRoute: (pattern) => {
|
|
90
|
+
route = pattern;
|
|
91
|
+
},
|
|
92
|
+
setSignedIn: (value) => {
|
|
93
|
+
signedIn = value;
|
|
94
|
+
},
|
|
95
|
+
install: (queryClient, target) => {
|
|
96
|
+
const onError = (event) => {
|
|
97
|
+
const { error, message } = event;
|
|
98
|
+
report("window", error ?? message);
|
|
99
|
+
};
|
|
100
|
+
const onRejection = (event) => {
|
|
101
|
+
const { reason } = event;
|
|
102
|
+
// An AppError is a request's answer: the caches below report the
|
|
103
|
+
// ones that are bugs, and the rest are expected.
|
|
104
|
+
if (isAppError(reason))
|
|
105
|
+
return;
|
|
106
|
+
report("unhandledrejection", reason);
|
|
107
|
+
};
|
|
108
|
+
target.addEventListener("error", onError);
|
|
109
|
+
target.addEventListener("unhandledrejection", onRejection);
|
|
110
|
+
const unsubscribeQueries = queryClient.getQueryCache().subscribe((event) => {
|
|
111
|
+
if (event.type === "updated" && event.action.type === "error")
|
|
112
|
+
reportAppError("query", event.action.error, "GET");
|
|
113
|
+
});
|
|
114
|
+
const unsubscribeMutations = queryClient.getMutationCache().subscribe((event) => {
|
|
115
|
+
if (event.type === "updated" && event.action.type === "error")
|
|
116
|
+
reportAppError("mutation", event.action.error);
|
|
117
|
+
});
|
|
118
|
+
return () => {
|
|
119
|
+
target.removeEventListener("error", onError);
|
|
120
|
+
target.removeEventListener("unhandledrejection", onRejection);
|
|
121
|
+
unsubscribeQueries();
|
|
122
|
+
unsubscribeMutations();
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -25,8 +25,9 @@ export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
|
|
25
25
|
export { RegisterRoute } from './routes/register.js';
|
|
26
26
|
export { returnPath } from './routes/return-path.js';
|
|
27
27
|
export { AuthProvider, useAuth } from './auth/use-auth.js';
|
|
28
|
-
export type { AuthContextValue, AuthStatus, AuthUser } from './auth/auth-context.js';
|
|
28
|
+
export type { AuthContextValue, AuthStatus, AuthUser, SignInResult } from './auth/auth-context.js';
|
|
29
29
|
export type { AppError, Page, QuerySpec } from './api/contracts.js';
|
|
30
|
+
export type { ErrorReportingOptions } from './error-reporting/reporter.js';
|
|
30
31
|
export { createQueryClient } from './api/query-client.js';
|
|
31
32
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
32
33
|
export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
package/dist/mocks/auth.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ import type { components } from '../api/schema.js';
|
|
|
2
2
|
type User = components['schemas']['User'];
|
|
3
3
|
export declare const MOCK_USER: User;
|
|
4
4
|
export declare const MOCK_PASSWORD = "dev-password-123";
|
|
5
|
+
export declare const MOCK_TOTP_USER: User;
|
|
6
|
+
export declare const MOCK_TOTP_CODE = "123456";
|
|
7
|
+
export declare const MOCK_RECOVERY_CODE = "abcd-efgh";
|
|
5
8
|
export declare function isMockAuthenticated(): boolean;
|
|
6
9
|
export declare function setMockAuthenticated(value: boolean): void;
|
|
7
10
|
/**
|
package/dist/mocks/auth.js
CHANGED
|
@@ -8,6 +8,14 @@ import { http, HttpResponse } from 'msw';
|
|
|
8
8
|
// logging in behaves the same whether MSW or the real API answers.
|
|
9
9
|
export const MOCK_USER = { id: 1, email: 'dev@example.com', name: 'Dev User' };
|
|
10
10
|
export const MOCK_PASSWORD = 'dev-password-123';
|
|
11
|
+
// An account with two-step sign-in, for an app's e2e test of both steps:
|
|
12
|
+
// MOCK_PASSWORD, then MOCK_TOTP_CODE (always right, as a fixed clock would
|
|
13
|
+
// make it) or MOCK_RECOVERY_CODE (once per reset). Five wrong codes end
|
|
14
|
+
// the pending sign-in (410), as the reference backend does.
|
|
15
|
+
export const MOCK_TOTP_USER = { id: 1001, email: 'two-step@example.com', name: 'Two Step User' };
|
|
16
|
+
export const MOCK_TOTP_CODE = '123456';
|
|
17
|
+
export const MOCK_RECOVERY_CODE = 'abcd-efgh';
|
|
18
|
+
const PENDING_MAX_TRIES = 5;
|
|
11
19
|
// Signed in by default: screen specs assume access, and only auth specs
|
|
12
20
|
// exercise the logged-out path (by overriding GET /auth/me to 401).
|
|
13
21
|
let authenticated = true;
|
|
@@ -16,6 +24,10 @@ let authenticated = true;
|
|
|
16
24
|
let registeredUsers = new Map([[MOCK_USER.email, MOCK_USER]]);
|
|
17
25
|
let currentUser = MOCK_USER;
|
|
18
26
|
let nextUserId = 2;
|
|
27
|
+
// The pending sign-in after MOCK_TOTP_USER's password: wrong codes so far,
|
|
28
|
+
// or null when none is waiting.
|
|
29
|
+
let pendingTries = null;
|
|
30
|
+
let recoveryCodeUsed = false;
|
|
19
31
|
export function isMockAuthenticated() {
|
|
20
32
|
return authenticated;
|
|
21
33
|
}
|
|
@@ -36,6 +48,8 @@ export function resetMockAuth() {
|
|
|
36
48
|
registeredUsers = new Map([[MOCK_USER.email, MOCK_USER]]);
|
|
37
49
|
currentUser = MOCK_USER;
|
|
38
50
|
nextUserId = 2;
|
|
51
|
+
pendingTries = null;
|
|
52
|
+
recoveryCodeUsed = false;
|
|
39
53
|
}
|
|
40
54
|
export const authHandlers = [
|
|
41
55
|
http.post('*/api/auth/register', async ({ request }) => {
|
|
@@ -68,6 +82,10 @@ export const authHandlers = [
|
|
|
68
82
|
}),
|
|
69
83
|
http.post('*/api/auth/login', async ({ request }) => {
|
|
70
84
|
const body = (await request.json());
|
|
85
|
+
if (body.email === MOCK_TOTP_USER.email && body.password === MOCK_PASSWORD) {
|
|
86
|
+
pendingTries = 0;
|
|
87
|
+
return HttpResponse.json({ next: 'totp' }, { status: 202 });
|
|
88
|
+
}
|
|
71
89
|
if (body.email !== MOCK_USER.email || body.password !== MOCK_PASSWORD) {
|
|
72
90
|
return HttpResponse.json({ detail: 'Invalid email or password' }, { status: 401 });
|
|
73
91
|
}
|
|
@@ -75,6 +93,27 @@ export const authHandlers = [
|
|
|
75
93
|
authenticated = true;
|
|
76
94
|
return HttpResponse.json(MOCK_USER);
|
|
77
95
|
}),
|
|
96
|
+
http.post('*/api/auth/login/totp', async ({ request }) => {
|
|
97
|
+
const { code } = (await request.json());
|
|
98
|
+
if (pendingTries === null) {
|
|
99
|
+
return HttpResponse.json({ detail: 'Your sign-in timed out. Enter your email and password again.' }, { status: 410 });
|
|
100
|
+
}
|
|
101
|
+
const recovery = !recoveryCodeUsed && code.trim().toLowerCase() === MOCK_RECOVERY_CODE;
|
|
102
|
+
if (code.trim() === MOCK_TOTP_CODE || recovery) {
|
|
103
|
+
if (recovery)
|
|
104
|
+
recoveryCodeUsed = true;
|
|
105
|
+
pendingTries = null;
|
|
106
|
+
currentUser = MOCK_TOTP_USER;
|
|
107
|
+
authenticated = true;
|
|
108
|
+
return HttpResponse.json(MOCK_TOTP_USER);
|
|
109
|
+
}
|
|
110
|
+
pendingTries += 1;
|
|
111
|
+
if (pendingTries >= PENDING_MAX_TRIES) {
|
|
112
|
+
pendingTries = null;
|
|
113
|
+
return HttpResponse.json({ detail: 'Too many wrong codes. Enter your email and password again.' }, { status: 410 });
|
|
114
|
+
}
|
|
115
|
+
return HttpResponse.json({ detail: "That code isn't right. Check it and try again." }, { status: 401 });
|
|
116
|
+
}),
|
|
78
117
|
http.post('*/api/auth/logout', () => {
|
|
79
118
|
authenticated = false;
|
|
80
119
|
return new HttpResponse(null, { status: 204 });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { components } from '../api/schema.js';
|
|
2
|
+
type ClientErrorReport = components['schemas']['ClientErrorReport'];
|
|
3
|
+
/** What POST /client-errors has received since the last reset. */
|
|
4
|
+
export declare function getMockClientErrors(): readonly ClientErrorReport[];
|
|
5
|
+
export declare function resetMockClientErrors(): void;
|
|
6
|
+
export declare const clientErrorHandlers: import("msw").HttpHandler[];
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// MSW handler for POST /client-errors (openapi/foundation.yaml), for an app
|
|
2
|
+
// that turns on FoundationProviders' errorReporting. It answers 204 like
|
|
3
|
+
// the reference backend and keeps what it was sent, so an app's tests can
|
|
4
|
+
// check a report was made and what it held. Like the backend, it needs the
|
|
5
|
+
// mock session.
|
|
6
|
+
import { http, HttpResponse } from 'msw';
|
|
7
|
+
import { isMockAuthenticated } from './auth.js';
|
|
8
|
+
let received = [];
|
|
9
|
+
/** What POST /client-errors has received since the last reset. */
|
|
10
|
+
export function getMockClientErrors() {
|
|
11
|
+
return received;
|
|
12
|
+
}
|
|
13
|
+
export function resetMockClientErrors() {
|
|
14
|
+
received = [];
|
|
15
|
+
}
|
|
16
|
+
export const clientErrorHandlers = [
|
|
17
|
+
http.post('*/api/client-errors', async ({ request }) => {
|
|
18
|
+
if (!isMockAuthenticated()) {
|
|
19
|
+
return HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 });
|
|
20
|
+
}
|
|
21
|
+
received = [...received, (await request.json())];
|
|
22
|
+
return new HttpResponse(null, { status: 204 });
|
|
23
|
+
}),
|
|
24
|
+
];
|
package/dist/mocks/e2e-hooks.js
CHANGED
|
@@ -19,10 +19,10 @@ export function exposeMswForE2E(worker) {
|
|
|
19
19
|
const bytes = Uint8Array.from(atob(override.bodyBase64), (character) => character.charCodeAt(0));
|
|
20
20
|
return new HttpResponse(bytes, {
|
|
21
21
|
status: override.status ?? 200,
|
|
22
|
-
headers: { 'Content-Type': override.contentType ?? 'application/octet-stream' },
|
|
22
|
+
headers: { 'Content-Type': override.contentType ?? 'application/octet-stream', ...override.headers },
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
|
-
return HttpResponse.json(override.body ?? {}, { status: override.status ?? 200 });
|
|
25
|
+
return HttpResponse.json(override.body ?? {}, { status: override.status ?? 200, headers: override.headers });
|
|
26
26
|
}));
|
|
27
27
|
}
|
|
28
28
|
}
|
package/dist/mocks/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
1
|
+
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_RECOVERY_CODE, MOCK_TOTP_CODE, MOCK_TOTP_USER, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
2
|
+
export { clientErrorHandlers, getMockClientErrors, resetMockClientErrors } from './client-errors.js';
|
|
2
3
|
export { environmentHandlers } from './environment.js';
|
|
3
4
|
export { exposeMswForE2E } from './e2e-hooks.js';
|
|
4
5
|
export type { MswHandle, MswOverride } from './override.js';
|
package/dist/mocks/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `@tristan2828/ui-foundation/mocks` — MSW support. A separate entry point
|
|
2
2
|
// so msw (an optional peer dependency) never reaches an app's main bundle
|
|
3
3
|
// unless it imports this.
|
|
4
|
-
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
4
|
+
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_RECOVERY_CODE, MOCK_TOTP_CODE, MOCK_TOTP_USER, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
5
|
+
export { clientErrorHandlers, getMockClientErrors, resetMockClientErrors } from './client-errors.js';
|
|
5
6
|
export { environmentHandlers } from './environment.js';
|
|
6
7
|
export { exposeMswForE2E } from './e2e-hooks.js';
|
package/dist/mocks/override.d.ts
CHANGED
|
@@ -5,3 +5,10 @@ export declare const loginFormSchema: z.ZodObject<{
|
|
|
5
5
|
}, z.core.$strip>;
|
|
6
6
|
export type LoginFormValues = z.infer<typeof loginFormSchema>;
|
|
7
7
|
export declare const LOGIN_FORM_DEFAULTS: LoginFormValues;
|
|
8
|
+
export declare const totpCodeFormSchema: z.ZodObject<{
|
|
9
|
+
code: z.ZodString;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
export declare const recoveryCodeFormSchema: z.ZodObject<{
|
|
12
|
+
code: z.ZodString;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
export type CodeFormValues = z.infer<typeof totpCodeFormSchema>;
|
|
@@ -11,3 +11,11 @@ export const LOGIN_FORM_DEFAULTS = {
|
|
|
11
11
|
email: '',
|
|
12
12
|
password: '',
|
|
13
13
|
};
|
|
14
|
+
// The second step, when the account has TOTP: six digits from an
|
|
15
|
+
// authenticator app, or (switched to) one of the account's recovery codes.
|
|
16
|
+
export const totpCodeFormSchema = z.object({
|
|
17
|
+
code: z.string().trim().regex(/^\d{6}$/, 'Enter the 6-digit code from your authenticator app'),
|
|
18
|
+
});
|
|
19
|
+
export const recoveryCodeFormSchema = z.object({
|
|
20
|
+
code: z.string().trim().min(1, 'Enter a recovery code').max(64, 'That is longer than a recovery code'),
|
|
21
|
+
});
|
package/dist/routes/login.js
CHANGED
|
@@ -5,26 +5,71 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
// chrome (title + Cancel + Submit), and a login screen has no "cancel"
|
|
6
6
|
// destination — so this uses the same mandated FieldGroup/Field primitives
|
|
7
7
|
// directly (AGENTS.md "Correct Patterns").
|
|
8
|
+
//
|
|
9
|
+
// Two steps when the account has TOTP (4.0): the password, then a code
|
|
10
|
+
// from an authenticator app or a recovery code. No session exists between
|
|
11
|
+
// them; the server keeps a pending sign-in for a few minutes, and when it
|
|
12
|
+
// has gone (expired, out of tries) the screen goes back to the password
|
|
13
|
+
// with the reason.
|
|
8
14
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
9
|
-
import { useState } from 'react';
|
|
15
|
+
import { useEffect, useState } from 'react';
|
|
10
16
|
import { useForm } from 'react-hook-form';
|
|
11
17
|
import { Link, useLocation, useNavigate } from 'react-router';
|
|
12
18
|
import { useAuth } from '../auth/use-auth.js';
|
|
13
19
|
import { ErrorState } from '../components/app/error-state.js';
|
|
14
20
|
import { PasswordInput } from '../components/app/password-input.js';
|
|
15
21
|
import { Button } from '../components/ui/button.js';
|
|
16
|
-
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card.js';
|
|
17
|
-
import { Field, FieldError, FieldGroup, FieldLabel } from '../components/ui/field.js';
|
|
22
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card.js';
|
|
23
|
+
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '../components/ui/field.js';
|
|
18
24
|
import { Input } from '../components/ui/input.js';
|
|
19
25
|
import { Spinner } from '../components/ui/spinner.js';
|
|
20
|
-
import { LOGIN_FORM_DEFAULTS, loginFormSchema } from './login-schema.js';
|
|
26
|
+
import { LOGIN_FORM_DEFAULTS, loginFormSchema, recoveryCodeFormSchema, totpCodeFormSchema, } from './login-schema.js';
|
|
21
27
|
import { returnPath } from './return-path.js';
|
|
28
|
+
// A refusal for too many attempts holds the submit button until the
|
|
29
|
+
// server's Retry-After has passed, so the screen never offers a retry it
|
|
30
|
+
// knows will be refused.
|
|
31
|
+
function useRateLimitHold(error) {
|
|
32
|
+
// The refusal whose wait has passed; the button holds until it's this one.
|
|
33
|
+
const [released, setReleased] = useState(null);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (error?.kind !== 'rate_limited')
|
|
36
|
+
return;
|
|
37
|
+
const seconds = error.retryAfterSeconds ?? 60;
|
|
38
|
+
const timer = window.setTimeout(() => setReleased(error), seconds * 1000);
|
|
39
|
+
return () => window.clearTimeout(timer);
|
|
40
|
+
}, [error]);
|
|
41
|
+
return error?.kind === 'rate_limited' && released !== error;
|
|
42
|
+
}
|
|
22
43
|
export function LoginRoute({ registerPath = '/register' } = {}) {
|
|
23
|
-
const { login } = useAuth();
|
|
24
44
|
const navigate = useNavigate();
|
|
25
45
|
const location = useLocation();
|
|
46
|
+
const [step, setStep] = useState('password');
|
|
47
|
+
// Why the screen came back to the password from the code step.
|
|
48
|
+
const [restartReason, setRestartReason] = useState(null);
|
|
49
|
+
const onResult = (result) => {
|
|
50
|
+
if (result.status === 'signed-in') {
|
|
51
|
+
navigate(returnPath(location.state), { replace: true });
|
|
52
|
+
}
|
|
53
|
+
else if (result.status === 'totp') {
|
|
54
|
+
setRestartReason(null);
|
|
55
|
+
setStep('code');
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
setRestartReason(result.message);
|
|
59
|
+
setStep('password');
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return (_jsx("main", { className: "flex min-h-screen items-center justify-center bg-background p-6", children: _jsxs(Card, { className: "w-full max-w-sm", children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-lg", children: _jsx("h1", { children: "Sign in" }) }), step === 'code' && _jsx(CardDescription, { children: "Your account has two-step sign-in." })] }), _jsx(CardContent, { children: step === 'password' ? (_jsx(PasswordStep, { registerPath: registerPath, restartReason: restartReason, onResult: onResult })) : (_jsx(CodeStep, { onResult: onResult, onStartOver: () => {
|
|
63
|
+
setRestartReason(null);
|
|
64
|
+
setStep('password');
|
|
65
|
+
} })) })] }) }));
|
|
66
|
+
}
|
|
67
|
+
function PasswordStep({ registerPath, restartReason, onResult, }) {
|
|
68
|
+
const { login } = useAuth();
|
|
69
|
+
const location = useLocation();
|
|
26
70
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
27
71
|
const [submitError, setSubmitError] = useState(null);
|
|
72
|
+
const held = useRateLimitHold(submitError);
|
|
28
73
|
const form = useForm({
|
|
29
74
|
resolver: zodResolver(loginFormSchema),
|
|
30
75
|
defaultValues: LOGIN_FORM_DEFAULTS,
|
|
@@ -33,8 +78,7 @@ export function LoginRoute({ registerPath = '/register' } = {}) {
|
|
|
33
78
|
setSubmitError(null);
|
|
34
79
|
setIsSubmitting(true);
|
|
35
80
|
try {
|
|
36
|
-
await login(values.email, values.password);
|
|
37
|
-
navigate(returnPath(location.state), { replace: true });
|
|
81
|
+
onResult(await login(values.email, values.password));
|
|
38
82
|
}
|
|
39
83
|
catch (err) {
|
|
40
84
|
const error = err;
|
|
@@ -46,7 +90,46 @@ export function LoginRoute({ registerPath = '/register' } = {}) {
|
|
|
46
90
|
else {
|
|
47
91
|
// 401 lands here as one form-level message — never bound to the
|
|
48
92
|
// email or password field individually, so a failed attempt can't
|
|
49
|
-
// be used to confirm which part was wrong.
|
|
93
|
+
// be used to confirm which part was wrong. So does a 429.
|
|
94
|
+
setSubmitError(error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
setIsSubmitting(false);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
return (_jsxs("form", { onSubmit: onSubmit, noValidate: true, className: "flex flex-col gap-6", children: [restartReason && _jsx(FieldError, { children: restartReason }), _jsxs(FieldGroup, { children: [_jsxs(Field, { "data-invalid": !!form.formState.errors.email, children: [_jsx(FieldLabel, { htmlFor: "login-email", children: "Email" }), _jsx(Input, { id: "login-email", type: "email", autoComplete: "username", "aria-invalid": !!form.formState.errors.email, ...form.register('email') }), _jsx(FieldError, { errors: [form.formState.errors.email] })] }), _jsxs(Field, { "data-invalid": !!form.formState.errors.password, children: [_jsx(FieldLabel, { htmlFor: "login-password", children: "Password" }), _jsx(PasswordInput, { id: "login-password", autoComplete: "current-password", "aria-invalid": !!form.formState.errors.password, ...form.register('password') }), _jsx(FieldError, { errors: [form.formState.errors.password] })] })] }), submitError && _jsx(ErrorState, { error: submitError }), _jsxs(Button, { type: "submit", disabled: isSubmitting || held, children: [isSubmitting && _jsx(Spinner, { className: "size-4" }), "Sign in"] }), registerPath !== null && (_jsx(Button, { variant: "link", size: "sm", nativeButton: false, render: _jsx(Link, { to: registerPath, state: location.state }), children: "Create account" }))] }));
|
|
102
|
+
}
|
|
103
|
+
function CodeStep({ onResult, onStartOver, }) {
|
|
104
|
+
const [recovery, setRecovery] = useState(false);
|
|
105
|
+
// A fresh form per mode: its own schema, an empty field, and the
|
|
106
|
+
// browser's one-time-code autofill and numeric keypad following the mode.
|
|
107
|
+
return (_jsx(CodeForm, { recovery: recovery, onSwitchMode: () => setRecovery((value) => !value), onResult: onResult, onStartOver: onStartOver }, recovery ? 'recovery' : 'totp'));
|
|
108
|
+
}
|
|
109
|
+
function CodeForm({ recovery, onSwitchMode, onResult, onStartOver, }) {
|
|
110
|
+
const { submitTotp } = useAuth();
|
|
111
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
112
|
+
const [submitError, setSubmitError] = useState(null);
|
|
113
|
+
const held = useRateLimitHold(submitError);
|
|
114
|
+
const form = useForm({
|
|
115
|
+
resolver: zodResolver(recovery ? recoveryCodeFormSchema : totpCodeFormSchema),
|
|
116
|
+
defaultValues: { code: '' },
|
|
117
|
+
});
|
|
118
|
+
// The field takes the caret when the step (or the mode) appears.
|
|
119
|
+
useEffect(() => form.setFocus('code'), [form]);
|
|
120
|
+
const onSubmit = form.handleSubmit(async (values) => {
|
|
121
|
+
setSubmitError(null);
|
|
122
|
+
setIsSubmitting(true);
|
|
123
|
+
try {
|
|
124
|
+
onResult(await submitTotp(values.code));
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
const error = err;
|
|
128
|
+
if (error.kind === 'auth' || (error.kind === 'validation' && error.fieldErrors?.code)) {
|
|
129
|
+
// A wrong code is the field's error: the code is the only thing typed.
|
|
130
|
+
form.setError('code', { message: error.fieldErrors?.code?.[0] ?? error.message }, { shouldFocus: true });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
50
133
|
setSubmitError(error);
|
|
51
134
|
}
|
|
52
135
|
}
|
|
@@ -54,5 +137,8 @@ export function LoginRoute({ registerPath = '/register' } = {}) {
|
|
|
54
137
|
setIsSubmitting(false);
|
|
55
138
|
}
|
|
56
139
|
});
|
|
57
|
-
|
|
140
|
+
const invalid = !!form.formState.errors.code;
|
|
141
|
+
return (_jsxs("form", { onSubmit: onSubmit, noValidate: true, className: "flex flex-col gap-6", children: [_jsx(FieldGroup, { children: _jsxs(Field, { "data-invalid": invalid, children: [_jsx(FieldLabel, { htmlFor: "login-code", children: recovery ? 'Recovery code' : 'Authentication code' }), _jsx(Input, { id: "login-code", autoComplete: recovery ? 'off' : 'one-time-code', inputMode: recovery ? 'text' : 'numeric', spellCheck: false, "aria-invalid": invalid, "aria-describedby": "login-code-description", ...form.register('code') }), _jsx(FieldDescription, { id: "login-code-description", children: recovery
|
|
142
|
+
? 'One of the recovery codes you saved when two-step sign-in was turned on. Each works once.'
|
|
143
|
+
: 'The 6-digit code your authenticator app shows for this account.' }), _jsx(FieldError, { errors: [form.formState.errors.code] })] }) }), submitError && _jsx(ErrorState, { error: submitError }), _jsxs(Button, { type: "submit", disabled: isSubmitting || held, children: [isSubmitting && _jsx(Spinner, { className: "size-4" }), "Verify"] }), _jsxs("div", { className: "flex flex-wrap justify-between gap-2", children: [_jsx(Button, { type: "button", variant: "link", size: "sm", onClick: onSwitchMode, children: recovery ? 'Use your authenticator app' : 'Use a recovery code' }), _jsx(Button, { type: "button", variant: "link", size: "sm", onClick: onStartOver, children: "Start over" })] })] }));
|
|
58
144
|
}
|