@payez/next-mvp 4.12.1 → 4.13.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 (75) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/api-handlers/auth/credential-login.js +28 -4
  3. package/dist/api-handlers/mfa/totp-confirm.js +8 -4
  4. package/dist/api-handlers/mfa/totp-enroll.js +8 -4
  5. package/dist/api-handlers/session/viability.js +6 -2
  6. package/dist/auth/better-auth.d.ts +6 -0
  7. package/dist/auth/better-auth.js +29 -2
  8. package/dist/auth/session-reset-link.d.ts +32 -0
  9. package/dist/auth/session-reset-link.js +45 -1
  10. package/dist/components/auth/CredentialSignInForm.d.ts +7 -1
  11. package/dist/components/auth/CredentialSignInForm.js +13 -4
  12. package/dist/components/auth/EnrollAuthenticatorOffer.d.ts +21 -0
  13. package/dist/components/auth/EnrollAuthenticatorOffer.js +23 -0
  14. package/dist/components/mfa/QrCode.d.ts +6 -0
  15. package/dist/components/mfa/QrCode.js +14 -0
  16. package/dist/components/mfa/TotpEnrollPanel.d.ts +6 -0
  17. package/dist/components/mfa/TotpEnrollPanel.js +117 -0
  18. package/dist/edge/middleware.d.ts +1 -1
  19. package/dist/edge/middleware.js +2 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/lib/credential-sign-in-client.d.ts +26 -1
  23. package/dist/lib/credential-sign-in-client.js +32 -6
  24. package/dist/lib/idp-client-config.js +4 -1
  25. package/dist/lib/login-error-message.d.ts +3 -0
  26. package/dist/lib/login-error-message.js +12 -1
  27. package/dist/lib/mfa-json-guard.d.ts +12 -0
  28. package/dist/lib/mfa-json-guard.js +19 -0
  29. package/dist/lib/nextauth-secret.js +3 -1
  30. package/dist/lib/payez-client-secret.d.ts +14 -0
  31. package/dist/lib/payez-client-secret.js +41 -0
  32. package/dist/lib/qrcode.d.ts +22 -0
  33. package/dist/lib/qrcode.js +362 -0
  34. package/dist/lib/sign-in-failure.d.ts +35 -0
  35. package/dist/lib/sign-in-failure.js +85 -0
  36. package/dist/lib/twofa-rejection.d.ts +2 -0
  37. package/dist/lib/twofa-rejection.js +5 -2
  38. package/dist/middleware/create-middleware.d.ts +24 -0
  39. package/dist/middleware/create-middleware.js +56 -9
  40. package/dist/middleware/prefetch-guard-status.d.ts +21 -0
  41. package/dist/middleware/prefetch-guard-status.js +42 -0
  42. package/dist/pages/enroll/page.d.ts +7 -0
  43. package/dist/pages/enroll/page.js +15 -0
  44. package/dist/pages/login/page.js +1 -1
  45. package/dist/routes/auth/session-reset.d.ts +11 -1
  46. package/dist/routes/auth/session-reset.js +38 -13
  47. package/dist/routes/auth/viability.js +11 -2
  48. package/package.json +11 -1
  49. package/src/api-handlers/auth/credential-login.ts +29 -4
  50. package/src/api-handlers/mfa/totp-confirm.ts +7 -4
  51. package/src/api-handlers/mfa/totp-enroll.ts +7 -4
  52. package/src/api-handlers/session/viability.ts +6 -2
  53. package/src/auth/better-auth.ts +27 -2
  54. package/src/auth/session-reset-link.ts +47 -0
  55. package/src/components/auth/CredentialSignInForm.tsx +21 -3
  56. package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
  57. package/src/components/mfa/QrCode.tsx +24 -0
  58. package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
  59. package/src/edge/middleware.ts +1 -1
  60. package/src/index.ts +1 -1
  61. package/src/lib/credential-sign-in-client.ts +48 -8
  62. package/src/lib/idp-client-config.ts +5 -1
  63. package/src/lib/login-error-message.ts +13 -1
  64. package/src/lib/mfa-json-guard.ts +19 -0
  65. package/src/lib/nextauth-secret.ts +3 -1
  66. package/src/lib/payez-client-secret.ts +39 -0
  67. package/src/lib/qrcode.ts +348 -0
  68. package/src/lib/sign-in-failure.ts +88 -0
  69. package/src/lib/twofa-rejection.ts +7 -2
  70. package/src/middleware/create-middleware.ts +69 -9
  71. package/src/middleware/prefetch-guard-status.ts +56 -0
  72. package/src/pages/enroll/page.tsx +22 -0
  73. package/src/pages/login/page.tsx +2 -1
  74. package/src/routes/auth/session-reset.ts +46 -11
  75. package/src/routes/auth/viability.ts +10 -2
