@payez/next-mvp 4.1.10 → 4.1.20

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 (181) hide show
  1. package/dist/api-handlers/account/profile.d.ts +38 -0
  2. package/dist/api-handlers/account/profile.js +73 -45
  3. package/dist/api-handlers/account/recovery/send-code.d.ts +33 -0
  4. package/dist/api-handlers/account/recovery/send-code.js +52 -18
  5. package/dist/api-handlers/account/recovery/verify-code.d.ts +33 -0
  6. package/dist/api-handlers/account/recovery/verify-code.js +52 -18
  7. package/dist/api-handlers/account/verify-email.js +15 -1
  8. package/dist/api-handlers/account/verify-sms.js +15 -1
  9. package/dist/api-handlers/auth/passkey-signin.d.ts +29 -0
  10. package/dist/api-handlers/auth/passkey-signin.js +97 -0
  11. package/dist/api-handlers/mfa/backup-code-verify.d.ts +73 -0
  12. package/dist/api-handlers/mfa/backup-code-verify.js +153 -0
  13. package/dist/api-handlers/mfa/backup-codes.d.ts +7 -0
  14. package/dist/api-handlers/mfa/backup-codes.js +40 -0
  15. package/dist/api-handlers/mfa/methods.d.ts +3 -0
  16. package/dist/api-handlers/mfa/methods.js +41 -0
  17. package/dist/api-handlers/mfa/totp-confirm.d.ts +8 -0
  18. package/dist/api-handlers/mfa/totp-confirm.js +41 -0
  19. package/dist/api-handlers/mfa/totp-enroll.d.ts +8 -0
  20. package/dist/api-handlers/mfa/totp-enroll.js +41 -0
  21. package/dist/api-handlers/mfa/totp-verify.d.ts +47 -0
  22. package/dist/api-handlers/mfa/totp-verify.js +134 -0
  23. package/dist/api-handlers/passkeys/list.d.ts +7 -0
  24. package/dist/api-handlers/passkeys/list.js +29 -0
  25. package/dist/api-handlers/passkeys/manage.d.ts +3 -0
  26. package/dist/api-handlers/passkeys/manage.js +54 -0
  27. package/dist/api-handlers/passkeys/register-options.d.ts +8 -0
  28. package/dist/api-handlers/passkeys/register-options.js +41 -0
  29. package/dist/api-handlers/passkeys/register-verify.d.ts +7 -0
  30. package/dist/api-handlers/passkeys/register-verify.js +40 -0
  31. package/dist/api-handlers/session/viability.js +19 -0
  32. package/dist/auth/better-auth.d.ts +68 -5
  33. package/dist/auth/better-auth.js +271 -45
  34. package/dist/auth/utils/idp-client.d.ts +13 -22
  35. package/dist/auth/utils/idp-client.js +12 -82
  36. package/dist/client/better-auth-client.d.ts +14 -2
  37. package/dist/lib/ensure-fresh-access-token.js +13 -2
  38. package/dist/lib/session-store.js +28 -21
  39. package/dist/lib/startup-init.js +19 -19
  40. package/dist/lib/token-lifecycle.d.ts +2 -0
  41. package/dist/lib/token-lifecycle.js +61 -7
  42. package/dist/models/SessionModel.d.ts +17 -0
  43. package/dist/pages/login/page.d.ts +10 -1
  44. package/dist/pages/login/page.js +69 -80
  45. package/dist/pages/verify-code/page.d.ts +10 -1
  46. package/dist/pages/verify-code/page.js +72 -4
  47. package/dist/routes/auth/passkey-signin.d.ts +13 -0
  48. package/dist/routes/auth/passkey-signin.js +18 -0
  49. package/dist/routes/auth/viability.d.ts +2 -0
  50. package/dist/routes/auth/viability.js +88 -0
  51. package/dist/routes/mfa/backup-code-verify.d.ts +1 -0
  52. package/dist/routes/mfa/backup-code-verify.js +5 -0
  53. package/dist/routes/mfa/backup-codes.d.ts +10 -0
  54. package/dist/routes/mfa/backup-codes.js +14 -0
  55. package/dist/routes/mfa/methods.d.ts +21 -0
  56. package/dist/routes/mfa/methods.js +10 -0
  57. package/dist/routes/mfa/totp-confirm.d.ts +10 -0
  58. package/dist/routes/mfa/totp-confirm.js +14 -0
  59. package/dist/routes/mfa/totp-enroll.d.ts +10 -0
  60. package/dist/routes/mfa/totp-enroll.js +14 -0
  61. package/dist/routes/mfa/totp-verify.d.ts +14 -0
  62. package/dist/routes/mfa/totp-verify.js +18 -0
  63. package/dist/routes/passkeys/list.d.ts +10 -0
  64. package/dist/routes/passkeys/list.js +14 -0
  65. package/dist/routes/passkeys/manage.d.ts +18 -0
  66. package/dist/routes/passkeys/manage.js +13 -0
  67. package/dist/routes/passkeys/register-options.d.ts +10 -0
  68. package/dist/routes/passkeys/register-options.js +14 -0
  69. package/dist/routes/passkeys/register-verify.d.ts +10 -0
  70. package/dist/routes/passkeys/register-verify.js +14 -0
  71. package/dist/server/auth.d.ts +2 -2
  72. package/dist/server/decode-session.js +37 -3
  73. package/package.json +958 -893
  74. package/scripts/check-internal-url-usage.sh +73 -73
  75. package/src/api/index.ts +17 -17
  76. package/src/api-handlers/account/masked-info.ts +45 -45
  77. package/src/api-handlers/account/profile.ts +99 -80
  78. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  79. package/src/api-handlers/account/recovery/send-code.ts +73 -25
  80. package/src/api-handlers/account/recovery/verify-code.ts +73 -25
  81. package/src/api-handlers/account/reset-password.ts +23 -23
  82. package/src/api-handlers/account/send-code.ts +76 -76
  83. package/src/api-handlers/account/validate-password.ts +118 -118
  84. package/src/api-handlers/account/verify-email.ts +15 -1
  85. package/src/api-handlers/account/verify-sms.ts +15 -1
  86. package/src/api-handlers/admin/stats.ts +249 -249
  87. package/src/api-handlers/anon/preferences.ts +123 -123
  88. package/src/api-handlers/auth/jwks.ts +20 -20
  89. package/src/api-handlers/auth/login.ts +240 -240
  90. package/src/api-handlers/auth/passkey-signin.ts +98 -0
  91. package/src/api-handlers/auth/validate.ts +43 -43
  92. package/src/api-handlers/mfa/backup-code-verify.ts +175 -0
  93. package/src/api-handlers/mfa/backup-codes.ts +41 -0
  94. package/src/api-handlers/mfa/methods.ts +46 -0
  95. package/src/api-handlers/mfa/totp-confirm.ts +42 -0
  96. package/src/api-handlers/mfa/totp-enroll.ts +42 -0
  97. package/src/api-handlers/mfa/totp-verify.ts +156 -0
  98. package/src/api-handlers/passkeys/list.ts +31 -0
  99. package/src/api-handlers/passkeys/manage.ts +54 -0
  100. package/src/api-handlers/passkeys/register-options.ts +42 -0
  101. package/src/api-handlers/passkeys/register-verify.ts +41 -0
  102. package/src/api-handlers/session/viability.ts +161 -142
  103. package/src/auth/better-auth.ts +708 -455
  104. package/src/auth/unauthenticated-routes.ts +2 -2
  105. package/src/auth/utils/idp-client.ts +13 -95
  106. package/src/client/better-auth-client.ts +70 -70
  107. package/src/client/fetchWithSession.ts +21 -21
  108. package/src/client/index.ts +13 -13
  109. package/src/client/useAnonSession.ts +131 -131
  110. package/src/components/admin/VibeAdminContext.tsx +87 -87
  111. package/src/config/logging-config.ts +82 -82
  112. package/src/config/unauthenticated-routes.ts +19 -19
  113. package/src/edge/internal-api-url.ts +65 -65
  114. package/src/edge/middleware.ts +42 -42
  115. package/src/hooks/useProfile.ts +75 -75
  116. package/src/lib/anon-session.ts +213 -213
  117. package/src/lib/auth-secret.ts +137 -137
  118. package/src/lib/demo-mode.ts +17 -17
  119. package/src/lib/ensure-fresh-access-token.ts +13 -2
  120. package/src/lib/idp-client-config.ts +559 -559
  121. package/src/lib/idp-fetch.ts +126 -126
  122. package/src/lib/internal-api.ts +171 -171
  123. package/src/lib/jwt-decode-client.ts +45 -45
  124. package/src/lib/rate-limit-service.ts +9 -9
  125. package/src/lib/redis.ts +26 -26
  126. package/src/lib/refresh-token-validator.ts +64 -64
  127. package/src/lib/secret-validation.ts +8 -8
  128. package/src/lib/session-store.ts +702 -695
  129. package/src/lib/session.ts +34 -34
  130. package/src/lib/startup-init.ts +251 -251
  131. package/src/lib/token-expiry.ts +40 -40
  132. package/src/lib/token-lifecycle.ts +533 -470
  133. package/src/lib/types/api-responses.ts +336 -336
  134. package/src/middleware/auth-decision.ts +43 -43
  135. package/src/models/DecodedAccessToken.ts +16 -16
  136. package/src/models/SessionModel.ts +282 -264
  137. package/src/pages/admin-roles/index.ts +9 -9
  138. package/src/pages/login/page.tsx +123 -228
  139. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  140. package/src/pages/verify-code/page.tsx +71 -5
  141. package/src/routes/account/index.ts +31 -31
  142. package/src/routes/account/masked-info.ts +36 -36
  143. package/src/routes/account/send-code.ts +39 -39
  144. package/src/routes/account/verify-email.ts +40 -40
  145. package/src/routes/account/verify-sms.ts +40 -40
  146. package/src/routes/auth/index.ts +22 -22
  147. package/src/routes/auth/passkey-signin.ts +13 -0
  148. package/src/routes/auth/refresh.ts +48 -48
  149. package/src/routes/auth/session.ts +166 -166
  150. package/src/routes/auth/viability.ts +89 -0
  151. package/src/routes/index.ts +17 -17
  152. package/src/routes/mfa/backup-code-verify.ts +1 -0
  153. package/src/routes/mfa/backup-codes.ts +10 -0
  154. package/src/routes/mfa/methods.ts +22 -0
  155. package/src/routes/mfa/totp-confirm.ts +10 -0
  156. package/src/routes/mfa/totp-enroll.ts +10 -0
  157. package/src/routes/mfa/totp-verify.ts +14 -0
  158. package/src/routes/passkeys/list.ts +10 -0
  159. package/src/routes/passkeys/manage.ts +23 -0
  160. package/src/routes/passkeys/register-options.ts +10 -0
  161. package/src/routes/passkeys/register-verify.ts +10 -0
  162. package/src/routes/session/index.ts +7 -7
  163. package/src/routes/session/refresh-viability.ts +17 -17
  164. package/src/server/auth.ts +373 -373
  165. package/src/server/decode-session.ts +235 -202
  166. package/src/services/signalrActivityService.ts +258 -258
  167. package/src/theme/ThemeProvider.tsx +39 -39
  168. package/src/theme/default.ts +33 -33
  169. package/src/theme/index.ts +31 -31
  170. package/src/theme/types.ts +69 -69
  171. package/src/theme/useTheme.ts +57 -57
  172. package/src/theme/utils.ts +40 -40
  173. package/src/types/api.ts +12 -12
  174. package/src/types/auth.d.ts +23 -23
  175. package/src/types/auth.ts +22 -22
  176. package/src/types/logging.ts +11 -11
  177. package/src/types/recovery.ts +54 -54
  178. package/src/utils/api.ts +353 -353
  179. package/src/utils/circuitBreaker.ts +40 -40
  180. package/src/utils/error-message.ts +108 -108
  181. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -135,6 +135,13 @@ async function getBetterAuthSession(sessionToken, appSlug) {
135
135
  idpRefreshToken: data.idpTokens?.idpRefreshToken,
136
136
  idpAccessTokenExpires: data.idpTokens?.idpAccessTokenExpires,
137
137
  mfaVerified: data.idpTokens?.mfaVerified ?? false,
138
+ // Cards 141688/141741 — the IDP's routing decision, carried not derived.
139
+ // `null` is a real value here (IDP said nothing is outstanding), so it
140
+ // must stay distinguishable from `undefined` (we never captured it —
141
+ // true of every session minted before this change). No `?? null`:
142
+ // defaulting would turn "unknown" into "fully authenticated" and
143
+ // fail open on the existing session population.
144
+ nextStep: data.idpTokens?.nextStep,
138
145
  roles: data.idpTokens?.roles || [],
139
146
  idpClientId: data.idpTokens?.idpClientId ?? data.idpTokens?.clientId ?? data.idpClientId,
140
147
  merchantId: data.idpTokens?.merchantId ?? data.merchantId,
@@ -489,27 +496,27 @@ async function releaseRefreshLock(sessionToken, requestId, lockVersion) {
489
496
  const lockKey = getRefreshLockKey(sessionToken);
490
497
  try {
491
498
  // Lua script for atomic lock validation and release
492
- const luaScript = `
493
- local lockKey = KEYS[1]
494
- local expectedRequestId = ARGV[1]
495
- local expectedVersion = ARGV[2]
496
-
497
- local lockData = redis.call('GET', lockKey)
498
- if not lockData then
499
- return 0 -- Lock doesn't exist
500
- end
501
-
502
- local lockInfo = cjson.decode(lockData)
503
- if lockInfo.acquiredBy == expectedRequestId then
504
- if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
505
- redis.call('DEL', lockKey)
506
- return 1 -- Successfully released
507
- else
508
- return -2 -- Version mismatch
509
- end
510
- else
511
- return -1 -- Wrong owner
512
- end
499
+ const luaScript = `
500
+ local lockKey = KEYS[1]
501
+ local expectedRequestId = ARGV[1]
502
+ local expectedVersion = ARGV[2]
503
+
504
+ local lockData = redis.call('GET', lockKey)
505
+ if not lockData then
506
+ return 0 -- Lock doesn't exist
507
+ end
508
+
509
+ local lockInfo = cjson.decode(lockData)
510
+ if lockInfo.acquiredBy == expectedRequestId then
511
+ if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
512
+ redis.call('DEL', lockKey)
513
+ return 1 -- Successfully released
514
+ else
515
+ return -2 -- Version mismatch
516
+ end
517
+ else
518
+ return -1 -- Wrong owner
519
+ end
513
520
  `;
514
521
  const result = await redis_1.default.eval(luaScript, 1, lockKey, requestId, lockVersion ? lockVersion.toString() : '');
515
522
  if (result === 1) {
@@ -213,25 +213,25 @@ async function performInitialization() {
213
213
  const connectionLine = isConnectionError
214
214
  ? '║ 🔌 CONNECTION REFUSED - IDP appears to be down ║\n║ ║\n'
215
215
  : '';
216
- console.error(`
217
- ╔══════════════════════════════════════════════════════════════╗
218
- ║ ❌ FATAL: BETTER_AUTH_SECRET NOT AVAILABLE ║
219
- ║ ║
220
- ║ The app cannot start without a valid auth signing secret. ║
221
- ║ This should be fetched from IDP at startup. ║
222
- ║ ║
223
- ${connectionLine}║ Possible causes: ║
224
- ║ • IDP is not running or unreachable ║
225
- ║ • CLIENT_ID is not registered in IDP ║
226
- ║ • IDP_URL is incorrect ║
227
- ║ • Network connectivity issue ║
228
- ║ ║
229
- ║ Current config: ║
230
- ║ • IDP_URL: ${idpUrl}║
231
- ║ • CLIENT_ID: ${clientId}║
232
- ╚══════════════════════════════════════════════════════════════╝
233
-
234
- [STARTUP] Error: ${errorMsg}
216
+ console.error(`
217
+ ╔══════════════════════════════════════════════════════════════╗
218
+ ║ ❌ FATAL: BETTER_AUTH_SECRET NOT AVAILABLE ║
219
+ ║ ║
220
+ ║ The app cannot start without a valid auth signing secret. ║
221
+ ║ This should be fetched from IDP at startup. ║
222
+ ║ ║
223
+ ${connectionLine}║ Possible causes: ║
224
+ ║ • IDP is not running or unreachable ║
225
+ ║ • CLIENT_ID is not registered in IDP ║
226
+ ║ • IDP_URL is incorrect ║
227
+ ║ • Network connectivity issue ║
228
+ ║ ║
229
+ ║ Current config: ║
230
+ ║ • IDP_URL: ${idpUrl}║
231
+ ║ • CLIENT_ID: ${clientId}║
232
+ ╚══════════════════════════════════════════════════════════════╝
233
+
234
+ [STARTUP] Error: ${errorMsg}
235
235
  `);
236
236
  // Re-throw so callers know initialization failed
237
237
  throw lastInitError;
@@ -31,6 +31,8 @@ export interface TokenError {
31
31
  error: 'NO_SESSION' | 'NO_TOKEN' | 'EXPIRED' | 'REFRESH_FAILED' | 'SESSION_EXPIRED_NO_REFRESH';
32
32
  message: string;
33
33
  terminal?: boolean;
34
+ code?: string;
35
+ resolution?: string;
34
36
  }
35
37
  export type EnsureFreshTokenResult = TokenResult | TokenError;
36
38
  /**
@@ -150,7 +150,16 @@ async function triggerRefresh(request, sessionToken, retryCount = 0) {
150
150
  // Don't retry - user must re-authenticate
151
151
  if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
152
152
  console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
153
- return { success: false, terminal: true, code: responseData.code };
153
+ // WO-1: preserve the verbatim upstream reason. refresh.ts puts the human
154
+ // message in `error` and the code in `code`; carry both up so the consumer
155
+ // can tell the user WHY (not just "session expired") and stop a futile loop.
156
+ return {
157
+ success: false,
158
+ terminal: true,
159
+ code: responseData.code,
160
+ message: responseData.error,
161
+ resolution: responseData.resolution,
162
+ };
154
163
  }
155
164
  // For other 401s, check if maybe session was refreshed by another request
156
165
  if (response.status === 401 && retryCount < maxRetries) {
@@ -241,8 +250,35 @@ async function ensureFreshToken(request) {
241
250
  idpRefreshToken: idpTokens?.idpRefreshToken,
242
251
  idpAccessTokenExpires: idpTokens?.idpAccessTokenExpires
243
252
  || (baSession.session?.expiresAt ? new Date(baSession.session.expiresAt).getTime() : Date.now() + 24 * 60 * 60 * 1000),
244
- mfaVerified: true,
245
- oauthProvider: 'google',
253
+ /**
254
+ * ⚠ THESE WERE HARDCODED `true` / `'google'`. (QAPert finding #3,
255
+ * BAPert ruling, card 167892.)
256
+ *
257
+ * This rebuild path assumed EVERY session is a Google OAuth sign-in
258
+ * that completed MFA. It reads `idpTokens` for everything else —
259
+ * including roles, two lines up — and then IGNORED the stored
260
+ * mfaVerified sitting in that same object and asserted `true`.
261
+ *
262
+ * WHY IT MATTERS MORE THAN A WRONG FIELD: a passkey sign-in stores
263
+ * an HONEST mfaVerified derived from the token's `amr`. On any
264
+ * Redis miss this path OVERWROTE that false with true — so the
265
+ * session claimed an MFA the user never performed, and the honest
266
+ * derivation upstream was not weakened but VOIDED. Same defect
267
+ * class as the mints that assert completion they never did,
268
+ * arriving through a cache-miss rather than a mint.
269
+ *
270
+ * `oauthProvider: 'google'` was the second lie on the same line:
271
+ * a passkey session rebuilt here was relabelled a Google sign-in,
272
+ * destroying the provenance the sign-in path deliberately records.
273
+ * The IDP emits NO provider claim (DotNetPert), so the honest
274
+ * answer when it was not stored is UNSET — not a guess.
275
+ *
276
+ * Both now default to the SAFE direction: absent/false is
277
+ * fail-closed at every consumer we measured (`!!mfaVerified`,
278
+ * `mfaVerified || ... || false`).
279
+ */
280
+ mfaVerified: idpTokens?.mfaVerified ?? false,
281
+ oauthProvider: idpTokens?.oauthProvider ?? baSession.oauthProvider ?? undefined,
246
282
  idpClientId: idpTokens?.idpClientId ?? idpTokens?.clientId ?? baSession.idpClientId,
247
283
  merchantId: idpTokens?.merchantId ?? baSession.merchantId,
248
284
  };
@@ -258,8 +294,17 @@ async function ensureFreshToken(request) {
258
294
  name: betterAuthSession.user.name,
259
295
  roles: [],
260
296
  idpAccessTokenExpires: Date.now() + 24 * 60 * 60 * 1000,
261
- mfaVerified: true,
262
- oauthProvider: 'google',
297
+ /**
298
+ * ⚠ ALSO HARDCODED `true` / `'google'`, and this path is the worse of
299
+ * the two: it runs with NO IDP TOKENS AT ALL — the comment above says
300
+ * so — and still asserted that MFA was completed. It fabricated an
301
+ * assurance claim from nothing, for every consumer, not just passkey.
302
+ *
303
+ * With nothing to derive from, FALSE is the only honest value and
304
+ * UNSET is the only honest provider. Both are fail-closed.
305
+ */
306
+ mfaVerified: false,
307
+ oauthProvider: undefined,
263
308
  };
264
309
  }
265
310
  if (!sessionData) {
@@ -318,12 +363,21 @@ async function ensureFreshToken(request) {
318
363
  if (!refreshResult.success) {
319
364
  // Check for terminal state - session cannot be recovered
320
365
  if (refreshResult.terminal) {
321
- console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired with no refresh token - redirect to login');
366
+ console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired - redirect to login', {
367
+ code: refreshResult.code,
368
+ message: refreshResult.message,
369
+ });
370
+ // WO-1: keep `error` as the stable terminal category (existing consumers
371
+ // branch on SESSION_EXPIRED_NO_REFRESH) but surface the verbatim upstream
372
+ // reason via code/message/resolution so the user learns WHY and a genuinely
373
+ // dead session (e.g. unapproved account) doesn't loop login→bounce→login.
322
374
  return {
323
375
  success: false,
324
376
  error: 'SESSION_EXPIRED_NO_REFRESH',
325
- message: 'Session expired. Please sign in again.',
377
+ message: refreshResult.message || 'Session expired. Please sign in again.',
326
378
  terminal: true,
379
+ code: refreshResult.code,
380
+ resolution: refreshResult.resolution,
327
381
  };
328
382
  }
329
383
  console.warn('[TOKEN_LIFECYCLE] Refresh failed');
@@ -48,6 +48,23 @@ export interface SessionData {
48
48
  bearerKeyId?: string;
49
49
  /** Whether MFA has been verified for this session */
50
50
  mfaVerified: boolean;
51
+ /**
52
+ * The IDP's own routing decision for a 2FA-pending login (cards 141688 /
53
+ * 141741, IDP WO 113023). Wire name is snake — `next_step`.
54
+ *
55
+ * 'enroll_mfa' — no USABLE method (confirmed and not revoked). The user
56
+ * cannot complete a challenge; they must enrol first.
57
+ * 'mfa_verify' — a usable method exists; challenge them.
58
+ * null — fully authenticated, nothing outstanding.
59
+ *
60
+ * RELAYED, NEVER DERIVED. The IDP owns this fact and is already usable-aware;
61
+ * recomputing it here from a method count would make a second owner and would
62
+ * get the revoked-method case wrong (count>0 but usable=0).
63
+ *
64
+ * NOT A COMPLETION SIGNAL. 'mfa_verify' means "can be challenged", not
65
+ * "is through". `mfaVerified` remains the only field that says they verified.
66
+ */
67
+ nextStep?: 'enroll_mfa' | 'mfa_verify' | null;
51
68
  /** The MFA method used (email, sms, totp) */
52
69
  mfaMethod?: 'email' | 'sms' | 'totp';
53
70
  /** When MFA was completed (Unix timestamp ms) */
@@ -19,4 +19,13 @@
19
19
  * 2. Wrap your app with ThemeProvider to customize branding
20
20
  * 3. Optionally add your health check component
21
21
  */
22
- export default function LoginPage(): import("react/jsx-runtime").JSX.Element;
22
+ import React from 'react';
23
+ /**
24
+ * @param altAuthSlot an additional sign-in control (e.g. a passkey link)
25
+ * rendered INSIDE the card, after the social row. Optional: omitting it
26
+ * leaves the page byte-identical to before, so existing consumers are
27
+ * unaffected.
28
+ */
29
+ export default function LoginPage({ altAuthSlot }?: {
30
+ altAuthSlot?: React.ReactNode;
31
+ }): import("react/jsx-runtime").JSX.Element;
@@ -21,9 +21,6 @@
21
21
  * 3. Optionally add your health check component
22
22
  */
23
23
  'use client';
24
- var __importDefault = (this && this.__importDefault) || function (mod) {
25
- return (mod && mod.__esModule) ? mod : { "default": mod };
26
- };
27
24
  Object.defineProperty(exports, "__esModule", { value: true });
28
25
  exports.default = LoginPage;
29
26
  const jsx_runtime_1 = require("react/jsx-runtime");
@@ -31,19 +28,66 @@ const react_1 = require("react");
31
28
  const better_auth_client_1 = require("../../client/better-auth-client");
32
29
  const navigation_1 = require("next/navigation");
33
30
  const react_2 = require("react");
34
- const ReservedStatusBox_1 = __importDefault(require("../../components/reserved/ReservedStatusBox"));
35
- const ReservedRecoveryWarning_1 = __importDefault(require("../../components/reserved/ReservedRecoveryWarning"));
36
31
  const useTheme_1 = require("../../theme/useTheme");
37
- function LoginForm() {
32
+ function LoginForm({ altAuthSlot }) {
38
33
  const searchParams = (0, navigation_1.useSearchParams)();
39
34
  const callbackUrl = searchParams?.get('callbackUrl') || '/dashboard';
40
35
  const urlError = searchParams?.get('error');
36
+ // Better Auth mount redirects OAuth failures here as ?oauth_error={provider}
37
+ // (the failure-signal guard from the merge spec). Distinct from `error`.
38
+ const oauthError = searchParams?.get('oauth_error');
41
39
  const { data: sessionData, isPending } = better_auth_client_1.authClient.useSession();
42
40
  const session = sessionData;
43
41
  const status = isPending ? 'loading' : session ? 'authenticated' : 'unauthenticated';
44
42
  const branding = (0, useTheme_1.useBranding)();
45
43
  const colors = (0, useTheme_1.useColors)();
46
44
  const layout = (0, useTheme_1.useLayout)();
45
+ // Social sign-in. The button renders ONLY when the IDP client config has an
46
+ // enabled provider row — discovered from the public settings route (the same
47
+ // config the Better Auth engine builds providers from), never assumed.
48
+ // Absent row / fetch failure = no button. Fail quiet is correct here.
49
+ const [googleEnabled, setGoogleEnabled] = (0, react_1.useState)(false);
50
+ const [googleLoading, setGoogleLoading] = (0, react_1.useState)(false);
51
+ (0, react_1.useEffect)(() => {
52
+ let mounted = true;
53
+ fetch('/api/auth/settings', { cache: 'no-store' })
54
+ .then((r) => (r.ok ? r.json() : null))
55
+ .then((json) => {
56
+ if (!mounted || !json)
57
+ return;
58
+ const providers = json?.data?.enabledProviders;
59
+ if (Array.isArray(providers) && providers.some((p) => typeof p === 'string' && p.toLowerCase() === 'google')) {
60
+ setGoogleEnabled(true);
61
+ }
62
+ })
63
+ .catch(() => undefined); // fail quiet: no button
64
+ return () => { mounted = false; };
65
+ }, []);
66
+ const handleGoogleSignIn = async () => {
67
+ setGoogleLoading(true);
68
+ setLoginError(null);
69
+ try {
70
+ // Pre-clean any stale session so account-switch works (idealvibe
71
+ // account-auth/login idiom: signOut before signIn.social).
72
+ await better_auth_client_1.authClient.signOut().catch(() => undefined);
73
+ // The package's OWN SDK, not a raw fetch — Jon's ruling (QA 17400 /
74
+ // BAPert 17401): the page is the template consumers copy, so it teaches
75
+ // the same one-line idiom idealvibe uses (49cc95f, four call sites).
76
+ // The SDK hits POST /api/auth/sign-in/social itself, with its own
77
+ // credentials mode, baseURL and error contract, and navigates on the
78
+ // returned authorize URL.
79
+ await better_auth_client_1.authClient.signIn.social({
80
+ provider: 'google',
81
+ // The social round trip lands on '/', not the form's /dashboard
82
+ // default: the post-callback session upgrade decides the destination.
83
+ callbackURL: searchParams?.get('callbackUrl') || '/',
84
+ });
85
+ }
86
+ catch {
87
+ setGoogleLoading(false);
88
+ setLoginError('Google sign-in is unavailable right now. Please try again.');
89
+ }
90
+ };
47
91
  // Helper to create lighter/darker shades for hover states
48
92
  const adjustBrightness = (hex, percent) => {
49
93
  const num = parseInt(hex.replace('#', ''), 16);
@@ -71,6 +115,13 @@ function LoginForm() {
71
115
  const [showRecoveryOptions, setShowRecoveryOptions] = (0, react_1.useState)(false);
72
116
  // Force show form after timeout if stuck in loading
73
117
  const [forceShowForm, setForceShowForm] = (0, react_1.useState)(false);
118
+ // Surface OAuth failures the Better Auth mount bounced back here.
119
+ (0, react_1.useEffect)(() => {
120
+ if (oauthError) {
121
+ const name = oauthError.charAt(0).toUpperCase() + oauthError.slice(1);
122
+ setLoginError(`${name} sign-in did not complete. Please try again.`);
123
+ }
124
+ }, [oauthError]);
74
125
  // Handle URL errors with user-friendly messages
75
126
  (0, react_1.useEffect)(() => {
76
127
  if (urlError) {
@@ -115,63 +166,9 @@ function LoginForm() {
115
166
  }, 1000);
116
167
  return () => clearTimeout(immediateTimeout);
117
168
  }, [status, session]);
118
- const handleSubmit = async (e) => {
119
- e.preventDefault();
120
- // Atomic state: set to submitting (clears error and success)
121
- setIsSubmitting(true);
122
- setLoading(true);
123
- setLoginError(null);
124
- setLoginSuccess(false);
125
- try {
126
- console.log('[LOGIN] Starting authentication...');
127
- const result = await better_auth_client_1.authClient.signIn.email({
128
- email,
129
- password,
130
- callbackURL: callbackUrl,
131
- });
132
- if (result?.error) {
133
- console.log('[LOGIN] Authentication failed:', result.error);
134
- setIsSubmitting(false);
135
- setLoading(false);
136
- setLoginSuccess(false);
137
- const errorMsg = typeof result.error === 'object'
138
- ? result.error.message || 'Authentication failed'
139
- : String(result.error);
140
- if (errorMsg.includes('password') || errorMsg.includes('Password')) {
141
- console.log('[LOGIN] Password error detected - showing recovery options');
142
- setShowRecoveryOptions(true);
143
- setLoginError(errorMsg);
144
- }
145
- else if (errorMsg.includes('Unable to connect')) {
146
- setLoginError('The authentication service is currently unavailable. Please try again later.');
147
- }
148
- else {
149
- setLoginError(errorMsg);
150
- }
151
- return;
152
- }
153
- // Atomic state: set to success
154
- console.log('[LOGIN] Authentication successful!');
155
- setLoginSuccess(true);
156
- setLoginError(null);
157
- setIsSubmitting(false);
158
- // Get updated session
159
- const freshSession = await better_auth_client_1.authClient.getSession();
160
- console.log('[LOGIN] Fresh session obtained, redirecting to 2FA...');
161
- // Redirect to verify-code for 2FA
162
- const verifyUrl = `/account-auth/verify-code?callbackUrl=${encodeURIComponent(callbackUrl)}`;
163
- console.log('[LOGIN] Redirecting to:', verifyUrl);
164
- window.location.href = verifyUrl;
165
- }
166
- catch (err) {
167
- console.error('[LOGIN] Unexpected error:', err);
168
- // Atomic state: set to error
169
- setIsSubmitting(false);
170
- setLoading(false);
171
- setLoginSuccess(false);
172
- setLoginError(err instanceof Error ? err.message : 'An error occurred during login');
173
- }
174
- };
169
+ // handleSubmit REMOVED with the email/password form it served: it called
170
+ // authClient.signIn.email, which better-auth always rejected because this
171
+ // package never configures emailAndPassword. See the note at the render site.
175
172
  // Show loading spinner for initial loads
176
173
  if (!forceShowForm && !loginError && (loading || (status === 'loading' && !session))) {
177
174
  return ((0, jsx_runtime_1.jsxs)("div", { className: `flex flex-col items-center justify-center min-h-screen ${colors.background}`, children: [(0, jsx_runtime_1.jsxs)("svg", { className: "animate-spin h-6 w-6", style: { color: buttonColor }, viewBox: "0 0 24 24", fill: "none", 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.jsx)("p", { className: `mt-4 text-sm ${colors.muted}`, children: status === 'loading' ? 'Loading...' : 'Authenticating...' })] }));
@@ -204,27 +201,19 @@ function LoginForm() {
204
201
  .themed-status-submitting svg {
205
202
  color: ${buttonColor} !important;
206
203
  }
207
- ` } }), (0, jsx_runtime_1.jsx)("div", { className: `min-h-screen flex items-center justify-center px-4 ${colors.background}`, children: (0, jsx_runtime_1.jsxs)("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" })] }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "space-y-6", "data-testid": "login-form", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "email", className: "block text-sm font-medium mb-2", style: { color: 'inherit' }, children: "Email Address" }), (0, jsx_runtime_1.jsx)("input", { id: "email", type: "email", required: true, autoFocus: true, autoComplete: "email", value: email, onChange: (e) => setEmail(e.target.value), className: `themed-input w-full px-4 py-3 border border-gray-300 rounded-lg transition-colors`, placeholder: "you@example.com", disabled: isSubmitting, style: { backgroundColor: 'white', color: '#1f2937' } })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "password", className: "block text-sm font-medium mb-2", style: { color: 'inherit' }, children: "Password" }), (0, jsx_runtime_1.jsxs)("div", { className: "relative", children: [(0, jsx_runtime_1.jsx)("input", { id: "password", type: showPassword ? "text" : "password", required: true, autoComplete: "current-password", value: password, onChange: (e) => setPassword(e.target.value), className: `themed-input w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg transition-colors`, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", disabled: isSubmitting, style: { backgroundColor: 'white', color: '#1f2937' } }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setShowPassword(!showPassword), className: "absolute right-3 top-1/2 transform -translate-y-1/2 hover:opacity-70", style: { color: 'inherit', opacity: 0.7 }, "aria-label": showPassword ? "Hide password" : "Show password", children: showPassword ? ((0, jsx_runtime_1.jsx)("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", className: "w-5 h-5", children: (0, jsx_runtime_1.jsx)("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L6.464 6.464m7.535 7.535l3.415 3.414M3 3l3.464 3.464M21 21l-3.415-3.414" }) })) : ((0, jsx_runtime_1.jsxs)("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", className: "w-5 h-5", children: [(0, jsx_runtime_1.jsx)("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" }), (0, jsx_runtime_1.jsx)("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" })] })) })] })] }), (0, jsx_runtime_1.jsx)(ReservedStatusBox_1.default, { candidates: [
208
- "Ready",
209
- "Authenticating...",
210
- "The authentication service is currently unavailable. Please try again later.",
211
- "Invalid email or password. Please try again.",
212
- "Login successful! Redirecting..."
213
- ], containerClass: "p-3 text-sm leading-relaxed rounded-lg border", iconSizePx: 16, children: isSubmitting ? (
214
- /* Themed: Submitting */
215
- (0, jsx_runtime_1.jsxs)("div", { className: "themed-status-submitting flex items-start space-x-2 p-3 rounded-lg", children: [(0, jsx_runtime_1.jsxs)("svg", { className: "animate-spin w-4 h-4 mt-0.5 flex-shrink-0", fill: "none", viewBox: "0 0 24 24", style: { color: buttonColor }, 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-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), (0, jsx_runtime_1.jsx)("span", { className: "themed-status-submitting-text text-sm leading-relaxed", style: { color: buttonColor }, children: "Authenticating..." })] })) : loginError ? (
216
- /* Red: Error */
217
- (0, jsx_runtime_1.jsxs)("div", { className: "flex items-start space-x-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20", children: [(0, jsx_runtime_1.jsx)("div", { className: "w-4 h-4 bg-red-500 rounded-full mt-0.5 flex-shrink-0" }), (0, jsx_runtime_1.jsx)("span", { className: "text-red-600 text-sm leading-relaxed", children: loginError })] })) : loginSuccess ? (
218
- /* Green: Success */
219
- (0, jsx_runtime_1.jsxs)("div", { className: "flex items-start space-x-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20", children: [(0, jsx_runtime_1.jsx)("div", { className: "w-4 h-4 bg-green-500 rounded-full mt-0.5 flex-shrink-0" }), (0, jsx_runtime_1.jsx)("span", { className: "text-green-600 text-sm leading-relaxed", children: "Login successful! Redirecting..." })] })) : (
220
- /* Ready State / Health Check Placeholder */
221
- (0, jsx_runtime_1.jsxs)("div", { className: "flex items-start space-x-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20", children: [(0, jsx_runtime_1.jsx)("div", { className: "w-4 h-4 bg-green-500 rounded-full mt-0.5 flex-shrink-0" }), (0, jsx_runtime_1.jsx)("span", { className: "text-green-600 text-sm leading-relaxed", children: "Ready" })] })) }), (0, jsx_runtime_1.jsx)(ReservedRecoveryWarning_1.default, { show: showRecoveryOptions, titleText: "Account Lockout Warning", bodyText: "Your account will be locked after one more failed attempt. Need help?", actionLabel: "Start Account Recovery", containerClass: "p-4 bg-amber-50 border border-amber-200 rounded-lg", titleClass: "text-amber-900 font-medium mb-2", bodyClass: "text-amber-800 text-sm mb-3", buttonClass: "w-full bg-amber-600 hover:bg-amber-700 text-white font-medium py-2 px-4 rounded-md text-sm transition-colors", iconSizePx: 20, children: showRecoveryOptions && ((0, jsx_runtime_1.jsx)("div", { className: "p-4 bg-amber-50 border border-amber-200 rounded-lg", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-start space-x-3", children: [(0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0", fill: "currentColor", viewBox: "0 0 20 20", children: (0, jsx_runtime_1.jsx)("path", { fillRule: "evenodd", d: "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", clipRule: "evenodd" }) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex-1", children: [(0, jsx_runtime_1.jsx)("h3", { className: "text-amber-900 font-medium mb-2", children: "Account Lockout Warning" }), (0, jsx_runtime_1.jsx)("p", { className: "text-amber-800 text-sm mb-3", children: "Your account will be locked after one more failed attempt. Need help?" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => window.location.href = `/account-auth/recovery?email=${encodeURIComponent(email)}`, className: "w-full bg-amber-600 hover:bg-amber-700 text-white font-medium py-2 px-4 rounded-md text-sm transition-colors", children: "Start Account Recovery" })] })] }) })) }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: isSubmitting, className: "themed-button w-full text-white font-semibold py-3 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center", children: isSubmitting ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("svg", { className: "animate-spin -ml-1 mr-3 h-5 w-5 text-white", xmlns: "http://www.w3.org/2000/svg", 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-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), "Signing in..."] })) : ('Sign In') })] }), (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" }) })] }), (0, jsx_runtime_1.jsxs)("p", { className: `mt-4 text-center text-sm ${colors.muted}`, children: ["Don't have an account?", ' ', (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/register", className: "themed-link font-medium hover:underline", children: "Sign up" })] })] }) })] }));
204
+ ` } }), (0, jsx_runtime_1.jsx)("div", { className: `min-h-screen flex items-center justify-center px-4 ${colors.background}`, children: (0, jsx_runtime_1.jsxs)("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" })] }), 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" }) })] }), (0, jsx_runtime_1.jsxs)("p", { className: `mt-4 text-center text-sm ${colors.muted}`, children: ["Don't have an account?", ' ', (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/register", className: "themed-link font-medium hover:underline", children: "Sign up" })] })] }) })] }));
222
205
  }
223
206
  function LoginPageFallback() {
224
207
  const colors = (0, useTheme_1.useColors)();
225
208
  const buttonColor = colors.secondary || colors.primary || '#11B588';
226
209
  return ((0, jsx_runtime_1.jsx)("div", { className: `min-h-screen flex items-center justify-center ${colors.background}`, children: (0, jsx_runtime_1.jsxs)("div", { className: "text-center", children: [(0, jsx_runtime_1.jsxs)("svg", { className: "animate-spin h-10 w-10 mx-auto", style: { color: buttonColor }, xmlns: "http://www.w3.org/2000/svg", 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-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), (0, jsx_runtime_1.jsx)("p", { className: `mt-4 ${colors.muted}`, children: "Loading..." })] }) }));
227
210
  }
228
- function LoginPage() {
229
- return ((0, jsx_runtime_1.jsx)(react_2.Suspense, { fallback: (0, jsx_runtime_1.jsx)(LoginPageFallback, {}), children: (0, jsx_runtime_1.jsx)(LoginForm, {}) }));
211
+ /**
212
+ * @param altAuthSlot an additional sign-in control (e.g. a passkey link)
213
+ * rendered INSIDE the card, after the social row. Optional: omitting it
214
+ * leaves the page byte-identical to before, so existing consumers are
215
+ * unaffected.
216
+ */
217
+ function LoginPage({ altAuthSlot } = {}) {
218
+ return ((0, jsx_runtime_1.jsx)(react_2.Suspense, { fallback: (0, jsx_runtime_1.jsx)(LoginPageFallback, {}), children: (0, jsx_runtime_1.jsx)(LoginForm, { altAuthSlot: altAuthSlot }) }));
230
219
  }
@@ -27,4 +27,13 @@
27
27
  * 1. Import from @payez/next-mvp/pages/verify-code
28
28
  * 2. Wrap your app with ThemeProvider to customize branding
29
29
  */
30
- export default function VerifyCodePage(): import("react/jsx-runtime").JSX.Element;
30
+ /**
31
+ * @param enrollHref where a user with NO usable 2FA method is sent to enrol.
32
+ * Defaults to `/account/security`, which is where the enrolment surface
33
+ * lives in the admin graft. It is a PROP rather than a constant because the
34
+ * mount path is the consumer's choice — the package must not assume another
35
+ * app's routing table.
36
+ */
37
+ export default function VerifyCodePage({ enrollHref }?: {
38
+ enrollHref?: string;
39
+ }): import("react/jsx-runtime").JSX.Element;
@@ -42,7 +42,7 @@ const useTheme_1 = require("../../theme/useTheme");
42
42
  * Prevents auto-redirect back to dashboard when session refreshes in background.
43
43
  */
44
44
  const VERIFY_IN_PROGRESS_KEY = 'idealvibe_2fa_verify_in_progress';
45
- function VerifyCodeForm() {
45
+ function VerifyCodeForm({ enrollHref }) {
46
46
  const router = (0, navigation_1.useRouter)();
47
47
  const searchParams = (0, navigation_1.useSearchParams)();
48
48
  const callbackUrl = searchParams?.get('callbackUrl') || '/dashboard';
@@ -166,6 +166,34 @@ function VerifyCodeForm() {
166
166
  const res = await fetch('/api/session/viability', {
167
167
  credentials: 'include',
168
168
  });
169
+ // ====================================================================
170
+ // THE ENROLMENT DEAD END (cards 170045 / 170047, was 141688 / 141741)
171
+ // ====================================================================
172
+ // A first-time social user arrives here with ZERO usable methods and is
173
+ // asked for a code from an authenticator that does not exist. There was
174
+ // no way out: not a missing feature, AN UNASKED QUESTION.
175
+ //
176
+ // The IDP already decides this and says so. The package already relays
177
+ // it — from BOTH viability exports, deliberately, so an app mounting
178
+ // either is not left blind. The graft already mounts the route. THIS
179
+ // PAGE ALREADY CALLS IT, four lines above. It simply never read the
180
+ // answer: the only branch was `res.status === 401`, so on a perfectly
181
+ // valid session the response was fetched and thrown away.
182
+ //
183
+ // 'enroll_mfa' no USABLE method — the user CANNOT complete a
184
+ // challenge, so keeping them on a code prompt is a trap
185
+ // 'mfa_verify' a usable method exists — the prompt is correct
186
+ // null nothing due
187
+ //
188
+ // Every link in that chain was built against these very cards. The
189
+ // last consumer just never asked.
190
+ if (res.ok) {
191
+ const data = await res.json().catch(() => ({}));
192
+ if (data?.nextStep === 'enroll_mfa') {
193
+ router.replace(enrollHref);
194
+ return;
195
+ }
196
+ }
169
197
  if (res.status === 401) {
170
198
  const data = await res.json().catch(() => ({}));
171
199
  // Session is no longer valid - warn user and redirect
@@ -193,7 +221,40 @@ function VerifyCodeForm() {
193
221
  clearTimeout(initialTimeout);
194
222
  clearInterval(interval);
195
223
  };
196
- }, [status, router]);
224
+ }, [status, router, enrollHref]);
225
+ // ==========================================================================
226
+ // Enrolment dead-end check — IMMEDIATE, and deliberately separate
227
+ // ==========================================================================
228
+ // The poller above first fires after 5 SECONDS. For expiry that is fine. For
229
+ // a user who can never complete this challenge it is five seconds of staring
230
+ // at an unanswerable prompt, which is the whole complaint in card 170045.
231
+ //
232
+ // This runs at once — but it is a SEPARATE effect rather than removing the
233
+ // 5s delay, because that delay guards the 401 path against racing session
234
+ // establishment immediately after sign-in. Firing the expiry logic instantly
235
+ // could sign a user out for having a session that is merely still settling.
236
+ // So: this reads ONLY the 200 body and ONLY routes on 'enroll_mfa'. It never
237
+ // signs anyone out, so it is safe to run with no delay.
238
+ (0, react_1.useEffect)(() => {
239
+ if (status !== 'authenticated')
240
+ return;
241
+ let cancelled = false;
242
+ (async () => {
243
+ try {
244
+ const res = await fetch('/api/session/viability', { credentials: 'include' });
245
+ if (!res.ok || cancelled)
246
+ return;
247
+ const data = await res.json().catch(() => ({}));
248
+ if (data?.nextStep === 'enroll_mfa')
249
+ router.replace(enrollHref);
250
+ }
251
+ catch {
252
+ // Silent: the poller and the next real API call both still cover this.
253
+ // A failed check must leave the user on the prompt, never redirect them.
254
+ }
255
+ })();
256
+ return () => { cancelled = true; };
257
+ }, [status, router, enrollHref]);
197
258
  const handleSendCode = async (selectedMethod) => {
198
259
  setSending(true);
199
260
  setError(null);
@@ -407,6 +468,13 @@ function VerifyCodePageFallback() {
407
468
  const colors = (0, useTheme_1.useColors)();
408
469
  return ((0, jsx_runtime_1.jsx)("div", { className: "flex items-center justify-center py-8", style: { background: 'hsl(var(--background))' }, children: (0, jsx_runtime_1.jsxs)("div", { className: "text-center", children: [(0, jsx_runtime_1.jsxs)("svg", { className: "animate-spin h-10 w-10 mx-auto", style: { color: 'hsl(var(--primary))' }, xmlns: "http://www.w3.org/2000/svg", 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-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), (0, jsx_runtime_1.jsx)("p", { className: "mt-4", style: { color: 'hsl(var(--muted-foreground))' }, children: "Loading..." })] }) }));
409
470
  }
410
- function VerifyCodePage() {
411
- return ((0, jsx_runtime_1.jsx)(react_2.Suspense, { fallback: (0, jsx_runtime_1.jsx)(VerifyCodePageFallback, {}), children: (0, jsx_runtime_1.jsx)(VerifyCodeForm, {}) }));
471
+ /**
472
+ * @param enrollHref where a user with NO usable 2FA method is sent to enrol.
473
+ * Defaults to `/account/security`, which is where the enrolment surface
474
+ * lives in the admin graft. It is a PROP rather than a constant because the
475
+ * mount path is the consumer's choice — the package must not assume another
476
+ * app's routing table.
477
+ */
478
+ function VerifyCodePage({ enrollHref = '/account/security' } = {}) {
479
+ return ((0, jsx_runtime_1.jsx)(react_2.Suspense, { fallback: (0, jsx_runtime_1.jsx)(VerifyCodePageFallback, {}), children: (0, jsx_runtime_1.jsx)(VerifyCodeForm, { enrollHref: enrollHref }) }));
412
480
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Ready-to-Use Passkey Sign-In Route (ANONYMOUS ceremony)
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/passkey/sign-in/options/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/auth/passkey-signin';
8
+ * // (optionsPOST is exported as POST; mount verify at its own path:)
9
+ * // app/api/passkey/sign-in/verify/route.ts
10
+ * export { verifyPOST as POST } from '@payez/next-mvp/routes/auth/passkey-signin';
11
+ * ```
12
+ */
13
+ export { optionsPOST, verifyPOST } from '../../api-handlers/auth/passkey-signin';