@alxia/react-router 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +6 -1
  2. package/dist/chunks/{index-3jfeew2y.js → index-fpy7f4bz.js} +5 -58
  3. package/dist/chunks/index-fpy7f4bz.js.map +11 -0
  4. package/dist/chunks/index-xvx63t7n.js +59 -0
  5. package/dist/chunks/index-xvx63t7n.js.map +13 -0
  6. package/dist/session/index.d.ts +1 -1
  7. package/dist/session/index.d.ts.map +1 -1
  8. package/dist/session/index.js +20 -2
  9. package/dist/session/index.js.map +3 -3
  10. package/dist/session/model/index.js +5 -3
  11. package/dist/session/model/index.js.map +1 -1
  12. package/dist/session/react/index.d.ts +3 -2
  13. package/dist/session/react/index.d.ts.map +1 -1
  14. package/dist/session/react/index.js +199 -17
  15. package/dist/session/react/index.js.map +8 -3
  16. package/dist/session/react/step-up/answers.d.ts +9 -0
  17. package/dist/session/react/step-up/answers.d.ts.map +1 -0
  18. package/dist/session/react/step-up/dialog-props.d.ts +27 -0
  19. package/dist/session/react/step-up/dialog-props.d.ts.map +1 -0
  20. package/dist/session/react/step-up/index.d.ts +6 -0
  21. package/dist/session/react/step-up/index.d.ts.map +1 -0
  22. package/dist/session/react/step-up/phase.d.ts +8 -0
  23. package/dist/session/react/step-up/phase.d.ts.map +1 -0
  24. package/dist/session/react/step-up/use-step-up-replay.d.ts +54 -0
  25. package/dist/session/react/step-up/use-step-up-replay.d.ts.map +1 -0
  26. package/dist/session/react/step-up/use-step-up.d.ts +51 -0
  27. package/dist/session/react/step-up/use-step-up.d.ts.map +1 -0
  28. package/dist/session/step-up-types.d.ts +25 -0
  29. package/dist/session/step-up-types.d.ts.map +1 -1
  30. package/dist/session/step-up.d.ts.map +1 -1
  31. package/docs/guide/session.md +133 -2
  32. package/docs/guide/subscriptions.md +18 -4
  33. package/docs/troubleshooting.md +24 -4
  34. package/package.json +4 -4
  35. package/dist/chunks/index-3jfeew2y.js.map +0 -15
@@ -13,7 +13,8 @@ It covers three subpaths:
13
13
  `react-router` and `/errors`' files, nothing else, knows no API client and
14
14
  adds no peer.
15
15
  - `@alxia/react-router/session/react`, the page's hooks around those flows:
16
- signing out, a mail link's parameter and a one-code field. They import
16
+ signing out, a mail link's parameter, a one-code field and the step-up
17
+ with the replay of the refused action. They import
17
18
  `react` and `react-router`, both already peers.
18
19
  - `@alxia/react-router/session/model`, the part that imports nothing and is
19
20
  safe in a component as well as in a loader: redirects that stay on the
@@ -664,6 +665,30 @@ export async function action({ context, request }: Route.ActionArgs) {
664
665
  }
