@payez/next-mvp 4.4.5 → 4.4.7

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 (219) hide show
  1. package/README.md +10 -3
  2. package/dist/api/auth-handler.js +5 -3
  3. package/dist/api-handlers/account/change-password.js +33 -8
  4. package/dist/api-handlers/account/masked-info.js +2 -1
  5. package/dist/api-handlers/account/recovery/forward.d.ts +14 -0
  6. package/dist/api-handlers/account/recovery/forward.js +105 -0
  7. package/dist/api-handlers/account/recovery/initiate.js +4 -0
  8. package/dist/api-handlers/account/recovery/send-code.d.ts +12 -2
  9. package/dist/api-handlers/account/recovery/send-code.js +11 -84
  10. package/dist/api-handlers/account/recovery/verify-code.d.ts +11 -2
  11. package/dist/api-handlers/account/recovery/verify-code.js +10 -84
  12. package/dist/api-handlers/account/reset-password.js +24 -1
  13. package/dist/api-handlers/account/send-code.d.ts +1 -15
  14. package/dist/api-handlers/account/send-code.js +3 -6
  15. package/dist/api-handlers/account/update-phone.js +6 -1
  16. package/dist/api-handlers/account/validate-password.js +3 -1
  17. package/dist/api-handlers/account/verify-email.d.ts +1 -17
  18. package/dist/api-handlers/account/verify-email.js +4 -6
  19. package/dist/api-handlers/account/verify-sms.d.ts +1 -17
  20. package/dist/api-handlers/account/verify-sms.js +4 -6
  21. package/dist/api-handlers/admin/analytics.js +12 -4
  22. package/dist/api-handlers/admin/audit.js +12 -4
  23. package/dist/api-handlers/admin/redis-sessions.js +16 -8
  24. package/dist/api-handlers/admin/sessions.js +14 -6
  25. package/dist/api-handlers/admin/site-logs.js +39 -16
  26. package/dist/api-handlers/admin/stats.js +14 -4
  27. package/dist/api-handlers/admin/users.js +14 -6
  28. package/dist/api-handlers/admin/vibe-data.js +19 -11
  29. package/dist/api-handlers/auth/jwks.js +2 -1
  30. package/dist/api-handlers/auth/login.js +13 -4
  31. package/dist/api-handlers/auth/passkey-signin.js +6 -0
  32. package/dist/api-handlers/auth/refresh.js +38 -11
  33. package/dist/api-handlers/auth/signout.js +2 -1
  34. package/dist/api-handlers/auth/status.js +4 -1
  35. package/dist/api-handlers/auth/update-session.d.ts +25 -4
  36. package/dist/api-handlers/auth/update-session.js +38 -11
  37. package/dist/api-handlers/auth/validate.js +4 -2
  38. package/dist/api-handlers/auth/verify-code.js +3 -2
  39. package/dist/api-handlers/mfa/backup-code-verify.d.ts +1 -18
  40. package/dist/api-handlers/mfa/backup-code-verify.js +4 -6
  41. package/dist/api-handlers/mfa/backup-codes.js +2 -1
  42. package/dist/api-handlers/mfa/methods.js +2 -1
  43. package/dist/api-handlers/mfa/totp-confirm.js +2 -1
  44. package/dist/api-handlers/mfa/totp-enroll.js +2 -1
  45. package/dist/api-handlers/mfa/totp-verify.d.ts +1 -19
  46. package/dist/api-handlers/mfa/totp-verify.js +4 -6
  47. package/dist/api-handlers/passkeys/list.js +2 -1
  48. package/dist/api-handlers/passkeys/manage.js +2 -1
  49. package/dist/api-handlers/passkeys/register-options.js +2 -1
  50. package/dist/api-handlers/passkeys/register-verify.js +2 -1
  51. package/dist/api-handlers/session/refresh-viability.d.ts +4 -2
  52. package/dist/api-handlers/session/refresh-viability.js +10 -7
  53. package/dist/api-handlers/session/viability.js +4 -3
  54. package/dist/api-handlers/test/force-expire.js +5 -3
  55. package/dist/auth/auth-decision.js +2 -1
  56. package/dist/auth/better-auth.d.ts +58 -14
  57. package/dist/auth/better-auth.js +125 -54
  58. package/dist/auth/utils/idp-client.js +2 -1
  59. package/dist/client/better-auth-client.d.ts +101 -956
  60. package/dist/components/admin/DataBrowserTab.js +4 -1
  61. package/dist/components/admin/SessionsTab.js +9 -3
  62. package/dist/components/recovery/RequestedStep.d.ts +11 -0
  63. package/dist/components/recovery/RequestedStep.js +14 -0
  64. package/dist/components/recovery/SetPasswordStep.js +10 -1
  65. package/dist/config/env.d.ts +0 -2
  66. package/dist/config/env.js +0 -2
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +8 -1
  69. package/dist/lib/app-slug.d.ts +21 -0
  70. package/dist/lib/app-slug.js +51 -0
  71. package/dist/lib/ensure-fresh-access-token.d.ts +26 -0
  72. package/dist/lib/ensure-fresh-access-token.js +105 -4
  73. package/dist/lib/idp-client-config.d.ts +20 -0
  74. package/dist/lib/idp-client-config.js +73 -45
  75. package/dist/lib/idp-fetch.js +1 -1
  76. package/dist/lib/never-in-response.d.ts +30 -0
  77. package/dist/lib/never-in-response.js +71 -0
  78. package/dist/lib/password-rules.d.ts +22 -0
  79. package/dist/lib/password-rules.js +43 -0
  80. package/dist/lib/safe-error-response.d.ts +67 -0
  81. package/dist/lib/safe-error-response.js +130 -0
  82. package/dist/lib/session-cookie-shape.d.ts +22 -19
  83. package/dist/lib/session-cookie-shape.js +14 -18
  84. package/dist/lib/session-store.d.ts +8 -1
  85. package/dist/lib/session-store.js +170 -24
  86. package/dist/lib/startup-init.d.ts +6 -0
  87. package/dist/lib/startup-init.js +30 -4
  88. package/dist/lib/token-lifecycle.js +2 -1
  89. package/dist/lib/token-log-id.d.ts +18 -0
  90. package/dist/lib/token-log-id.js +84 -0
  91. package/dist/lib/two-factor-verify-failure.d.ts +78 -0
  92. package/dist/lib/two-factor-verify-failure.js +220 -0
  93. package/dist/middleware/create-middleware.d.ts +4 -1
  94. package/dist/middleware/create-middleware.js +79 -8
  95. package/dist/pages/login/page.js +5 -0
  96. package/dist/pages/recovery/page.js +14 -4
  97. package/dist/pages/test-env/JwtInspectPage.js +7 -149
  98. package/dist/pages/test-env/RefreshTokenPage.js +4 -1
  99. package/dist/pages/test-env/redact-credentials.d.ts +6 -0
  100. package/dist/pages/test-env/redact-credentials.js +101 -0
  101. package/dist/pages/verify-code/page.js +6 -3
  102. package/dist/routes/auth/logout.d.ts +2 -1
  103. package/dist/routes/auth/logout.js +4 -2
  104. package/dist/routes/auth/session.d.ts +0 -3
  105. package/dist/routes/auth/session.js +16 -10
  106. package/dist/routes/auth/settings.js +4 -1
  107. package/dist/routes/auth/viability.d.ts +3 -2
  108. package/dist/routes/auth/viability.js +11 -7
  109. package/dist/server/auth.d.ts +36 -294
  110. package/dist/server/auth.js +32 -31
  111. package/dist/server/decode-session.js +15 -26
  112. package/dist/server/verified-session-token.d.ts +13 -0
  113. package/dist/server/verified-session-token.js +103 -0
  114. package/dist/stores/authStore.js +14 -3
  115. package/dist/types/recovery.d.ts +1 -1
  116. package/dist/utils/api.d.ts +18 -0
  117. package/dist/utils/api.js +51 -9
  118. package/dist/vibe/client.d.ts +132 -16
  119. package/dist/vibe/client.js +288 -82
  120. package/dist/vibe/document-unwrap.d.ts +50 -0
  121. package/dist/vibe/document-unwrap.js +78 -0
  122. package/dist/vibe/errors.d.ts +17 -0
  123. package/dist/vibe/errors.js +21 -1
  124. package/dist/vibe/generic.d.ts +2 -49
  125. package/dist/vibe/generic.js +17 -78
  126. package/dist/vibe/index.d.ts +1 -1
  127. package/dist/vibe/index.js +2 -1
  128. package/dist/vibe/sessions.js +2 -1
  129. package/package.json +13 -2
  130. package/scripts/verify-release-provenance.mjs +410 -0
  131. package/src/api/auth-handler.ts +5 -3
  132. package/src/api-handlers/account/change-password.ts +36 -8
  133. package/src/api-handlers/account/masked-info.ts +2 -1
  134. package/src/api-handlers/account/recovery/forward.ts +117 -0
  135. package/src/api-handlers/account/recovery/initiate.ts +3 -0
  136. package/src/api-handlers/account/recovery/send-code.ts +13 -99
  137. package/src/api-handlers/account/recovery/verify-code.ts +12 -99
  138. package/src/api-handlers/account/reset-password.ts +30 -1
  139. package/src/api-handlers/account/send-code.ts +3 -9
  140. package/src/api-handlers/account/update-phone.ts +6 -1
  141. package/src/api-handlers/account/validate-password.ts +3 -1
  142. package/src/api-handlers/account/verify-email.ts +4 -9
  143. package/src/api-handlers/account/verify-sms.ts +4 -9
  144. package/src/api-handlers/admin/analytics.ts +13 -5
  145. package/src/api-handlers/admin/audit.ts +13 -5
  146. package/src/api-handlers/admin/redis-sessions.ts +16 -8
  147. package/src/api-handlers/admin/sessions.ts +15 -7
  148. package/src/api-handlers/admin/site-logs.ts +39 -16
  149. package/src/api-handlers/admin/stats.ts +15 -5
  150. package/src/api-handlers/admin/users.ts +15 -7
  151. package/src/api-handlers/admin/vibe-data.ts +20 -12
  152. package/src/api-handlers/auth/jwks.ts +2 -1
  153. package/src/api-handlers/auth/login.ts +13 -4
  154. package/src/api-handlers/auth/passkey-signin.ts +4 -0
  155. package/src/api-handlers/auth/refresh.ts +38 -11
  156. package/src/api-handlers/auth/signout.ts +2 -1
  157. package/src/api-handlers/auth/status.ts +4 -1
  158. package/src/api-handlers/auth/update-session.ts +45 -11
  159. package/src/api-handlers/auth/validate.ts +4 -2
  160. package/src/api-handlers/auth/verify-code.ts +3 -2
  161. package/src/api-handlers/mfa/backup-code-verify.ts +4 -9
  162. package/src/api-handlers/mfa/backup-codes.ts +2 -1
  163. package/src/api-handlers/mfa/methods.ts +2 -1
  164. package/src/api-handlers/mfa/totp-confirm.ts +2 -1
  165. package/src/api-handlers/mfa/totp-enroll.ts +2 -1
  166. package/src/api-handlers/mfa/totp-verify.ts +4 -9
  167. package/src/api-handlers/passkeys/list.ts +2 -1
  168. package/src/api-handlers/passkeys/manage.ts +2 -1
  169. package/src/api-handlers/passkeys/register-options.ts +2 -1
  170. package/src/api-handlers/passkeys/register-verify.ts +2 -1
  171. package/src/api-handlers/session/refresh-viability.ts +10 -7
  172. package/src/api-handlers/session/viability.ts +4 -3
  173. package/src/api-handlers/test/force-expire.ts +5 -3
  174. package/src/auth/auth-decision.ts +2 -1
  175. package/src/auth/better-auth.ts +128 -61
  176. package/src/auth/utils/idp-client.ts +2 -1
  177. package/src/components/admin/DataBrowserTab.tsx +4 -1
  178. package/src/components/admin/SessionsTab.tsx +10 -3
  179. package/src/components/recovery/RequestedStep.tsx +30 -0
  180. package/src/components/recovery/SetPasswordStep.tsx +24 -3
  181. package/src/config/env.ts +0 -2
  182. package/src/hooks/useViabilitySession.ts +2 -1
  183. package/src/index.ts +4 -0
  184. package/src/lib/app-slug.ts +50 -0
  185. package/src/lib/ensure-fresh-access-token.ts +133 -4
  186. package/src/lib/idp-client-config.ts +98 -45
  187. package/src/lib/idp-fetch.ts +1 -1
  188. package/src/lib/never-in-response.ts +64 -0
  189. package/src/lib/password-rules.ts +50 -0
  190. package/src/lib/safe-error-response.ts +126 -0
  191. package/src/lib/session-cookie-shape.ts +23 -22
  192. package/src/lib/session-store.ts +167 -24
  193. package/src/lib/startup-init.ts +29 -4
  194. package/src/lib/token-lifecycle.ts +2 -1
  195. package/src/lib/token-log-id.ts +69 -0
  196. package/src/lib/two-factor-verify-failure.ts +248 -0
  197. package/src/middleware/create-middleware.ts +97 -9
  198. package/src/pages/login/page.tsx +4 -0
  199. package/src/pages/recovery/page.tsx +21 -4
  200. package/src/pages/test-env/JwtInspectPage.tsx +11 -233
  201. package/src/pages/test-env/RefreshTokenPage.tsx +9 -4
  202. package/src/pages/test-env/redact-credentials.ts +96 -0
  203. package/src/pages/verify-code/page.tsx +6 -3
  204. package/src/routes/auth/logout.ts +4 -2
  205. package/src/routes/auth/session.ts +16 -10
  206. package/src/routes/auth/settings.ts +5 -1
  207. package/src/routes/auth/viability.ts +11 -7
  208. package/src/server/auth.ts +33 -35
  209. package/src/server/decode-session.ts +16 -26
  210. package/src/server/verified-session-token.ts +103 -0
  211. package/src/stores/authStore.ts +16 -5
  212. package/src/types/recovery.ts +1 -1
  213. package/src/utils/api.ts +47 -8
  214. package/src/vibe/client.ts +378 -91
  215. package/src/vibe/document-unwrap.ts +103 -0
  216. package/src/vibe/errors.ts +24 -0
  217. package/src/vibe/generic.ts +8 -100
  218. package/src/vibe/index.ts +1 -0
  219. package/src/vibe/sessions.ts +2 -1
