@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.
- package/dist/api-handlers/account/profile.d.ts +38 -0
- package/dist/api-handlers/account/profile.js +73 -45
- package/dist/api-handlers/account/recovery/send-code.d.ts +33 -0
- package/dist/api-handlers/account/recovery/send-code.js +52 -18
- package/dist/api-handlers/account/recovery/verify-code.d.ts +33 -0
- package/dist/api-handlers/account/recovery/verify-code.js +52 -18
- package/dist/api-handlers/account/verify-email.js +15 -1
- package/dist/api-handlers/account/verify-sms.js +15 -1
- package/dist/api-handlers/auth/passkey-signin.d.ts +29 -0
- package/dist/api-handlers/auth/passkey-signin.js +97 -0
- package/dist/api-handlers/mfa/backup-code-verify.d.ts +73 -0
- package/dist/api-handlers/mfa/backup-code-verify.js +153 -0
- package/dist/api-handlers/mfa/backup-codes.d.ts +7 -0
- package/dist/api-handlers/mfa/backup-codes.js +40 -0
- package/dist/api-handlers/mfa/methods.d.ts +3 -0
- package/dist/api-handlers/mfa/methods.js +41 -0
- package/dist/api-handlers/mfa/totp-confirm.d.ts +8 -0
- package/dist/api-handlers/mfa/totp-confirm.js +41 -0
- package/dist/api-handlers/mfa/totp-enroll.d.ts +8 -0
- package/dist/api-handlers/mfa/totp-enroll.js +41 -0
- package/dist/api-handlers/mfa/totp-verify.d.ts +47 -0
- package/dist/api-handlers/mfa/totp-verify.js +134 -0
- package/dist/api-handlers/passkeys/list.d.ts +7 -0
- package/dist/api-handlers/passkeys/list.js +29 -0
- package/dist/api-handlers/passkeys/manage.d.ts +3 -0
- package/dist/api-handlers/passkeys/manage.js +54 -0
- package/dist/api-handlers/passkeys/register-options.d.ts +8 -0
- package/dist/api-handlers/passkeys/register-options.js +41 -0
- package/dist/api-handlers/passkeys/register-verify.d.ts +7 -0
- package/dist/api-handlers/passkeys/register-verify.js +40 -0
- package/dist/api-handlers/session/viability.js +19 -0
- package/dist/auth/better-auth.d.ts +68 -5
- package/dist/auth/better-auth.js +271 -45
- package/dist/auth/utils/idp-client.d.ts +13 -22
- package/dist/auth/utils/idp-client.js +12 -82
- package/dist/client/better-auth-client.d.ts +14 -2
- package/dist/lib/ensure-fresh-access-token.js +13 -2
- package/dist/lib/session-store.js +28 -21
- package/dist/lib/startup-init.js +19 -19
- package/dist/lib/token-lifecycle.d.ts +2 -0
- package/dist/lib/token-lifecycle.js +61 -7
- package/dist/models/SessionModel.d.ts +17 -0
- package/dist/pages/login/page.d.ts +10 -1
- package/dist/pages/login/page.js +69 -80
- package/dist/pages/verify-code/page.d.ts +10 -1
- package/dist/pages/verify-code/page.js +72 -4
- package/dist/routes/auth/passkey-signin.d.ts +13 -0
- package/dist/routes/auth/passkey-signin.js +18 -0
- package/dist/routes/auth/viability.d.ts +2 -0
- package/dist/routes/auth/viability.js +88 -0
- package/dist/routes/mfa/backup-code-verify.d.ts +1 -0
- package/dist/routes/mfa/backup-code-verify.js +5 -0
- package/dist/routes/mfa/backup-codes.d.ts +10 -0
- package/dist/routes/mfa/backup-codes.js +14 -0
- package/dist/routes/mfa/methods.d.ts +21 -0
- package/dist/routes/mfa/methods.js +10 -0
- package/dist/routes/mfa/totp-confirm.d.ts +10 -0
- package/dist/routes/mfa/totp-confirm.js +14 -0
- package/dist/routes/mfa/totp-enroll.d.ts +10 -0
- package/dist/routes/mfa/totp-enroll.js +14 -0
- package/dist/routes/mfa/totp-verify.d.ts +14 -0
- package/dist/routes/mfa/totp-verify.js +18 -0
- package/dist/routes/passkeys/list.d.ts +10 -0
- package/dist/routes/passkeys/list.js +14 -0
- package/dist/routes/passkeys/manage.d.ts +18 -0
- package/dist/routes/passkeys/manage.js +13 -0
- package/dist/routes/passkeys/register-options.d.ts +10 -0
- package/dist/routes/passkeys/register-options.js +14 -0
- package/dist/routes/passkeys/register-verify.d.ts +10 -0
- package/dist/routes/passkeys/register-verify.js +14 -0
- package/dist/server/auth.d.ts +2 -2
- package/dist/server/decode-session.js +37 -3
- package/package.json +958 -893
- package/scripts/check-internal-url-usage.sh +73 -73
- package/src/api/index.ts +17 -17
- package/src/api-handlers/account/masked-info.ts +45 -45
- package/src/api-handlers/account/profile.ts +99 -80
- package/src/api-handlers/account/recovery/initiate.ts +23 -23
- package/src/api-handlers/account/recovery/send-code.ts +73 -25
- package/src/api-handlers/account/recovery/verify-code.ts +73 -25
- package/src/api-handlers/account/reset-password.ts +23 -23
- package/src/api-handlers/account/send-code.ts +76 -76
- package/src/api-handlers/account/validate-password.ts +118 -118
- package/src/api-handlers/account/verify-email.ts +15 -1
- package/src/api-handlers/account/verify-sms.ts +15 -1
- package/src/api-handlers/admin/stats.ts +249 -249
- package/src/api-handlers/anon/preferences.ts +123 -123
- package/src/api-handlers/auth/jwks.ts +20 -20
- package/src/api-handlers/auth/login.ts +240 -240
- package/src/api-handlers/auth/passkey-signin.ts +98 -0
- package/src/api-handlers/auth/validate.ts +43 -43
- package/src/api-handlers/mfa/backup-code-verify.ts +175 -0
- package/src/api-handlers/mfa/backup-codes.ts +41 -0
- package/src/api-handlers/mfa/methods.ts +46 -0
- package/src/api-handlers/mfa/totp-confirm.ts +42 -0
- package/src/api-handlers/mfa/totp-enroll.ts +42 -0
- package/src/api-handlers/mfa/totp-verify.ts +156 -0
- package/src/api-handlers/passkeys/list.ts +31 -0
- package/src/api-handlers/passkeys/manage.ts +54 -0
- package/src/api-handlers/passkeys/register-options.ts +42 -0
- package/src/api-handlers/passkeys/register-verify.ts +41 -0
- package/src/api-handlers/session/viability.ts +161 -142
- package/src/auth/better-auth.ts +708 -455
- package/src/auth/unauthenticated-routes.ts +2 -2
- package/src/auth/utils/idp-client.ts +13 -95
- package/src/client/better-auth-client.ts +70 -70
- package/src/client/fetchWithSession.ts +21 -21
- package/src/client/index.ts +13 -13
- package/src/client/useAnonSession.ts +131 -131
- package/src/components/admin/VibeAdminContext.tsx +87 -87
- package/src/config/logging-config.ts +82 -82
- package/src/config/unauthenticated-routes.ts +19 -19
- package/src/edge/internal-api-url.ts +65 -65
- package/src/edge/middleware.ts +42 -42
- package/src/hooks/useProfile.ts +75 -75
- package/src/lib/anon-session.ts +213 -213
- package/src/lib/auth-secret.ts +137 -137
- package/src/lib/demo-mode.ts +17 -17
- package/src/lib/ensure-fresh-access-token.ts +13 -2
- package/src/lib/idp-client-config.ts +559 -559
- package/src/lib/idp-fetch.ts +126 -126
- package/src/lib/internal-api.ts +171 -171
- package/src/lib/jwt-decode-client.ts +45 -45
- package/src/lib/rate-limit-service.ts +9 -9
- package/src/lib/redis.ts +26 -26
- package/src/lib/refresh-token-validator.ts +64 -64
- package/src/lib/secret-validation.ts +8 -8
- package/src/lib/session-store.ts +702 -695
- package/src/lib/session.ts +34 -34
- package/src/lib/startup-init.ts +251 -251
- package/src/lib/token-expiry.ts +40 -40
- package/src/lib/token-lifecycle.ts +533 -470
- package/src/lib/types/api-responses.ts +336 -336
- package/src/middleware/auth-decision.ts +43 -43
- package/src/models/DecodedAccessToken.ts +16 -16
- package/src/models/SessionModel.ts +282 -264
- package/src/pages/admin-roles/index.ts +9 -9
- package/src/pages/login/page.tsx +123 -228
- package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
- package/src/pages/verify-code/page.tsx +71 -5
- package/src/routes/account/index.ts +31 -31
- package/src/routes/account/masked-info.ts +36 -36
- package/src/routes/account/send-code.ts +39 -39
- package/src/routes/account/verify-email.ts +40 -40
- package/src/routes/account/verify-sms.ts +40 -40
- package/src/routes/auth/index.ts +22 -22
- package/src/routes/auth/passkey-signin.ts +13 -0
- package/src/routes/auth/refresh.ts +48 -48
- package/src/routes/auth/session.ts +166 -166
- package/src/routes/auth/viability.ts +89 -0
- package/src/routes/index.ts +17 -17
- package/src/routes/mfa/backup-code-verify.ts +1 -0
- package/src/routes/mfa/backup-codes.ts +10 -0
- package/src/routes/mfa/methods.ts +22 -0
- package/src/routes/mfa/totp-confirm.ts +10 -0
- package/src/routes/mfa/totp-enroll.ts +10 -0
- package/src/routes/mfa/totp-verify.ts +14 -0
- package/src/routes/passkeys/list.ts +10 -0
- package/src/routes/passkeys/manage.ts +23 -0
- package/src/routes/passkeys/register-options.ts +10 -0
- package/src/routes/passkeys/register-verify.ts +10 -0
- package/src/routes/session/index.ts +7 -7
- package/src/routes/session/refresh-viability.ts +17 -17
- package/src/server/auth.ts +373 -373
- package/src/server/decode-session.ts +235 -202
- package/src/services/signalrActivityService.ts +258 -258
- package/src/theme/ThemeProvider.tsx +39 -39
- package/src/theme/default.ts +33 -33
- package/src/theme/index.ts +31 -31
- package/src/theme/types.ts +69 -69
- package/src/theme/useTheme.ts +57 -57
- package/src/theme/utils.ts +40 -40
- package/src/types/api.ts +12 -12
- package/src/types/auth.d.ts +23 -23
- package/src/types/auth.ts +22 -22
- package/src/types/logging.ts +11 -11
- package/src/types/recovery.ts +54 -54
- package/src/utils/api.ts +353 -353
- package/src/utils/circuitBreaker.ts +40 -40
- package/src/utils/error-message.ts +108 -108
- 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) {
|
package/dist/lib/startup-init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/pages/login/page.js
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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';
|