@payez/next-mvp 4.12.1 → 4.13.1
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/CHANGELOG.md +57 -0
- package/dist/api-handlers/account/profile.d.ts +2 -39
- package/dist/api-handlers/account/profile.js +5 -2
- package/dist/api-handlers/anon/preferences.d.ts +1 -14
- package/dist/api-handlers/anon/preferences.js +5 -5
- package/dist/api-handlers/auth/credential-login.js +28 -4
- package/dist/api-handlers/health.d.ts +5 -0
- package/dist/api-handlers/health.js +48 -0
- package/dist/api-handlers/mfa/totp-confirm.js +8 -4
- package/dist/api-handlers/mfa/totp-enroll.js +8 -4
- package/dist/api-handlers/session/refresh-viability.d.ts +3 -15
- package/dist/api-handlers/session/refresh-viability.js +5 -2
- package/dist/api-handlers/session/viability.d.ts +3 -8
- package/dist/api-handlers/session/viability.js +11 -4
- package/dist/auth/better-auth-exposure.d.ts +11 -0
- package/dist/auth/better-auth-exposure.js +73 -0
- package/dist/auth/better-auth.d.ts +6 -0
- package/dist/auth/better-auth.js +42 -3
- package/dist/auth/route-config.js +9 -0
- package/dist/auth/session-reset-link.d.ts +32 -0
- package/dist/auth/session-reset-link.js +45 -1
- package/dist/components/auth/CredentialSignInForm.d.ts +7 -1
- package/dist/components/auth/CredentialSignInForm.js +13 -4
- package/dist/components/auth/EnrollAuthenticatorOffer.d.ts +21 -0
- package/dist/components/auth/EnrollAuthenticatorOffer.js +23 -0
- package/dist/components/mfa/QrCode.d.ts +6 -0
- package/dist/components/mfa/QrCode.js +14 -0
- package/dist/components/mfa/TotpEnrollPanel.d.ts +6 -0
- package/dist/components/mfa/TotpEnrollPanel.js +117 -0
- package/dist/edge/middleware.d.ts +1 -1
- package/dist/edge/middleware.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/credential-sign-in-client.d.ts +26 -1
- package/dist/lib/credential-sign-in-client.js +32 -6
- package/dist/lib/idp-client-config.js +4 -1
- package/dist/lib/login-error-message.d.ts +3 -0
- package/dist/lib/login-error-message.js +12 -1
- package/dist/lib/mfa-json-guard.d.ts +12 -0
- package/dist/lib/mfa-json-guard.js +19 -0
- package/dist/lib/nextauth-secret.js +3 -1
- package/dist/lib/no-store.d.ts +13 -0
- package/dist/lib/no-store.js +35 -0
- package/dist/lib/payez-client-secret.d.ts +14 -0
- package/dist/lib/payez-client-secret.js +41 -0
- package/dist/lib/qrcode.d.ts +22 -0
- package/dist/lib/qrcode.js +362 -0
- package/dist/lib/sign-in-failure.d.ts +35 -0
- package/dist/lib/sign-in-failure.js +85 -0
- package/dist/lib/twofa-rejection.d.ts +2 -0
- package/dist/lib/twofa-rejection.js +5 -2
- package/dist/middleware/create-middleware.d.ts +24 -0
- package/dist/middleware/create-middleware.js +56 -9
- package/dist/middleware/prefetch-guard-status.d.ts +21 -0
- package/dist/middleware/prefetch-guard-status.js +42 -0
- package/dist/pages/enroll/page.d.ts +7 -0
- package/dist/pages/enroll/page.js +15 -0
- package/dist/pages/login/page.js +1 -1
- package/dist/routes/auth/session-reset.d.ts +11 -1
- package/dist/routes/auth/session-reset.js +38 -13
- package/dist/routes/auth/session.d.ts +2 -9
- package/dist/routes/auth/session.js +5 -2
- package/dist/routes/auth/viability.d.ts +3 -54
- package/dist/routes/auth/viability.js +16 -4
- package/dist/routes/health.d.ts +1 -0
- package/dist/routes/health.js +16 -0
- package/dist/routes/mfa/methods.d.ts +2 -2
- package/dist/routes/mfa/methods.js +3 -1
- package/dist/routes/passkeys/list.d.ts +2 -10
- package/dist/routes/passkeys/list.js +4 -2
- package/dist/routes/session/refresh-viability.d.ts +2 -1
- package/dist/routes/session/refresh-viability.js +4 -2
- package/dist/utils/api.d.ts +33 -6
- package/dist/utils/api.js +35 -2
- package/package.json +21 -1
- package/src/api-handlers/account/profile.ts +5 -1
- package/src/api-handlers/anon/preferences.ts +5 -4
- package/src/api-handlers/auth/credential-login.ts +29 -4
- package/src/api-handlers/health.ts +53 -0
- package/src/api-handlers/mfa/totp-confirm.ts +7 -4
- package/src/api-handlers/mfa/totp-enroll.ts +7 -4
- package/src/api-handlers/session/refresh-viability.ts +5 -1
- package/src/api-handlers/session/viability.ts +12 -4
- package/src/auth/better-auth-exposure.ts +69 -0
- package/src/auth/better-auth.ts +42 -3
- package/src/auth/route-config.ts +9 -0
- package/src/auth/session-reset-link.ts +47 -0
- package/src/components/auth/CredentialSignInForm.tsx +21 -3
- package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
- package/src/components/mfa/QrCode.tsx +24 -0
- package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
- package/src/edge/middleware.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/credential-sign-in-client.ts +48 -8
- package/src/lib/idp-client-config.ts +5 -1
- package/src/lib/login-error-message.ts +13 -1
- package/src/lib/mfa-json-guard.ts +19 -0
- package/src/lib/nextauth-secret.ts +3 -1
- package/src/lib/no-store.ts +30 -0
- package/src/lib/payez-client-secret.ts +39 -0
- package/src/lib/qrcode.ts +348 -0
- package/src/lib/sign-in-failure.ts +88 -0
- package/src/lib/twofa-rejection.ts +7 -2
- package/src/middleware/create-middleware.ts +69 -9
- package/src/middleware/prefetch-guard-status.ts +56 -0
- package/src/pages/enroll/page.tsx +22 -0
- package/src/pages/login/page.tsx +2 -1
- package/src/routes/auth/session-reset.ts +46 -11
- package/src/routes/auth/session.ts +5 -1
- package/src/routes/auth/viability.ts +16 -4
- package/src/routes/health.ts +14 -0
- package/src/routes/mfa/methods.ts +3 -1
- package/src/routes/passkeys/list.ts +5 -1
- package/src/routes/session/refresh-viability.ts +5 -1
- package/src/utils/api.ts +50 -8
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { TotpEnrollPanel } from '../../components/mfa/TotpEnrollPanel';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PAY-2631: /account-auth/enroll in a generated app. The page SHELL is public (every /account-auth/* path is public by default);
|
|
8
|
+
* what needs a session is each action: enrol and confirm answer 401 without one and the panel then says to sign in (measured on a
|
|
9
|
+
* scaffolded app: POST enrol with no cookie -> 401, no secret in the body). The scaffold lists the path on the 2FA bypass list
|
|
10
|
+
* because public routes still redirect a 2FA-incomplete session to verify-code, which sends enroll_mfa users back here: a loop.
|
|
11
|
+
*/
|
|
12
|
+
export default function EnrollPage() {
|
|
13
|
+
return (
|
|
14
|
+
<main style={{ maxWidth: 480, margin: '48px auto', padding: '0 16px' }}>
|
|
15
|
+
<h1>Set up an authenticator app</h1>
|
|
16
|
+
<TotpEnrollPanel />
|
|
17
|
+
<p style={{ marginTop: 24 }}>
|
|
18
|
+
<a href="/">Continue to the app</a>
|
|
19
|
+
</p>
|
|
20
|
+
</main>
|
|
21
|
+
);
|
|
22
|
+
}
|
package/src/pages/login/page.tsx
CHANGED
|
@@ -29,7 +29,7 @@ import { Suspense } from 'react';
|
|
|
29
29
|
import ReservedStatusBox from '../../components/reserved/ReservedStatusBox';
|
|
30
30
|
import ReservedRecoveryWarning from '../../components/reserved/ReservedRecoveryWarning';
|
|
31
31
|
import { useBranding, useColors, useLayout } from '../../theme/useTheme';
|
|
32
|
-
import { friendlyLoginError, oauthFailureMessage } from '../../lib/login-error-message';
|
|
32
|
+
import { friendlyLoginError, loginErrorCode, oauthFailureMessage } from '../../lib/login-error-message';
|
|
33
33
|
|
|
34
34
|
function LoginForm({ altAuthSlot }: { altAuthSlot?: React.ReactNode }) {
|
|
35
35
|
const searchParams = useSearchParams();
|
|
@@ -226,6 +226,7 @@ function LoginForm({ altAuthSlot }: { altAuthSlot?: React.ReactNode }) {
|
|
|
226
226
|
<div
|
|
227
227
|
role="alert"
|
|
228
228
|
data-testid="login-error"
|
|
229
|
+
data-error-code={loginError === undefined ? (loginErrorCode(urlError) ?? undefined) : undefined}
|
|
229
230
|
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
|
230
231
|
>
|
|
231
232
|
{shownError}
|
|
@@ -20,18 +20,53 @@ import { NextRequest, NextResponse } from 'next/server';
|
|
|
20
20
|
import { isPrefetchRequest } from '../../middleware/create-middleware';
|
|
21
21
|
import { expireSessionCookies } from '../../auth/session-cookie-clear';
|
|
22
22
|
import { getSessionCookieName, getSecureSessionCookieName } from '../../lib/app-slug';
|
|
23
|
-
import { boundSessionCookie, loginLocation, readResetRequest, resetSecret } from '../../auth/session-reset-link';
|
|
23
|
+
import { boundSessionCookie, loginLocation, readResetRequest, readResetRequestAnyKey, resetSecret } from '../../auth/session-reset-link';
|
|
24
24
|
|
|
25
25
|
export const dynamic = 'force-dynamic';
|
|
26
26
|
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
response.headers.set('cache-control', 'no-store');
|
|
35
|
-
if (signed) expireSessionCookies(response, Array.from(request.cookies, ([name]) => name));
|
|
36
|
-
return response;
|
|
27
|
+
export interface SessionResetOptions {
|
|
28
|
+
/**
|
|
29
|
+
* PAY-2218 opt-in, OFF by default (BAPert 83102). OFF: only a link signed with the env secret (BETTER_AUTH_SECRET / NEXTAUTH_SECRET) clears, so every app that can
|
|
30
|
+
* see the secret stays strict. ON: a link signed with the key derived from this request's own session cookie also clears. Turn it on only where the EDGE middleware
|
|
31
|
+
* cannot see the env secret, and set `sessionResetCookieKey: true` on createMvpMiddleware too, or the route refuses the link the middleware signed.
|
|
32
|
+
*/
|
|
33
|
+
cookieKey?: boolean;
|
|
37
34
|
}
|
|
35
|
+
|
|
36
|
+
export function createSessionResetHandler(options: SessionResetOptions = {}) {
|
|
37
|
+
const cookieKey = options.cookieKey === true;
|
|
38
|
+
let splitLogged = false;
|
|
39
|
+
let halfSetLogged = false;
|
|
40
|
+
return async function GET(request: NextRequest): Promise<NextResponse> {
|
|
41
|
+
if (isPrefetchRequest(request)) {
|
|
42
|
+
return new NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
|
|
43
|
+
}
|
|
44
|
+
const bound = boundSessionCookie((n) => request.cookies.get(n)?.value, getSessionCookieName(), getSecureSessionCookieName());
|
|
45
|
+
const env = resetSecret();
|
|
46
|
+
const { params, signed, via } = cookieKey
|
|
47
|
+
? await readResetRequestAnyKey(request.nextUrl.searchParams, env, bound)
|
|
48
|
+
: { ...(await readResetRequest(request.nextUrl.searchParams, env, bound)), via: null as 'env' | 'cookie' | null };
|
|
49
|
+
// The dev-93 prefetch walk reads this line: a link that verified ONLY with the cookie-derived key while this route sees the env secret is the
|
|
50
|
+
// edge-blind / route-sighted split deployment. Once per process, no values.
|
|
51
|
+
if (cookieKey && signed && via === 'cookie' && env && !splitLogged) {
|
|
52
|
+
splitLogged = true;
|
|
53
|
+
console.info('[next-mvp] session-reset: a link verified with the cookie-derived key while the env secret is visible to this route (the edge middleware signed without it: split deployment)');
|
|
54
|
+
}
|
|
55
|
+
// HALF-SET (NightHawk 83161): this route is strict (cookieKey OFF) but the middleware signed with the cookie-derived key (sessionResetCookieKey ON). The link is refused
|
|
56
|
+
// and the stale cookie survives. Probe ONLY refused links, for the cookie-derived key alone, and say so once per process. No values.
|
|
57
|
+
if (!cookieKey && !signed && !halfSetLogged) {
|
|
58
|
+
const probe = await readResetRequestAnyKey(request.nextUrl.searchParams, undefined, bound);
|
|
59
|
+
if (probe.signed) {
|
|
60
|
+
halfSetLogged = true;
|
|
61
|
+
console.warn('[next-mvp] session-reset: refused a link signed with the cookie-derived key because this route has cookieKey OFF; the middleware has sessionResetCookieKey ON. Set createSessionResetHandler({ cookieKey: true }) or turn the middleware option off');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const response = NextResponse.redirect(new URL(loginLocation(params), request.url), 307);
|
|
65
|
+
response.headers.set('cache-control', 'no-store');
|
|
66
|
+
if (signed) expireSessionCookies(response, Array.from(request.cookies, ([name]) => name));
|
|
67
|
+
return response;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The strict default: env-signed links only. An app that opted in uses createSessionResetHandler({ cookieKey: true }). */
|
|
72
|
+
export const GET = createSessionResetHandler();
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* @since auth-ready-v2
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { noStore } from '../../lib/no-store';
|
|
16
17
|
import { NextRequest, NextResponse } from 'next/server';
|
|
17
18
|
import { getSession as getBetterAuthSession } from '../../server/auth';
|
|
18
19
|
import { getSession as getRedisSession, updateSession } from '../../lib/session-store';
|
|
@@ -27,7 +28,7 @@ import { tokenLogId } from '../../lib/token-log-id';
|
|
|
27
28
|
* - Token expiry status
|
|
28
29
|
* - Session validity
|
|
29
30
|
*/
|
|
30
|
-
|
|
31
|
+
async function getHandler(req: NextRequest) {
|
|
31
32
|
try {
|
|
32
33
|
const authSession = await getBetterAuthSession(req);
|
|
33
34
|
|
|
@@ -121,6 +122,9 @@ export async function GET(req: NextRequest) {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
126
|
+
export const GET = noStore(getHandler);
|
|
127
|
+
|
|
124
128
|
/**
|
|
125
129
|
* The fields this route USED to accept from the request body and must never accept
|
|
126
130
|
* again. Cards 308833 (critical) / 312092 (remediation spec) / 318272 / 311425.
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
25
25
|
import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
|
|
26
26
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
27
|
+
import { noStore } from '../../lib/no-store';
|
|
27
28
|
import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -141,7 +142,7 @@ function resolveClientId(sessionClientId: string | undefined): string | null {
|
|
|
141
142
|
* - needsRefresh: boolean - Whether a refresh is recommended
|
|
142
143
|
* - expiresIn: number - Seconds until token expires
|
|
143
144
|
*/
|
|
144
|
-
|
|
145
|
+
async function getHandler(req: NextRequest) {
|
|
145
146
|
try {
|
|
146
147
|
// PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
|
|
147
148
|
// session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
|
|
@@ -199,6 +200,9 @@ export async function GET(req: NextRequest) {
|
|
|
199
200
|
// stack just resolved the cookie cleanly. Keeps the two viability impls
|
|
200
201
|
// (this route + `api-handlers/session/viability.ts`) in lockstep.
|
|
201
202
|
let session = sessionToken ? await getRedisSession(sessionToken) : null;
|
|
203
|
+
// PAY-2467: a row read from the CANONICAL store is the raw JSON the callback wrote, so an `mfaVerified` boolean on it is a
|
|
204
|
+
// MEASUREMENT (an absent key is a legacy row). Only the BA-record fallback collapses absent to false and needs mfaSignalPresent.
|
|
205
|
+
const fromCanonicalStore = !!session;
|
|
202
206
|
if (sessionToken && !session) {
|
|
203
207
|
session = await getBetterAuthRedisSession(sessionToken);
|
|
204
208
|
if (session) {
|
|
@@ -294,9 +298,14 @@ export async function GET(req: NextRequest) {
|
|
|
294
298
|
* it names TENANT policy, and this override is not tenant state.
|
|
295
299
|
*/
|
|
296
300
|
const oauthSessionNeedsTwoFactor =
|
|
297
|
-
|
|
301
|
+
// PAY-2467: the signal is present on the BA-fallback path (mfaSignalPresent) OR when the canonical row itself carries a boolean.
|
|
302
|
+
// Before this, the override was dead on the canonical path every consumer mounts, so gating rested on tenant config alone.
|
|
303
|
+
(session?.mfaSignalPresent === true || (fromCanonicalStore && typeof session?.mfaVerified === 'boolean')) &&
|
|
298
304
|
session?.mfaVerified === false &&
|
|
299
|
-
!(session.idpRefreshToken || (session as any)?.refreshToken)
|
|
305
|
+
!(session.idpRefreshToken || (session as any)?.refreshToken) &&
|
|
306
|
+
// An IdP DISCARD (refresh.ts / ensure-fresh-access-token.ts write idpRefreshToken '' with refreshTokenClearedReason 'IDP_DISCARD_TOKEN:<code>') leaves
|
|
307
|
+
// mfaVerified false and no refresh token too, but that is a DEAD session, not a pending second factor: it must not be sent to verify-code (QAPert 84816).
|
|
308
|
+
!String((session as any)?.refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN');
|
|
300
309
|
|
|
301
310
|
// --- Session revocation status (PAY-1882 step 4 producer half) ---
|
|
302
311
|
/*
|
|
@@ -517,4 +526,7 @@ export async function GET(req: NextRequest) {
|
|
|
517
526
|
correlationId
|
|
518
527
|
}, { status: 500 });
|
|
519
528
|
}
|
|
520
|
-
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
532
|
+
export const GET = noStore(getHandler);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ready-to-use /api/health route: reports Redis reachability (PAY-2804).
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* // app/api/health/route.ts
|
|
7
|
+
* export const runtime = 'nodejs';
|
|
8
|
+
* export { GET } from '@payez/next-mvp/routes/health';
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
import { createHealthHandler } from '../api-handlers/health';
|
|
12
|
+
import { getRedis } from '../lib/redis';
|
|
13
|
+
|
|
14
|
+
export const GET = createHealthHandler(() => getRedis());
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { NextRequest } from 'next/server';
|
|
13
13
|
import { GET as listMethods, DELETE as deleteMethod } from '../../api-handlers/mfa/methods';
|
|
14
|
+
import { noStore } from '../../lib/no-store';
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
/** PAY-2994: the user's MFA methods; never cached (noStore). */
|
|
17
|
+
export const GET = noStore(listMethods);
|
|
16
18
|
|
|
17
19
|
type Ctx = { params: Promise<{ id: string }> };
|
|
18
20
|
|
|
@@ -7,4 +7,8 @@
|
|
|
7
7
|
* export { GET } from '@payez/next-mvp/routes/passkeys/list';
|
|
8
8
|
* ```
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
import { GET as listPasskeys } from '../../api-handlers/passkeys/list';
|
|
11
|
+
import { noStore } from '../../lib/no-store';
|
|
12
|
+
|
|
13
|
+
/** PAY-2994: the signed-in user's passkeys; never cached (noStore). */
|
|
14
|
+
export const GET = noStore(listPasskeys);
|
|
@@ -14,4 +14,8 @@
|
|
|
14
14
|
* @since auth-ready-v2
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
import { GET as refreshViability } from '../../api-handlers/session/refresh-viability';
|
|
18
|
+
import { noStore } from '../../lib/no-store';
|
|
19
|
+
|
|
20
|
+
/** PAY-2994: refresh-token viability of this session; never cached (noStore). */
|
|
21
|
+
export const GET = noStore(refreshViability);
|
package/src/utils/api.ts
CHANGED
|
@@ -133,6 +133,45 @@ export function resetFailureText(status: number, rules: string[] = [], policy =
|
|
|
133
133
|
return "We couldn't reset your password just now. Please try again.";
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* SMS-first sign-up (door 3b): when the phone code is right but the email is still unconfirmed, the server answers 200 success with
|
|
138
|
+
* `next_step = EMAIL_CONFIRMATION_REQUIRED` and NO reset token. `verifyOnboardingSmsCode` surfaces that as `nextStep`;
|
|
139
|
+
* `interpretSmsVerification` turns a result into ONE outcome, testing `nextStep` BEFORE `success`, so a consumer that uses it can
|
|
140
|
+
* never reach "verified" without a reset token or with an unfinished step. The sentences are the package's own, never the server's text.
|
|
141
|
+
*/
|
|
142
|
+
export const SMS_NEXT_STEP_EMAIL_CONFIRMATION_REQUIRED = 'EMAIL_CONFIRMATION_REQUIRED';
|
|
143
|
+
export const SMS_EMAIL_CONFIRMATION_TEXT = 'Your phone is verified. Confirm your email to finish signing up.';
|
|
144
|
+
export const SMS_MORE_STEPS_TEXT = 'One more step is needed to finish signing up.';
|
|
145
|
+
|
|
146
|
+
export interface SmsCodeVerification {
|
|
147
|
+
success: boolean;
|
|
148
|
+
resetToken: string;
|
|
149
|
+
email: string;
|
|
150
|
+
clientKey: string;
|
|
151
|
+
/** Present when the server says more steps remain (an UNRECOGNISED value is kept as a non-empty string, never dropped). */
|
|
152
|
+
nextStep?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type SmsVerificationOutcome =
|
|
156
|
+
| { kind: 'verified'; resetToken: string; email: string; clientKey: string }
|
|
157
|
+
| { kind: 'email_confirmation_required'; message: string }
|
|
158
|
+
| { kind: 'more_steps'; message: string }
|
|
159
|
+
| { kind: 'not_verified' };
|
|
160
|
+
|
|
161
|
+
export function interpretSmsVerification(result: SmsCodeVerification): SmsVerificationOutcome {
|
|
162
|
+
// success:false WINS over any step (BAPert 88508): a failed answer must never say "Your phone is verified".
|
|
163
|
+
if (!result.success) return { kind: 'not_verified' };
|
|
164
|
+
// For every answer that is not a failure, nextStep comes BEFORE the token test: a step that is not finished is never "verified".
|
|
165
|
+
if (result.nextStep) {
|
|
166
|
+
return result.nextStep === SMS_NEXT_STEP_EMAIL_CONFIRMATION_REQUIRED
|
|
167
|
+
? { kind: 'email_confirmation_required', message: SMS_EMAIL_CONFIRMATION_TEXT }
|
|
168
|
+
: { kind: 'more_steps', message: SMS_MORE_STEPS_TEXT };
|
|
169
|
+
}
|
|
170
|
+
// success with an EMPTY reset token is the shape that is dangerous (set-password would be called with ''): not verified.
|
|
171
|
+
if (!result.resetToken) return { kind: 'more_steps', message: SMS_MORE_STEPS_TEXT };
|
|
172
|
+
return { kind: 'verified', resetToken: result.resetToken, email: result.email, clientKey: result.clientKey };
|
|
173
|
+
}
|
|
174
|
+
|
|
136
175
|
export class AccountApi {
|
|
137
176
|
/**
|
|
138
177
|
* Get masked user info (email, phone, authenticator status)
|
|
@@ -338,17 +377,12 @@ export class AccountApi {
|
|
|
338
377
|
};
|
|
339
378
|
}
|
|
340
379
|
|
|
341
|
-
async verifyOnboardingSmsCode(onboardToken: string, code: string, clientKey?: string): Promise<{
|
|
342
|
-
success: boolean;
|
|
343
|
-
resetToken: string;
|
|
344
|
-
email: string;
|
|
345
|
-
clientKey: string;
|
|
346
|
-
}> {
|
|
380
|
+
async verifyOnboardingSmsCode(onboardToken: string, code: string, clientKey?: string): Promise<SmsCodeVerification> {
|
|
347
381
|
const qs = clientKey ? `?clientId=${encodeURIComponent(clientKey)}` : '';
|
|
348
382
|
const res = await fetch(`/api/onboarding/verify-sms-code${qs}`, {
|
|
349
383
|
method: 'POST',
|
|
350
384
|
headers: { 'Content-Type': 'application/json' },
|
|
351
|
-
body: JSON.stringify({
|
|
385
|
+
body: JSON.stringify({
|
|
352
386
|
onboard_token: onboardToken,
|
|
353
387
|
code: code
|
|
354
388
|
})
|
|
@@ -360,11 +394,19 @@ export class AccountApi {
|
|
|
360
394
|
}
|
|
361
395
|
// Extract from nested data structure
|
|
362
396
|
const data = result.data?.data || result.data || {};
|
|
397
|
+
// next_step (door 3b): read from the nested shapes AND a flat body. Absent / null / '' = no step; a non-string value is an
|
|
398
|
+
// UNRECOGNISED step and is kept as a non-empty string (never dropped, never mistaken for the known one). That includes false and 0:
|
|
399
|
+
// a server that means "no step" must send nothing or null (or '').
|
|
400
|
+
const rawStep = data.next_step ?? result.next_step;
|
|
401
|
+
const nextStep = rawStep === undefined || rawStep === null || rawStep === ''
|
|
402
|
+
? undefined
|
|
403
|
+
: typeof rawStep === 'string' ? rawStep : 'UNRECOGNISED_NEXT_STEP';
|
|
363
404
|
return {
|
|
364
405
|
success: data.success !== false,
|
|
365
406
|
resetToken: data.reset_token || '',
|
|
366
407
|
email: data.email || '',
|
|
367
|
-
clientKey: data.client_key || ''
|
|
408
|
+
clientKey: data.client_key || '',
|
|
409
|
+
...(nextStep !== undefined ? { nextStep } : {})
|
|
368
410
|
};
|
|
369
411
|
}
|
|
370
412
|
|