@@ -0,0 +1,69 @@
1
+ /**
2
+ * tokenLogId - a log-safe correlation id for a session token (PR 154 follow-up, BAPert ruling on PAY-1427:
3
+ * "no token material in logs, not even partial").
4
+ *
5
+ * The package used to log `sessionToken.substring(0, 8) + '...'` so lines about one session could be
6
+ * correlated. Those 8 characters ARE token material. This returns `h:` + the first 12 hex characters of
7
+ * SHA-256(token) instead: the same correlation (one token -> one stable id, across every log line and
8
+ * process), with nothing of the token itself.
9
+ *
10
+ * Synchronous and dependency-free on purpose: the call sites include Edge middleware
11
+ * (create-middleware, auth-decision) and browser code (authStore, SessionSync), where node:crypto is
12
+ * unavailable and Web Crypto's digest is async. SHA-256 below follows FIPS 180-4 and is verified
13
+ * against node:crypto in test/session-token-log-id.test.cjs.
14
+ */
15
+
16
+ const K = new Uint32Array([
17
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
18
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
19
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
20
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
21
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
22
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
23
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
24
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
25
+ ]);
26
+
27
+ const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
28
+
29
+ /** SHA-256 of the UTF-8 encoding of `input`, as lowercase hex. */
30
+ export function sha256Hex(input: string): string {
31
+ const msg = new TextEncoder().encode(input);
32
+ const bitLen = msg.length * 8;
33
+ const padded = new Uint8Array(((msg.length + 9 + 63) >> 6) << 6);
34
+ padded.set(msg);
35
+ padded[msg.length] = 0x80;
36
+ const view = new DataView(padded.buffer);
37
+ view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
38
+ view.setUint32(padded.length - 4, bitLen >>> 0);
39
+
40
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
41
+ const w = new Uint32Array(64);
42
+ for (let off = 0; off < padded.length; off += 64) {
43
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4);
44
+ for (let i = 16; i < 64; i++) {
45
+ const s15 = w[i - 15];
46
+ const s2 = w[i - 2];
47
+ const sigma0 = rotr(s15, 7) ^ rotr(s15, 18) ^ (s15 >>> 3);
48
+ const sigma1 = rotr(s2, 17) ^ rotr(s2, 19) ^ (s2 >>> 10);
49
+ w[i] = (w[i - 16] + sigma0 + w[i - 7] + sigma1) | 0;
50
+ }
51
+ let a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
52
+ for (let i = 0; i < 64; i++) {
53
+ const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) | 0;
54
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0;
55
+ hh = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
56
+ }
57
+ h[0] = (h[0] + a) | 0; h[1] = (h[1] + b) | 0; h[2] = (h[2] + c) | 0; h[3] = (h[3] + d) | 0;
58
+ h[4] = (h[4] + e) | 0; h[5] = (h[5] + f) | 0; h[6] = (h[6] + g) | 0; h[7] = (h[7] + hh) | 0;
59
+ }
60
+ let out = '';
61
+ for (let i = 0; i < 8; i++) out += (h[i] >>> 0).toString(16).padStart(8, '0');
62
+ return out;
63
+ }
64
+
65
+ /** `h:` + 12 hex of SHA-256(token); `none` when there is no token. Never any part of the token itself. */
66
+ export function tokenLogId(token: string | null | undefined): string {
67
+ if (typeof token !== 'string' || token.length === 0) return 'none';
68
+ return 'h:' + sha256Hex(token).slice(0, 12);
69
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * PAY-1443: the ONE way a 2FA verify handler answers an IDP rejection.
3
+ *
4
+ * Found live (portal.keelauth.com /account-auth/verify-code, 2026-09-17): a wrong
5
+ * authenticator code rendered as the banner text `{ message = Invalid authentication code }`.
6
+ * The handlers relayed `result.json?.message` verbatim, and the IDP's error text is not a
7
+ * user-facing string:
8
+ * - deployed IDP: the >=400 envelope sets Message = value.ToString() on the controller's
9
+ * `new { message = ... }`, producing C#'s anonymous-object ToString `{ message = X }`
10
+ * (or a DTO's TYPE NAME for a typed body);
11
+ * - IDP master (e03a0e449): no ToString, but the lookup is case-sensitive, so the envelope
12
+ * message is "An error occurred" and the real text sits only in error.details.message.
13
+ *
14
+ * So the upstream text is used ONLY TO CLASSIFY, never rendered. The caller gets:
15
+ * - the upstream HTTP status, unchanged (verify-code's 401 branch probes session liveness);
16
+ * - a stable `code` the UI can switch on;
17
+ * - one of the fixed sentences below.
18
+ */
19
+ import { NextResponse } from 'next/server';
20
+
21
+ export type TwoFactorVerifyMethod = 'totp' | 'sms' | 'email' | 'backup_code';
22
+
23
+ export type TwoFactorVerifyFailureCode =
24
+ | 'INVALID_CODE'
25
+ | 'LOCKED'
26
+ | 'RATE_LIMITED'
27
+ | 'SESSION_REJECTED'
28
+ | 'STEP_UP_FACTOR_NOT_ALLOWED'
29
+ | 'VERIFY_UNAVAILABLE'
30
+ | 'VERIFY_FAILED';
31
+
32
+ const INVALID_CODE_TEXT: Record<TwoFactorVerifyMethod, string> = {
33
+ totp: 'Invalid authentication code. Please try again.',
34
+ sms: 'Invalid verification code. Please try again.',
35
+ email: 'Invalid verification code. Please try again.',
36
+ backup_code: 'Invalid backup code. Please try again.',
37
+ };
38
+
39
+ const FIXED_TEXT: Record<Exclude<TwoFactorVerifyFailureCode, 'INVALID_CODE' | 'STEP_UP_FACTOR_NOT_ALLOWED'>, string> = {
40
+ LOCKED: 'Too many incorrect codes. Verification is locked for 15 minutes - wait before entering another code.',
41
+ RATE_LIMITED: 'Too many attempts. Please wait a moment and try again.',
42
+ SESSION_REJECTED: 'We could not verify the code for this session. Please try again.',
43
+ VERIFY_UNAVAILABLE: 'We could not verify the code right now. Please try again.',
44
+ VERIFY_FAILED: 'We could not verify that code. Please try again.',
45
+ };
46
+
47
+ /**
48
+ * MfaTotpService ErrorCode "locked": the bare code, or `code = locked` inside a deployed
49
+ * ToString envelope.
50
+ *
51
+ * CAUGHT BEFORE SHIPPING (self-test, this file): an earlier draft wrote this with  word
52
+ * boundaries around `code` and `locked`. The literal that landed on disk was not the two
53
+ * characters backslash+b - it was a raw ASCII backspace byte (0x08), invisible in every editor
54
+ * and terminal that rendered this file, INCLUDING Read/cat/grep in this session. A regex with a
55
+ * literal backspace control character where a word-boundary assertion was intended matches
56
+ * nothing real, so `IDP_LOCKED.test(...)` was silently false on every fixture - caught only
57
+ * because the RED-first test stayed red after the earlier "fix" and a hex dump of the compiled
58
+ * line showed 08 where 5c62 (backslash, b) should have been. Word boundaries are not
59
+ * load-bearing for the shipped control cases (a bare code=locked substring is specific enough
60
+ * here - the IdP field name is literally code), so this drops them rather than re-risk the same
61
+ * invisible-byte trap.
62
+ */
63
+ const IDP_LOCKED = /^locked$|code\s*=\s*locked/i;
64
+
65
+ /**
66
+ * MfaTotpService ErrorCode "server_error" (PAY-1589: secret cannot be decrypted) or "service_unavailable" (330830): the replay store could not be read or written, so
67
+ * the verify FAILED CLOSED. It is a fault on our side, not a wrong code. Matched on the CODE only, never on
68
+ * the prose - "unavailable" appears in unrelated messages - in the same two forms as IDP_LOCKED.
69
+ */
70
+ const IDP_UNAVAILABLE = /^(?:service_unavailable|server_error)$|code\s*=\s*(?:service_unavailable|server_error)\b/i;
71
+
72
+ const WRONG_CODE = /\b(invalid|incorrect|wrong|expired)\b[^.]{0,40}\b(code|otp|totp|token)\b|\binvalid_code\b/i;
73
+
74
+ /**
75
+ * PAY-1611: the IdP's 403 STEP_UP_FACTOR_NOT_ALLOWED refusal (Core ExternalAuthController.DeliveredCodeStepUpRefusal).
76
+ * A session signed in with an emailed/texted code only cannot re-prove itself with another emailed/texted code; the
77
+ * IdP says which factors it WILL take and what the user can do instead. The portal can only offer those options if
78
+ * this BFF relays them, so on that one refusal we emit the code plus ALLOWLISTED copies of the two lists.
79
+ *
80
+ * On the wire PayEzControllerBase.OnActionExecuted re-envelopes the action's object: the code is `error.code` and the
81
+ * object sits under `error.details`, so the lists are `error.details.allowed_methods` / `error.details.next_steps`.
82
+ * The unenveloped action shape (top-level `error` string, lists at top level) is accepted too.
83
+ *
84
+ * NEVER relayed (PAY-1443 stands): `error_description` or any other IdP prose, and any IdP `endpoint` - the consumer
85
+ * maps each action to its OWN route from a constant table. Unknown methods/actions are dropped, not passed through;
86
+ * missing or malformed lists give EMPTY lists, never invented ones.
87
+ */
88
+ export const STEP_UP_REFUSAL_CODE = 'STEP_UP_FACTOR_NOT_ALLOWED';
89
+ const STEP_UP_METHODS = ['totp', 'webauthn', 'backup_code'] as const;
90
+ const STEP_UP_ACTIONS = ['use_authenticator', 'reset_password', 'enroll_authenticator'] as const;
91
+ export type StepUpMethod = (typeof STEP_UP_METHODS)[number];
92
+ export type StepUpAction = (typeof STEP_UP_ACTIONS)[number];
93
+ export type StepUpNextStep = { action: 'use_authenticator'; methods: StepUpMethod[] } | { action: Exclude<StepUpAction, 'use_authenticator'> };
94
+ export type StepUpRefusal = { allowed_methods: StepUpMethod[]; next_steps: StepUpNextStep[] };
95
+
96
+ const STEP_UP_TEXT =
97
+ 'This session was signed in with an emailed or texted code only, so another emailed or texted code cannot confirm it is you.';
98
+
99
+ function allowedMethods(v: unknown): StepUpMethod[] {
100
+ if (!Array.isArray(v)) return [];
101
+ const out: StepUpMethod[] = [];
102
+ for (const m of v) {
103
+ if ((STEP_UP_METHODS as readonly unknown[]).includes(m) && !out.includes(m as StepUpMethod)) out.push(m as StepUpMethod);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** The step-up refusal's allowlisted lists, or null when this is not that refusal (status 403 + the exact code only). */
109
+ export function stepUpRefusal(status: number, json: unknown): StepUpRefusal | null {
110
+ if (status !== 403 || !json || typeof json !== 'object') return null;
111
+ const j = json as Record<string, unknown>;
112
+ const err = j.error;
113
+ let code: unknown;
114
+ let src: Record<string, unknown> = j;
115
+ if (err && typeof err === 'object') {
116
+ const e = err as Record<string, unknown>;
117
+ code = e.code;
118
+ src = e.details && typeof e.details === 'object' ? (e.details as Record<string, unknown>) : {};
119
+ } else {
120
+ code = err;
121
+ }
122
+ if (code !== STEP_UP_REFUSAL_CODE) return null;
123
+ const next_steps: StepUpNextStep[] = [];
124
+ const seen = new Set<string>();
125
+ for (const step of Array.isArray(src.next_steps) ? src.next_steps : []) {
126
+ if (!step || typeof step !== 'object') continue;
127
+ const action = (step as Record<string, unknown>).action;
128
+ if (typeof action !== 'string' || !(STEP_UP_ACTIONS as readonly string[]).includes(action) || seen.has(action)) continue;
129
+ seen.add(action);
130
+ next_steps.push(
131
+ action === 'use_authenticator'
132
+ ? { action, methods: allowedMethods((step as Record<string, unknown>).methods) }
133
+ : { action: action as Exclude<StepUpAction, 'use_authenticator'> },
134
+ );
135
+ }
136
+ return { allowed_methods: allowedMethods(src.allowed_methods), next_steps };
137
+ }
138
+
139
+ function stepUpResponse(refusal: StepUpRefusal, attemptedRefresh: boolean): NextResponse {
140
+ return NextResponse.json(
141
+ { success: false, error: STEP_UP_TEXT, code: STEP_UP_REFUSAL_CODE, ...refusal, meta: { attemptedRefresh } },
142
+ { status: 403 },
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Every string the IDP might have put anywhere in the error envelope, unwrapped from
148
+ * `{ message = X }` ToString form. Classification input only.
149
+ */
150
+ export function upstreamErrorTexts(json: unknown): string[] {
151
+ const out: string[] = [];
152
+ const add = (v: unknown) => {
153
+ if (typeof v !== 'string' || !v) return;
154
+ out.push(v);
155
+ const m = v.match(/^\{\s*\w+\s*=\s*(.*?)\s*\}$/s);
156
+ if (m) out.push(m[1]);
157
+ };
158
+ const j = json as Record<string, any> | null | undefined;
159
+ if (!j || typeof j !== 'object') return out;
160
+ add(j.message);
161
+ add(j.code);
162
+ add(j.error);
163
+ if (j.error && typeof j.error === 'object') {
164
+ add(j.error.message);
165
+ add(j.error.code);
166
+ const d = j.error.details;
167
+ if (d && typeof d === 'object') { add(d.message); add(d.Message); add(d.code); add(d.errorCode); }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ export function classifyTwoFactorVerifyFailure(status: number, json: unknown): TwoFactorVerifyFailureCode {
173
+ // PAY-1611: checked FIRST - an exact code on a 403, so no prose rule below can claim it first.
174
+ if (stepUpRefusal(status, json)) return 'STEP_UP_FACTOR_NOT_ALLOWED';
175
+ // PAY-902 (BAPert R484): the IdP's lockout CODE, not its prose. Door A is post-authentication, so stating
176
+ // the lockout discloses nothing; telling a locked-out user "try again" sends them to spend attempts.
177
+ //
178
+ // PAY-1574: THIS MUST COME BEFORE THE 429 TEST. PayEz-Core PR 366 answers a TOTP lockout as HTTP 429 carrying
179
+ // code "locked". With `status === 429` first, that status returned RATE_LIMITED and the lock code the IdP had
180
+ // just started sending was discarded unread - the tester saw a plain rate limit. A 429 with NO lock code is
181
+ // still RATE_LIMITED, below.
182
+ if (upstreamErrorTexts(json).some((t) => IDP_LOCKED.test(t))) return 'LOCKED';
183
+ if (status === 429) return 'RATE_LIMITED';
184
+ // PAY-1574: the verify failed closed on OUR side (replay store down). Not a wrong code, whatever the status.
185
+ if (upstreamErrorTexts(json).some((t) => IDP_UNAVAILABLE.test(t))) return 'VERIFY_UNAVAILABLE';
186
+ if (upstreamErrorTexts(json).some((t) => WRONG_CODE.test(t))) return 'INVALID_CODE';
187
+ if (status === 401 || status === 403) return 'SESSION_REJECTED';
188
+ if (status >= 500 || status === 0) return 'VERIFY_UNAVAILABLE';
189
+ return 'VERIFY_FAILED';
190
+ }
191
+
192
+ export function twoFactorVerifyFailure(
193
+ result: { status: number; json: unknown; attemptedRefresh: boolean },
194
+ method: TwoFactorVerifyMethod,
195
+ ): NextResponse {
196
+ const refusal = stepUpRefusal(result.status, result.json);
197
+ if (refusal) return stepUpResponse(refusal, result.attemptedRefresh);
198
+ const code = classifyTwoFactorVerifyFailure(result.status, result.json);
199
+ const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method] : FIXED_TEXT[code as keyof typeof FIXED_TEXT];
200
+ return NextResponse.json(
201
+ { success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } },
202
+ { status: result.status },
203
+ );
204
+ }
205
+
206
+ /**
207
+ * PAY-1443 residual: account/send-code on the SAME verify-code screen. A send has no
208
+ * "wrong code" outcome, so it gets its own small classification, but the same rule applies:
209
+ * upstream text/code never reach the response. The portal renders `data.message || data.error`,
210
+ * so no `message` field is emitted on failure.
211
+ */
212
+ export type TwoFactorSendMethod = 'sms' | 'email';
213
+
214
+ export type TwoFactorSendFailureCode =
215
+ | 'RATE_LIMITED'
216
+ | 'SESSION_REJECTED'
217
+ | 'STEP_UP_FACTOR_NOT_ALLOWED'
218
+ | 'SEND_UNAVAILABLE'
219
+ | 'SEND_FAILED';
220
+
221
+ const SEND_LABEL: Record<TwoFactorSendMethod, string> = { sms: 'text message', email: 'email' };
222
+
223
+ export function classifyTwoFactorSendFailure(status: number, json?: unknown): TwoFactorSendFailureCode {
224
+ if (stepUpRefusal(status, json)) return 'STEP_UP_FACTOR_NOT_ALLOWED'; // PAY-1611, before the 401/403 branch
225
+ if (status === 429) return 'RATE_LIMITED';
226
+ if (status === 401 || status === 403) return 'SESSION_REJECTED';
227
+ if (status >= 500 || status === 0) return 'SEND_UNAVAILABLE';
228
+ return 'SEND_FAILED';
229
+ }
230
+
231
+ export function twoFactorSendCodeFailure(
232
+ result: { status: number; attemptedRefresh: boolean; json?: unknown },
233
+ method: TwoFactorSendMethod,
234
+ ): NextResponse {
235
+ const refusal = stepUpRefusal(result.status, result.json);
236
+ if (refusal) return stepUpResponse(refusal, result.attemptedRefresh);
237
+ const code = classifyTwoFactorSendFailure(result.status, result.json);
238
+ const label = SEND_LABEL[method];
239
+ const error =
240
+ code === 'RATE_LIMITED' ? FIXED_TEXT.RATE_LIMITED
241
+ : code === 'SESSION_REJECTED' ? `We could not send a ${label} code for this session. Please try again.`
242
+ : code === 'SEND_UNAVAILABLE' ? `We could not send a ${label} code right now. Please try again.`
243
+ : `We could not send a ${label} code. Please try again.`;
244
+ return NextResponse.json(
245
+ { success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } },
246
+ { status: result.status },
247
+ );
248
+ }
@@ -23,6 +23,7 @@ import { resolveCallbackDestination } from '../auth/safe-callback';
23
23
  import { getInternalApiUrl } from '../edge/internal-api-url';
24
24
  import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
25
25
  import { checkPagePermission, isRBACEnabled } from './rbac-check';
26
+ import { tokenLogId } from '../lib/token-log-id';
26
27
 
27
28
  // =============================================================================
28
29
  // TYPES
@@ -40,7 +41,8 @@ export interface AuthContext {
40
41
 
41
42
  export interface SessionPointer {
42
43
  exists: boolean;
43
- sessionToken?: string;
44
+ /** PAY-1481: tokenLogId of the session token (presence + log correlation), never the token itself. */
45
+ sessionTokenId?: string | null;
44
46
  expired?: boolean;
45
47
  hasRefreshToken?: boolean;
46
48
  roles: string[];
@@ -208,6 +210,40 @@ function redirectTo(
208
210
  return response;
209
211
  }
210
212
 
213
+ /**
214
+ * Terminal response for a FAILED token refresh.
215
+ *
216
+ * For `/api/` requests a redirect is the wrong terminator: fetch() follows the 307,
217
+ * receives 200 + an HTML login page, and the caller parses garbage instead of
218
+ * branching on an error. Those callers get a 401 JSON body they can branch on;
219
+ * document requests keep the existing redirect exactly as before.
220
+ *
221
+ * Cookie-clearing is preserved on BOTH arms. That is load-bearing, not incidental:
222
+ * on a permanent failure (401/403) stale cookies must be cleared or the next request
223
+ * refreshes again and loops (see the CRITICAL note in handleRefresh). A JSON arm that
224
+ * skipped clearing would convert a redirect loop into a silent refresh loop.
225
+ *
226
+ * Scoped deliberately to handleRefresh's failure exits. It is NOT folded into
227
+ * redirectTo(), and NOT hoisted above the refresh decision: an `/api/` hatch placed
228
+ * higher would intercept 'refresh_needed' and disable middleware token refresh across
229
+ * the whole API surface (card 340024 C1).
230
+ */
231
+ function refreshFailureResponse(
232
+ request: NextRequest,
233
+ location: string,
234
+ errorCode: string,
235
+ clearCookies = false
236
+ ): NextResponse {
237
+ if (request.nextUrl.pathname.startsWith('/api/')) {
238
+ const response = NextResponse.json({ error: errorCode }, { status: 401 });
239
+ if (clearCookies) {
240
+ clearSessionCookies(response, request);
241
+ }
242
+ return response;
243
+ }
244
+ return redirectTo(request, location, clearCookies);
245
+ }
246
+
211
247
  // =============================================================================
212
248
  // AUTH DECISION ENGINE
213
249
  // =============================================================================
@@ -220,6 +256,29 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
220
256
  const { pathname, isPublicRoute, isLoginPage: onLoginPage, searchParams, sessionPointer, sessionStatus, circuitBreakerOpen } = ctx;
221
257
  const safeCallback = getSafeCallbackUrl(pathname);
222
258
 
259
+ // --- PAY-884: a provisional (pre-2FA) session whose token has expired can never finish 2FA ---
260
+ // The IdP issues no refresh token until 2FA completes, and it validates token lifetime, so
261
+ // once the provisional access token expires neither refresh nor code verification can
262
+ // succeed. Sending the user to the verify-code page (or to error=SessionExpired, which the
263
+ // login page deliberately shows as NO message) strands them mid-2FA with no explanation.
264
+ // Say what happened instead, and clear the dead session so the next sign-in starts clean.
265
+ // Scoped to the verify-code page and protected pages: /api/ keeps its 401-JSON carve-out
266
+ // (307290), the login page stays reachable, and public pages are left to the existing arms.
267
+ if (
268
+ isExpiredProvisionalSession(sessionPointer, sessionStatus) &&
269
+ !pathname.startsWith('/api/') &&
270
+ !onLoginPage &&
271
+ (pathname === VERIFY_CODE_PAGE || !isPublicRoute)
272
+ ) {
273
+ const callback = pathname === VERIFY_CODE_PAGE ? getSafeCallbackUrl(pathname, searchParams) : safeCallback;
274
+ return {
275
+ type: 'redirect',
276
+ location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(callback)}&error=${TWO_FACTOR_TIMEOUT_ERROR}`,
277
+ reason: 'pre_2fa_token_expired',
278
+ clearCookies: true,
279
+ };
280
+ }
281
+
223
282
  // --- Public routes: Allow, but enforce 2FA for authenticated users ---
224
283
  if (isPublicRoute && !onLoginPage) {
225
284
  if (needsTwoFactorRedirect(pathname, sessionPointer, sessionStatus)) {
@@ -328,6 +387,25 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
328
387
  return { type: 'allow' };
329
388
  }
330
389
 
390
+ /** Login error code for a pre-2FA session that expired before the code was verified (PAY-884). */
391
+ export const TWO_FACTOR_TIMEOUT_ERROR = 'TwoFactorTimeout';
392
+
393
+ /**
394
+ * PAY-884: 2FA outstanding, access token expired, and no refresh token to renew it.
395
+ * Only a viability answer that positively says the session exists qualifies (null = check failed).
396
+ */
397
+ function isExpiredProvisionalSession(session: SessionPointer, status: SessionStatus): boolean {
398
+ return (
399
+ session.exists &&
400
+ status.exists === true &&
401
+ status.forceInvalid === false &&
402
+ status.requires2FA &&
403
+ !status.twoFactorComplete &&
404
+ session.expired === true &&
405
+ !session.hasRefreshToken
406
+ );
407
+ }
408
+
331
409
  /** Check if user needs 2FA redirect */
332
410
  function needsTwoFactorRedirect(pathname: string, session: SessionPointer, status: SessionStatus): boolean {
333
411
  if (!session.exists || !status.exists) return false;
@@ -423,9 +501,16 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
423
501
  // The warning is deliberately KEPT: it is the only operational signal that
424
502
  // stale sessions are occurring, and it costs nothing to log before
425
503
  // delegating. Do not re-introduce a `return` in this block.
426
- if (!isPublic && sessionStatus.exists === false && sessionPointer.sessionToken) {
504
+ if (!isPublic && sessionStatus.exists === false && sessionPointer.sessionTokenId) {
505
+ // PAY-1505: the KEY is `sessionTokenId`, not `sessionToken`. The value has
506
+ // always been a tokenLogId fingerprint (viability.ts builds it as
507
+ // `tokenLogId(sessionToken)`), but labelling it `sessionToken` told any
508
+ // operator reading this log that the line carried the token itself. The
509
+ // credential-in-logs source guard reads the key for exactly that reason:
510
+ // a field named `sessionToken` must hold a safe value, and proving that
511
+ // across an API boundary is not possible from the name alone.
427
512
  log.warn('Stale session detected (delegating to decision engine)', {
428
- sessionToken: sessionPointer.sessionToken?.substring(0, 8) + '...',
513
+ sessionTokenId: sessionPointer.sessionTokenId,
429
514
  pathname
430
515
  });
431
516
  }
@@ -470,7 +555,7 @@ async function checkViability(
470
555
  };
471
556
  const sessionPointer: SessionPointer = {
472
557
  exists: false,
473
- sessionToken: undefined,
558
+ sessionTokenId: undefined,
474
559
  expired: false,
475
560
  hasRefreshToken: false,
476
561
  roles: [],
@@ -493,11 +578,11 @@ async function checkViability(
493
578
  if (response.ok) {
494
579
  const data = await response.json();
495
580
  // Support both response formats (viability and refresh-viability)
496
- sessionStatus.exists = data.authenticated ?? (data.sessionToken && data.reason !== 'session_not_found');
581
+ sessionStatus.exists = data.authenticated ?? (!!data.sessionTokenId && data.reason !== 'session_not_found');
497
582
  sessionStatus.requires2FA = data.requires2FA || false;
498
583
  sessionStatus.twoFactorComplete = data.twoFactorComplete || false;
499
584
  sessionPointer.exists = sessionStatus.exists ?? false;
500
- sessionPointer.sessionToken = data.sessionToken;
585
+ sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
501
586
  sessionPointer.expired = data.accessTokenExpired || false;
502
587
  sessionPointer.hasRefreshToken = data.hasRefreshToken ?? data.canRefresh ?? false;
503
588
  sessionPointer.roles = data.roles || [];
@@ -630,9 +715,10 @@ async function handleRefresh(
630
715
  // Check if circuit breaker allows refresh attempt
631
716
  if (!cb.canAttemptRefresh()) {
632
717
  log.warn('Circuit breaker preventing refresh attempt');
633
- return redirectTo(
718
+ return refreshFailureResponse(
634
719
  request,
635
720
  `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=ServiceUnavailable`,
721
+ 'ServiceUnavailable',
636
722
  true
637
723
  );
638
724
  }
@@ -677,9 +763,10 @@ async function handleRefresh(
677
763
 
678
764
  onRefreshFailure?.(response.status, false);
679
765
 
680
- return redirectTo(
766
+ return refreshFailureResponse(
681
767
  request,
682
768
  `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=${errorParam}`,
769
+ errorParam,
683
770
  isPermanentFailure
684
771
  );
685
772
  } catch (error: any) {
@@ -697,9 +784,10 @@ async function handleRefresh(
697
784
  onRefreshFailure?.(0, isNetwork);
698
785
 
699
786
  // Network error - don't clear cookies, might be transient
700
- return redirectTo(
787
+ return refreshFailureResponse(
701
788
  request,
702
789
  `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=RefreshError`,
790
+ 'RefreshError',
703
791
  false
704
792
  );
705
793
  }
@@ -141,6 +141,10 @@ function LoginForm({ altAuthSlot }: { altAuthSlot?: React.ReactNode }) {
141
141
  // Session expiration is normal - don't show as error
142
142
  console.log('[LOGIN] SessionExpired detected - normal behavior');
143
143
  friendlyError = null;
144
+ } else if (urlError === 'TwoFactorTimeout') {
145
+ // PAY-884: the sign-in expired while the verification code was outstanding. Unlike
146
+ // SessionExpired this is NOT silent - the user was mid-2FA and must know why they are here.
147
+ friendlyError = 'Your verification step timed out before the code was confirmed. Please sign in again.';
144
148
  } else if (urlError === 'ServiceUnavailable') {
145
149
  friendlyError = 'Authentication service is temporarily unavailable. Please try again in a few minutes.';
146
150
  } else if (urlError.startsWith("Unexpected token")) {
@@ -9,6 +9,7 @@ import { SelectMethodStep } from '../../components/recovery/SelectMethodStep';
9
9
  import { VerifyCodeStep } from '../../components/recovery/VerifyCodeStep';
10
10
  import { SetPasswordStep } from '../../components/recovery/SetPasswordStep';
11
11
  import { CompleteStep } from '../../components/recovery/CompleteStep';
12
+ import { RequestedStep } from '../../components/recovery/RequestedStep';
12
13
  import { useColors } from '../../theme/useTheme';
13
14
 
14
15
  function RecoveryContent() {
@@ -24,7 +25,12 @@ function RecoveryContent() {
24
25
  const [verificationCode, setVerificationCode] = useState('');
25
26
  const [passwordResetToken, setPasswordResetToken] = useState<PasswordResetToken | null>(null);
26
27
  const [error, setError] = useState<string | null>(null);
28
+ // PAY-1591: true when the reset failed because the recovery itself is dead; the error box then offers a way back.
29
+ const [recoveryExpired, setRecoveryExpired] = useState(false);
27
30
  const [loading, setLoading] = useState(false);
31
+ // PAY-1588: true only after the IdP accepted the new password. The 'complete' step is ALSO reached from initiate()
32
+ // when no recovery session comes back, and that path must not tell the user a password was set.
33
+ const [resetSucceeded, setResetSucceeded] = useState(false);
28
34
 
29
35
  const handleInitiateRecovery = async () => {
30
36
  setLoading(true);
@@ -33,7 +39,7 @@ function RecoveryContent() {
33
39
  try {
34
40
  const response = await accountApi.initiateRecovery(email);
35
41
 
36
- if (response.success && response.data.recovery_session_token) {
42
+ if (response.success && response.data?.recovery_session_token) {
37
43
  setRecoverySession({
38
44
  recoveryToken: response.data.recovery_session_token,
39
45
  email: email,
@@ -45,7 +51,8 @@ function RecoveryContent() {
45
51
  });
46
52
  setCurrentStep('select-method');
47
53
  } else {
48
- setCurrentStep('complete');
54
+ // No recovery session (unknown / locked / throttled): the ONE neutral step, never CompleteStep (rigpert 60711).
55
+ setCurrentStep('requested');
49
56
  }
50
57
  } catch (err) {
51
58
  setError(err instanceof Error ? err.message : 'Failed to initiate recovery. Please try again.');
@@ -110,6 +117,7 @@ function RecoveryContent() {
110
117
 
111
118
  setLoading(true);
112
119
  setError(null);
120
+ setRecoveryExpired(false);
113
121
 
114
122
  try {
115
123
  const response = await accountApi.resetPasswordWithToken(
@@ -120,12 +128,14 @@ function RecoveryContent() {
120
128
  );
121
129
 
122
130
  if (response.success) {
131
+ setResetSucceeded(true);
123
132
  setCurrentStep('complete');
124
133
  } else {
125
134
  setError('Failed to reset password. Please try again.');
126
135
  }
127
136
  } catch (err) {
128
137
  setError(err instanceof Error ? err.message : 'Failed to reset password.');
138
+ setRecoveryExpired(!!err && (err as { code?: string }).code === 'RECOVERY_EXPIRED');
129
139
  } finally {
130
140
  setLoading(false);
131
141
  }
@@ -143,6 +153,11 @@ function RecoveryContent() {
143
153
  {error && (
144
154
  <div className="mb-4 bg-red-50 border border-red-200 rounded-2xl p-3">
145
155
  <p className="text-red-700 text-sm">{error}</p>
156
+ {recoveryExpired && (
157
+ <a href="/account-auth/recovery" className="text-sm font-medium underline">
158
+ Start password recovery again
159
+ </a>
160
+ )}
146
161
  </div>
147
162
  )}
148
163
 
@@ -191,13 +206,15 @@ function RecoveryContent() {
191
206
  />
192
207
  )}
193
208
 
209
+ {currentStep === 'requested' && <RequestedStep onGoToLogin={() => router.push('/account-auth/login')} />}
210
+
194
211
  {currentStep === 'complete' && (
195
212
  <CompleteStep
196
- onGoToLogin={() => router.push('/account-auth/login')}
213
+ onGoToLogin={() => router.push(resetSucceeded ? '/account-auth/login?notice=password-set' : '/account-auth/login')}
197
214
  />
198
215
  )}
199
216
 
200
- {currentStep !== 'complete' && currentStep !== 'initiate' && !loading && (
217
+ {currentStep !== 'complete' && currentStep !== 'requested' && currentStep !== 'initiate' && !loading && (
201
218
  <div className="mt-6 text-center">
202
219
  <button
203
220
  type="button"