@@ -0,0 +1,21 @@
1
+ /**
2
+ * PAY-2218 P1 (BAPert 86123, reworded 86209): say, once, at middleware creation, that the HEADER-based router-prefetch guard cannot see router prefetches
3
+ * on this Next version. After P2 the session-cookie clear no longer depends on it (it keys on Sec-Fetch-Mode/Dest), so this is information, not a call to
4
+ * migrate. No behaviour change: this only logs.
5
+ *
6
+ * MEASURED (adapter.js read in each version's own node_modules): Next 15.5.9 and 16.1.5 delete the flight headers (rsc, next-router-prefetch, ...)
7
+ * from the request middleware sees UNCONDITIONALLY, and 15.5.9 also strips `_rsc` from the URL, so isPrefetchRequest() is false for every router
8
+ * prefetch and no config can change that. Next 16.2.x skips the strip when `skipProxyUrlNormalize: true`.
9
+ *
10
+ * NOT warned, on purpose: Next >= 16.2 (the flag is a build-time define that user middleware code cannot reliably read back, so a warning there
11
+ * would be a guess) and Next 14 (not measured). Unparseable or missing version: silent.
12
+ */
13
+ export type PrefetchGuardStatus = {
14
+ dead: boolean;
15
+ reason: string;
16
+ };
17
+ export declare function prefetchGuardStatus(nextVersion: string | undefined | null): PrefetchGuardStatus;
18
+ /** Logs PAYEZ_PREFETCH_GUARD_DEAD once per process when the guard is dead on this Next version. Returns true when it logged. */
19
+ export declare function warnPrefetchGuardDeadOnce(warn: (message: string, data?: unknown) => void, nextVersion?: string | undefined | null): boolean;
20
+ /** Test seam: forget that the warning was logged. */
21
+ export declare function resetPrefetchGuardWarningForTests(): void;
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.prefetchGuardStatus = prefetchGuardStatus;
4
+ exports.warnPrefetchGuardDeadOnce = warnPrefetchGuardDeadOnce;
5
+ exports.resetPrefetchGuardWarningForTests = resetPrefetchGuardWarningForTests;
6
+ function prefetchGuardStatus(nextVersion) {
7
+ const m = /^(\d+)\.(\d+)\./.exec(String(nextVersion ?? '').trim());
8
+ if (!m)
9
+ return { dead: false, reason: 'unknown version' };
10
+ const major = Number(m[1]);
11
+ const minor = Number(m[2]);
12
+ if (major === 15 || (major === 16 && minor < 2)) {
13
+ return { dead: true, reason: 'strips the router-prefetch headers (rsc, next-router-prefetch) before middleware runs and has no opt-out' };
14
+ }
15
+ return { dead: false, reason: 'not measured as dead' };
16
+ }
17
+ function detectNextVersion() {
18
+ try {
19
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
20
+ return require('next/package.json').version;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ let warned = false;
27
+ /** Logs PAYEZ_PREFETCH_GUARD_DEAD once per process when the guard is dead on this Next version. Returns true when it logged. */
28
+ function warnPrefetchGuardDeadOnce(warn, nextVersion = detectNextVersion()) {
29
+ if (warned)
30
+ return false;
31
+ const s = prefetchGuardStatus(nextVersion);
32
+ if (!s.dead)
33
+ return false;
34
+ warned = true;
35
+ warn(`PAYEZ_PREFETCH_GUARD_DEAD next=${nextVersion}: this Next version ${s.reason}, so isPrefetchRequest() cannot see a router prefetch. ` +
36
+ "The session-cookie clear relies on the browser's Sec-Fetch-Mode/Dest instead (PAY-2218 P2); non-browser clients and browsers that send no Sec-Fetch headers are not covered.");
37
+ return true;
38
+ }
39
+ /** Test seam: forget that the warning was logged. */
40
+ function resetPrefetchGuardWarningForTests() {
41
+ warned = false;
42
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * PAY-2631: /account-auth/enroll in a generated app. The page SHELL is public (every /account-auth/* path is public by default);
3
+ * what needs a session is each action: enrol and confirm answer 401 without one and the panel then says to sign in (measured on a
4
+ * scaffolded app: POST enrol with no cookie -> 401, no secret in the body). The scaffold lists the path on the 2FA bypass list
5
+ * because public routes still redirect a 2FA-incomplete session to verify-code, which sends enroll_mfa users back here: a loop.
6
+ */
7
+ export default function EnrollPage(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.default = EnrollPage;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const TotpEnrollPanel_1 = require("../../components/mfa/TotpEnrollPanel");
7
+ /**
8
+ * PAY-2631: /account-auth/enroll in a generated app. The page SHELL is public (every /account-auth/* path is public by default);
9
+ * what needs a session is each action: enrol and confirm answer 401 without one and the panel then says to sign in (measured on a
10
+ * scaffolded app: POST enrol with no cookie -> 401, no secret in the body). The scaffold lists the path on the 2FA bypass list
11
+ * because public routes still redirect a 2FA-incomplete session to verify-code, which sends enroll_mfa users back here: a loop.
12
+ */
13
+ function EnrollPage() {
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: "Set up an authenticator app" }), (0, jsx_runtime_1.jsx)(TotpEnrollPanel_1.TotpEnrollPanel, {}), (0, jsx_runtime_1.jsx)("p", { style: { marginTop: 24 }, children: (0, jsx_runtime_1.jsx)("a", { href: "/", children: "Continue to the app" }) })] }));
15
+ }
@@ -180,7 +180,7 @@ function LoginForm({ altAuthSlot }) {
180
180
  .themed-status-submitting svg {
181
181
  color: ${buttonColor} !important;
182
182
  }
183
- ` } }), (0, jsx_runtime_1.jsx)("div", { className: `min-h-screen flex items-center justify-center px-4 ${colors.background}`, children: (0, jsx_runtime_1.jsx)("div", { className: "max-w-md w-full", children: (0, jsx_runtime_1.jsxs)("div", { className: `rounded-2xl shadow-xl p-8 ${colors.card} ${colors.border} text-gray-800`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "text-center mb-8", children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-3xl font-bold mb-2", style: { color: 'inherit' }, children: branding.tagline || 'Welcome Back' }), (0, jsx_runtime_1.jsx)("p", { className: colors.muted, children: "Sign in to your account" })] }), shownError && ((0, jsx_runtime_1.jsx)("div", { role: "alert", "data-testid": "login-error", className: "mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700", children: shownError })), googleEnabled && ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => void handleGoogleSignIn(), disabled: googleLoading || isSubmitting, "data-testid": "google-sign-in", className: "w-full px-4 py-3 mb-6 rounded-lg border border-gray-300 font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50", style: { backgroundColor: 'white', color: '#1f2937' }, children: [googleLoading ? ((0, jsx_runtime_1.jsxs)("svg", { className: "animate-spin w-5 h-5", fill: "none", viewBox: "0 0 24 24", children: [(0, jsx_runtime_1.jsx)("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), (0, jsx_runtime_1.jsx)("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" })] })) : ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: [(0, jsx_runtime_1.jsx)("path", { d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z", fill: "#4285F4" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z", fill: "#34A853" }), (0, jsx_runtime_1.jsx)("path", { d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z", fill: "#FBBC05" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z", fill: "#EA4335" })] })), googleLoading ? 'Redirecting to Google…' : 'Continue with Google'] }) })), altAuthSlot && (0, jsx_runtime_1.jsx)("div", { className: "mt-4", children: altAuthSlot }), (0, jsx_runtime_1.jsx)("div", { className: `mt-6 text-center text-sm ${colors.muted}`, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/recovery", className: "themed-link hover:underline", children: "Start account recovery" }) })] }) }) })] }));
183
+ ` } }), (0, jsx_runtime_1.jsx)("div", { className: `min-h-screen flex items-center justify-center px-4 ${colors.background}`, children: (0, jsx_runtime_1.jsx)("div", { className: "max-w-md w-full", children: (0, jsx_runtime_1.jsxs)("div", { className: `rounded-2xl shadow-xl p-8 ${colors.card} ${colors.border} text-gray-800`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "text-center mb-8", children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-3xl font-bold mb-2", style: { color: 'inherit' }, children: branding.tagline || 'Welcome Back' }), (0, jsx_runtime_1.jsx)("p", { className: colors.muted, children: "Sign in to your account" })] }), shownError && ((0, jsx_runtime_1.jsx)("div", { role: "alert", "data-testid": "login-error", "data-error-code": loginError === undefined ? ((0, login_error_message_1.loginErrorCode)(urlError) ?? undefined) : undefined, className: "mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700", children: shownError })), googleEnabled && ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => void handleGoogleSignIn(), disabled: googleLoading || isSubmitting, "data-testid": "google-sign-in", className: "w-full px-4 py-3 mb-6 rounded-lg border border-gray-300 font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50", style: { backgroundColor: 'white', color: '#1f2937' }, children: [googleLoading ? ((0, jsx_runtime_1.jsxs)("svg", { className: "animate-spin w-5 h-5", fill: "none", viewBox: "0 0 24 24", children: [(0, jsx_runtime_1.jsx)("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), (0, jsx_runtime_1.jsx)("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" })] })) : ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: [(0, jsx_runtime_1.jsx)("path", { d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z", fill: "#4285F4" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z", fill: "#34A853" }), (0, jsx_runtime_1.jsx)("path", { d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z", fill: "#FBBC05" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z", fill: "#EA4335" })] })), googleLoading ? 'Redirecting to Google…' : 'Continue with Google'] }) })), altAuthSlot && (0, jsx_runtime_1.jsx)("div", { className: "mt-4", children: altAuthSlot }), (0, jsx_runtime_1.jsx)("div", { className: `mt-6 text-center text-sm ${colors.muted}`, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/recovery", className: "themed-link hover:underline", children: "Start account recovery" }) })] }) }) })] }));
184
184
  }
185
185
  function LoginPageFallback() {
186
186
  const colors = (0, useTheme_1.useColors)();
@@ -18,4 +18,14 @@
18
18
  */
19
19
  import { NextRequest, NextResponse } from 'next/server';
20
20
  export declare const dynamic = "force-dynamic";
21
- export declare function GET(request: NextRequest): Promise<NextResponse>;
21
+ export interface SessionResetOptions {
22
+ /**
23
+ * PAY-2218 opt-in, OFF by default (BAPert 83102). OFF: only a link signed with the env secret (BETTER_AUTH_SECRET / NEXTAUTH_SECRET) clears, so every app that can
24
+ * see the secret stays strict. ON: a link signed with the key derived from this request's own session cookie also clears. Turn it on only where the EDGE middleware
25
+ * cannot see the env secret, and set `sessionResetCookieKey: true` on createMvpMiddleware too, or the route refuses the link the middleware signed.
26
+ */
27
+ cookieKey?: boolean;
28
+ }
29
+ export declare function createSessionResetHandler(options?: SessionResetOptions): (request: NextRequest) => Promise<NextResponse>;
30
+ /** The strict default: env-signed links only. An app that opted in uses createSessionResetHandler({ cookieKey: true }). */
31
+ export declare const GET: (request: NextRequest) => Promise<NextResponse>;
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.dynamic = void 0;
4
- exports.GET = GET;
3
+ exports.GET = exports.dynamic = void 0;
4
+ exports.createSessionResetHandler = createSessionResetHandler;
5
5
  /**
6
6
  * PAY-2218 PR 2: the session-reset route. Mount it at app/api/auth/session-reset/route.ts:
7
7
  *
@@ -26,15 +26,40 @@ const session_cookie_clear_1 = require("../../auth/session-cookie-clear");
26
26
  const app_slug_1 = require("../../lib/app-slug");
27
27
  const session_reset_link_1 = require("../../auth/session-reset-link");
28
28
  exports.dynamic = 'force-dynamic';
29
- async function GET(request) {
30
- if ((0, create_middleware_1.isPrefetchRequest)(request)) {
31
- return new server_1.NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
32
- }
33
- const bound = (0, session_reset_link_1.boundSessionCookie)((n) => request.cookies.get(n)?.value, (0, app_slug_1.getSessionCookieName)(), (0, app_slug_1.getSecureSessionCookieName)());
34
- const { params, signed } = await (0, session_reset_link_1.readResetRequest)(request.nextUrl.searchParams, (0, session_reset_link_1.resetSecret)(), bound);
35
- const response = server_1.NextResponse.redirect(new URL((0, session_reset_link_1.loginLocation)(params), request.url), 307);
36
- response.headers.set('cache-control', 'no-store');
37
- if (signed)
38
- (0, session_cookie_clear_1.expireSessionCookies)(response, Array.from(request.cookies, ([name]) => name));
39
- return response;
29
+ function createSessionResetHandler(options = {}) {
30
+ const cookieKey = options.cookieKey === true;
31
+ let splitLogged = false;
32
+ let halfSetLogged = false;
33
+ return async function GET(request) {
34
+ if ((0, create_middleware_1.isPrefetchRequest)(request)) {
35
+ return new server_1.NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
36
+ }
37
+ const bound = (0, session_reset_link_1.boundSessionCookie)((n) => request.cookies.get(n)?.value, (0, app_slug_1.getSessionCookieName)(), (0, app_slug_1.getSecureSessionCookieName)());
38
+ const env = (0, session_reset_link_1.resetSecret)();
39
+ const { params, signed, via } = cookieKey
40
+ ? await (0, session_reset_link_1.readResetRequestAnyKey)(request.nextUrl.searchParams, env, bound)
41
+ : { ...(await (0, session_reset_link_1.readResetRequest)(request.nextUrl.searchParams, env, bound)), via: null };
42
+ // The dev-93 prefetch walk reads this line: a link that verified ONLY with the cookie-derived key while this route sees the env secret is the
43
+ // edge-blind / route-sighted split deployment. Once per process, no values.
44
+ if (cookieKey && signed && via === 'cookie' && env && !splitLogged) {
45
+ splitLogged = true;
46
+ console.info('[next-mvp] session-reset: a link verified with the cookie-derived key while the env secret is visible to this route (the edge middleware signed without it: split deployment)');
47
+ }
48
+ // HALF-SET (NightHawk 83161): this route is strict (cookieKey OFF) but the middleware signed with the cookie-derived key (sessionResetCookieKey ON). The link is refused
49
+ // and the stale cookie survives. Probe ONLY refused links, for the cookie-derived key alone, and say so once per process. No values.
50
+ if (!cookieKey && !signed && !halfSetLogged) {
51
+ const probe = await (0, session_reset_link_1.readResetRequestAnyKey)(request.nextUrl.searchParams, undefined, bound);
52
+ if (probe.signed) {
53
+ halfSetLogged = true;
54
+ console.warn('[next-mvp] session-reset: refused a link signed with the cookie-derived key because this route has cookieKey OFF; the middleware has sessionResetCookieKey ON. Set createSessionResetHandler({ cookieKey: true }) or turn the middleware option off');
55
+ }
56
+ }
57
+ const response = server_1.NextResponse.redirect(new URL((0, session_reset_link_1.loginLocation)(params), request.url), 307);
58
+ response.headers.set('cache-control', 'no-store');
59
+ if (signed)
60
+ (0, session_cookie_clear_1.expireSessionCookies)(response, Array.from(request.cookies, ([name]) => name));
61
+ return response;
62
+ };
40
63
  }
64
+ /** The strict default: env-signed links only. An app that opted in uses createSessionResetHandler({ cookieKey: true }). */
65
+ exports.GET = createSessionResetHandler();
@@ -187,6 +187,9 @@ async function GET(req) {
187
187
  // stack just resolved the cookie cleanly. Keeps the two viability impls
188
188
  // (this route + `api-handlers/session/viability.ts`) in lockstep.
189
189
  let session = sessionToken ? await (0, session_store_1.getSession)(sessionToken) : null;
190
+ // PAY-2467: a row read from the CANONICAL store is the raw JSON the callback wrote, so an `mfaVerified` boolean on it is a
191
+ // MEASUREMENT (an absent key is a legacy row). Only the BA-record fallback collapses absent to false and needs mfaSignalPresent.
192
+ const fromCanonicalStore = !!session;
190
193
  if (sessionToken && !session) {
191
194
  session = await (0, session_store_1.getBetterAuthSession)(sessionToken);
192
195
  if (session) {
@@ -278,9 +281,15 @@ async function GET(req) {
278
281
  * middleware actually gates on; `tenantRequiresTwoFactor` stays pure —
279
282
  * it names TENANT policy, and this override is not tenant state.
280
283
  */
281
- const oauthSessionNeedsTwoFactor = session?.mfaSignalPresent === true &&
284
+ const oauthSessionNeedsTwoFactor =
285
+ // PAY-2467: the signal is present on the BA-fallback path (mfaSignalPresent) OR when the canonical row itself carries a boolean.
286
+ // Before this, the override was dead on the canonical path every consumer mounts, so gating rested on tenant config alone.
287
+ (session?.mfaSignalPresent === true || (fromCanonicalStore && typeof session?.mfaVerified === 'boolean')) &&
282
288
  session?.mfaVerified === false &&
283
- !(session.idpRefreshToken || session?.refreshToken);
289
+ !(session.idpRefreshToken || session?.refreshToken) &&
290
+ // An IdP DISCARD (refresh.ts / ensure-fresh-access-token.ts write idpRefreshToken '' with refreshTokenClearedReason 'IDP_DISCARD_TOKEN:<code>') leaves
291
+ // mfaVerified false and no refresh token too, but that is a DEAD session, not a pending second factor: it must not be sent to verify-code (QAPert 84816).
292
+ !String(session?.refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN');
284
293
  // --- Session revocation status (PAY-1882 step 4 producer half) ---
285
294
  /*
286
295
  * Resolve the four-state revocation status for this user (BAPert 66320).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.12.1",
3
+ "version": "4.13.0",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1084,6 +1084,16 @@
1084
1084
  "types": "./dist/lib/credential-sign-in-client.d.ts",
1085
1085
  "require": "./dist/lib/credential-sign-in-client.js",
1086
1086
  "default": "./dist/lib/credential-sign-in-client.js"
1087
+ },
1088
+ "./components/mfa/TotpEnrollPanel": {
1089
+ "types": "./dist/components/mfa/TotpEnrollPanel.d.ts",
1090
+ "require": "./dist/components/mfa/TotpEnrollPanel.js",
1091
+ "default": "./dist/components/mfa/TotpEnrollPanel.js"
1092
+ },
1093
+ "./dist/pages/enroll": {
1094
+ "types": "./dist/pages/enroll/page.d.ts",
1095
+ "require": "./dist/pages/enroll/page.js",
1096
+ "default": "./dist/pages/enroll/page.js"
1087
1097
  }
1088
1098
  },
1089
1099
  "peerDependencies": {
@@ -38,6 +38,7 @@
38
38
  * lib/session-store.js").
39
39
  */
40
40
  import { NextRequest, NextResponse } from 'next/server';
41
+ import { capPolicyDetail, classifySignInFailure, signInFailureMessage, type SignInFailureCode } from '../../lib/sign-in-failure';
41
42
  import { SignJWT } from 'jose';
42
43
  import { getIDPClientConfig } from '../../lib/idp-client-config';
43
44
  import { resolveClientKey } from '../../lib/client-key';
@@ -325,10 +326,15 @@ export async function POST(req: NextRequest) {
325
326
  // (MfaController.VerifyTotp answers a 15-minute lockout as 400 {code:"locked"}), which status-only
326
327
  // classification told to "try again".
327
328
  const rejection = classifyTwofaRejection(laneResults, idpResponse.headers.get('retry-after'));
329
+ // PAY-2568 follow-up (BAPert 85795): the page takes its sentence from a CODE in the closed table, so this lane must send one.
330
+ // The branch already decided the cause (classifyTwofaRejection); the code follows its status, the IdP's wording is never used.
331
+ const rejectionCode: SignInFailureCode =
332
+ rejection.status === 429 ? (rejection.rateLimited ? 'second_factor_rate_limited' : 'too_many_attempts') : rejection.status === 502 ? 'second_factor_unavailable' : 'invalid_second_factor';
328
333
  return NextResponse.json(
329
334
  {
330
335
  success: false,
331
- error: rejection.error,
336
+ code: rejectionCode,
337
+ error: signInFailureMessage(rejectionCode),
332
338
  ...(rejection.retryAfter !== undefined ? { retryAfter: rejection.retryAfter } : {}),
333
339
  // PAY-2284: the card shows a real "email me a code" action only when this is true.
334
340
  ...(rejection.offerEmailCode ? { offerEmailCode: true } : {}),
@@ -389,19 +395,25 @@ export async function POST(req: NextRequest) {
389
395
  if (idpResponse.status === 429) {
390
396
  const retryAfter = idpResponse.headers.get('retry-after');
391
397
  return NextResponse.json(
392
- { success: false, error: 'Too many attempts. Please try again later.', retryAfter: retryAfter ? parseInt(retryAfter, 10) : 60 },
398
+ {
399
+ success: false,
400
+ code: 'too_many_attempts',
401
+ error: signInFailureMessage('too_many_attempts'),
402
+ // BAPert 85807: the page prints this number as minutes, so only a real Retry-After is sent; no invented default.
403
+ ...(retryAfter && Number.isFinite(parseInt(retryAfter, 10)) && parseInt(retryAfter, 10) > 0 ? { retryAfter: parseInt(retryAfter, 10) } : {}),
404
+ },
393
405
  { status: 429 }
394
406
  );
395
407
  }
396
408
 
397
409
  if (!idpResponse.ok) {
398
410
  const f = logIdpAuthRefusal('non-ok', idpResponse.status, data);
399
- return NextResponse.json({ success: false, error: publicRefusalMessage(f) }, { status: idpResponse.status });
411
+ return NextResponse.json(refusalBody(f, idpResponse.status), { status: idpResponse.status });
400
412
  }
401
413
 
402
414
  if (data?.success === false) {
403
415
  const f = logIdpAuthRefusal('envelope-success-false', idpResponse.status, data);
404
- return NextResponse.json({ success: false, error: publicRefusalMessage(f) }, { status: 200 });
416
+ return NextResponse.json(refusalBody(f, idpResponse.status), { status: 200 });
405
417
  }
406
418
 
407
419
  // Unwrap the IDP's canonical envelope, same shape login.js normalizes.
@@ -761,6 +773,19 @@ function publicRefusalMessage(f: IdpFailure): string {
761
773
  return UNIFORM_REFUSAL;
762
774
  }
763
775
 
776
+ /**
777
+ * The browser-facing body of a refusal (BAPert 85415): a stable machine `code` from the closed table in lib/sign-in-failure, and
778
+ * the fixed sentence for it. The IdP's own message text is never copied in. The one exception is the allowlisted policy refusals
779
+ * (SsoRequired / SsoRequiredBreakGlass / InvalidRequest): a property of the email domain or the request, not of an account, and the
780
+ * user cannot proceed without being told; they carry code 'policy_message' and the allowlisted detail (see SAFE_TO_SHOW).
781
+ */
782
+ function refusalBody(f: IdpFailure, status: number): { success: false; code: string; error: string } {
783
+ const policy = f.code && SAFE_TO_SHOW.has(f.code) ? capPolicyDetail(f.detail) : null;
784
+ if (policy) return { success: false, code: 'policy_message', error: policy };
785
+ const code = classifySignInFailure(status, f.code);
786
+ return { success: false, code, error: signInFailureMessage(code) as string };
787
+ }
788
+
764
789
  function logIdpAuthRefusal(branch: string, httpStatus: number, data: unknown): IdpFailure {
765
790
  const f = extractIdpFailure(data);
766
791
  const d = (data ?? {}) as Record<string, unknown>;
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { idpFetchJSON } from '../../lib/idp-fetch';
3
3
  import { ENV_CONFIG } from '../../config/env';
4
4
  import { projectIdpError } from '../../lib/safe-error-response';
5
+ import { NO_STORE, refuseNonJson } from '../../lib/mfa-json-guard';
5
6
 
6
7
  /**
7
8
  * Confirm TOTP enrolment with the first authenticator code
@@ -10,6 +11,8 @@ import { projectIdpError } from '../../lib/safe-error-response';
10
11
  * the method ACTIVE.
11
12
  */
12
13
  export async function POST(req: NextRequest) {
14
+ const refused = refuseNonJson(req);
15
+ if (refused) return refused;
13
16
  let body = '{}';
14
17
  try {
15
18
  const raw = await req.text();
@@ -30,14 +33,14 @@ export async function POST(req: NextRequest) {
30
33
  error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: projectIdpError(result.json) },
31
34
  meta: { attemptedRefresh: result.attemptedRefresh },
32
35
  },
33
- { status: result.status },
36
+ { status: result.status, headers: NO_STORE },
34
37
  );
35
38
  }
36
39
 
37
40
  const bodyJson: any = result.json;
38
41
  if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
39
- if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200 });
40
- return NextResponse.json(bodyJson, { status: 200 });
42
+ if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200, headers: NO_STORE });
43
+ return NextResponse.json(bodyJson, { status: 200, headers: NO_STORE });
41
44
  }
42
- return NextResponse.json(bodyJson ?? {}, { status: 200 });
45
+ return NextResponse.json(bodyJson ?? {}, { status: 200, headers: NO_STORE });
43
46
  }
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { idpFetchJSON } from '../../lib/idp-fetch';
3
3
  import { ENV_CONFIG } from '../../config/env';
4
4
  import { projectIdpError } from '../../lib/safe-error-response';
5
+ import { NO_STORE, refuseNonJson } from '../../lib/mfa-json-guard';
5
6
 
6
7
  /**
7
8
  * Begin TOTP enrolment (POST /api/Account/mfa/totp/enroll, user JWT).
@@ -10,6 +11,8 @@ import { projectIdpError } from '../../lib/safe-error-response';
10
11
  * as "Active" (MFA honesty fixes, 2026-08-01).
11
12
  */
12
13
  export async function POST(req: NextRequest) {
14
+ const refused = refuseNonJson(req);
15
+ if (refused) return refused;
13
16
  let body = '{}';
14
17
  try {
15
18
  const raw = await req.text();
@@ -30,14 +33,14 @@ export async function POST(req: NextRequest) {
30
33
  error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: projectIdpError(result.json) },
31
34
  meta: { attemptedRefresh: result.attemptedRefresh },
32
35
  },
33
- { status: result.status },
36
+ { status: result.status, headers: NO_STORE },
34
37
  );
35
38
  }
36
39
 
37
40
  const bodyJson: any = result.json;
38
41
  if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
39
- if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200 });
40
- return NextResponse.json(bodyJson, { status: 200 });
42
+ if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200, headers: NO_STORE });
43
+ return NextResponse.json(bodyJson, { status: 200, headers: NO_STORE });
41
44
  }
