@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
@@ -30,10 +30,13 @@ import ReservedStatusBox from '../../components/reserved/ReservedStatusBox';
30
30
  import ReservedRecoveryWarning from '../../components/reserved/ReservedRecoveryWarning';
31
31
  import { useBranding, useColors, useLayout } from '../../theme/useTheme';
32
32
 
33
- function LoginForm() {
33
+ function LoginForm({ altAuthSlot }: { altAuthSlot?: React.ReactNode }) {
34
34
  const searchParams = useSearchParams();
35
35
  const callbackUrl = searchParams?.get('callbackUrl') || '/dashboard';
36
36
  const urlError = searchParams?.get('error');
37
+ // Better Auth mount redirects OAuth failures here as ?oauth_error={provider}
38
+ // (the failure-signal guard from the merge spec). Distinct from `error`.
39
+ const oauthError = searchParams?.get('oauth_error');
37
40
 
38
41
  const { data: sessionData, isPending } = authClient.useSession();
39
42
  const session = sessionData;
@@ -42,6 +45,53 @@ function LoginForm() {
42
45
  const colors = useColors();
43
46
  const layout = useLayout();
44
47
 
48
+ // Social sign-in. The button renders ONLY when the IDP client config has an
49
+ // enabled provider row — discovered from the public settings route (the same
50
+ // config the Better Auth engine builds providers from), never assumed.
51
+ // Absent row / fetch failure = no button. Fail quiet is correct here.
52
+ const [googleEnabled, setGoogleEnabled] = useState(false);
53
+ const [googleLoading, setGoogleLoading] = useState(false);
54
+
55
+ useEffect(() => {
56
+ let mounted = true;
57
+ fetch('/api/auth/settings', { cache: 'no-store' })
58
+ .then((r) => (r.ok ? r.json() : null))
59
+ .then((json) => {
60
+ if (!mounted || !json) return;
61
+ const providers: unknown = json?.data?.enabledProviders;
62
+ if (Array.isArray(providers) && providers.some((p) => typeof p === 'string' && p.toLowerCase() === 'google')) {
63
+ setGoogleEnabled(true);
64
+ }
65
+ })
66
+ .catch(() => undefined); // fail quiet: no button
67
+ return () => { mounted = false; };
68
+ }, []);
69
+
70
+ const handleGoogleSignIn = async () => {
71
+ setGoogleLoading(true);
72
+ setLoginError(null);
73
+ try {
74
+ // Pre-clean any stale session so account-switch works (idealvibe
75
+ // account-auth/login idiom: signOut before signIn.social).
76
+ await authClient.signOut().catch(() => undefined);
77
+ // The package's OWN SDK, not a raw fetch — Jon's ruling (QA 17400 /
78
+ // BAPert 17401): the page is the template consumers copy, so it teaches
79
+ // the same one-line idiom idealvibe uses (49cc95f, four call sites).
80
+ // The SDK hits POST /api/auth/sign-in/social itself, with its own
81
+ // credentials mode, baseURL and error contract, and navigates on the
82
+ // returned authorize URL.
83
+ await authClient.signIn.social({
84
+ provider: 'google',
85
+ // The social round trip lands on '/', not the form's /dashboard
86
+ // default: the post-callback session upgrade decides the destination.
87
+ callbackURL: searchParams?.get('callbackUrl') || '/',
88
+ });
89
+ } catch {
90
+ setGoogleLoading(false);
91
+ setLoginError('Google sign-in is unavailable right now. Please try again.');
92
+ }
93
+ };
94
+
45
95
  // Helper to create lighter/darker shades for hover states
46
96
  const adjustBrightness = (hex: string, percent: number) => {
47
97
  const num = parseInt(hex.replace('#', ''), 16);
@@ -74,6 +124,14 @@ function LoginForm() {
74
124
  // Force show form after timeout if stuck in loading
75
125
  const [forceShowForm, setForceShowForm] = useState(false);
76
126
 
127
+ // Surface OAuth failures the Better Auth mount bounced back here.
128
+ useEffect(() => {
129
+ if (oauthError) {
130
+ const name = oauthError.charAt(0).toUpperCase() + oauthError.slice(1);
131
+ setLoginError(`${name} sign-in did not complete. Please try again.`);
132
+ }
133
+ }, [oauthError]);
134
+
77
135
  // Handle URL errors with user-friendly messages
78
136
  useEffect(() => {
79
137
  if (urlError) {
@@ -119,71 +177,9 @@ function LoginForm() {
119
177
  return () => clearTimeout(immediateTimeout);
120
178
  }, [status, session]);
121
179
 
122
- const handleSubmit = async (e: React.FormEvent) => {
123
- e.preventDefault();
124
-
125
- // Atomic state: set to submitting (clears error and success)
126
- setIsSubmitting(true);
127
- setLoading(true);
128
- setLoginError(null);
129
- setLoginSuccess(false);
130
-
131
- try {
132
- console.log('[LOGIN] Starting authentication...');
133
- const result = await authClient.signIn.email({
134
- email,
135
- password,
136
- callbackURL: callbackUrl,
137
- });
138
-
139
- if ((result as any)?.error) {
140
- console.log('[LOGIN] Authentication failed:', (result as any).error);
141
-
142
- setIsSubmitting(false);
143
- setLoading(false);
144
- setLoginSuccess(false);
145
-
146
- const errorMsg = typeof (result as any).error === 'object'
147
- ? ((result as any).error as any).message || 'Authentication failed'
148
- : String((result as any).error);
149
-
150
- if (errorMsg.includes('password') || errorMsg.includes('Password')) {
151
- console.log('[LOGIN] Password error detected - showing recovery options');
152
- setShowRecoveryOptions(true);
153
- setLoginError(errorMsg);
154
- } else if (errorMsg.includes('Unable to connect')) {
155
- setLoginError('The authentication service is currently unavailable. Please try again later.');
156
- } else {
157
- setLoginError(errorMsg);
158
- }
159
- return;
160
- }
161
-
162
- // Atomic state: set to success
163
- console.log('[LOGIN] Authentication successful!');
164
- setLoginSuccess(true);
165
- setLoginError(null);
166
- setIsSubmitting(false);
167
-
168
- // Get updated session
169
- const freshSession = await authClient.getSession();
170
- console.log('[LOGIN] Fresh session obtained, redirecting to 2FA...');
171
-
172
- // Redirect to verify-code for 2FA
173
- const verifyUrl = `/account-auth/verify-code?callbackUrl=${encodeURIComponent(callbackUrl)}`;
174
- console.log('[LOGIN] Redirecting to:', verifyUrl);
175
- window.location.href = verifyUrl;
176
-
177
- } catch (err) {
178
- console.error('[LOGIN] Unexpected error:', err);
179
-
180
- // Atomic state: set to error
181
- setIsSubmitting(false);
182
- setLoading(false);
183
- setLoginSuccess(false);
184
- setLoginError(err instanceof Error ? err.message : 'An error occurred during login');
185
- }
186
- };
180
+ // handleSubmit REMOVED with the email/password form it served: it called
181
+ // authClient.signIn.email, which better-auth always rejected because this
182
+ // package never configures emailAndPassword. See the note at the render site.
187
183
 
188
184
  // Show loading spinner for initial loads
189
185
  if (!forceShowForm && !loginError && (loading || (status === 'loading' && !session))) {
@@ -246,169 +242,62 @@ function LoginForm() {
246
242
  </p>
247
243
  </div>
248
244
 
249
- {/* Login Form */}
250
- <form onSubmit={handleSubmit} className="space-y-6" data-testid="login-form">
251
- {/* Email Field */}
252
- <div>
253
- <label htmlFor="email" className="block text-sm font-medium mb-2" style={{ color: 'inherit' }}>
254
- Email Address
255
- </label>
256
- <input
257
- id="email"
258
- type="email"
259
- required
260
- autoFocus
261
- autoComplete="email"
262
- value={email}
263
- onChange={(e) => setEmail(e.target.value)}
264
- className={`themed-input w-full px-4 py-3 border border-gray-300 rounded-lg transition-colors`}
265
- placeholder="you@example.com"
266
- disabled={isSubmitting}
245
+ {/* Social sign-in — rendered ONLY from the IDP client config's
246
+ enabled providers (see the /api/auth/settings discovery above).
247
+ Above the form, divider idiom per the package's ModeAwareLoginPage. */}
248
+ {googleEnabled && (
249
+ <>
250
+ <button
251
+ type="button"
252
+ onClick={() => void handleGoogleSignIn()}
253
+ disabled={googleLoading || isSubmitting}
254
+ data-testid="google-sign-in"
255
+ 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"
267
256
  style={{ backgroundColor: 'white', color: '#1f2937' }}
268
- />
269
- </div>
270
-
271
- {/* Password Field with Show/Hide Toggle */}
272
- <div>
273
- <label htmlFor="password" className="block text-sm font-medium mb-2" style={{ color: 'inherit' }}>
274
- Password
275
- </label>
276
- <div className="relative">
277
- <input
278
- id="password"
279
- type={showPassword ? "text" : "password"}
280
- required
281
- autoComplete="current-password"
282
- value={password}
283
- onChange={(e) => setPassword(e.target.value)}
284
- className={`themed-input w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg transition-colors`}
285
- placeholder="••••••••"
286
- disabled={isSubmitting}
287
- style={{ backgroundColor: 'white', color: '#1f2937' }}
288
- />
289
- <button
290
- type="button"
291
- onClick={() => setShowPassword(!showPassword)}
292
- className="absolute right-3 top-1/2 transform -translate-y-1/2 hover:opacity-70"
293
- style={{ color: 'inherit', opacity: 0.7 }}
294
- aria-label={showPassword ? "Hide password" : "Show password"}
295
- >
296
- {showPassword ? (
297
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" className="w-5 h-5">
298
- <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" />
299
- </svg>
300
- ) : (
301
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" className="w-5 h-5">
302
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
303
- <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" />
304
- </svg>
305
- )}
306
- </button>
307
- </div>
308
- </div>
309
-
310
- {/* Atomic Status Display - Deterministic Reserved Space (zero layout shift) */}
311
- <ReservedStatusBox
312
- candidates={[
313
- "Ready",
314
- "Authenticating...",
315
- "The authentication service is currently unavailable. Please try again later.",
316
- "Invalid email or password. Please try again.",
317
- "Login successful! Redirecting..."
318
- ]}
319
- containerClass="p-3 text-sm leading-relaxed rounded-lg border"
320
- iconSizePx={16}
321
- >
322
- {isSubmitting ? (
323
- /* Themed: Submitting */
324
- <div className="themed-status-submitting flex items-start space-x-2 p-3 rounded-lg">
325
- <svg className="animate-spin w-4 h-4 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" style={{ color: buttonColor }}>
326
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
327
- <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"></path>
257
+ >
258
+ {googleLoading ? (
259
+ <svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
260
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
261
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
328
262
  </svg>
329
- <span className="themed-status-submitting-text text-sm leading-relaxed" style={{ color: buttonColor }}>Authenticating...</span>
330
- </div>
331
- ) : loginError ? (
332
- /* Red: Error */
333
- <div className="flex items-start space-x-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
334
- <div className="w-4 h-4 bg-red-500 rounded-full mt-0.5 flex-shrink-0"></div>
335
- <span className="text-red-600 text-sm leading-relaxed">{loginError}</span>
336
- </div>
337
- ) : loginSuccess ? (
338
- /* Green: Success */
339
- <div className="flex items-start space-x-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
340
- <div className="w-4 h-4 bg-green-500 rounded-full mt-0.5 flex-shrink-0"></div>
341
- <span className="text-green-600 text-sm leading-relaxed">Login successful! Redirecting...</span>
342
- </div>
343
- ) : (
344
- /* Ready State / Health Check Placeholder */
345
- <div className="flex items-start space-x-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
346
- <div className="w-4 h-4 bg-green-500 rounded-full mt-0.5 flex-shrink-0"></div>
347
- <span className="text-green-600 text-sm leading-relaxed">Ready</span>
348
- {/*
349
- CUSTOMIZATION: Replace above with your health check component
350
- Example: <SignalRHealthCheck />
351
- Or any real-time health status component
352
- */}
353
- </div>
354
- )}
355
- </ReservedStatusBox>
356
-
357
- {/* Account Recovery Options - Deterministic Reserved Space (zero layout shift) */}
358
- <ReservedRecoveryWarning
359
- show={showRecoveryOptions}
360
- titleText="Account Lockout Warning"
361
- bodyText="Your account will be locked after one more failed attempt. Need help?"
362
- actionLabel="Start Account Recovery"
363
- containerClass="p-4 bg-amber-50 border border-amber-200 rounded-lg"
364
- titleClass="text-amber-900 font-medium mb-2"
365
- bodyClass="text-amber-800 text-sm mb-3"
366
- buttonClass="w-full bg-amber-600 hover:bg-amber-700 text-white font-medium py-2 px-4 rounded-md text-sm transition-colors"
367
- iconSizePx={20}
368
- >
369
- {showRecoveryOptions && (
370
- <div className="p-4 bg-amber-50 border border-amber-200 rounded-lg">
371
- <div className="flex items-start space-x-3">
372
- <svg className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
373
- <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" />
374
- </svg>
375
- <div className="flex-1">
376
- <h3 className="text-amber-900 font-medium mb-2">Account Lockout Warning</h3>
377
- <p className="text-amber-800 text-sm mb-3">
378
- Your account will be locked after one more failed attempt. Need help?
379
- </p>
380
- <button
381
- type="button"
382
- onClick={() => window.location.href = `/account-auth/recovery?email=${encodeURIComponent(email)}`}
383
- className="w-full bg-amber-600 hover:bg-amber-700 text-white font-medium py-2 px-4 rounded-md text-sm transition-colors"
384
- >
385
- Start Account Recovery
386
- </button>
387
- </div>
388
- </div>
389
- </div>
390
- )}
391
- </ReservedRecoveryWarning>
392
-
393
- {/* Submit Button */}
394
- <button
395
- type="submit"
396
- disabled={isSubmitting}
397
- 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"
398
- >
399
- {isSubmitting ? (
400
- <>
401
- <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">
402
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
403
- <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"></path>
263
+ ) : (
264
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
265
+ <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"/>
266
+ <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"/>
267
+ <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"/>
268
+ <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"/>
404
269
  </svg>
405
- Signing in...
406
- </>
407
- ) : (
408
- 'Sign In'
409
- )}
410
- </button>
411
- </form>
270
+ )}
271
+ {googleLoading ? 'Redirecting to Google…' : 'Continue with Google'}
272
+ </button>
273
+ </>
274
+ )}
275
+
276
+ {/* EMAIL/PASSWORD FORM REMOVED — the package has NEVER implemented it.
277
+ `emailAndPassword` appears ZERO times in src/ and dist/: better-auth.ts
278
+ configures socialProviders only, so authClient.signIn.email always
279
+ returned "Email and password is not enabled". The form was a dead
280
+ control on the first screen of every consumer, and no consumer could
281
+ enable it without forking. Removed 2026-08-02 (Jon via BAPert 17586).
282
+ To ADD password auth later: set emailAndPassword in better-auth.ts and
283
+ restore this form together — one without the other is what created this. */}
284
+
285
+ {/* ALTERNATE SIGN-IN SLOT — the "B2 slot" (card 169071).
286
+ ─────────────────────────────────────────────────────────────
287
+ Consumers with an extra sign-in route (today: passkey) rendered
288
+ it AFTER <LoginPage/> as a sibling, which put the control BELOW
289
+ THE FOLD, because this page owns `min-h-screen` centering. The
290
+ graft's own comment called that "knowingly ugly and knowingly
291
+ temporary" and named the fix: give LoginPage a slot.
292
+
293
+ It sits here — after the social row, before the footer links —
294
+ so an alternate method reads as a peer of "Continue with
295
+ Google" rather than as an afterthought below the card.
296
+
297
+ A SLOT, not a passkey prop: this page must not know what a
298
+ passkey is. The consumer owns the control and its ceremony;
299
+ the package owns only where it goes. */}
300
+ {altAuthSlot && <div className="mt-4">{altAuthSlot}</div>}
412
301
 
413
302
  {/* Footer Links */}
414
303
  <div className={`mt-6 text-center text-sm ${colors.muted}`}>
@@ -447,10 +336,16 @@ function LoginPageFallback() {
447
336
  );
448
337
  }
449
338
 
450
- export default function LoginPage() {
339
+ /**
340
+ * @param altAuthSlot an additional sign-in control (e.g. a passkey link)
341
+ * rendered INSIDE the card, after the social row. Optional: omitting it
342
+ * leaves the page byte-identical to before, so existing consumers are
343
+ * unaffected.
344
+ */
345
+ export default function LoginPage({ altAuthSlot }: { altAuthSlot?: React.ReactNode } = {}) {
451
346
  return (
452
347
  <Suspense fallback={<LoginPageFallback />}>
453
- <LoginForm />
348
+ <LoginForm altAuthSlot={altAuthSlot} />
454
349
  </Suspense>
455
350
  );
456
351
  }
@@ -200,9 +200,9 @@ export default function EnhancedSecurityPage() {
200
200
  const layout = useLayout();
201
201
  const colors = useColors();
202
202
 
203
- // Re-auth action types (moved for scope)
204
- type ReAuthAction = 'enable_2fa' | 'disable_2fa' | 'export_data' | 'delete_account' | null;
205
-
203
+ // Re-auth action types (moved for scope)
204
+ type ReAuthAction = 'enable_2fa' | 'disable_2fa' | 'export_data' | 'delete_account' | null;
205
+
206
206
  // Security data state (would come from API in production)
207
207
  const [securityData, setSecurityData] = useState<SecurityData | null>(null);
208
208
  const [loadingSecurityData, setLoadingSecurityData] = useState(true);
@@ -218,12 +218,12 @@ type ReAuthAction = 'enable_2fa' | 'disable_2fa' | 'export_data' | 'delete_accou
218
218
  new_password: '',
219
219
  confirm_password: '',
220
220
  });
221
- const [showPasswords, setShowPasswords] = useState({ current: false, new: false, confirm: false });
222
-
223
- // Re-authentication modal state (Security Addendum)
224
- const [reAuthModal, setReAuthModal] = useState<{
225
- isOpen: boolean;
226
- action: ReAuthAction;
221
+ const [showPasswords, setShowPasswords] = useState({ current: false, new: false, confirm: false });
222
+
223
+ // Re-authentication modal state (Security Addendum)
224
+ const [reAuthModal, setReAuthModal] = useState<{
225
+ isOpen: boolean;
226
+ action: ReAuthAction;
227
227
  }>({ isOpen: false, action: null });
228
228
  const [submitting, setSubmitting] = useState(false);
229
229
  const [error, setError] = useState('');
@@ -49,7 +49,7 @@ interface MaskedInfo {
49
49
  has_authenticator?: boolean;
50
50
  }
51
51
 
52
- function VerifyCodeForm() {
52
+ function VerifyCodeForm({ enrollHref }: { enrollHref: string }) {
53
53
  const router = useRouter();
54
54
  const searchParams = useSearchParams();
55
55
  const callbackUrl = searchParams?.get('callbackUrl') || '/dashboard';
@@ -193,7 +193,36 @@ function VerifyCodeForm() {
193
193
  const res = await fetch('/api/session/viability', {
194
194
  credentials: 'include',
195
195
  });
196
-
196
+
197
+ // ====================================================================
198
+ // THE ENROLMENT DEAD END (cards 170045 / 170047, was 141688 / 141741)
199
+ // ====================================================================
200
+ // A first-time social user arrives here with ZERO usable methods and is
201
+ // asked for a code from an authenticator that does not exist. There was
202
+ // no way out: not a missing feature, AN UNASKED QUESTION.
203
+ //
204
+ // The IDP already decides this and says so. The package already relays
205
+ // it — from BOTH viability exports, deliberately, so an app mounting
206
+ // either is not left blind. The graft already mounts the route. THIS
207
+ // PAGE ALREADY CALLS IT, four lines above. It simply never read the
208
+ // answer: the only branch was `res.status === 401`, so on a perfectly
209
+ // valid session the response was fetched and thrown away.
210
+ //
211
+ // 'enroll_mfa' no USABLE method — the user CANNOT complete a
212
+ // challenge, so keeping them on a code prompt is a trap
213
+ // 'mfa_verify' a usable method exists — the prompt is correct
214
+ // null nothing due
215
+ //
216
+ // Every link in that chain was built against these very cards. The
217
+ // last consumer just never asked.
218
+ if (res.ok) {
219
+ const data = await res.json().catch(() => ({}));
220
+ if (data?.nextStep === 'enroll_mfa') {
221
+ router.replace(enrollHref);
222
+ return;
223
+ }
224
+ }
225
+
197
226
  if (res.status === 401) {
198
227
  const data = await res.json().catch(() => ({}));
199
228
 
@@ -223,7 +252,37 @@ function VerifyCodeForm() {
223
252
  clearTimeout(initialTimeout);
224
253
  clearInterval(interval);
225
254
  };
226
- }, [status, router]);
255
+ }, [status, router, enrollHref]);
256
+
257
+ // ==========================================================================
258
+ // Enrolment dead-end check — IMMEDIATE, and deliberately separate
259
+ // ==========================================================================
260
+ // The poller above first fires after 5 SECONDS. For expiry that is fine. For
261
+ // a user who can never complete this challenge it is five seconds of staring
262
+ // at an unanswerable prompt, which is the whole complaint in card 170045.
263
+ //
264
+ // This runs at once — but it is a SEPARATE effect rather than removing the
265
+ // 5s delay, because that delay guards the 401 path against racing session
266
+ // establishment immediately after sign-in. Firing the expiry logic instantly
267
+ // could sign a user out for having a session that is merely still settling.
268
+ // So: this reads ONLY the 200 body and ONLY routes on 'enroll_mfa'. It never
269
+ // signs anyone out, so it is safe to run with no delay.
270
+ useEffect(() => {
271
+ if (status !== 'authenticated') return;
272
+ let cancelled = false;
273
+ (async () => {
274
+ try {
275
+ const res = await fetch('/api/session/viability', { credentials: 'include' });
276
+ if (!res.ok || cancelled) return;
277
+ const data = await res.json().catch(() => ({}));
278
+ if (data?.nextStep === 'enroll_mfa') router.replace(enrollHref);
279
+ } catch {
280
+ // Silent: the poller and the next real API call both still cover this.
281
+ // A failed check must leave the user on the prompt, never redirect them.
282
+ }
283
+ })();
284
+ return () => { cancelled = true; };
285
+ }, [status, router, enrollHref]);
227
286
 
228
287
  const handleSendCode = async (selectedMethod: 'email' | 'sms') => {
229
288
  setSending(true);
@@ -643,10 +702,17 @@ function VerifyCodePageFallback() {
643
702
  );
644
703
  }
645
704
 
646
- export default function VerifyCodePage() {
705
+ /**
706
+ * @param enrollHref where a user with NO usable 2FA method is sent to enrol.
707
+ * Defaults to `/account/security`, which is where the enrolment surface
708
+ * lives in the admin graft. It is a PROP rather than a constant because the
709
+ * mount path is the consumer's choice — the package must not assume another
710
+ * app's routing table.
711
+ */
712
+ export default function VerifyCodePage({ enrollHref = '/account/security' }: { enrollHref?: string } = {}) {
647
713
  return (
648
714
  <Suspense fallback={<VerifyCodePageFallback />}>
649
- <VerifyCodeForm />
715
+ <VerifyCodeForm enrollHref={enrollHref} />
650
716
  </Suspense>
651
717
  );
652
718
  }
@@ -1,32 +1,32 @@
1
- /**
2
- * Account Route Exports
3
- *
4
- * Provides ready-to-use route exports for all 2FA/account management endpoints.
5
- * These routes handle the complete 2FA flow with zero configuration.
6
- *
7
- * @example
8
- * ```typescript
9
- * // Import individual routes
10
- * export { POST } from '@payez/next-mvp/routes/account/masked-info';
11
- * export { POST } from '@payez/next-mvp/routes/account/send-code';
12
- * export { POST } from '@payez/next-mvp/routes/account/verify-email';
13
- * export { POST } from '@payez/next-mvp/routes/account/verify-sms';
14
- * ```
15
- *
16
- * @version 2.3.0
17
- * @since auth-ready-v2
18
- */
19
-
20
- // Export individual route modules
21
- export * as maskedInfo from './masked-info';
22
- export * as sendCode from './send-code';
23
- export * as verifyEmail from './verify-email';
24
- export * as verifySms from './verify-sms';
25
- export * as updatePhone from './update-phone';
26
-
27
- // Re-export POST handlers for convenience (all 2FA endpoints use POST)
28
- export { POST as maskedInfoPOST } from './masked-info';
29
- export { POST as sendCodePOST } from './send-code';
30
- export { POST as verifyEmailPOST } from './verify-email';
31
- export { POST as verifySmsPOST } from './verify-sms';
1
+ /**
2
+ * Account Route Exports
3
+ *
4
+ * Provides ready-to-use route exports for all 2FA/account management endpoints.
5
+ * These routes handle the complete 2FA flow with zero configuration.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // Import individual routes
10
+ * export { POST } from '@payez/next-mvp/routes/account/masked-info';
11
+ * export { POST } from '@payez/next-mvp/routes/account/send-code';
12
+ * export { POST } from '@payez/next-mvp/routes/account/verify-email';
13
+ * export { POST } from '@payez/next-mvp/routes/account/verify-sms';
14
+ * ```
15
+ *
16
+ * @version 2.3.0
17
+ * @since auth-ready-v2
18
+ */
19
+
20
+ // Export individual route modules
21
+ export * as maskedInfo from './masked-info';
22
+ export * as sendCode from './send-code';
23
+ export * as verifyEmail from './verify-email';
24
+ export * as verifySms from './verify-sms';
25
+ export * as updatePhone from './update-phone';
26
+
27
+ // Re-export POST handlers for convenience (all 2FA endpoints use POST)
28
+ export { POST as maskedInfoPOST } from './masked-info';
29
+ export { POST as sendCodePOST } from './send-code';
30
+ export { POST as verifyEmailPOST } from './verify-email';
31
+ export { POST as verifySmsPOST } from './verify-sms';
32
32
  export { POST as updatePhonePOST } from './update-phone';