@payez/next-mvp 4.13.1 → 4.15.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 (58) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/dist/api-handlers/auth/credential-login.js +46 -0
  3. package/dist/api-handlers/mfa/first-enrol.d.ts +5 -0
  4. package/dist/api-handlers/mfa/first-enrol.js +58 -0
  5. package/dist/api-handlers/mfa/totp-confirm.d.ts +1 -1
  6. package/dist/api-handlers/mfa/totp-confirm.js +10 -6
  7. package/dist/api-handlers/test/force-expire.d.ts +4 -5
  8. package/dist/api-handlers/test/force-expire.js +14 -63
  9. package/dist/auth/better-auth.d.ts +16 -0
  10. package/dist/auth/better-auth.js +34 -0
  11. package/dist/components/auth/CredentialSignInForm.d.ts +6 -1
  12. package/dist/components/auth/CredentialSignInForm.js +15 -1
  13. package/dist/components/mfa/FirstEnrolPanel.d.ts +21 -0
  14. package/dist/components/mfa/FirstEnrolPanel.js +115 -0
  15. package/dist/lib/credential-sign-in-client.d.ts +4 -0
  16. package/dist/lib/credential-sign-in-client.js +13 -1
  17. package/dist/lib/first-enrol.d.ts +66 -0
  18. package/dist/lib/first-enrol.js +188 -0
  19. package/dist/lib/idp-client-config.js +1 -1
  20. package/dist/lib/log-safe.d.ts +6 -0
  21. package/dist/lib/log-safe.js +9 -0
  22. package/dist/lib/payez-client-secret.js +2 -1
  23. package/dist/lib/token-pages-switch.d.ts +23 -0
  24. package/dist/lib/token-pages-switch.js +48 -0
  25. package/dist/pages/first-enroll/page.d.ts +7 -0
  26. package/dist/pages/first-enroll/page.js +15 -0
  27. package/dist/pages/test-env/JwtInspectPage.d.ts +16 -14
  28. package/dist/pages/test-env/JwtInspectPage.js +16 -14
  29. package/dist/pages/test-env/RefreshTokenPage.d.ts +7 -10
  30. package/dist/pages/test-env/RefreshTokenPage.js +7 -10
  31. package/dist/pages/test-env/guarded.js +21 -57
  32. package/dist/pages/test-env/index.d.ts +15 -24
  33. package/dist/pages/test-env/index.js +15 -26
  34. package/dist/routes/mfa/first-enrol-confirm.d.ts +10 -0
  35. package/dist/routes/mfa/first-enrol-confirm.js +14 -0
  36. package/dist/routes/mfa/first-enrol-enroll.d.ts +10 -0
  37. package/dist/routes/mfa/first-enrol-enroll.js +14 -0
  38. package/package.json +26 -1
  39. package/src/api-handlers/auth/credential-login.ts +49 -0
  40. package/src/api-handlers/mfa/first-enrol.ts +55 -0
  41. package/src/api-handlers/mfa/totp-confirm.ts +13 -5
  42. package/src/api-handlers/test/force-expire.ts +13 -70
  43. package/src/auth/better-auth.ts +44 -2
  44. package/src/components/auth/CredentialSignInForm.tsx +18 -1
  45. package/src/components/mfa/FirstEnrolPanel.tsx +189 -0
  46. package/src/lib/credential-sign-in-client.ts +13 -1
  47. package/src/lib/first-enrol.ts +180 -0
  48. package/src/lib/idp-client-config.ts +2 -2
  49. package/src/lib/log-safe.ts +9 -0
  50. package/src/lib/payez-client-secret.ts +2 -1
  51. package/src/lib/token-pages-switch.ts +44 -0
  52. package/src/pages/first-enroll/page.tsx +19 -0
  53. package/src/pages/test-env/JwtInspectPage.tsx +16 -14
  54. package/src/pages/test-env/RefreshTokenPage.tsx +7 -10
  55. package/src/pages/test-env/guarded.tsx +21 -55
  56. package/src/pages/test-env/index.ts +15 -26
  57. package/src/routes/mfa/first-enrol-confirm.ts +10 -0
  58. package/src/routes/mfa/first-enrol-enroll.ts +10 -0
package/CHANGELOG.md CHANGED
@@ -1,6 +1,38 @@
1
1
  # Changelog — @payez/next-mvp
2
2
 
