@payez/next-mvp 4.13.1 → 4.14.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.
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.default = FirstEnrollPage;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const FirstEnrolPanel_1 = require("../../components/mfa/FirstEnrolPanel");
7
+ /**
8
+ * AUTH-045 (PAY-2938): /account-auth/first-enroll in a generated app, the page a person is sent to when the IdP says they must enrol an authenticator
9
+ * on this app and they have NO session yet. The page SHELL is public (every /account-auth/* path is public by default); the two routes behind it read
10
+ * ONLY the httpOnly enrolment cookie the credential door parked, and answer 401 without it (the panel then sends the person to sign-in).
11
+ * Not the signed-in enrolment page (/account-auth/enroll), which needs a session.
12
+ */
13
+ function FirstEnrollPage() {
14
+ return ((0, jsx_runtime_1.jsxs)("main", { style: { maxWidth: 480, margin: '48px auto', padding: '0 16px' }, children: [(0, jsx_runtime_1.jsx)("h1", { children: "Secure your account" }), (0, jsx_runtime_1.jsx)(FirstEnrolPanel_1.FirstEnrolPanel, {})] }));
15
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * AUTH-045 first-enrolment: confirm the first authenticator code, clear the enrolment cookie, return the recovery codes. Mints no session.
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/auth/first-enrol/confirm/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-confirm';
8
+ * ```
9
+ */
10
+ export { POST_CONFIRM as POST } from '../../api-handlers/mfa/first-enrol';
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = void 0;
4
+ /**
5
+ * AUTH-045 first-enrolment: confirm the first authenticator code, clear the enrolment cookie, return the recovery codes. Mints no session.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // app/api/auth/first-enrol/confirm/route.ts
10
+ * export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-confirm';
11
+ * ```
12
+ */
13
+ var first_enrol_1 = require("../../api-handlers/mfa/first-enrol");
14
+ Object.defineProperty(exports, "POST", { enumerable: true, get: function () { return first_enrol_1.POST_CONFIRM; } });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * AUTH-045 first-enrolment: begin TOTP enrolment for a user the IdP told to enrol (uses ONLY the httpOnly enrolment cookie the credential door parked).
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/auth/first-enrol/enroll/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-enroll';
8
+ * ```
9
+ */
10
+ export { POST_ENROLL as POST } from '../../api-handlers/mfa/first-enrol';
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = void 0;
4
+ /**
5
+ * AUTH-045 first-enrolment: begin TOTP enrolment for a user the IdP told to enrol (uses ONLY the httpOnly enrolment cookie the credential door parked).
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // app/api/auth/first-enrol/enroll/route.ts
10
+ * export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-enroll';
11
+ * ```
12
+ */
13
+ var first_enrol_1 = require("../../api-handlers/mfa/first-enrol");
14
+ Object.defineProperty(exports, "POST", { enumerable: true, get: function () { return first_enrol_1.POST_ENROLL; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.13.1",
3
+ "version": "4.14.0",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -990,6 +990,16 @@
990
990
  "require": "./dist/routes/mfa/totp-confirm.js",
991
991
  "default": "./dist/routes/mfa/totp-confirm.js"
992
992
  },
993
+ "./routes/mfa/first-enrol-enroll": {
994
+ "types": "./dist/routes/mfa/first-enrol-enroll.d.ts",
995
+ "require": "./dist/routes/mfa/first-enrol-enroll.js",
996
+ "default": "./dist/routes/mfa/first-enrol-enroll.js"
997
+ },
998
+ "./routes/mfa/first-enrol-confirm": {
999
+ "types": "./dist/routes/mfa/first-enrol-confirm.d.ts",
1000
+ "require": "./dist/routes/mfa/first-enrol-confirm.js",
1001
+ "default": "./dist/routes/mfa/first-enrol-confirm.js"
1002
+ },
993
1003
  "./routes/mfa/totp-verify": {
994
1004
  "types": "./dist/routes/mfa/totp-verify.d.ts",
995
1005
  "require": "./dist/routes/mfa/totp-verify.js",
@@ -1100,6 +1110,11 @@
1100
1110
  "require": "./dist/components/mfa/TotpEnrollPanel.js",
1101
1111
  "default": "./dist/components/mfa/TotpEnrollPanel.js"
1102
1112
  },
1113
+ "./dist/pages/first-enroll": {
1114
+ "types": "./dist/pages/first-enroll/page.d.ts",
1115
+ "require": "./dist/pages/first-enroll/page.js",
1116
+ "default": "./dist/pages/first-enroll/page.js"
1117
+ },
1103
1118
  "./dist/pages/enroll": {
1104
1119
  "types": "./dist/pages/enroll/page.d.ts",
1105
1120
  "require": "./dist/pages/enroll/page.js",
@@ -49,6 +49,8 @@ import { DEVICE_ID_HEADER, isValidDeviceId } from '../../lib/device-id';
49
49
  import { classifyTwofaRejection, type TwofaLaneResult } from '../../lib/twofa-rejection';
50
50
  import { bearerFetch } from '../../lib/bearer-fetch';
51
51
  import { errForLog } from '../../lib/log-safe';
52
+ // 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).
53
+ import { ENROL_COOKIE, ENROL_COOKIE_MAX_AGE } from '../../lib/first-enrol';
52
54
 
53
55
  // `export const runtime = 'nodejs'` must be written LITERALLY in the app's route file (Next ignores a re-exported segment config).
54
56
 
@@ -348,6 +350,53 @@ export async function POST(req: NextRequest) {
348
350
  const vresult = verifyData?.data?.result ?? verifyData?.data ?? verifyData;
349
351
  const vAccess: string | undefined = vresult?.access_token;
350
352
  const vRefresh: string | undefined = vresult?.refresh_token;
353
+ // PAY-3083 (AUTH-045): ACCESS WITHOUT REFRESH AT THE CODE TURN IS NOT A FAILURE - IT IS ENROLMENT.
354
+ //
355
+ // The IdP withholds the refresh token for a user who has no usable second factor on THIS client.
356
+ // TOTP enrolment is PER CLIENT (DotNetPert 99446 sec A, client 90717 / user 1175): the password turn
357
+ // answered Require2FA=true, IssueRefreshToken=false, step=enroll_mfa, and this verify turn then
358
+ // accepted the emailed code as CONTACT PROOF and issued an ENROLMENT-SCOPED access token, again with
359
+ // no refresh token. That is exactly what PAY-2798 requires of the IdP - the IdP is RIGHT.
360
+ //
361
+ // This branch used to read "access, no refresh" as an incomplete response and answer 502, so the
362
+ // developer who had just proved their contact was told the server was broken and could never reach
363
+ // the enrolment page: the 502 the field saw as
364
+ // '[ADMIN-LOGIN] verify response missing token { hasAccess: true, hasRefresh: false }'.
365
+ //
366
+ // Send them to ENROLMENT instead, in the ONE contract the door and the form already share for it
367
+ // (PAY-2630): requiresTwoFactor + nextStep 'enroll_mfa'. No session is minted - an enrolment-scoped
368
+ // token is not a signed-in session - and the token value is NEVER logged nor echoed (BAPert 99521 #2).
369
+ //
370
+ // Guard kept: no access token AT ALL (a refresh-only or empty body) is still a broken success and
371
+ // still 502 below - this widening is only for a genuine access-with-no-refresh answer.
372
+ if (vAccess && !vRefresh) {
373
+ const step = vresult?.next_step === 'enroll_mfa' || vresult?.next_step === 'mfa_verify' ? vresult.next_step : 'enroll_mfa';
374
+ const enrolling = step === 'enroll_mfa';
375
+ const answer = NextResponse.json(
376
+ {
377
+ success: false,
378
+ requiresTwoFactor: true,
379
+ acceptedFactors: acceptedFactorsFor('email'),
380
+ sentVia: null,
381
+ codeRequested: false,
382
+ emailSent: false,
383
+ nextStep: step,
384
+ // 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
385
+ // and looped. This flag is the ONLY signal that means "go enrol"; it is set on this answer and nowhere else.
386
+ ...(enrolling ? { enrolmentRequired: true } : {}),
387
+ },
388
+ { status: 200 },
389
+ );
390
+ if (enrolling) {
391
+ // No session exists for an enrolment-scoped token, and the enrolment page's actions need one, so the token rides in this httpOnly
392
+ // 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.
393
+ answer.cookies.set(ENROL_COOKIE, vAccess, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', path: '/', maxAge: ENROL_COOKIE_MAX_AGE });
394
+ // The provisional bearer is spent: the enrolment token replaces it.
395
+ answer.cookies.set('adm2fa', '', { path: '/', maxAge: 0 });
396
+ answer.cookies.set('adm2fa_lane', '', { path: '/', maxAge: 0 });
397
+ }
398
+ return answer;
399
+ }
351
400
  if (!vAccess || !vRefresh) {
352
401
  console.error('[ADMIN-LOGIN] verify response missing tokens', { hasAccess: !!vAccess, hasRefresh: !!vRefresh });
353
402
  return NextResponse.json({ success: false, error: 'Authentication service returned an incomplete response' }, { status: 502 });
@@ -0,0 +1,55 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import { getIDPClientConfig } from '../../lib/idp-client-config';
4
+ import { resolveClientKey } from '../../lib/client-key';
5
+ import { NO_STORE, refuseNonJson } from '../../lib/mfa-json-guard';
6
+ import { ENROL_COOKIE, LIMIT_PER_COOKIE, attemptAllowed, runEnrolStep, type EnrolStep } from '../../lib/first-enrol';
7
+
8
+ /**
9
+ * 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.
10
+ *
11
+ * WHO MAY CALL: the browser that holds the httpOnly enrolment cookie the credential door parked (ENROL_COOKIE). This is the ONLY module besides the
12
+ * 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
13
+ * holding nothing but this cookie (a gate row holds that). No cookie -> 401 SIGN_IN_AGAIN and no IdP call.
14
+ *
15
+ * 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
16
+ * way to mint one is added. The person signs in again with their password and the authenticator, through the same credential door as before.
17
+ *
18
+ * The token never leaves the server: not in a body, a URL, a log or a Referer. The IdP's prose is never relayed.
19
+ */
20
+ const digest = (s: string) => createHash('sha256').update(s).digest('hex');
21
+
22
+ const cookieOpts = (maxAge: number) => ({ httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' as const, path: '/', maxAge });
23
+
24
+ function reply(status: number, body: Record<string, unknown>, clear: boolean): NextResponse {
25
+ const res = NextResponse.json(body, { status, headers: NO_STORE });
26
+ if (clear) res.cookies.set(ENROL_COOKIE, '', cookieOpts(0));
27
+ return res;
28
+ }
29
+
30
+ export function enrolStepPOST(step: EnrolStep) {
31
+ return async function POST(req: NextRequest): Promise<NextResponse> {
32
+ const refused = refuseNonJson(req);
33
+ if (refused) return refused;
34
+ const token = req.cookies.get(ENROL_COOKIE)?.value;
35
+ if (!token) return reply(401, { success: false, code: 'SIGN_IN_AGAIN' }, false);
36
+ if (!attemptAllowed(`${step}|ck|${digest(token)}`, LIMIT_PER_COOKIE)) return reply(429, { success: false, code: 'TOO_MANY', error: 'Too many attempts. Wait a few minutes and try again.' }, true);
37
+
38
+ const idp = process.env.IDP_URL;
39
+ let clientKey = '';
40
+ try {
41
+ const config = await getIDPClientConfig();
42
+ clientKey = resolveClientKey(config, 'first-enrol').key ?? '';
43
+ } catch {
44
+ return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
45
+ }
46
+ if (!idp || !clientKey) return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
47
+
48
+ const body = await req.json().catch(() => null);
49
+ const r = await runEnrolStep({ fetchImpl: (u, i) => fetch(u, i), idp, clientKey }, { step, token, body });
50
+ return reply(r.status, r.body, r.cookie === 'clear');
51
+ };
52
+ }
53
+
54
+ export const POST_ENROLL = enrolStepPOST('enroll');
55
+ export const POST_CONFIRM = enrolStepPOST('confirm');
@@ -51,6 +51,11 @@ interface Props {
51
51
  * default would 404 a generated app's user right after a good sign-in).
52
52
  */
53
53
  enrollHref?: string;
54
+ /**
55
+ * 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).
56
+ * Distinct from enrollHref (which needs a session). NO DEFAULT: without it the form says so instead of redrawing the code box.
57
+ */
58
+ firstEnrolHref?: string;
54
59
  }
55
60
 
56
61
  const inputStyle: React.CSSProperties = {
@@ -73,7 +78,7 @@ export function resolveDestination(explicit?: string): string {
73
78
  return safeCallbackPath(authorizeResumeHref(returnUrl), safeCallbackPath(raw, '/dashboard'));
74
79
  }
75
80
 
76
- export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref }: Props) {
81
+ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref, firstEnrolHref }: Props) {
77
82
  const [step, setStep] = useState<'credentials' | 'code'>('credentials');
78
83
  const [email, setEmail] = useState('');
79
84
  const [password, setPassword] = useState('');
@@ -103,6 +108,18 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
103
108
  else window.location.href = destination;
104
109
  return;
105
110
  }
111
+ if (r.kind === 'enrol_required') {
112
+ // 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
113
+ // the code box (the 14 Nov walk looped there). A host that gave no enrolment page gets a sentence, not a silent loop.
114
+ if (firstEnrolHref) {
115
+ if (onSignedIn) onSignedIn(firstEnrolHref);
116
+ else window.location.href = firstEnrolHref;
117
+ return;
118
+ }
119
+ setError('This app has no authenticator setup page, so sign-in cannot continue. Tell the app owner.');
120
+ setErrorCode('sign_in_failed');
121
+ return;
122
+ }
106
123
  if (r.kind === 'two_factor') {
107
124
  setCodeRequest(r.codeRequest);
108
125
  setDevCode(r.devCode);
@@ -0,0 +1,189 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef, useState } from 'react';
4
+ import { QrCode } from './QrCode';
5
+
6
+ /**
7
+ * 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.
8
+ * Ported from the portal's EnrolRequired (PAY-2798 part 2). It talks ONLY to the two routes that read the httpOnly enrolment cookie:
9
+ * POST /api/auth/first-enrol/enroll (started on mount: the person already asked for this by signing in)
10
+ * POST /api/auth/first-enrol/confirm (the 6-digit code from the app) -> the recovery codes, then "sign in again"
11
+ *
12
+ * 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
13
+ * 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.
14
+ */
15
+
16
+ export const FIRST_ENROL_ENROLL_URL = '/api/auth/first-enrol/enroll';
17
+ export const FIRST_ENROL_CONFIRM_URL = '/api/auth/first-enrol/confirm';
18
+
19
+ /** BAPert 100969 row 6: one sentence that this app has its own authenticator entry (per-client factor, by design). */
20
+ export 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.';
21
+ export const DONE_SENTENCE = 'Your authenticator is set. Sign in with your password and the code from your app.';
22
+ export const SIGN_IN_AGAIN_SENTENCE = 'This setup timed out or the sign-in expired. Sign in again to continue.';
23
+
24
+ const REFUSAL_COPY: Record<string, string> = {
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
+
32
+ type Enrollment = { secret: string; otpauth_uri: string; enrollment_id: number };
33
+ type Stage = 'starting' | 'enrolling' | 'done' | 'failed';
34
+
35
+ const readJson = async (res: Response): Promise<any> => res.json().catch(() => null);
36
+
37
+ export function FirstEnrolPanel({
38
+ fetchImpl,
39
+ signInHref = '/account-auth/login',
40
+ onSignInAgain,
41
+ }: { fetchImpl?: typeof fetch; signInHref?: string; onSignInAgain?: (href: string) => void } = {}) {
42
+ const doFetch: typeof fetch = fetchImpl ?? ((...a) => fetch(...a));
43
+ const [stage, setStage] = useState<Stage>('starting');
44
+ const [enroll, setEnroll] = useState<Enrollment | null>(null);
45
+ const [code, setCode] = useState('');
46
+ const [error, setError] = useState<string | null>(null);
47
+ const [codes, setCodes] = useState<string[] | null>(null);
48
+ const [busy, setBusy] = useState(false);
49
+ const started = useRef(false);
50
+
51
+ const toSignIn = () => {
52
+ if (onSignInAgain) onSignInAgain(signInHref);
53
+ else window.location.href = signInHref;
54
+ };
55
+
56
+ async function begin() {
57
+ setBusy(true);
58
+ setError(null);
59
+ try {
60
+ const res = await doFetch(FIRST_ENROL_ENROLL_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ friendly_name: 'This device' }) });
61
+ if (res.status === 401) return toSignIn(); // a 401 is never a message box
62
+ const body = await readJson(res);
63
+ if (res.ok && body?.data?.secret && body?.data?.otpauth_uri && Number.isInteger(body?.data?.enrollment_id)) {
64
+ setEnroll({ secret: body.data.secret, otpauth_uri: body.data.otpauth_uri, enrollment_id: body.data.enrollment_id });
65
+ setStage('enrolling');
66
+ return;
67
+ }
68
+ setError(REFUSAL_COPY[body?.code as string] ?? FALLBACK_COPY);
69
+ setStage('failed');
70
+ } catch {
71
+ setError(REFUSAL_COPY.UNAVAILABLE);
72
+ setStage('failed');
73
+ } finally {
74
+ setBusy(false);
75
+ }
76
+ }
77
+
78
+ useEffect(() => {
79
+ if (started.current) return;
80
+ started.current = true;
81
+ void begin();
82
+ // eslint-disable-next-line react-hooks/exhaustive-deps
83
+ }, []);
84
+
85
+ async function confirm() {
86
+ if (!enroll || !/^\d{6}$/.test(code)) {
87
+ setError('Enter the 6-digit code from your app.');
88
+ return;
89
+ }
90
+ setBusy(true);
91
+ setError(null);
92
+ try {
93
+ const res = await doFetch(FIRST_ENROL_CONFIRM_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enrollment_id: enroll.enrollment_id, code }) });
94
+ if (res.status === 401) return toSignIn();
95
+ const body = await readJson(res);
96
+ if (res.ok && body?.confirmed === true) {
97
+ setEnroll(null);
98
+ setCode('');
99
+ setCodes(Array.isArray(body.codes) ? body.codes.filter((c: unknown) => typeof c === 'string') : null);
100
+ setStage('done');
101
+ return;
102
+ }
103
+ setCode('');
104
+ setError(REFUSAL_COPY[body?.code as string] ?? FALLBACK_COPY);
105
+ } catch {
106
+ setError(REFUSAL_COPY.UNAVAILABLE);
107
+ } finally {
108
+ setBusy(false);
109
+ }
110
+ }
111
+
112
+ if (stage === 'done') {
113
+ return (
114
+ <div data-testid="first-enrol-done" role="status">
115
+ <p>{DONE_SENTENCE}</p>
116
+ {codes && codes.length > 0 ? (
117
+ <>
118
+ <p>Save these recovery codes somewhere safe. Each one works once, and they are not shown again.</p>
119
+ <ul data-testid="first-enrol-codes">
120
+ {codes.map((c) => (
121
+ <li key={c}>
122
+ <code>{c}</code>
123
+ </li>
124
+ ))}
125
+ </ul>
126
+ </>
127
+ ) : (
128
+ <p data-testid="first-enrol-no-codes">We could not show your recovery codes. You can create new ones after you sign in.</p>
129
+ )}
130
+ <p>
131
+ <a href={signInHref}>Sign in</a>
132
+ </p>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ return (
138
+ <div data-testid="first-enrol-panel" style={{ display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }}>
139
+ <p data-testid="first-enrol-per-app">{PER_APP_SENTENCE}</p>
140
+ {stage === 'starting' ? <p data-testid="first-enrol-starting">Setting up…</p> : null}
141
+ {stage === 'enrolling' && enroll ? (
142
+ <>
143
+ <p>Open your authenticator app and scan this code. A phone camera works too.</p>
144
+ <QrCode value={enroll.otpauth_uri} />
145
+ <div>
146
+ <div>Can&apos;t scan? Enter this code manually:</div>
147
+ <code data-testid="first-enrol-secret" style={{ wordBreak: 'break-all' }}>
148
+ {enroll.secret}
149
+ </code>
150
+ </div>
151
+ <form
152
+ onSubmit={(e) => {
153
+ e.preventDefault();
154
+ void confirm();
155
+ }}
156
+ >
157
+ <label htmlFor="first-enrol-code">Enter the 6-digit code from your app:</label>
158
+ <br />
159
+ <input
160
+ id="first-enrol-code"
161
+ data-testid="first-enrol-code"
162
+ inputMode="numeric"
163
+ autoComplete="one-time-code"
164
+ maxLength={6}
165
+ value={code}
166
+ disabled={busy}
167
+ onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
168
+ />{' '}
169
+ <button type="submit" data-testid="first-enrol-confirm" disabled={busy || code.length !== 6}>
170
+ Confirm
171
+ </button>
172
+ </form>
173
+ </>
174
+ ) : null}
175
+ {error ? (
176
+ <p role="alert" data-testid="first-enrol-error">
177
+ {error}
178
+ </p>
179
+ ) : null}
180
+ {stage === 'failed' ? (
181
+ <p>
182
+ <a href={signInHref}>Sign in again</a>
183
+ </p>
184
+ ) : null}
185
+ </div>
186
+ );
187
+ }
188
+
189
+ export default FirstEnrolPanel;
@@ -24,6 +24,8 @@ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
24
24
  export type CredentialSignInResult =
25
25
  | { kind: 'success' }
26
26
  | { kind: 'two_factor'; codeRequest: CodeRequest; devCode: string | null; nextStep: NextStep }
27
+ /** AUTH-045: the code was accepted as contact proof and the IdP says this user must ENROL a factor on this client. Its own state, so it can never be drawn as the code box. */
28
+ | { kind: 'enrol_required' }
27
29
  | { kind: 'rate_limited'; message: string; code: ErrorCode }
28
30
  | { kind: 'error'; message: string; code: ErrorCode; offerEmailCode: boolean };
29
31
 
@@ -104,7 +106,17 @@ export async function credentialSignIn(
104
106
  const code = body?.code === 'second_factor_rate_limited' ? 'second_factor_rate_limited' : 'too_many_attempts';
105
107
  return { kind: 'rate_limited', message: tooManyAttemptsMessage(body?.retryAfter, code), code };
106
108
  }
107
- if (body?.requiresTwoFactor) return { kind: 'two_factor', codeRequest: readCodeRequest(body), devCode: readDevCode(body), nextStep: readNextStep(body) };
109
+ if (body?.requiresTwoFactor) {
110
+ // AUTH-045 (BAPert 100969 rows 1-2): requiresTwoFactor alone cannot mean "go enrol" (it is also the password turn's "a code was sent"); the walk of
111
+ // 14 Nov showed the form redrawing the code box forever. The door's enrolmentRequired flag is the only signal for enrolment, and anything the
112
+ // client does not understand is a VISIBLE error, never a redraw.
113
+ const raw = body?.nextStep;
114
+ const unexpected: CredentialSignInResult = { kind: 'error', message: MSG_UNEXPECTED, code: 'sign_in_failed', offerEmailCode: false };
115
+ if (body?.enrolmentRequired === true) return raw === 'enroll_mfa' ? { kind: 'enrol_required' } : unexpected;
116
+ if (raw !== undefined && raw !== null && raw !== 'enroll_mfa' && raw !== 'mfa_verify') return unexpected;
117
+ if (args.code && raw === 'enroll_mfa') return unexpected; // a CODE turn told "enrol" without the flag is an older door: never a redraw
118
+ return { kind: 'two_factor', codeRequest: readCodeRequest(body), devCode: readDevCode(body), nextStep: readNextStep(body) };
119
+ }
108
120
  if (!res.ok || body?.success !== true) {
109
121
  const offerEmailCode = body?.offerEmailCode === true;
110
122
  if (isSignInFailureCode(body?.code)) return { kind: 'error', message: signInFailureMessage(body.code) as string, code: body.code, offerEmailCode };