665
666
  ```
666
667
 
668
+ To serve the step-up to every page from one resource route, `route(run, {
669
+ request, language })` is that whole action: it reads the same-site JSON body
670
+ and runs `run.request()` for `{ intent: 'request-step-up' }` or
671
+ `run.confirm({ challenge, code })` for `{ intent: 'confirm-step-up',
672
+ challenge, code, via? }`. Any other intent is a 400.
673
+
674
+ ```ts
675
+ // app/routes/step-up.tsx: any page posts the step-up intents here
676
+ export async function action({ context, request }: Route.ActionArgs) {
677
+ const { language } = alxiaOf(context);
678
+ const caller = await sessionCaller(request);
679
+ return stepUp.route(
680
+ {
681
+ request: async () => {
682
+ const { requestStepUp } = await execute(RequestStepUpMutation, {}, caller);
683
+ return { challenge: requestStepUp.challenge, via: toVia(requestStepUp.method) };
684
+ },
685
+ confirm: (input) => execute(ConfirmStepUpMutation, input, caller),
686
+ },
687
+ { request, language },
688
+ );
689
+ }
690
+ ```
691
+
667
692
  `request(run, { request, language })` answers `{ ok: true,
668
693
  intent: 'request-step-up', challenge, via }`. `confirm(run, { request,
669
694
  language, via? })` answers `{ ok: true, intent: 'confirm-step-up' }`. Both
@@ -991,7 +1016,7 @@ out of any `Referer`.
991
1016
 
992
1017
  ## The page's hooks
993
1018
 
994
- `@alxia/react-router/session/react` holds the three client pieces every app
1019
+ `@alxia/react-router/session/react` holds the client pieces every app
995
1020
  writes around the flows above. It imports `react` and `react-router` (peers
996
1021
  the package already has) and nothing else; every hook renders on the server.
997
1022
 
@@ -1131,6 +1156,112 @@ The package takes no form library as a peer: react-hook-form would also need
1131
1156
  its resolver for the schema, and a library-agnostic core serves every form
1132
1157
  library at the price of three lines.
1133
1158
 
1159
+ ### `useStepUpReplay`
1160
+
1161
+ A sensitive action the API refuses with `step-up-required` answers with
1162
+ `stepUp` set. `useStepUpReplay` opens the step-up on that answer, posts the
1163
+ `request-step-up` and `confirm-step-up` intents, and sends the refused action
1164
+ again once the session is confirmed. The dialog stays, saying so, until the
1165
+ replayed action is answered; an answer that asks for a step-up again reopens
1166
+ it. Cancelling forgets the step-up: a code accepted or a challenge granted
1167
+ after `cancel()` is ignored and replays nothing. It is generic over your own
1168
+ `post`s, so each intent keeps the fields your action types.
1169
+
1170
+ An action that carries a one-time code (`spendsCode`) is not replayed after a
1171
+ step-up by the second factor: that code was just spent, so the dialog closes
1172
+ and `confirmedNotice` is true ("confirmed, type a new code").
1173
+
1174
+ `route()` answers both intents from one action, so a fetcher on it has their
1175
+ union as data. `requestAnswerOf(data)` and `confirmAnswerOf(data)` narrow it on
1176
+ `intent` (and give `undefined` for any other answer), so each channel is typed
1177
+ from the one action.
1178
+
1179
+ ```tsx
1180
+ import { useRef } from 'react';
1181
+ import { useFetcher } from 'react-router';
1182
+ import {
1183
+ confirmAnswerOf,
1184
+ requestAnswerOf,
1185
+ useStepUpReplay,
1186
+ } from '@alxia/react-router/session/react';
1187
+ import type { action as stepUpAction } from './step-up'; // the route with `stepUp.route`
1188
+
1189
+ type DeleteItem = { intent: 'delete-item'; id: string };
1190
+
1191
+ export function useDeleteItem() {
1192
+ const fetcher = useFetcher<{ ok: boolean; stepUp?: boolean }>();
1193
+ const request = useFetcher<typeof stepUpAction>({ key: 'step-up-request' });
1194
+ const confirm = useFetcher<typeof stepUpAction>({ key: 'step-up-confirm' });
1195
+ const last = useRef<DeleteItem | null>(null);
1196
+
1197
+ const post = (action: DeleteItem) => {
1198
+ last.current = action;
1199
+ void fetcher.submit(action, { method: 'post', encType: 'application/json' });
1200
+ };
1201
+ const toStepUp = (body: Record<string, unknown>) =>
1202
+ void request.submit(body, {
1203
+ method: 'post',
1204
+ action: '/step-up',
1205
+ encType: 'application/json',
1206
+ });
1207
+
1208
+ const stepUp = useStepUpReplay({
1209
+ answer: fetcher.state === 'idle' ? fetcher.data : undefined,
1210
+ action: last.current ?? undefined,
1211
+ replay: () => {
1212
+ if (last.current) post(last.current);
1213
+ },
1214
+ request: {
1215
+ post: () => toStepUp({ intent: 'request-step-up' }),
1216
+ answer: requestAnswerOf(request.data),
1217
+ busy: request.state !== 'idle',
1218
+ reset: () => request.reset(),
1219
+ },
1220
+ confirm: {
1221
+ post: (fields) =>
1222
+ void confirm.submit(
1223
+ { intent: 'confirm-step-up', ...fields },
1224
+ { method: 'post', action: '/step-up', encType: 'application/json' },
1225
+ ),
1226
+ answer: confirmAnswerOf(confirm.data),
1227
+ busy: confirm.state !== 'idle',
1228
+ reset: () => confirm.reset(),
1229
+ },
1230
+ });
1231
+ return { post, ...stepUp };
1232
+ }
1233
+ // Pass `dialogProps` to your step-up dialog: phase, via, props, message,
1234
+ // busy, confirming, onCancel, onRetry, onSubmit, onResend.
1235
+ ```
1236
+
1237
+ `dialogProps` is `dialogPropsOf(stepUp, open, cancel)`, which is plain and
1238
+ exported, for a host that builds them itself.
1239
+
1240
+ ### `useStepUp`, `asksStepUp` and `stepUpDialogPhase`
1241
+
1242
+ `useStepUp({ request, confirm, onConfirmed })` is the part under it, for a host
1243
+ that decides itself what to do once the code is accepted. `start()` asks for a
1244
+ challenge, `submit(code)` posts it, `release()` forgets everything.
1245
+
1246
+ ```tsx
1247
+ import { useStepUp } from '@alxia/react-router/session/react';
1248
+
1249
+ const stepUp = useStepUp({ request, confirm, onConfirmed: () => revalidate() });
1250
+ // stepUp.phase: 'asking' | 'failed' | 'code' | 'expired' | 'confirmed'
1251
+ ```
1252
+
1253
+ `asksStepUp(answer)` is true for a refusal carrying `stepUp` (what opens the
1254
+ dialog), and `stepUpDialogPhase(state)` is what the dialog shows for a
1255
+ `stepUpReducer` state:
1256
+
1257
+ ```ts
1258
+ import { asksStepUp, stepUpDialogPhase } from '@alxia/react-router/session/react';
1259
+ import { INITIAL_STEP_UP } from '@alxia/react-router/session/model';
1260
+
1261
+ asksStepUp({ ok: false, stepUp: true }); // true
1262
+ stepUpDialogPhase(INITIAL_STEP_UP); // 'asking'
1263
+ ```
1264
+
1134
1265
  ## The types and the rest of `/session/model`
1135
1266
 
1136
1267
  The rest of `@alxia/react-router/session/model`, beside `safeNext`,
@@ -113,10 +113,24 @@ adapter or reverse proxy needs to buffer it.
113
113
 
114
114
  ## Keep the stream alive
115
115
 
116
- Bun closes an idle connection after `idleTimeout` seconds (10 by default).
117
- The heartbeat covers it as long as `heartbeatMs` is shorter, and so is a proxy's
118
- read timeout. Behind nginx, keep `proxy_buffering off` for the route (the
119
- `X-Accel-Buffering: no` header does it per response).
116
+ Bun closes a connection that has been idle for `idleTimeout` seconds, 10 by
117
+ default, which is shorter than the proxy's 15 second heartbeat (`heartbeatMs`)
118
+ and than the API's own 12 second ping. Left alone, an idle subscription is
119
+ reset at about 8 to 10 seconds, before the first heartbeat. Set `idleTimeout`
120
+ above both in the server's `listen` options:
121
+
122
+ ```ts
123
+ // app/server.ts
124
+ export default createServer({
125
+ listen: { idleTimeout: 30 }, // seconds
126
+ });
127
+ ```
128
+
129
+ The same goes for the API's server (`app.listen({ idleTimeout: 30 })` with
130
+ `@alxia/graphql`), and for a proxy's read timeout, which must be longer than
131
+ `heartbeatMs` too. Calling `server.timeout(request, 0)` from a resolver does
132
+ not lift it for the stream. Behind nginx, keep `proxy_buffering off` for the
133
+ route (the `X-Accel-Buffering: no` header does it per response).
120
134
 
121
135
  ## Cross-site requests
122
136
 
@@ -142,6 +142,24 @@ a loader, a message React Router or the browser prints, or an error from
142
142
 
143
143
  ## Thrown or printed
144
144
 
145
+ ### `Not a step-up intent` (a 400 from `stepUp.route`)
146
+
147
+ **When:** a page posts to the route that serves `stepUpSteps().route` with an
148
+ `intent` other than `request-step-up` or `confirm-step-up`, or a
149
+ `confirm-step-up` whose `challenge` or `code` is not a string.
150
+
151
+ **Why:** `route()` answers only those two intents and runs nothing else.
152
+
153
+ **Fix:** post the intents with the fields `useStepUp` gives your `confirm.post`
154
+ (`challenge`, `code`, `via`), as JSON:
155
+
156
+ ```ts
157
+ confirm.submit(
158
+ { intent: 'confirm-step-up', challenge, code, via },
159
+ { method: 'post', action: '/step-up', encType: 'application/json' },
160
+ );
161
+ ```
162
+
145
163
  ### `alxia-react-router: … is running on Node, and alxia's server runs on Bun. …`