3
- ## Unreleased
3
+ ## 4.15.0 (to be published under dist-tag `next` only, after 4.14.0; `latest` stays 4.8.9 until Jon says otherwise)
4
+
5
+ What main holds since 4.14.0, plus two new exports. Built from GitHub `keelsecurity/keelauth-next` main `1e65be6c9305`; this release commit changes only this file and the version. 4.14.0 was built from `33c188e0` (no tag, no `gitHead`); this one should be tagged `v4.15.0` at the sha it is published from. Consumers keep their own pins. The number is Jon's: two of the changes below are additive exports, which semver would call a minor.
6
+
7
+ **Behaviour changes (read first)**
8
+
9
+ - **CHANGE (PAY-3216, PR #25, Jon's order): the four test-env pages and the force-expire handler work with NO flag again, as before 4.2.0.** 4.14.0 still carried the `ENABLE_TEST_ROUTES` gate (`1d40a8c`: the pages rendered `notFound()` unless the flag was `true`; `072d20e`: force-expire answered 404 by default). Both gates are removed: `pages/test-env` renders each page unconditionally (the `guarded` barrel stays as the one seam; its only condition is PAY-3220's `PAYEZ_TOKEN_PAGES_OFF` for the two token pages, below, unset by default), and `api-handlers/test/force-expire` reaches its own session check (401 without a session) in development AND production. Its caller-supplied-token removal and the page redaction stay. **A consumer that mounts these pages or the force-expire route and relied on the flag to hide them in production must now gate them itself, or set `PAYEZ_TOKEN_PAGES_OFF` (next item) for the two token pages and force-expire.** Test: `test/pay-3216-test-env-no-flag.test.cjs`.
10
+ - **NEW SWITCH (PAY-3220, PR #33): `PAYEZ_TOKEN_PAGES_OFF`, UNSET = as today.** Read on the server per request (never at module scope, never `NEXT_PUBLIC_`, never from request input). `true`/`1`/`yes`/`on` (any case) makes `JwtInspectPage` and `RefreshTokenPage` answer `notFound()` (a real 404) and `POST /api/test/force-expire` answer an empty 404 before any session is read; `false`/`0`/`no`/`off` or unset/blank leaves them on; ANY OTHER value is OFF with one warning naming the variable. `TestEnvPage` and `EmergencyLogoutPage` are not covered. **Set it only on a production host, on Jon's word, together with the PAY-3220 production-roll note** (also unmount or gate the consumer's own `/api/internal/session-data`, which the jwt-inspect page reads, and leave `ENABLE_TOKEN_INSPECTOR` unset). Test: `test/pay-3220-token-pages-switch.test.cjs`.
11
+ - **FIX (PAY-3214, PR #26): `routes/mfa/totp-confirm` answers only `{ confirmed, stepped_up }`.** In 4.14.0 the re-minted access/refresh pair the IdP returns on a confirm reached the browser in the response body. It is now dropped: nothing is stored, the session's MFA state is untouched, and every 200 is that one projected body with `no-store`. A consumer that read tokens from this body (none should) gets none. Test: `test/pay-3214-totp-confirm-no-tokens.test.cjs`.
12
+ - **FIX (PAY-3139, PRs #17, #19): an enabled sign-in provider whose required credentials are missing is left out** (empty client id for any provider; empty client secret for google), with one `PROVIDER_CREDENTIALS_MISSING` console.error naming the provider and the missing FIELD, never a value. Its sign-in then answers better-auth's typed `PROVIDER_NOT_FOUND` instead of a zero-byte 500. The provider name in that line, and at the two other IdP-config log sites, passes through one shared `logSafeName` (`lib/log-safe`), so a name from the IdP's row cannot forge a second log line. Tests: `test/provider-credentials-missing.test.cjs` (8 rows, mutants red).
13
+
14
+ **New exports (additive)**
15
+
16
+ - **`./lib/idp-logout` (PAY-3245, PR #30):** `revokeSessionAtIdp(req)` and `IdpLogoutUnconfirmedError`, so a consumer with its own sign-out route calls the package's one IdP revoke instead of copying it. Test: `test/next-mvp-pay-3245-idp-logout-export.test.cjs`.
17
+ - **`./lib/first-enrol` (PAY-3270, PR #31):** `ENROL_COOKIE` (`'payez_enrol'`) and `ENROL_COOKIE_MAX_AGE` (`600`), so a consumer that parks the enrolment-scoped token for the first-enrol routes uses the package's cookie name instead of copying it. Test: `test/next-mvp-pay-3270-first-enrol-export.test.cjs`.
18
+
19
+ **Docs and tests only (no runtime change)**
20
+
21
+ - PAY-3228 B3 (PR #28): the five places that show `auth/login` say it is SERVER-ONLY and point a browser sign-in at `credential-login`.
22
+ - Tests: AUTH-045 provisioning half, `test/credential-login.test.cjs`: the first-sign-in journey is captured and a second enrolment is proven not to happen, with F1 as the red-first control and F2/F3 as positive controls (PR #12); AUTH-045 F1 asserts `enrolmentRequired` on the code turn (PR #23); C1 compares TypeScript diagnostic paths after `path.resolve` and has a positive control (PR #18); PAY-3214 M1's stub matchers accept either path separator; AUTH-045 R7b skips transient `__*mutant*` files.
23
+
24
+ **Not in this package:** `@payez/cli` 0.1.3 (pin on 4.14.0, PR #21) ships with `@payez/cli`.
25
+
26
+ ## 4.14.0 (to be published under dist-tag `next` only, after 4.13.1; `latest` stays 4.8.9 until Jon says otherwise)
27
+
28
+ A minor: additive exports, plus one behaviour change in the generated-app credential sign-in (listed first). Built from GitHub `keelsecurity/keelauth-next` main `33c188e0`; this release commit changes only this file and the version. Consumers keep their own pins.
29
+
30
+ - **CHANGE (PAY-3083 / PAY-2938, AUTH-045): a first sign-in on an app with no authenticator now reaches enrolment instead of failing.** When the second-factor step answers with an access token and no refresh token, `api-handlers/auth/credential-login` no longer answers 502. It answers 200 in both cases below, never mints a session, and never echoes or logs the token. **(1) The IdP says `next_step: 'enroll_mfa'` (the enrolment-scoped answer after a verified email code, and the default when the IdP names no step):** the answer is `{ success: false, requiresTwoFactor: true, nextStep: 'enroll_mfa', enrolmentRequired: true }`, parks the token in an httpOnly cookie `payez_enrol` (SameSite=Strict, path `/`, 600 s) that only the two new routes below read; it expires the `adm2fa` and `adm2fa_lane` cookies. **(2) The IdP says `next_step: 'mfa_verify'`:** the answer passes that step through as `nextStep: 'mfa_verify'` with NO `enrolmentRequired` flag, NO `payez_enrol` cookie, and the `adm2fa` and `adm2fa_lane` cookies are left in place (the sign-in form continues at the code step). A refresh-only or empty success is still a 502. TOTP enrolment is per client (per app), by design.
31
+ - **ADDITIVE: new exports** `./routes/mfa/first-enrol-enroll` and `./routes/mfa/first-enrol-confirm` (POST routes that read ONLY the `payez_enrol` cookie, accept JSON only, answer 401 `SIGN_IN_AGAIN` with no cookie, allow 5 attempts per cookie (the 6th is 429 and clears it), and on confirm return the recovery codes and clear the cookie; no session is minted), and `./dist/pages/first-enroll` (the page: QR, the key as selectable text under "Enter this code manually:", a sentence saying the authenticator is for this app only, the recovery codes, then "sign in again").
32
+ - **ADDITIVE: `CredentialSignInForm` takes an optional `firstEnrolHref`**, and `credential-sign-in-client` has a new result kind `enrol_required` (returned only when the answer carries `enrolmentRequired: true` with `nextStep: 'enroll_mfa'`). With the prop set the form navigates there; **without it the form shows "This app has no authenticator setup page, so sign-in cannot continue. Tell the app owner." and does not loop.** A consumer that does not adopt it sees that sentence instead of the old silent loop. Generated apps from `@payez/cli` mount the routes and page and pass the prop (CLI release follows this one).
33
+ - Tests: `test/credential-login.test.cjs`, `credential-sign-in-client.test.cjs`, `first-enrol-core.test.cjs`, `first-enrol-routes.test.cjs`, `first-enrol-panel.test.cjs` (with in-memory mutants, `test/lib/compile-mutant.cjs`).
34
+
35
+ **Also in this release (additive, BAPert 88395/88454):**
4
36
 
5
37
  - **ADDITIVE (MINOR when released): SMS-first sign-up learns the `EMAIL_CONFIRMATION_REQUIRED` step (BAPert 88395 #2, 88454 #3; NextPert 88471).** The server (door 3b) now answers `verify-sms-code` with 200 success, `next_step = EMAIL_CONFIRMATION_REQUIRED` and NO reset token when the email is still unconfirmed. Before this, `accountApi.verifyOnboardingSmsCode` returned `{ success: true, resetToken: '' }` for that answer, which a consumer reads as "verified" and then calls set-password with an EMPTY token. Now: (a) the result type is the exported `SmsCodeVerification` and gains an OPTIONAL `nextStep` (`nextStep` ALONE is read from the nested shapes AND from a flat body; `reset_token`, `email` and `client_key` are still read from the nested shapes only, as before, so a consumer behind a proxy that forwards a flat body gets no token and the helper then says `more_steps`, the safe direction; absent / null / '' = no step; any other value, including `false`, `0` and a non-string, is kept as a non-empty UNRECOGNISED string, never dropped, so a server that means "no step" must send nothing or null); (b) new pure `interpretSmsVerification(result)` returns `verified` (only with a non-empty reset token and no step), `email_confirmation_required` (the package sentence "Your phone is verified. Confirm your email to finish signing up.", no error styling), `more_steps` ("One more step is needed to finish signing up." for ANY other step, and for success with an empty reset token) or `not_verified`; `success: false` WINS over any step (`not_verified`, so a failed answer never says "Your phone is verified"), and for every other answer it tests `nextStep` BEFORE the token; the sentences are the package's own, never the server's text. **The method itself still returns the server's `success` unchanged: a consumer MUST use `interpretSmsVerification` (or test `nextStep` first) and must not treat `success` alone as "verified", because `success: true` with an empty `resetToken` is what the server now sends for the unconfirmed-email case.** No breaking type change: a consumer that ignores `nextStep` compiles unchanged (test S6), and the email variant `verifyOnboardingCode` is untouched. **A consumer must adopt `interpretSmsVerification` (or branch on `nextStep` itself) to show the new screen; until it does, it behaves as before.** There is no SMS-first page in this package: the screen lives in the consuming sites, so the deploy of the server change (PR 85) waits on every live consumer taking this release. Tests: `test/sms-next-step.test.cjs` (32), `test/sms-next-step-mutants.test.cjs` (9 hand mutants, one per branch).
6
38
 
@@ -52,6 +52,8 @@ const device_id_1 = require("../../lib/device-id");
52
52
  const twofa_rejection_1 = require("../../lib/twofa-rejection");
53
53
  const bearer_fetch_1 = require("../../lib/bearer-fetch");
54
54
  const log_safe_1 = require("../../lib/log-safe");
55
+ // AUTH-045: the httpOnly cookie that carries the enrolment-scoped token from the code turn to the two enrolment routes (ONE name, defined in lib/first-enrol).
56
+ const first_enrol_1 = require("../../lib/first-enrol");
55
57
  // `export const runtime = 'nodejs'` must be written LITERALLY in the app's route file (Next ignores a re-exported segment config).
56
58
  /**
57
59
  * The device id to forward to the IDP for THIS caller — card 240566.
@@ -325,6 +327,50 @@ async function POST(req) {
325
327
  const vresult = verifyData?.data?.result ?? verifyData?.data ?? verifyData;
326
328
  const vAccess = vresult?.access_token;
327
329
  const vRefresh = vresult?.refresh_token;
330
+ // PAY-3083 (AUTH-045): ACCESS WITHOUT REFRESH AT THE CODE TURN IS NOT A FAILURE - IT IS ENROLMENT.
331
+ //
332
+ // The IdP withholds the refresh token for a user who has no usable second factor on THIS client.
333
+ // TOTP enrolment is PER CLIENT (DotNetPert 99446 sec A, client 90717 / user 1175): the password turn
334
+ // answered Require2FA=true, IssueRefreshToken=false, step=enroll_mfa, and this verify turn then
335
+ // accepted the emailed code as CONTACT PROOF and issued an ENROLMENT-SCOPED access token, again with
336
+ // no refresh token. That is exactly what PAY-2798 requires of the IdP - the IdP is RIGHT.
337
+ //
338
+ // This branch used to read "access, no refresh" as an incomplete response and answer 502, so the
339
+ // developer who had just proved their contact was told the server was broken and could never reach
340
+ // the enrolment page: the 502 the field saw as
341
+ // '[ADMIN-LOGIN] verify response missing token { hasAccess: true, hasRefresh: false }'.
342
+ //
343
+ // Send them to ENROLMENT instead, in the ONE contract the door and the form already share for it
344
+ // (PAY-2630): requiresTwoFactor + nextStep 'enroll_mfa'. No session is minted - an enrolment-scoped
345
+ // token is not a signed-in session - and the token value is NEVER logged nor echoed (BAPert 99521 #2).
346
+ //
347
+ // Guard kept: no access token AT ALL (a refresh-only or empty body) is still a broken success and
348
+ // still 502 below - this widening is only for a genuine access-with-no-refresh answer.
349
+ if (vAccess && !vRefresh) {
350
+ const step = vresult?.next_step === 'enroll_mfa' || vresult?.next_step === 'mfa_verify' ? vresult.next_step : 'enroll_mfa';
351
+ const enrolling = step === 'enroll_mfa';
352
+ const answer = server_1.NextResponse.json({
353
+ success: false,
354
+ requiresTwoFactor: true,
355
+ acceptedFactors: acceptedFactorsFor('email'),
356
+ sentVia: null,
357
+ codeRequested: false,
358
+ emailSent: false,
359
+ nextStep: step,
360
+ // AUTH-045 (walk 14 Nov 2026): requiresTwoFactor alone is also the password turn's "a code was sent", so the form re-drew the code box
361
+ // and looped. This flag is the ONLY signal that means "go enrol"; it is set on this answer and nowhere else.
362
+ ...(enrolling ? { enrolmentRequired: true } : {}),
363
+ }, { status: 200 });
364
+ if (enrolling) {
365
+ // No session exists for an enrolment-scoped token, and the enrolment page's actions need one, so the token rides in this httpOnly
366
+ // cookie to the enrolment routes (the portal's PAY-2798 part 2 pattern): never in a body, a URL or a log; strict; 600 s.
367
+ answer.cookies.set(first_enrol_1.ENROL_COOKIE, vAccess, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', path: '/', maxAge: first_enrol_1.ENROL_COOKIE_MAX_AGE });
368
+ // The provisional bearer is spent: the enrolment token replaces it.
369
+ answer.cookies.set('adm2fa', '', { path: '/', maxAge: 0 });
370
+ answer.cookies.set('adm2fa_lane', '', { path: '/', maxAge: 0 });
371
+ }
372
+ return answer;
373
+ }
328
374
  if (!vAccess || !vRefresh) {
329
375
  console.error('[ADMIN-LOGIN] verify response missing tokens', { hasAccess: !!vAccess, hasRefresh: !!vRefresh });
330
376
  return server_1.NextResponse.json({ success: false, error: 'Authentication service returned an incomplete response' }, { status: 502 });
@@ -0,0 +1,5 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { type EnrolStep } from '../../lib/first-enrol';
3
+ export declare function enrolStepPOST(step: EnrolStep): (req: NextRequest) => Promise<NextResponse>;
4
+ export declare const POST_ENROLL: (req: NextRequest) => Promise<NextResponse>;
5
+ export declare const POST_CONFIRM: (req: NextRequest) => Promise<NextResponse>;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST_CONFIRM = exports.POST_ENROLL = void 0;
4
+ exports.enrolStepPOST = enrolStepPOST;
5
+ const node_crypto_1 = require("node:crypto");
6
+ const server_1 = require("next/server");
7
+ const idp_client_config_1 = require("../../lib/idp-client-config");
8
+ const client_key_1 = require("../../lib/client-key");
9
+ const mfa_json_guard_1 = require("../../lib/mfa-json-guard");
10
+ const first_enrol_1 = require("../../lib/first-enrol");
11
+ /**
12
+ * AUTH-045 first-enrolment carry (PAY-2938, BAPert 100969 rows 3-5): the TWO routes a user the IdP told to enrol uses, built on lib/first-enrol.
13
+ *
14
+ * WHO MAY CALL: the browser that holds the httpOnly enrolment cookie the credential door parked (ENROL_COOKIE). This is the ONLY module besides the
15
+ * door that reads that cookie, and it serves ONLY these two operations; every other package route stays session-gated and answers 401 to a caller
16
+ * holding nothing but this cookie (a gate row holds that). No cookie -> 401 SIGN_IN_AGAIN and no IdP call.
17
+ *
18
+ * What follows a confirm (BAPert 100969 row 5): the cookie is cleared and the answer carries the recovery codes; NO session is minted here and no new
19
+ * way to mint one is added. The person signs in again with their password and the authenticator, through the same credential door as before.
20
+ *
21
+ * The token never leaves the server: not in a body, a URL, a log or a Referer. The IdP's prose is never relayed.
22
+ */
23
+ const digest = (s) => (0, node_crypto_1.createHash)('sha256').update(s).digest('hex');
24
+ const cookieOpts = (maxAge) => ({ httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', path: '/', maxAge });
25
+ function reply(status, body, clear) {
26
+ const res = server_1.NextResponse.json(body, { status, headers: mfa_json_guard_1.NO_STORE });
27
+ if (clear)
28
+ res.cookies.set(first_enrol_1.ENROL_COOKIE, '', cookieOpts(0));
29
+ return res;
30
+ }
31
+ function enrolStepPOST(step) {
32
+ return async function POST(req) {
33
+ const refused = (0, mfa_json_guard_1.refuseNonJson)(req);
34
+ if (refused)
35
+ return refused;
36
+ const token = req.cookies.get(first_enrol_1.ENROL_COOKIE)?.value;
37
+ if (!token)
38
+ return reply(401, { success: false, code: 'SIGN_IN_AGAIN' }, false);
39
+ if (!(0, first_enrol_1.attemptAllowed)(`${step}|ck|${digest(token)}`, first_enrol_1.LIMIT_PER_COOKIE))
40
+ return reply(429, { success: false, code: 'TOO_MANY', error: 'Too many attempts. Wait a few minutes and try again.' }, true);
41
+ const idp = process.env.IDP_URL;
42
+ let clientKey = '';
43
+ try {
44
+ const config = await (0, idp_client_config_1.getIDPClientConfig)();
45
+ clientKey = (0, client_key_1.resolveClientKey)(config, 'first-enrol').key ?? '';
46
+ }
47
+ catch {
48
+ return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
49
+ }
50
+ if (!idp || !clientKey)
51
+ return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
52
+ const body = await req.json().catch(() => null);
53
+ const r = await (0, first_enrol_1.runEnrolStep)({ fetchImpl: (u, i) => fetch(u, i), idp, clientKey }, { step, token, body });
54
+ return reply(r.status, r.body, r.cookie === 'clear');
55
+ };
56
+ }
57
+ exports.POST_ENROLL = enrolStepPOST('enroll');
58
+ exports.POST_CONFIRM = enrolStepPOST('confirm');
@@ -5,4 +5,4 @@ import { NextRequest, NextResponse } from 'next/server';
5
5
  * it carries the enrolment handle plus the code. Only after this succeeds is
6
6
  * the method ACTIVE.
7
7
  */
8
- export declare function POST(req: NextRequest): Promise<NextResponse<any>>;
8
+ export declare function POST(req: NextRequest): Promise<NextResponse<unknown>>;
@@ -36,11 +36,15 @@ async function POST(req) {
36
36
  meta: { attemptedRefresh: result.attemptedRefresh },
37
37
  }, { status: result.status, headers: mfa_json_guard_1.NO_STORE });
38
38
  }
39
+ // PAY-3214: the browser gets ONLY `confirmed` and `stepped_up`. When the IdP steps the session up it
40
+ // re-mints and answers { confirmed, stepped_up: true, access_token, refresh_token, token_type }; relaying
41
+ // `data` whole put the person's bearer and refresh token in a browser-readable body. The pair is DROPPED
42
+ // here, not stored: storing it would change the session's MFA state from an ENROLMENT, which
43
+ // totp-verify.ts's header forbids, and that is a ruling not yet given (BAPert-MB 105226 s3-s4). With the
44
+ // pair dropped the session keeps its own token, as on the IdP's own `stepped_up: false` path.
45
+ // `stepped_up` is passed back as the IdP said it (its meaning, with the pair dropped, is s3 Q2 of that ruling).
39
46
  const bodyJson = result.json;
40
- if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
41
- if (bodyJson.success === true)
42
- return server_1.NextResponse.json(bodyJson.data, { status: 200, headers: mfa_json_guard_1.NO_STORE });
43
- return server_1.NextResponse.json(bodyJson, { status: 200, headers: mfa_json_guard_1.NO_STORE });
44
- }
45
- return server_1.NextResponse.json(bodyJson ?? {}, { status: 200, headers: mfa_json_guard_1.NO_STORE });
47
+ const enveloped = bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson;
48
+ const data = (enveloped ? (bodyJson.success === true ? bodyJson.data : null) : bodyJson) ?? {};
49
+ return server_1.NextResponse.json({ confirmed: data.confirmed === true, stepped_up: data.stepped_up === true }, { status: 200, headers: mfa_json_guard_1.NO_STORE });
46
50
  }
@@ -15,19 +15,18 @@ import { NextRequest, NextResponse } from 'next/server';
15
15
  * mounts a destructive endpoint. Every one of the three surfaces this route
16
16
  * ended up on followed exactly these instructions.
17
17
  *
18
- * The handler itself was fixed (244877: default-deny behind ENABLE_TEST_ROUTES,
19
- * plus the caller-supplied-token IDOR removed). But a fixed handler shipped
18
+ * The handler itself was fixed (244877: the caller-supplied-token IDOR removed;
19
+ * its ENABLE_TEST_ROUTES gate was later removed again by PAY-3216). But a fixed handler shipped
20
20
  * with unqualified mounting instructions still spreads: the guidance was never
21
21
  * corrected alongside the code, and a package that documents a dangerous route
22
22
  * as a plain convenience is instructing the weaker configuration.
23
23
  *
24
24
  * ── IF YOU MOUNT IT, MOUNT IT LIKE THIS ───────────────────────────────────
25
- * Required: set ENABLE_TEST_ROUTES=true IN THAT ENVIRONMENT ONLY. Absent, empty
26
- * or any other value default-DENIES (404). Never set it in production.
25
+ * There is no env flag (PAY-3216): a mounted handler answers. Mount it only
26
+ * where you mean it to answer.
27
27
  *
28
28
  * ```typescript
29
29
  * // app/api/test/force-expire/route.ts — TEST/DEV ENVIRONMENTS ONLY
30
- * // Requires ENABLE_TEST_ROUTES=true in that environment; 404s otherwise.
31
30
  * export { POST } from '@payez/next-mvp/api-handlers/test/force-expire';
32
31
  * ```
33
32
  *
@@ -6,6 +6,7 @@ const auth_1 = require("../../server/auth");
6
6
  const session_store_1 = require("../../lib/session-store");
7
7
  const safe_error_response_1 = require("../../lib/safe-error-response");
8
8
  const token_log_id_1 = require("../../lib/token-log-id");
9
+ const token_pages_switch_1 = require("../../lib/token-pages-switch");
9
10
  /**
10
11
  * ⚠️ DESTRUCTIVE TEST-ONLY HANDLER — DO NOT MOUNT IN PRODUCTION. ⚠️
11
12
  *
@@ -22,19 +23,18 @@ const token_log_id_1 = require("../../lib/token-log-id");
22
23
  * mounts a destructive endpoint. Every one of the three surfaces this route
23
24
  * ended up on followed exactly these instructions.
24
25
  *
25
- * The handler itself was fixed (244877: default-deny behind ENABLE_TEST_ROUTES,
26
- * plus the caller-supplied-token IDOR removed). But a fixed handler shipped
26
+ * The handler itself was fixed (244877: the caller-supplied-token IDOR removed;
27
+ * its ENABLE_TEST_ROUTES gate was later removed again by PAY-3216). But a fixed handler shipped
27
28
  * with unqualified mounting instructions still spreads: the guidance was never
28
29
  * corrected alongside the code, and a package that documents a dangerous route
29
30
  * as a plain convenience is instructing the weaker configuration.
30
31
  *
31
32
  * ── IF YOU MOUNT IT, MOUNT IT LIKE THIS ───────────────────────────────────
32
- * Required: set ENABLE_TEST_ROUTES=true IN THAT ENVIRONMENT ONLY. Absent, empty
33
- * or any other value default-DENIES (404). Never set it in production.
33
+ * There is no env flag (PAY-3216): a mounted handler answers. Mount it only
34
+ * where you mean it to answer.
34
35
  *
35
36
  * ```typescript
36
37
  * // app/api/test/force-expire/route.ts — TEST/DEV ENVIRONMENTS ONLY
37
- * // Requires ENABLE_TEST_ROUTES=true in that environment; 404s otherwise.
38
38
  * export { POST } from '@payez/next-mvp/api-handlers/test/force-expire';
39
39
  * ```
40
40
  *
@@ -42,59 +42,12 @@ const token_log_id_1 = require("../../lib/token-log-id");
42
42
  * The safest configuration is the absence of the route.
43
43
  */
44
44
  const POST = async (req) => {
45
- // 244877 (package-level): DEFAULT DENY behind ENABLE_TEST_ROUTES.
46
- //
47
- // This handler MUTATES session state — it rewrites idpAccessTokenExpires two
48
- // minutes into the past — and was gated on nothing but "any signed-in
49
- // session": no role check, no env kill-switch. Because consuming apps mount
50
- // it with a one-line re-export, EVERY consumer inherited that, including
51
- // deployments on production hosts.
52
- //
53
- // The card was raised against one app's local copy. Sweeping the pattern
54
- // rather than the instance found the same route in three places — a local
55
- // copy in the graft, a local copy in idealvibe.online, and idealresume.online
56
- // re-exporting THIS handler — so the fix belongs here, at the source all
57
- // three ultimately derive from, not only in whichever copy was noticed first.
58
- //
59
- // The gate is the FIRST statement, before any session decode, so a disabled
60
- // build does not reveal that the route is live. Absent, empty, or any value
61
- // other than 'true' disables it. Distinguishable body on purpose: a bare 404
62
- // reads as "route missing / build broken" and sends someone debugging
63
- // deployment instead of reading a flag.
64
- //
65
- // A consumer that genuinely needs this in a test environment sets
66
- // ENABLE_TEST_ROUTES=true there, which is the same posture the token
67
- // inspector already uses for a strictly LESS dangerous, read-only route.
68
- if (process.env.ENABLE_TEST_ROUTES !== 'true') {
69
- // DISCLOSURE IS CONDITIONAL ON ENVIRONMENT (QAPert-NightHawk 34539, after
70
- // QAPert caught that my first version shipped the very leak we had just
71
- // criticised elsewhere).
72
- //
73
- // My original body named the flag: "Set ENABLE_TEST_ROUTES=true...". That
74
- // is the same class of disclosure as TOKEN_INSPECTOR_DISABLED — it confirms
75
- // the route exists, names the capability, and hands a prober the exact
76
- // switch that turns it on. Having argued against that pattern one directory
77
- // over, shipping it here would have been incoherent.
78
- //
79
- // Deleting the message outright is also wrong: it is what stops a developer
80
- // losing an hour deciding whether their build is broken. The tension
81
- // resolves by AUDIENCE, not by picking a side:
82
- // production -> 404, EMPTY body. Nothing about the capability, the
83
- // flag, or even that a handler is here.
84
- // non-production -> 404 + the helpful message naming the flag.
85
- //
86
- // The operator affordance survives in production too, via the log line
87
- // below: logs are visible to operators and not to probers, which is exactly
88
- // the split the response body cannot make.
89
- console.warn('[FORCE-EXPIRE] blocked: ENABLE_TEST_ROUTES is not "true" (test routes default-deny).');
90
- if (process.env.NODE_ENV === 'production') {
91
- return new server_1.NextResponse(null, { status: 404 });
92
- }
93
- return server_1.NextResponse.json({
94
- error: 'TEST_ROUTES_DISABLED',
95
- message: 'Test routes are disabled in this build. Set ENABLE_TEST_ROUTES=true to enable them (development only).',
96
- }, { status: 404 });
97
- }
45
+ // PAY-3216 (Jon's order, 15 Nov): the ENABLE_TEST_ROUTES default-deny gate that 072d20e (244877) put here is
46
+ // REMOVED. The handler works with no flag set, as before that commit. Mounting is the gate.
47
+ // PAY-3220 (ruled Win 105653 s1): the production switch for the token pages covers this route too, since the refresh
48
+ // page is its one caller. Unset, nothing changes. Set, an empty 404 before any session is read.
49
+ if ((0, token_pages_switch_1.tokenPagesOff)())
50
+ return new server_1.NextResponse(null, { status: 404 });
98
51
  try {
99
52
  const betterAuthSession = await (0, auth_1.getSession)(req);
100
53
  // ACCESS CONTROL (QAPert 34553). This handler previously fell back to a
@@ -112,11 +65,9 @@ const POST = async (req) => {
112
65
  // now ALWAYS the caller's own, which is also what the graft's local copy
113
66
  // has always done.
114
67
  //
115
- // This is defence in depth, not the only control: the ENABLE_TEST_ROUTES
116
- // gate above already 404s this handler by default. But the gate is
117
- // switched ON in test environments, and "unauthenticated users can expire
118
- // each other's sessions" is not acceptable there either — a control that
119
- // only holds while a feature is switched off is not an access control.
68
+ // A control that only held while a feature was switched off would not be
69
+ // an access control: "unauthenticated users can expire each other's
70
+ // sessions" is not acceptable in any environment that mounts this.
120
71
  //
121
72
  // Corrects NextPert's own scope claim on card 244877 ("a caller could only
122
73
  // ever expire their OWN session"): that was read off the graft's LOCAL
@@ -80,6 +80,22 @@ export interface IdpOAuthProviderConfig {
80
80
  getUserInfo: ReturnType<typeof createIdpOAuthHooks>['getUserInfo'];
81
81
  }
82
82
  export declare function buildIdpOAuthProviderConfig(appBaseUrl?: string): IdpOAuthProviderConfig | null;
83
+ /**
84
+ * PAY-3139: the credentials better-auth ITSELF requires before it can build a provider's authorize URL.
85
+ *
86
+ * Read from @better-auth/core 1.5.6 (social-providers/*.mjs, createAuthorizationURL): google throws
87
+ * `BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED")` when clientId OR clientSecret is empty. That is a plain
88
+ * throw, not an APIError, so the sign-in door answered a 500 with an EMPTY body (dev-93, 14 Nov: the IdP served
89
+ * the public, secret-redacted client config, so google's secret arrived empty). apple, facebook and github have
90
+ * no such guard (facebook signs in by id_token; apple's secret is the IdP's), so only the id is required there:
91
+ * an empty client id cannot produce a working authorize URL for any provider.
92
+ *
93
+ * Returns the missing FIELD NAMES, never a value.
94
+ */
95
+ export declare function missingProviderCredentials(name: string, oauth: {
96
+ clientId?: string | null;
97
+ clientSecret?: string | null;
98
+ }): Array<'clientId' | 'clientSecret'>;
83
99
  export declare function buildBetterAuthProviders(config: IDPClientConfig): Record<string, BetterAuthSocialProvider>;
84
100
  /**
85
101
  * Optional configuration for `createBetterAuthInstance`.
@@ -46,6 +46,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
46
46
  exports.IDP_ERROR_CODES_TO_FORWARD = exports.OAUTH_SIGN_IN_FAILED_ERROR = void 0;
47
47
  exports.secretFingerprint = secretFingerprint;
48
48
  exports.buildIdpOAuthProviderConfig = buildIdpOAuthProviderConfig;
49
+ exports.missingProviderCredentials = missingProviderCredentials;
49
50
  exports.buildBetterAuthProviders = buildBetterAuthProviders;
50
51
  exports.createBetterAuthInstance = createBetterAuthInstance;
51
52
  exports.isBetterAuthEnabled = isBetterAuthEnabled;
@@ -241,12 +242,45 @@ function buildIdpOAuthProviderConfig(appBaseUrl) {
241
242
  */
242
243
  };
243
244
  }
245
+ /**
246
+ * PAY-3139: the credentials better-auth ITSELF requires before it can build a provider's authorize URL.
247
+ *
248
+ * Read from @better-auth/core 1.5.6 (social-providers/*.mjs, createAuthorizationURL): google throws
249
+ * `BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED")` when clientId OR clientSecret is empty. That is a plain
250
+ * throw, not an APIError, so the sign-in door answered a 500 with an EMPTY body (dev-93, 14 Nov: the IdP served
251
+ * the public, secret-redacted client config, so google's secret arrived empty). apple, facebook and github have
252
+ * no such guard (facebook signs in by id_token; apple's secret is the IdP's), so only the id is required there:
253
+ * an empty client id cannot produce a working authorize URL for any provider.
254
+ *
255
+ * Returns the missing FIELD NAMES, never a value.
256
+ */
257
+ function missingProviderCredentials(name, oauth) {
258
+ const required = name === 'google' ? ['clientId', 'clientSecret'] : ['clientId'];
259
+ return required.filter((field) => String(oauth[field] ?? '').trim() === '');
260
+ }
244
261
  function buildBetterAuthProviders(config) {
245
262
  const providers = {};
246
263
  for (const oauth of config.oauthProviders || []) {
247
264
  if (!oauth.enabled)
248
265
  continue;
249
266
  const name = oauth.provider.toLowerCase();
267
+ /**
268
+ * PAY-3139: an ENABLED row that lacks a credential better-auth requires is LEFT OUT, loudly. Left in, its
269
+ * sign-in throws inside better-auth and the door answers an empty 500 that names nothing. Left out, the
270
+ * same door answers better-auth's typed 404 PROVIDER_NOT_FOUND, and this line names the provider and the
271
+ * missing field (never a value), once per instance build.
272
+ */
273
+ const missing = missingProviderCredentials(name, oauth);
274
+ if (missing.length > 0) {
275
+ const secretHint = missing.includes('clientSecret')
276
+ ? ` (the secret is read env-first from ${(0, idp_client_config_1.providerSecretEnvVar)(oauth.provider)}, else from the IdP's ` +
277
+ `secret-bearing client config, which a client that sends no valid client_secret does not receive)`
278
+ : '';
279
+ console.error(`[BETTER_AUTH] PROVIDER_CREDENTIALS_MISSING provider=${(0, log_safe_1.logSafeName)(name)} missing=${missing.join(',')}: ` +
280
+ `left out of the sign-in providers, so its sign-in answers PROVIDER_NOT_FOUND until the ` +
281
+ `${missing.join(' and ')} reach this app${secretHint}`);
282
+ continue;
283
+ }
250
284
  const additionalParams = oauth.additionalParams ?? {};
251
285
  const rawPrompt = additionalParams.prompt;
252
286
  const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
@@ -33,7 +33,12 @@ interface Props {
33
33
  * default would 404 a generated app's user right after a good sign-in).
34
34
  */
35
35
  enrollHref?: string;
36
+ /**
37
+ * AUTH-045: where a person is sent when the IdP says they must ENROL a factor and they have NO session (the door parked an httpOnly enrolment cookie).
38
+ * Distinct from enrollHref (which needs a session). NO DEFAULT: without it the form says so instead of redrawing the code box.
39
+ */
40
+ firstEnrolHref?: string;
36
41
  }
37
42
  export declare function resolveDestination(explicit?: string): string;
38
- export declare function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword, enrollHref }: Props): import("react/jsx-runtime").JSX.Element;
43
+ export declare function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword, enrollHref, firstEnrolHref }: Props): import("react/jsx-runtime").JSX.Element;
39
44
  export default CredentialSignInForm;
@@ -31,7 +31,7 @@ function resolveDestination(explicit) {
31
31
  }
32
32
  return (0, safe_callback_1.safeCallbackPath)((0, authorize_resume_1.authorizeResumeHref)(returnUrl), (0, safe_callback_1.safeCallbackPath)(raw, '/dashboard'));
33
33
  }
34
- function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref }) {
34
+ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref, firstEnrolHref }) {
35
35
  const [step, setStep] = (0, react_1.useState)('credentials');
36
36
  const [email, setEmail] = (0, react_1.useState)('');
37
37
  const [password, setPassword] = (0, react_1.useState)('');
@@ -62,6 +62,20 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
62
62
  window.location.href = destination;
63
63
  return;
64
64
  }
65
+ if (r.kind === 'enrol_required') {
66
+ // AUTH-045: the code was accepted as contact proof; the door parked the enrolment-scoped token in an httpOnly cookie. Go and enrol: never redraw
67
+ // the code box (the 14 Nov walk looped there). A host that gave no enrolment page gets a sentence, not a silent loop.
68
+ if (firstEnrolHref) {
69
+ if (onSignedIn)
70
+ onSignedIn(firstEnrolHref);
71
+ else
72
+ window.location.href = firstEnrolHref;
73
+ return;
74
+ }
75
+ setError('This app has no authenticator setup page, so sign-in cannot continue. Tell the app owner.');
76
+ setErrorCode('sign_in_failed');
77
+ return;
78
+ }
65
79
  if (r.kind === 'two_factor') {
66
80
  setCodeRequest(r.codeRequest);
67
81
  setDevCode(r.devCode);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * AUTH-045 first-enrolment carry (PAY-2938, BAPert 100969): the panel for a person the IdP told to ENROL a factor on THIS app, who has NO session.
3
+ * Ported from the portal's EnrolRequired (PAY-2798 part 2). It talks ONLY to the two routes that read the httpOnly enrolment cookie:
4
+ * POST /api/auth/first-enrol/enroll (started on mount: the person already asked for this by signing in)
5
+ * POST /api/auth/first-enrol/confirm (the 6-digit code from the app) -> the recovery codes, then "sign in again"
6
+ *
7
+ * Rules: the secret and otpauth_uri live in component state only (never a URL, storage, console or a third-party QR service); a 401 is never a
8
+ * message box (the person is sent back to sign-in); a fixed sentence per refusal code, never the server's text; no session is minted here.
9
+ */
10
+ export declare const FIRST_ENROL_ENROLL_URL = "/api/auth/first-enrol/enroll";
11
+ export declare const FIRST_ENROL_CONFIRM_URL = "/api/auth/first-enrol/confirm";
12
+ /** BAPert 100969 row 6: one sentence that this app has its own authenticator entry (per-client factor, by design). */
13
+ export declare const PER_APP_SENTENCE = "This app has its own authenticator entry: an authenticator you set up for another app or site does not carry over, so you add one here once.";
14
+ export declare const DONE_SENTENCE = "Your authenticator is set. Sign in with your password and the code from your app.";
15
+ export declare const SIGN_IN_AGAIN_SENTENCE = "This setup timed out or the sign-in expired. Sign in again to continue.";
16
+ export declare function FirstEnrolPanel({ fetchImpl, signInHref, onSignInAgain, }?: {
17
+ fetchImpl?: typeof fetch;
18
+ signInHref?: string;
19
+ onSignInAgain?: (href: string) => void;
20
+ }): import("react/jsx-runtime").JSX.Element;
21
+ export default FirstEnrolPanel;
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.SIGN_IN_AGAIN_SENTENCE = exports.DONE_SENTENCE = exports.PER_APP_SENTENCE = exports.FIRST_ENROL_CONFIRM_URL = exports.FIRST_ENROL_ENROLL_URL = void 0;
5
+ exports.FirstEnrolPanel = FirstEnrolPanel;
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const react_1 = require("react");
8
+ const QrCode_1 = require("./QrCode");
9
+ /**
10
+ * AUTH-045 first-enrolment carry (PAY-2938, BAPert 100969): the panel for a person the IdP told to ENROL a factor on THIS app, who has NO session.
11
+ * Ported from the portal's EnrolRequired (PAY-2798 part 2). It talks ONLY to the two routes that read the httpOnly enrolment cookie:
12
+ * POST /api/auth/first-enrol/enroll (started on mount: the person already asked for this by signing in)
13
+ * POST /api/auth/first-enrol/confirm (the 6-digit code from the app) -> the recovery codes, then "sign in again"
14
+ *
15
+ * Rules: the secret and otpauth_uri live in component state only (never a URL, storage, console or a third-party QR service); a 401 is never a
16
+ * message box (the person is sent back to sign-in); a fixed sentence per refusal code, never the server's text; no session is minted here.
17
+ */
18
+ exports.FIRST_ENROL_ENROLL_URL = '/api/auth/first-enrol/enroll';
19
+ exports.FIRST_ENROL_CONFIRM_URL = '/api/auth/first-enrol/confirm';
20
+ /** BAPert 100969 row 6: one sentence that this app has its own authenticator entry (per-client factor, by design). */
21
+ exports.PER_APP_SENTENCE = 'This app has its own authenticator entry: an authenticator you set up for another app or site does not carry over, so you add one here once.';
22
+ exports.DONE_SENTENCE = 'Your authenticator is set. Sign in with your password and the code from your app.';
23
+ exports.SIGN_IN_AGAIN_SENTENCE = 'This setup timed out or the sign-in expired. Sign in again to continue.';
24
+ const REFUSAL_COPY = {
25
+ CODE_INVALID: 'That code did not work. Check the 6 digits in your authenticator app and try again.',
26
+ TOO_MANY: 'Too many attempts. Wait a few minutes and sign in again.',
27
+ ENROLMENT_RESTART: 'This setup timed out. Reload the page to get a new code to scan.',
28
+ UNAVAILABLE: 'We could not reach the sign-in service. Nothing was changed. Try again in a moment.',
29
+ };
30
+ const FALLBACK_COPY = 'Setup was not completed. Try again.';
31
+ const readJson = async (res) => res.json().catch(() => null);
32
+ function FirstEnrolPanel({ fetchImpl, signInHref = '/account-auth/login', onSignInAgain, } = {}) {
33
+ const doFetch = fetchImpl ?? ((...a) => fetch(...a));
34
+ const [stage, setStage] = (0, react_1.useState)('starting');
35
+ const [enroll, setEnroll] = (0, react_1.useState)(null);
36
+ const [code, setCode] = (0, react_1.useState)('');
37
+ const [error, setError] = (0, react_1.useState)(null);
38
+ const [codes, setCodes] = (0, react_1.useState)(null);
39
+ const [busy, setBusy] = (0, react_1.useState)(false);
40
+ const started = (0, react_1.useRef)(false);
41
+ const toSignIn = () => {
42
+ if (onSignInAgain)
43
+ onSignInAgain(signInHref);
44
+ else
45
+ window.location.href = signInHref;
46
+ };
47
+ async function begin() {
48
+ setBusy(true);
49
+ setError(null);
50
+ try {
51
+ const res = await doFetch(exports.FIRST_ENROL_ENROLL_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ friendly_name: 'This device' }) });
52
+ if (res.status === 401)
53
+ return toSignIn(); // a 401 is never a message box
54
+ const body = await readJson(res);
55
+ if (res.ok && body?.data?.secret && body?.data?.otpauth_uri && Number.isInteger(body?.data?.enrollment_id)) {
56
+ setEnroll({ secret: body.data.secret, otpauth_uri: body.data.otpauth_uri, enrollment_id: body.data.enrollment_id });
57
+ setStage('enrolling');
58
+ return;
59
+ }
60
+ setError(REFUSAL_COPY[body?.code] ?? FALLBACK_COPY);
61
+ setStage('failed');
62
+ }
63
+ catch {
64
+ setError(REFUSAL_COPY.UNAVAILABLE);
65
+ setStage('failed');
66
+ }
67
+ finally {
68
+ setBusy(false);
69
+ }
70
+ }
71
+ (0, react_1.useEffect)(() => {
72
+ if (started.current)
73
+ return;
74
+ started.current = true;
75
+ void begin();
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
77
+ }, []);
78
+ async function confirm() {
79
+ if (!enroll || !/^\d{6}$/.test(code)) {
80
+ setError('Enter the 6-digit code from your app.');
81
+ return;
82
+ }
83
+ setBusy(true);
84
+ setError(null);
85
+ try {
86
+ const res = await doFetch(exports.FIRST_ENROL_CONFIRM_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enrollment_id: enroll.enrollment_id, code }) });
87
+ if (res.status === 401)
88
+ return toSignIn();
89
+ const body = await readJson(res);
90
+ if (res.ok && body?.confirmed === true) {
91
+ setEnroll(null);
92
+ setCode('');
93
+ setCodes(Array.isArray(body.codes) ? body.codes.filter((c) => typeof c === 'string') : null);
94
+ setStage('done');
95
+ return;
96
+ }
97
+ setCode('');
98
+ setError(REFUSAL_COPY[body?.code] ?? FALLBACK_COPY);
99
+ }
100
+ catch {
101
+ setError(REFUSAL_COPY.UNAVAILABLE);
102
+ }
103
+ finally {
104
+ setBusy(false);
105
+ }
106
+ }
107
+ if (stage === 'done') {
108
+ return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "first-enrol-done", role: "status", children: [(0, jsx_runtime_1.jsx)("p", { children: exports.DONE_SENTENCE }), codes && codes.length > 0 ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { children: "Save these recovery codes somewhere safe. Each one works once, and they are not shown again." }), (0, jsx_runtime_1.jsx)("ul", { "data-testid": "first-enrol-codes", children: codes.map((c) => ((0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("code", { children: c }) }, c))) })] })) : ((0, jsx_runtime_1.jsx)("p", { "data-testid": "first-enrol-no-codes", children: "We could not show your recovery codes. You can create new ones after you sign in." })), (0, jsx_runtime_1.jsx)("p", { children: (0, jsx_runtime_1.jsx)("a", { href: signInHref, children: "Sign in" }) })] }));
109
+ }
110
+ return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "first-enrol-panel", style: { display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }, children: [(0, jsx_runtime_1.jsx)("p", { "data-testid": "first-enrol-per-app", children: exports.PER_APP_SENTENCE }), stage === 'starting' ? (0, jsx_runtime_1.jsx)("p", { "data-testid": "first-enrol-starting", children: "Setting up\u2026" }) : null, stage === 'enrolling' && enroll ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { children: "Open your authenticator app and scan this code. A phone camera works too." }), (0, jsx_runtime_1.jsx)(QrCode_1.QrCode, { value: enroll.otpauth_uri }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { children: "Can't scan? Enter this code manually:" }), (0, jsx_runtime_1.jsx)("code", { "data-testid": "first-enrol-secret", style: { wordBreak: 'break-all' }, children: enroll.secret })] }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: (e) => {
111
+ e.preventDefault();
112
+ void confirm();
113
+ }, children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "first-enrol-code", children: "Enter the 6-digit code from your app:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("input", { id: "first-enrol-code", "data-testid": "first-enrol-code", inputMode: "numeric", autoComplete: "one-time-code", maxLength: 6, value: code, disabled: busy, onChange: (e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6)) }), ' ', (0, jsx_runtime_1.jsx)("button", { type: "submit", "data-testid": "first-enrol-confirm", disabled: busy || code.length !== 6, children: "Confirm" })] })] })) : null, error ? ((0, jsx_runtime_1.jsx)("p", { role: "alert", "data-testid": "first-enrol-error", children: error })) : null, stage === 'failed' ? ((0, jsx_runtime_1.jsx)("p", { children: (0, jsx_runtime_1.jsx)("a", { href: signInHref, children: "Sign in again" }) })) : null] }));
114
+ }
115
+ exports.default = FirstEnrolPanel;