42
- return NextResponse.json(bodyJson ?? {}, { status: 200 });
45
+ return NextResponse.json(bodyJson ?? {}, { status: 200, headers: NO_STORE });
43
46
  }
@@ -76,6 +76,8 @@ export async function GET(req: NextRequest) {
76
76
  if (betterAuthSession && sessionToken) {
77
77
  // Try legacy session store first, then Better Auth format
78
78
  let sessionData = await getRedisSession(sessionToken);
79
+ // PAY-2467: see routes/auth/viability.ts - a boolean mfaVerified on the raw canonical row is a measurement.
80
+ const fromCanonicalStore = !!sessionData;
79
81
  if (!sessionData) {
80
82
  // Better Auth stores sessions with ba:{appSlug}:{token} prefix
81
83
  sessionData = await getBetterAuthSession(sessionToken);
@@ -127,9 +129,11 @@ export async function GET(req: NextRequest) {
127
129
  // keep gating on `mfaVerified`, which stays fail-closed at all five sites. R807 is
128
130
  // void if this signal reaches an auth decision.
129
131
  if (
130
- sessionData.mfaSignalPresent === true &&
132
+ (sessionData.mfaSignalPresent === true || (fromCanonicalStore && typeof sessionData.mfaVerified === 'boolean')) &&
131
133
  sessionData.mfaVerified === false &&
132
- !sessionData.idpRefreshToken
134
+ !sessionData.idpRefreshToken &&
135
+ // same exception as routes/auth/viability: an IdP-discarded refresh token is a dead session, not a pending second factor
136
+ !String((sessionData as any)?.refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN')
133
137
  ) {
134
138
  requires2FA = true;
135
139
  }
@@ -1563,6 +1563,20 @@ async function abortOAuthSignIn(
1563
1563
  return new Response(null, { status: 302, headers });
1564
1564
  }
1565
1565
 
1566
+ /**
1567
+ * PAY-2568: the Better Auth error code of an /api/auth/error redirect (`?error=<code>`), or null when absent or not a plain
1568
+ * token. The value rides a URL, so only a short [A-Za-z0-9_.-] token is ever returned for logging. Location may be relative
1569
+ * or absolute; the base is a placeholder used only for parsing.
1570
+ */
1571
+ export function authErrorCodeFromLocation(location: string): string | null {
1572
+ try {
1573
+ const code = new URL(location, 'http://placeholder.invalid').searchParams.get('error');
1574
+ return code && /^[A-Za-z0-9_.-]{1,64}$/.test(code) ? code : null;
1575
+ } catch {
1576
+ return null;
1577
+ }
1578
+ }
1579
+
1566
1580
  /**
1567
1581
  * Create a production-ready GET handler for the auth catch-all route.
1568
1582
  *
@@ -1600,7 +1614,15 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1600
1614
  if (response.status === 302) {
1601
1615
  const location = response.headers.get('location') || '';
1602
1616
  if (location.includes('/api/auth/error') || location.includes('please_restart')) {
1603
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
1617
+ // PAY-2568: log WHICH error. Better Auth routes every callback failure to /api/auth/error?error=<code>
1618
+ // (signup_disabled, state_mismatch, ...); the old fixed 'OAuth state error' sentence made distinct faults
1619
+ // look identical. Only the code is logged - never the Location (it can carry other params).
1620
+ const authErrorCode = authErrorCodeFromLocation(location);
1621
+ if (authErrorCode) {
1622
+ console.warn('[BETTER_AUTH] auth error code=' + authErrorCode + ', redirecting to login');
1623
+ } else {
1624
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
1625
+ }
1604
1626
  // *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
1605
1627
  // Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
1606
1628
  // runtime and again on node 24:
@@ -1637,7 +1659,10 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1637
1659
  // Works whether loginPath is relative (package default '/account-auth/login',
1638
1660
  // and the graft passes '/sign-in') or absolute — an absolute Location is also
1639
1661
  // legal, so a consumer passing a full URL still behaves.
1640
- return new Response(null, { status: 302, headers: { location: loginPath } });
1662
+ // PAY-2554: the redirect used to carry NO code, so the login page showed nothing and looked like nothing happened.
1663
+ // It now carries the same neutral OAUTH_SIGN_IN_FAILED the abort path uses (the login page maps it to one sentence;
1664
+ // no provider text, URL or internal detail goes on the URL).
1665
+ return new Response(null, { status: 302, headers: { location: `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${OAUTH_SIGN_IN_FAILED_ERROR}` } });
1641
1666
  }
1642
1667
  }
1643
1668
 
@@ -91,6 +91,32 @@ export function resetSecret(): string | undefined {
91
91
  return process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET || undefined;
92
92
  }
93
93
 
94
+ /**
95
+ * The domain-separation label for the cookie-derived FALLBACK key (PAY-2218, BAPert 83028). It is the only place the session cookie is used as
96
+ * key material, and it is never the raw SHA-256 of the cookie (that is the binding, a different value for a different purpose).
97
+ */
98
+ export const RESET_COOKIE_KEY_CONTEXT = 'payez:session-reset:v1:cookie-derived';
99
+
100
+ /**
101
+ * The secret the reset link is signed and verified with.
102
+ *
103
+ * 1. The env secret (BETTER_AUTH_SECRET / NEXTAUTH_SECRET) when the edge can see it. ALWAYS FIRST: where it is present nothing changes.
104
+ * 2. Otherwise a key derived from the session cookie the link is bound to: HMAC(key = cookie value, message = RESET_COOKIE_KEY_CONTEXT).
105
+ * Why that is safe: the cookie is HttpOnly and is the user's credential, so a third-party page that links a victim to the route cannot
106
+ * produce a valid signature (the logout-CSRF protection the signature exists for), and anyone who holds the cookie can already act as
107
+ * the user, logging out included. The edge holds only a one-way value computed per request from a cookie it already received.
108
+ * RESIDUAL RISK, ACCEPTED (BAPert 83028): an attacker who controls a sibling subdomain can plant a cookie value they know under the
109
+ * session cookie name and sign a link that clears THAT value. The effect is a forced logout, and an attacker in that position can already
110
+ * set or shadow cookies and log the victim out, so the scheme adds no capability.
111
+ * 3. Neither: undefined ("cannot sign"; callers fall back to the direct clear).
112
+ * Never log the cookie value or the returned key.
113
+ */
114
+ export async function resetSigningSecret(envSecret: string | undefined, sessionCookieValue: string | undefined): Promise<string | undefined> {
115
+ if (envSecret) return envSecret;
116
+ if (!sessionCookieValue) return undefined;
117
+ return b64url(await hmacBytes(encoder.encode(sessionCookieValue), RESET_COOKIE_KEY_CONTEXT));
118
+ }
119
+
94
120
  /** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
95
121
  export function boundSessionCookie(get: (name: string) => string | undefined, plainName: string, secureName: string): string | undefined {
96
122
  return get(secureName) || get(plainName) || undefined;
@@ -137,6 +163,27 @@ export async function signedResetHref(
137
163
  return `${route}?${q.toString()}`;
138
164
  }
139
165
 
166
+ /**
167
+ * VERIFY side, ONLY for an app that opted in (sessionResetCookieKey + createSessionResetHandler({ cookieKey: true }); BAPert 83102). PAY-2218, BAPert 83074 option a: the signer and the verifier can see different things. The edge middleware may be BLIND to the env
168
+ * secret (it signs cookie-derived) while the Node route handler is SIGHTED (the IdP-supplied secret is in its process.env). So the route accepts a link
169
+ * signed with EITHER sound key: the env secret first (when it has one), then the key derived from THIS request's session cookie. Both are sound: the
170
+ * cookie-derived link is still bound to the request's own session cookie, carries its expiry inside the MAC, and is compared in constant time. A link
171
+ * signed with neither key, or with a key derived from a different cookie, is refused.
172
+ */
173
+ export async function readResetRequestAnyKey(
174
+ search: URLSearchParams,
175
+ envSecret: string | undefined,
176
+ sessionCookieValue: string | undefined,
177
+ nowMs: number = Date.now()
178
+ ): Promise<{ params: ResetParams; signed: boolean; via: 'env' | 'cookie' | null }> {
179
+ if (envSecret) {
180
+ const viaEnv = await readResetRequest(search, envSecret, sessionCookieValue, nowMs);
181
+ if (viaEnv.signed) return { ...viaEnv, via: 'env' };
182
+ }
183
+ const viaCookie = await readResetRequest(search, await resetSigningSecret(undefined, sessionCookieValue), sessionCookieValue, nowMs);
184
+ return { ...viaCookie, via: viaCookie.signed ? 'cookie' : null };
185
+ }
186
+
140
187
  /**
141
188
  * Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
142
189
  * this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
@@ -6,11 +6,14 @@ import { safeCallbackPath } from '../../auth/safe-callback';
6
6
  import { authorizeResumeHref } from '../../lib/authorize-resume';
7
7
  import { NO_CODE_REQUEST, codeStepInstruction, type CodeRequest } from '../../lib/code-step-copy';
8
8
  import { DevCodeNotice } from './DevCodeNotice';
9
+ import { EnrollAuthenticatorOffer } from './EnrollAuthenticatorOffer';
9
10
  import {
10
11
  credentialSignIn,
12
+ postSignInDestination,
11
13
  requestEmailCode,
12
14
  validateCredentials,
13
15
  type FetchLike,
16
+ type NextStep,
14
17
  } from '../../lib/credential-sign-in-client';
15
18
 
16
19
  /**
@@ -42,6 +45,12 @@ interface Props {
42
45
  /** Test seam: navigation after success. */
43
46
  onSignedIn?: (destination: string) => void;
44
47
  showForgotPassword?: boolean;
48
+ /**
49
+ * PAY-2630: the enrolment page a user the IdP told to enrol is taken to after signing in. NO DEFAULT: the offer renders only when
50
+ * the host passes this, because the host is the one that serves the page (the CLI scaffold writes no /account/security, so a
51
+ * default would 404 a generated app's user right after a good sign-in).
52
+ */
53
+ enrollHref?: string;
45
54
  }
46
55
 
47
56
  const inputStyle: React.CSSProperties = {
@@ -64,27 +73,32 @@ export function resolveDestination(explicit?: string): string {
64
73
  return safeCallbackPath(authorizeResumeHref(returnUrl), safeCallbackPath(raw, '/dashboard'));
65
74
  }
66
75
 
67
- export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false }: Props) {
76
+ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref }: Props) {
68
77
  const [step, setStep] = useState<'credentials' | 'code'>('credentials');
69
78
  const [email, setEmail] = useState('');
70
79
  const [password, setPassword] = useState('');
71
80
  const [code, setCode] = useState('');
72
81
  const [busy, setBusy] = useState(false);
73
82
  const [error, setError] = useState<string | null>(null);
83
+ const [errorCode, setErrorCode] = useState<string | null>(null);
74
84
  const [fieldError, setFieldError] = useState<string | null>(null);
75
85
  const [codeRequest, setCodeRequest] = useState<CodeRequest>(NO_CODE_REQUEST);
76
86
  const [offerEmailCode, setOfferEmailCode] = useState(false);
77
87
  const [devCode, setDevCode] = useState<string | null>(null);
78
88
  const [emailCodeMsg, setEmailCodeMsg] = useState<string | null>(null);
89
+ // PAY-2630: what the IdP said is outstanding on this client, and whether the user kept the 'take me to enrol' offer ticked.
90
+ const [nextStep, setNextStep] = useState<NextStep>(null);
91
+ const [enrolAfter, setEnrolAfter] = useState(true);
79
92
 
80
93
  async function submit(withCode?: string) {
81
94
  setBusy(true);
82
95
  setError(null);
96
+ setErrorCode(null);
83
97
  setOfferEmailCode(false);
84
98
  try {
85
99
  const r = await credentialSignIn({ email, password, code: withCode }, fetchImpl);
86
100
  if (r.kind === 'success') {
87
- const destination = resolveDestination(callbackUrl);
101
+ const destination = postSignInDestination(resolveDestination(callbackUrl), nextStep, enrolAfter, enrollHref);
88
102
  if (onSignedIn) onSignedIn(destination);
89
103
  else window.location.href = destination;
90
104
  return;
@@ -92,10 +106,12 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
92
106
  if (r.kind === 'two_factor') {
93
107
  setCodeRequest(r.codeRequest);
94
108
  setDevCode(r.devCode);
109
+ setNextStep(r.nextStep);
95
110
  setStep('code');
96
111
  return;
97
112
  }
98
113
  setError(r.message);
114
+ setErrorCode(r.code);
99
115
  if (r.kind === 'error') setOfferEmailCode(r.offerEmailCode);
100
116
  } finally {
101
117
  setBusy(false);
@@ -128,6 +144,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
128
144
  setStep('credentials');
129
145
  setCodeRequest(NO_CODE_REQUEST);
130
146
  setDevCode(null);
147
+ setNextStep(null);
131
148
  setCode('');
132
149
  setFieldError(null);
133
150
  setError(null);
@@ -156,7 +173,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
156
173
 
157
174
  const problem = fieldError ?? error;
158
175
  const problemLine = problem && (
159
- <p role="alert" data-testid="credential-sign-in-error" className="text-sm mt-3" style={{ color: 'var(--color-error, #ef4444)' }}>
176
+ <p role="alert" data-testid="credential-sign-in-error" data-error-code={fieldError ? 'invalid_input' : (errorCode ?? undefined)} className="text-sm mt-3" style={{ color: 'var(--color-error, #ef4444)' }}>
160
177
  {problem}
161
178
  </p>
162
179
  );
@@ -198,6 +215,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
198
215
  {busy ? 'Verifying…' : 'Verify'}
199
216
  </button>
200
217
  </form>
218
+ <EnrollAuthenticatorOffer nextStep={enrollHref ? nextStep : null} checked={enrolAfter} onChange={setEnrolAfter} disabled={busy} />
201
219
  <button type="button" onClick={onBack} disabled={busy} className="text-sm underline mt-4" style={{ background: 'none', border: 'none', padding: 0, color: 'var(--text-muted, #6b7280)' }}>
202
220
  Back
203
221
  </button>