146
164
 
147
165
  **When:** `bun run dev`, `vite preview`, or a `react-router build` that
@@ -2909,10 +2927,12 @@ blank line.
2909
2927
  ### Events arrive in bursts, or the stream closes after a few seconds
2910
2928
 
2911
2929
  **Why:** a proxy or CDN between the app and the browser buffers the response,
2912
- or closes an idle connection (Bun's `idleTimeout`, a load balancer's timeout)
2930
+ or closes an idle connection (Bun's `idleTimeout`, 10 seconds by default,
2931
+ shorter than the 15 second `heartbeatMs`; a load balancer's timeout)
2913
2932
  because `heartbeatMs` is longer than it.
2914
2933
 
2915
- **Fix:** keep `heartbeatMs` shorter than the shortest idle timeout on the
2916
- path, turn response buffering off for the route (nginx `proxy_buffering off`;
2917
- the route already sends `X-Accel-Buffering: no`), and do not compress the
2934
+ **Fix:** set `listen: { idleTimeout: 30 }` in `createServer` (and
2935
+ `idleTimeout: 30` on the API's `listen`), keep `heartbeatMs` shorter than the
2936
+ shortest idle timeout on the path, turn response buffering off for the
2937
+ route (nginx `proxy_buffering off`; the route already sends `X-Accel-Buffering: no`), and do not compress the
2918
2938
  route's responses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/react-router",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served; the code with no UI an installable app shares (`/pwa`), its hooks (`/pwa/react`), `noCache` for the worker's script (`/pwa/server`), the service worker on Workbox (`/pwa/worker`) and its build (`/pwa/build`), an API's refusals worded for the page (`/errors`), the BFF session: cookies, sign-in, step-up and mail links (`/session`), its page hooks (`/session/react`) and what it shares with the page (`/session/model`), a GraphQL subscription relayed to the browser over SSE (`/subscription`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -136,9 +136,9 @@
136
136
  },
137
137
  "devDependencies": {
138
138
  "@alxia/compress": "^0.3.12",
139
- "@alxia/core": "^0.17.0",
139
+ "@alxia/core": "^0.17.1",
140
140
  "@alxia/openapi": "^0.6.12",
141
- "@nxgt/graphql-client": "^0.5.0",
141
+ "@nxgt/graphql-client": "^0.6.0",
142
142
  "@nxgt/janus": "^0.29.0",
143
143
  "@react-router/dev": "^8.5.0",
144
144
  "@types/bun": "^1.4.4",
@@ -161,7 +161,7 @@
161
161
  "zod": "^4.2.0"
162
162
  },
163
163
  "peerDependencies": {
164
- "@alxia/core": "^0.17.0",
164
+ "@alxia/core": "^0.17.1",
165
165
  "isbot": "^5.0.0",
166
166
  "react": "^19.0.0",
167
167
  "react-dom": "^19.0.0",
@@ -1,15 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../src/session/model/safe-next.ts", "../src/session/model/site-path.ts", "../src/session/model/paths.ts", "../src/session/model/second-factor.ts", "../src/session/model/step-up.ts", "../src/session/model/step-up-props.ts"],
4
- "sourcesContent": [
5
- "/** Where a sign-in lands when `next` is missing or unsafe. */\nexport const HOME = '/';\n\n/** The base a `next` is resolved against; `.invalid` never resolves. */\nconst BASE = 'http://next.invalid';\n\n/**\n * The `next` a sign-in may redirect to: a path of this site only. Anything\n * that could leave it (`//host`, `/\\host`, `https://…`, `javascript:…`, a\n * control character a browser would strip, a value over 2000 characters)\n * falls back to `/`. The path is read after normalisation, so `/a/..//host`\n * is refused too.\n *\n * @example\n * safeNext('/orders?page=2'); // '/orders?page=2'\n * safeNext('//evil.test'); // '/'\n */\nexport function safeNext(value: string | null | undefined): string {\n\tif (!value || value.length > 2000) return HOME;\n\tif (!value.startsWith('/') || value.startsWith('//')) return HOME;\n\t// Browsers read `\\` as `/` and drop tabs and newlines in URLs.\n\tif (/[\\\\\\p{Cc}\\s]/u.test(value)) return HOME;\n\ttry {\n\t\tconst url = new URL(value, BASE);\n\t\tconst path = `${url.pathname}${url.search}${url.hash}`;\n\t\t// Dot segments can collapse into `//host` again: check what is answered.\n\t\tif (url.origin !== BASE || path.startsWith('//')) return HOME;\n\t\treturn path;\n\t} catch {\n\t\treturn HOME;\n\t}\n}\n",
6
- "import { safeNext } from './safe-next';\n\n/**\n * A path of this site, or a `TypeError` naming `label`. With no `query`, the\n * path holds no `?` or `#` either, and the message prints it only up to\n * there, since what follows may be a token.\n */\nexport function sitePath(\n\tpath: string,\n\t{\n\t\tlabel,\n\t\tquery = false,\n\t}: { readonly label: string; readonly query?: boolean | undefined },\n): string {\n\tif (safeNext(path) !== path || (!query && /[?#]/.test(path))) {\n\t\tconst shown = query ? path : path.replace(/[?#].*$/s, '?…');\n\t\tthrow new TypeError(\n\t\t\t`${label} must be a path of this site${query ? '' : ' with no query or fragment'}, starting with a single \"/\"; got ${JSON.stringify(shown)}.`,\n\t\t);\n\t}\n\treturn path;\n}\n",
7
- "import { HOME, safeNext } from './safe-next';\nimport { sitePath } from './site-path';\n\n/** The two pages of a sign-in. */\nexport interface SignInPaths {\n\treadonly signIn: string;\n\treadonly secondFactor: string;\n}\n\n/** The defaults: `/sign-in` and `/sign-in/second-factor`. */\nexport const SIGN_IN_PATHS: SignInPaths = {\n\tsignIn: '/sign-in',\n\tsecondFactor: '/sign-in/second-factor',\n};\n\nfunction withNext(\n\tpath: string,\n\tnext: string,\n\tquery?: Record<string, string>,\n): string {\n\tconst params = new URLSearchParams(query);\n\tconst target = safeNext(next);\n\tif (target !== HOME) params.set('next', target);\n\tconst search = params.toString();\n\treturn search ? `${path}?${search}` : path;\n}\n\n/**\n * The sign-in page's URL, coming back to `next` (none for the home page, and\n * `next` goes through `safeNext`). `query` adds parameters, such as\n * `{ expired: '1' }`; `path` replaces `/sign-in` and must be a path of this\n * site with no query or fragment (a `TypeError` otherwise).\n *\n * @example\n * signInPath('/orders'); // '/sign-in?next=%2Forders'\n * signInPath('/', { query: { expired: '1' } }); // '/sign-in?expired=1'\n */\nexport function signInPath(\n\tnext: string,\n\toptions: {\n\t\treadonly query?: Record<string, string> | undefined;\n\t\treadonly path?: string | undefined;\n\t} = {},\n): string {\n\tconst path =\n\t\toptions.path === undefined\n\t\t\t? SIGN_IN_PATHS.signIn\n\t\t\t: sitePath(options.path, { label: 'signInPath(): options.path' });\n\treturn withNext(path, next, options.query);\n}\n\n/** The second-factor page's URL, coming back to `next` as `signInPath` does. */\nexport function secondFactorPath(\n\tnext: string,\n\tpath: string = SIGN_IN_PATHS.secondFactor,\n): string {\n\treturn withNext(path, next);\n}\n",
8
- "/** A code of an authenticator app: six digits. */\nexport const TOTP_CODE: RegExp = /^\\d{6}$/;\n\n/**\n * What a second-factor form sends: `code` is the issued code (an app's, or\n * the one mailed), `recovery` a recovery code. Janus has no name for it, only\n * `confirm` and `recover`.\n */\nexport type SecondFactorKind = 'code' | 'recovery';\n\n/** How a sign-in ended, in `@nxgt/janus`'s words (`SignInResult['status']`). */\nexport type SignInStatus = 'signedIn' | 'secondFactor';\n\n/**\n * How a step-up is confirmed, in `@nxgt/janus`'s words: a code mailed to the\n * current address, or the account's second factor.\n */\nexport type StepUpVia = 'email' | 'secondFactor';\n",
9
- "import type { StepUpVia } from './second-factor';\nimport type {\n\tStepUpConfirmAnswer,\n\tStepUpError,\n\tStepUpRequestAnswer,\n} from './step-up-answers';\n\n/** Where a step-up is. */\nexport type StepUpPhase =\n\t| 'idle'\n\t| 'requesting'\n\t| 'code'\n\t| 'confirming'\n\t| 'done';\n\nexport interface StepUpState {\n\treadonly phase: StepUpPhase;\n\t/** How the challenge held (or the last one) is confirmed. */\n\treadonly via?: StepUpVia | undefined;\n\t/** The challenge held. A secret: never in a URL, a log or loader data. */\n\treadonly challenge?: string | undefined;\n\t/** The tries the last refused code left. */\n\treadonly attemptsLeft?: number | undefined;\n\t/** No try left on an app or recovery code: wait, asking again would fail too. */\n\treadonly locked: boolean;\n\treadonly error?: StepUpError | undefined;\n\t/** The last refusal worded for the page, until the next step or the release. */\n\treadonly message?: string | undefined;\n\t/** The seconds a throttled request asks to wait before asking again. */\n\treadonly retryAfter?: number | undefined;\n\t/** A request or a code is in flight. */\n\treadonly pending: boolean;\n\t/** The last request replaced a challenge that was held. */\n\treadonly resent: boolean;\n}\n\nexport type StepUpAction =\n\t| { readonly type: 'start' }\n\t| { readonly type: 'requested'; readonly answer: StepUpRequestAnswer }\n\t| { readonly type: 'submit' }\n\t| { readonly type: 'confirmed'; readonly answer: StepUpConfirmAnswer }\n\t| { readonly type: 'release' };\n\nexport const INITIAL_STEP_UP: StepUpState = {\n\tphase: 'idle',\n\tlocked: false,\n\tpending: false,\n\tresent: false,\n};\n\nfunction requested(\n\tstate: StepUpState,\n\tanswer: StepUpRequestAnswer,\n): StepUpState {\n\tif (!answer.ok) {\n\t\t// A resend refused leaves the code form up, and says why in it.\n\t\treturn {\n\t\t\t...state,\n\t\t\tphase: state.challenge ? 'code' : 'idle',\n\t\t\terror: answer.error,\n\t\t\tmessage: answer.message,\n\t\t\tretryAfter: answer.retryAfter,\n\t\t\tpending: false,\n\t\t\tresent: false,\n\t\t};\n\t}\n\treturn {\n\t\tphase: 'code',\n\t\tvia: answer.via,\n\t\tchallenge: answer.challenge,\n\t\tlocked: false,\n\t\tpending: false,\n\t\tresent: state.challenge !== undefined,\n\t};\n}\n\n/** A refused code. At 0 tries left a mailed code is over (ask again), an app or recovery code is locked (wait). */\nfunction refused(\n\tstate: StepUpState,\n\tanswer: Extract<StepUpConfirmAnswer, { ok: false }>,\n): StepUpState {\n\tconst none = answer.attemptsLeft === 0;\n\tconst over = answer.error === 'expired' || (none && state.via === 'email');\n\tif (over) {\n\t\treturn {\n\t\t\tphase: 'idle',\n\t\t\t...(state.via ? { via: state.via } : {}),\n\t\t\tlocked: false,\n\t\t\terror: 'expired',\n\t\t\tmessage: answer.message,\n\t\t\tpending: false,\n\t\t\tresent: false,\n\t\t};\n\t}\n\tif (answer.error === 'throttled') {\n\t\t// Asking too often spends nothing: the challenge and its tries stay.\n\t\treturn {\n\t\t\t...state,\n\t\t\tphase: 'code',\n\t\t\terror: 'throttled',\n\t\t\tmessage: answer.message,\n\t\t\tretryAfter: answer.retryAfter,\n\t\t\tpending: false,\n\t\t\tresent: false,\n\t\t};\n\t}\n\treturn {\n\t\t...state,\n\t\tphase: 'code',\n\t\terror: answer.error,\n\t\tmessage: answer.message,\n\t\tretryAfter: undefined,\n\t\tattemptsLeft: answer.attemptsLeft,\n\t\tlocked: answer.locked || none,\n\t\tpending: false,\n\t\tresent: false,\n\t};\n}\n\n/**\n * The step-up's transitions, pure. `start` (a request in flight) forgets the\n * last error but keeps a challenge held, so a refused \"Resend code\" leaves\n * the code form up; a refusal keeps its `message` (and a throttle its\n * `retryAfter`) until the next step; a granted request replaces the challenge; a code accepted\n * spends it; an expired or spent one leaves \"ask again\"; at 0 tries left a\n * mailed code is `expired` and an app or recovery code is `locked`.\n */\nexport function stepUpReducer(\n\tstate: StepUpState,\n\taction: StepUpAction,\n): StepUpState {\n\tswitch (action.type) {\n\t\tcase 'start':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tphase: state.challenge ? 'code' : 'requesting',\n\t\t\t\terror: undefined,\n\t\t\t\tmessage: undefined,\n\t\t\t\tretryAfter: undefined,\n\t\t\t\tpending: true,\n\t\t\t\tresent: false,\n\t\t\t};\n\t\tcase 'requested':\n\t\t\treturn requested(state, action.answer);\n\t\tcase 'submit':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tphase: 'confirming',\n\t\t\t\terror: undefined,\n\t\t\t\tmessage: undefined,\n\t\t\t\tretryAfter: undefined,\n\t\t\t\tpending: true,\n\t\t\t};\n\t\tcase 'confirmed':\n\t\t\treturn action.answer.ok\n\t\t\t\t? {\n\t\t\t\t\t\tphase: 'done',\n\t\t\t\t\t\t...(state.via ? { via: state.via } : {}),\n\t\t\t\t\t\tlocked: false,\n\t\t\t\t\t\tpending: false,\n\t\t\t\t\t\tresent: false,\n\t\t\t\t\t}\n\t\t\t\t: refused(state, action.answer);\n\t\tcase 'release':\n\t\t\treturn INITIAL_STEP_UP;\n\t}\n}\n",
10
- "import type { StepUpVia } from './second-factor';\nimport type { StepUpState } from './step-up';\nimport type { StepUpError } from './step-up-answers';\n\n/**\n * The props of `@nxgt/material`'s `VerificationCode` and `StepUpDialog` that\n * the state decides. The state's `message` is not one: the dialog words its\n * own texts from `error`, `attemptsLeft`, `locked` and `retryAfter`. The callbacks (`onSubmit`, `onResend`) are the app's.\n */\nexport interface StepUpProps {\n\treadonly via?: StepUpVia;\n\treadonly attemptsLeft?: number;\n\treadonly locked: boolean;\n\treadonly error?: StepUpError;\n\t/** The seconds to wait when `error` is `throttled` and the API said how long. */\n\treadonly retryAfter?: number;\n\treadonly pending: boolean;\n\treadonly resent: boolean;\n}\n\n/** The state as the dialog's props: `{ ...stepUpProps(state), onSubmit, onResend }`. */\nexport function stepUpProps(state: StepUpState): StepUpProps {\n\treturn {\n\t\t...(state.via ? { via: state.via } : {}),\n\t\t...(state.attemptsLeft === undefined\n\t\t\t? {}\n\t\t\t: { attemptsLeft: state.attemptsLeft }),\n\t\tlocked: state.locked,\n\t\t...(state.error ? { error: state.error } : {}),\n\t\t...(state.retryAfter === undefined ? {} : { retryAfter: state.retryAfter }),\n\t\tpending: state.pending,\n\t\tresent: state.resent,\n\t};\n}\n"
11
- ],
12
- "mappings": "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",
13
- "debugId": "E77CC5BBAAF44F7C64756E2164756E21",
14
- "names": []
15
- }