@tristan2828/ui-foundation 3.30.0 → 4.0.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/conventions/docs/cell-patterns.md +6 -0
- 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/errors.d.ts +5 -1
- package/dist/api/gateway/errors.js +22 -1
- package/dist/api/schema.d.ts +90 -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/error-state.js +1 -0
- package/dist/components/app/multi-choice.js +13 -1
- package/dist/components/app/multi-reference.js +13 -1
- package/dist/index.d.ts +1 -1
- package/dist/mocks/auth.d.ts +3 -0
- package/dist/mocks/auth.js +39 -0
- package/dist/mocks/e2e-hooks.js +2 -2
- package/dist/mocks/index.d.ts +1 -1
- package/dist/mocks/index.js +1 -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 +70 -2
- package/package.json +1 -1
|
@@ -688,6 +688,12 @@ Save button, no Undo:
|
|
|
688
688
|
| `choice`: a select, a switch, a single reference | the pick (its list opens with the field, non-modal) | closes the list with nothing picked: gives up |
|
|
689
689
|
| `multi`: a multi reference | leaving it, or Ctrl/Cmd+Enter (Enter picks) | closes the list first; Esc again gives up |
|
|
690
690
|
|
|
691
|
+
In either multi picker (`MultiChoice`, `MultiReference`), on a form, in
|
|
692
|
+
a toolbar filter or in place, Escape never removes a pick: it closes the
|
|
693
|
+
list when it's open and leaves the value alone when it's closed (Base UI
|
|
694
|
+
would empty it). A pick comes off only by its chip's remove button,
|
|
695
|
+
Backspace in the empty input, or unpicking it in the list.
|
|
696
|
+
|
|
691
697
|
Leaving means clicking or tabbing anywhere else. Unchanged closes with no
|
|
692
698
|
request; a value the form's schema refuses never leaves the browser and
|
|
693
699
|
shows the schema's message. Opening another value saves the open one
|
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
|
|
@@ -1,5 +1,9 @@
|
|
|
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
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Pass the response's `headers` so a 429 carries its Retry-After
|
|
6
|
+
* (`retryAfterSeconds`); without them it's still `rate_limited`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function toAppError(status: number, body: unknown, headers?: Headers): AppError;
|
|
5
9
|
export declare function networkError(): AppError;
|
|
@@ -11,7 +11,11 @@ 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
|
+
export function toAppError(status, body, headers) {
|
|
15
19
|
if (status === 422) {
|
|
16
20
|
// A rule the server enforces (a cap, a status the change needs) is
|
|
17
21
|
// often refused with FastAPI's HTTPException shape: a sentence, not a
|
|
@@ -32,8 +36,25 @@ export function toAppError(status, body) {
|
|
|
32
36
|
return { kind: "notfound", message };
|
|
33
37
|
if (status === 401)
|
|
34
38
|
return { kind: "auth", message };
|
|
39
|
+
if (status === 429) {
|
|
40
|
+
const retryAfterSeconds = parseRetryAfter(headers?.get("retry-after") ?? null);
|
|
41
|
+
return retryAfterSeconds === undefined
|
|
42
|
+
? { kind: "rate_limited", message }
|
|
43
|
+
: { kind: "rate_limited", message, retryAfterSeconds };
|
|
44
|
+
}
|
|
35
45
|
return { kind: "server", message };
|
|
36
46
|
}
|
|
47
|
+
// Retry-After is either whole seconds or an HTTP date (RFC 9110 10.2.3).
|
|
48
|
+
function parseRetryAfter(value) {
|
|
49
|
+
if (value === null || value.trim() === "")
|
|
50
|
+
return undefined;
|
|
51
|
+
if (/^\d+$/.test(value.trim()))
|
|
52
|
+
return Number(value.trim());
|
|
53
|
+
const at = Date.parse(value);
|
|
54
|
+
if (Number.isNaN(at))
|
|
55
|
+
return undefined;
|
|
56
|
+
return Math.max(0, Math.ceil((at - Date.now()) / 1000));
|
|
57
|
+
}
|
|
37
58
|
// The key a form binds a 422 to (react-hook-form's field path):
|
|
38
59
|
// - ["body", "price"] → "price"
|
|
39
60
|
// - ["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;
|
|
@@ -110,6 +130,17 @@ export interface components {
|
|
|
110
130
|
email: string;
|
|
111
131
|
password: string;
|
|
112
132
|
};
|
|
133
|
+
LoginNextStep: {
|
|
134
|
+
/**
|
|
135
|
+
* @description The step the sign-in still needs.
|
|
136
|
+
* @enum {string}
|
|
137
|
+
*/
|
|
138
|
+
next: "totp";
|
|
139
|
+
};
|
|
140
|
+
TotpLoginRequest: {
|
|
141
|
+
/** @description The 6-digit code from the authenticator app, or a recovery code. */
|
|
142
|
+
code: string;
|
|
143
|
+
};
|
|
113
144
|
RegisterRequest: {
|
|
114
145
|
email: components["schemas"]["User"]["email"];
|
|
115
146
|
name: components["schemas"]["User"]["name"];
|
|
@@ -156,6 +187,26 @@ export interface components {
|
|
|
156
187
|
"application/json": components["schemas"]["HTTPErrorBody"];
|
|
157
188
|
};
|
|
158
189
|
};
|
|
190
|
+
/** @description Too many failed attempts. Try again after Retry-After seconds. */
|
|
191
|
+
RateLimitedError: {
|
|
192
|
+
headers: {
|
|
193
|
+
/** @description Seconds until another attempt is allowed. */
|
|
194
|
+
"Retry-After"?: number;
|
|
195
|
+
[name: string]: unknown;
|
|
196
|
+
};
|
|
197
|
+
content: {
|
|
198
|
+
"application/json": components["schemas"]["HTTPErrorBody"];
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
/** @description The pending sign-in is missing, expired or out of tries. Start again with POST /auth/login. */
|
|
202
|
+
SignInExpiredError: {
|
|
203
|
+
headers: {
|
|
204
|
+
[name: string]: unknown;
|
|
205
|
+
};
|
|
206
|
+
content: {
|
|
207
|
+
"application/json": components["schemas"]["HTTPErrorBody"];
|
|
208
|
+
};
|
|
209
|
+
};
|
|
159
210
|
/** @description Unhandled server error. */
|
|
160
211
|
ServerError: {
|
|
161
212
|
headers: {
|
|
@@ -185,6 +236,43 @@ export interface operations {
|
|
|
185
236
|
"application/json": components["schemas"]["LoginRequest"];
|
|
186
237
|
};
|
|
187
238
|
};
|
|
239
|
+
responses: {
|
|
240
|
+
/** @description Authenticated. Sets the session_id cookie. */
|
|
241
|
+
200: {
|
|
242
|
+
headers: {
|
|
243
|
+
[name: string]: unknown;
|
|
244
|
+
};
|
|
245
|
+
content: {
|
|
246
|
+
"application/json": components["schemas"]["User"];
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
/** @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. */
|
|
250
|
+
202: {
|
|
251
|
+
headers: {
|
|
252
|
+
[name: string]: unknown;
|
|
253
|
+
};
|
|
254
|
+
content: {
|
|
255
|
+
"application/json": components["schemas"]["LoginNextStep"];
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
401: components["responses"]["UnauthorizedError"];
|
|
259
|
+
422: components["responses"]["ValidationError"];
|
|
260
|
+
429: components["responses"]["RateLimitedError"];
|
|
261
|
+
500: components["responses"]["ServerError"];
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
loginTotp: {
|
|
265
|
+
parameters: {
|
|
266
|
+
query?: never;
|
|
267
|
+
header?: never;
|
|
268
|
+
path?: never;
|
|
269
|
+
cookie?: never;
|
|
270
|
+
};
|
|
271
|
+
requestBody: {
|
|
272
|
+
content: {
|
|
273
|
+
"application/json": components["schemas"]["TotpLoginRequest"];
|
|
274
|
+
};
|
|
275
|
+
};
|
|
188
276
|
responses: {
|
|
189
277
|
/** @description Authenticated. Sets the session_id cookie. */
|
|
190
278
|
200: {
|
|
@@ -196,7 +284,9 @@ export interface operations {
|
|
|
196
284
|
};
|
|
197
285
|
};
|
|
198
286
|
401: components["responses"]["UnauthorizedError"];
|
|
287
|
+
410: components["responses"]["SignInExpiredError"];
|
|
199
288
|
422: components["responses"]["ValidationError"];
|
|
289
|
+
429: components["responses"]["RateLimitedError"];
|
|
200
290
|
500: components["responses"]["ServerError"];
|
|
201
291
|
};
|
|
202
292
|
};
|
|
@@ -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 }));
|
|
@@ -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, }) {
|
|
@@ -13,5 +13,17 @@ import * as React from 'react';
|
|
|
13
13
|
import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxValue, useComboboxAnchor, } from '../ui/combobox.js';
|
|
14
14
|
export function MultiChoice({ options, value, onValueChange, getLabel = String, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }) {
|
|
15
15
|
const anchor = useComboboxAnchor();
|
|
16
|
-
return (_jsxs(Combobox, { multiple: true, autoHighlight: true, items: options, value: value, onValueChange: (next
|
|
16
|
+
return (_jsxs(Combobox, { multiple: true, autoHighlight: true, items: options, value: value, onValueChange: (next, details) => {
|
|
17
|
+
// Base UI empties the value on Escape while the list is closed, so
|
|
18
|
+
// a second Escape after a pick wiped every chip (issue #123). Escape
|
|
19
|
+
// never removes picks here: a chip's remove button, Backspace or
|
|
20
|
+
// unpicking in the list does. Let the key go on, so a dialog or an
|
|
21
|
+
// edit in place gets its own Escape.
|
|
22
|
+
if (details.reason === 'escape-key') {
|
|
23
|
+
details.cancel();
|
|
24
|
+
details.allowPropagation();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
onValueChange(next);
|
|
28
|
+
}, itemToStringLabel: getLabel, children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: getLabel(option) }, option))), _jsx(ComboboxChipsInput, { id: id, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, placeholder: selected.length === 0 ? placeholder : undefined })] })) }) }), _jsxs(ComboboxContent, { anchor: anchor, children: [_jsx(ComboboxEmpty, { children: "No options found." }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: getLabel(option) }, option)) })] })] }));
|
|
17
29
|
}
|
|
@@ -27,7 +27,19 @@ export function MultiReference({ options, value, onValueChange, getLabel, onSear
|
|
|
27
27
|
return (_jsxs(Combobox, { multiple: true, autoHighlight: true,
|
|
28
28
|
// Ids, not {id,label} objects: Base UI compares items to the value
|
|
29
29
|
// with Object.is, so the items must have the value's own shape.
|
|
30
|
-
items: options.map((option) => option.id), value: value, onValueChange: (next
|
|
30
|
+
items: options.map((option) => option.id), value: value, onValueChange: (next, details) => {
|
|
31
|
+
// Base UI empties the value on Escape while the list is closed, so
|
|
32
|
+
// a second Escape after a pick wiped every chip (issue #123). Escape
|
|
33
|
+
// never removes picks here: a chip's remove button, Backspace or
|
|
34
|
+
// unpicking in the list does. Let the key go on, so a dialog or an
|
|
35
|
+
// edit in place gets its own Escape.
|
|
36
|
+
if (details.reason === 'escape-key') {
|
|
37
|
+
details.cancel();
|
|
38
|
+
details.allowPropagation();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
onValueChange(next);
|
|
42
|
+
}, onInputValueChange: onSearchChange, itemToStringLabel: label, filter: null, readOnly: readOnly, children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: label(option) }, option))), _jsx(ComboboxChipsInput, { id: id, onKeyDown: (event) => {
|
|
31
43
|
if (searching && event.key === 'Enter') {
|
|
32
44
|
event.preventDefault();
|
|
33
45
|
event.preventBaseUIHandler();
|
package/dist/index.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ 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
30
|
export { createQueryClient } from './api/query-client.js';
|
|
31
31
|
export { useTableUrlState } from './hooks/use-table-url-state.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 });
|
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,4 @@
|
|
|
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
2
|
export { environmentHandlers } from './environment.js';
|
|
3
3
|
export { exposeMswForE2E } from './e2e-hooks.js';
|
|
4
4
|
export type { MswHandle, MswOverride } from './override.js';
|
package/dist/mocks/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
5
|
export { environmentHandlers } from './environment.js';
|
|
6
6
|
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
|
}
|
package/openapi/foundation.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
openapi: 3.1.0
|
|
2
2
|
info:
|
|
3
3
|
title: UI Foundation — the contract every app shares
|
|
4
|
-
version: "
|
|
4
|
+
version: "4.0.0"
|
|
5
5
|
description: >
|
|
6
6
|
The part of an app's API contract that the foundation package's own code
|
|
7
7
|
calls: session-cookie auth under /auth/*, and the two error envelopes the
|
|
@@ -30,6 +30,39 @@ paths:
|
|
|
30
30
|
content:
|
|
31
31
|
application/json:
|
|
32
32
|
schema: { $ref: "#/components/schemas/LoginRequest" }
|
|
33
|
+
responses:
|
|
34
|
+
"200":
|
|
35
|
+
description: Authenticated. Sets the session_id cookie.
|
|
36
|
+
content:
|
|
37
|
+
application/json:
|
|
38
|
+
schema: { $ref: "#/components/schemas/User" }
|
|
39
|
+
"202":
|
|
40
|
+
description: >
|
|
41
|
+
The password is right and the account has a second step. No
|
|
42
|
+
session yet: sets a short-lived, single-use pending_login cookie
|
|
43
|
+
for POST /auth/login/totp.
|
|
44
|
+
content:
|
|
45
|
+
application/json:
|
|
46
|
+
schema: { $ref: "#/components/schemas/LoginNextStep" }
|
|
47
|
+
"401": { $ref: "#/components/responses/UnauthorizedError" }
|
|
48
|
+
"422": { $ref: "#/components/responses/ValidationError" }
|
|
49
|
+
"429": { $ref: "#/components/responses/RateLimitedError" }
|
|
50
|
+
"500": { $ref: "#/components/responses/ServerError" }
|
|
51
|
+
|
|
52
|
+
/auth/login/totp:
|
|
53
|
+
x-optional: true
|
|
54
|
+
post:
|
|
55
|
+
operationId: loginTotp
|
|
56
|
+
summary: The second step of a sign-in, a code from an authenticator app or a recovery code
|
|
57
|
+
description: >
|
|
58
|
+
Needs the pending_login cookie a 202 from POST /auth/login set. A
|
|
59
|
+
right code starts the session as a 200 from /auth/login does.
|
|
60
|
+
tags: [auth]
|
|
61
|
+
requestBody:
|
|
62
|
+
required: true
|
|
63
|
+
content:
|
|
64
|
+
application/json:
|
|
65
|
+
schema: { $ref: "#/components/schemas/TotpLoginRequest" }
|
|
33
66
|
responses:
|
|
34
67
|
"200":
|
|
35
68
|
description: Authenticated. Sets the session_id cookie.
|
|
@@ -37,7 +70,9 @@ paths:
|
|
|
37
70
|
application/json:
|
|
38
71
|
schema: { $ref: "#/components/schemas/User" }
|
|
39
72
|
"401": { $ref: "#/components/responses/UnauthorizedError" }
|
|
73
|
+
"410": { $ref: "#/components/responses/SignInExpiredError" }
|
|
40
74
|
"422": { $ref: "#/components/responses/ValidationError" }
|
|
75
|
+
"429": { $ref: "#/components/responses/RateLimitedError" }
|
|
41
76
|
"500": { $ref: "#/components/responses/ServerError" }
|
|
42
77
|
|
|
43
78
|
/auth/register:
|
|
@@ -127,7 +162,26 @@ components:
|
|
|
127
162
|
required: [email, password]
|
|
128
163
|
properties:
|
|
129
164
|
email: { type: string, format: email }
|
|
130
|
-
password: { type: string, minLength: 8 }
|
|
165
|
+
password: { type: string, minLength: 8, maxLength: 1024 }
|
|
166
|
+
|
|
167
|
+
LoginNextStep:
|
|
168
|
+
type: object
|
|
169
|
+
required: [next]
|
|
170
|
+
properties:
|
|
171
|
+
next:
|
|
172
|
+
type: string
|
|
173
|
+
enum: [totp]
|
|
174
|
+
description: The step the sign-in still needs.
|
|
175
|
+
|
|
176
|
+
TotpLoginRequest:
|
|
177
|
+
type: object
|
|
178
|
+
required: [code]
|
|
179
|
+
properties:
|
|
180
|
+
code:
|
|
181
|
+
type: string
|
|
182
|
+
minLength: 1
|
|
183
|
+
maxLength: 64
|
|
184
|
+
description: The 6-digit code from the authenticator app, or a recovery code.
|
|
131
185
|
|
|
132
186
|
RegisterRequest:
|
|
133
187
|
type: object
|
|
@@ -178,6 +232,20 @@ components:
|
|
|
178
232
|
content:
|
|
179
233
|
application/json:
|
|
180
234
|
schema: { $ref: "#/components/schemas/HTTPErrorBody" }
|
|
235
|
+
RateLimitedError:
|
|
236
|
+
description: Too many failed attempts. Try again after Retry-After seconds.
|
|
237
|
+
headers:
|
|
238
|
+
Retry-After:
|
|
239
|
+
description: Seconds until another attempt is allowed.
|
|
240
|
+
schema: { type: integer, minimum: 1 }
|
|
241
|
+
content:
|
|
242
|
+
application/json:
|
|
243
|
+
schema: { $ref: "#/components/schemas/HTTPErrorBody" }
|
|
244
|
+
SignInExpiredError:
|
|
245
|
+
description: The pending sign-in is missing, expired or out of tries. Start again with POST /auth/login.
|
|
246
|
+
content:
|
|
247
|
+
application/json:
|
|
248
|
+
schema: { $ref: "#/components/schemas/HTTPErrorBody" }
|
|
181
249
|
ServerError:
|
|
182
250
|
description: Unhandled server error.
|
|
183
251
|
content:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|