@payez/next-mvp 4.4.8 → 4.5.0
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/README.md +53 -0
- package/dist/api-handlers/account/reset-password.js +3 -1
- package/dist/api-handlers/onboarding/send-email-code.d.ts +11 -0
- package/dist/api-handlers/onboarding/send-email-code.js +11 -0
- package/dist/api-handlers/onboarding/set-password.d.ts +12 -0
- package/dist/api-handlers/onboarding/set-password.js +15 -0
- package/dist/api-handlers/onboarding/verify-code.d.ts +11 -0
- package/dist/api-handlers/onboarding/verify-code.js +14 -0
- package/dist/api-handlers/onboarding/verify-welcome-email.d.ts +10 -0
- package/dist/api-handlers/onboarding/verify-welcome-email.js +11 -0
- package/dist/api-handlers/session/viability.js +32 -0
- package/dist/auth/password-reset-bridge.d.ts +70 -0
- package/dist/auth/password-reset-bridge.js +99 -0
- package/dist/auth/route-config.js +17 -0
- package/dist/components/auth/FederatedAuthSection.js +50 -21
- package/dist/components/auth/PasswordResetBridge.d.ts +17 -0
- package/dist/components/auth/PasswordResetBridge.js +32 -0
- package/dist/hooks/useAvailableProviders.js +39 -17
- package/dist/lib/onboarding-proxy.d.ts +30 -0
- package/dist/lib/onboarding-proxy.js +62 -0
- package/dist/lib/safe-error-response.d.ts +8 -0
- package/dist/lib/safe-error-response.js +27 -0
- package/dist/middleware/create-middleware.d.ts +41 -0
- package/dist/middleware/create-middleware.js +74 -2
- package/dist/pages/login/page.js +1 -1
- package/dist/pages/onboarding/index.d.ts +11 -0
- package/dist/pages/onboarding/index.js +20 -0
- package/dist/pages/onboarding/page.d.ts +1 -0
- package/dist/pages/onboarding/page.js +118 -0
- package/dist/pages/recovery/page.js +20 -7
- package/dist/routes/auth/viability.d.ts +5 -0
- package/dist/routes/auth/viability.js +53 -0
- package/dist/routes/onboarding/index.d.ts +15 -0
- package/dist/routes/onboarding/index.js +22 -0
- package/dist/routes/onboarding/send-email-code.d.ts +2 -0
- package/dist/routes/onboarding/send-email-code.js +6 -0
- package/dist/routes/onboarding/set-password.d.ts +2 -0
- package/dist/routes/onboarding/set-password.js +6 -0
- package/dist/routes/onboarding/verify-code.d.ts +2 -0
- package/dist/routes/onboarding/verify-code.js +6 -0
- package/dist/routes/onboarding/verify-welcome-email.d.ts +2 -0
- package/dist/routes/onboarding/verify-welcome-email.js +6 -0
- package/dist/utils/api.d.ts +6 -0
- package/dist/utils/api.js +10 -2
- package/dist/vibe/sessions.d.ts +88 -1
- package/dist/vibe/sessions.js +260 -19
- package/package.json +58 -2
- package/scripts/check-publish-eol.mjs +135 -0
- package/src/api-handlers/account/reset-password.ts +4 -2
- package/src/api-handlers/onboarding/send-email-code.ts +18 -0
- package/src/api-handlers/onboarding/set-password.ts +21 -0
- package/src/api-handlers/onboarding/verify-code.ts +20 -0
- package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -0
- package/src/api-handlers/session/viability.ts +31 -0
- package/src/auth/password-reset-bridge.ts +98 -0
- package/src/auth/route-config.ts +17 -0
- package/src/components/auth/FederatedAuthSection.tsx +56 -22
- package/src/components/auth/PasswordResetBridge.tsx +30 -0
- package/src/hooks/useAvailableProviders.ts +41 -18
- package/src/lib/onboarding-proxy.ts +57 -0
- package/src/lib/safe-error-response.ts +23 -0
- package/src/middleware/create-middleware.ts +106 -2
- package/src/pages/login/page.tsx +4 -7
- package/src/pages/onboarding/index.ts +11 -0
- package/src/pages/onboarding/page.tsx +212 -0
- package/src/pages/recovery/page.tsx +24 -8
- package/src/routes/auth/viability.ts +52 -0
- package/src/routes/onboarding/index.ts +15 -0
- package/src/routes/onboarding/send-email-code.ts +2 -0
- package/src/routes/onboarding/set-password.ts +2 -0
- package/src/routes/onboarding/verify-code.ts +2 -0
- package/src/routes/onboarding/verify-welcome-email.ts +2 -0
- package/src/utils/api.ts +10 -1
- package/src/vibe/document-unwrap.ts +103 -103
- package/src/vibe/sessions.ts +291 -24
|
@@ -57,27 +57,48 @@ function useAvailableProviders() {
|
|
|
57
57
|
let mounted = true;
|
|
58
58
|
async function fetchProviders() {
|
|
59
59
|
try {
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
// PAY-2104: THIS USED TO BE A HARDCODED GOOGLE LITERAL.
|
|
61
|
+
//
|
|
62
|
+
// const result = { google: { id: 'google', name: 'Google' } };
|
|
63
|
+
//
|
|
64
|
+
// under a comment claiming providers were "loaded dynamically". It
|
|
65
|
+
// returned Google for EVERY client whatever the tenant had enabled -
|
|
66
|
+
// a config-shaped constant, so Apple/Facebook (PAY-2104) could never
|
|
67
|
+
// appear and a client that had DISABLED Google would still see its
|
|
68
|
+
// button. The real answer is served by /api/auth/settings (the same
|
|
69
|
+
// route routes/auth/settings.ts builds from the IDP's oauthProviders).
|
|
70
|
+
const response = await fetch('/api/auth/settings', { cache: 'no-store' });
|
|
66
71
|
if (!mounted)
|
|
67
72
|
return;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
.filter(id => OAUTH_PROVIDERS.includes(id))
|
|
74
|
-
.map(id => PROVIDER_MAP[id])
|
|
75
|
-
.filter((p) => p !== undefined);
|
|
76
|
-
setProviders(oauthProviders);
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
// FAIL CLOSED. An unreadable policy is not an empty provider list
|
|
75
|
+
// that we may then pad with a guess - it is "we do not know". See
|
|
76
|
+
// the PAY-1243 rule: never fabricate a provider list.
|
|
77
|
+
throw new Error(`Failed to fetch auth settings: ${response.status}`);
|
|
77
78
|
}
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
const body = await response.json().catch(() => null);
|
|
80
|
+
const settings = body?.data ?? body;
|
|
81
|
+
if (body?.success === false || !settings) {
|
|
82
|
+
throw new Error(body?.error?.code ?? 'POLICY_UNAVAILABLE');
|
|
80
83
|
}
|
|
84
|
+
// Keep the raw provider rows (id -> the settings row) for callers that
|
|
85
|
+
// want more than the mapped union type, exactly as before.
|
|
86
|
+
const enabledIds = Array.isArray(settings.enabledProviders)
|
|
87
|
+
? settings.enabledProviders.filter((p) => typeof p === 'string')
|
|
88
|
+
: [];
|
|
89
|
+
const raw = {};
|
|
90
|
+
for (const id of enabledIds)
|
|
91
|
+
raw[id] = { id, name: id };
|
|
92
|
+
if (!mounted)
|
|
93
|
+
return;
|
|
94
|
+
setRawProviders(raw);
|
|
95
|
+
// Filter to OAuth providers only (exclude credentials) and map to our
|
|
96
|
+
// FederatedProvider union, in the IDP's order.
|
|
97
|
+
const oauthProviders = enabledIds
|
|
98
|
+
.filter(id => OAUTH_PROVIDERS.includes(id))
|
|
99
|
+
.map(id => PROVIDER_MAP[id])
|
|
100
|
+
.filter((p) => p !== undefined);
|
|
101
|
+
setProviders(oauthProviders);
|
|
81
102
|
setError(null);
|
|
82
103
|
}
|
|
83
104
|
catch (err) {
|
|
@@ -85,6 +106,7 @@ function useAvailableProviders() {
|
|
|
85
106
|
return;
|
|
86
107
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
87
108
|
setProviders([]);
|
|
109
|
+
setRawProviders(null); // never leave a stale list behind a failure
|
|
88
110
|
}
|
|
89
111
|
finally {
|
|
90
112
|
if (mounted) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2121: shared anonymous IDP proxy for the /api/onboarding/* handlers.
|
|
3
|
+
*
|
|
4
|
+
* The onboarding flow is TOKEN-DRIVEN: the invitee has no account or session
|
|
5
|
+
* yet, and the onboard_token IS the credential. So these calls must NOT go
|
|
6
|
+
* through idpFetchJSON (which injects a Bearer from a Redis session) - there is
|
|
7
|
+
* no session. They are plain server-to-server POSTs to the IdP, exactly as the
|
|
8
|
+
* VibeID portal's hand-built /api/onboarding/* routes do (card 182611).
|
|
9
|
+
*
|
|
10
|
+
* WHY A SERVER ROUTE AT ALL, AND NOT A BROWSER CALL: the IdP URL is not exposed
|
|
11
|
+
* to the browser, and the route is the one place that owns that. The token is
|
|
12
|
+
* forwarded, never the IdP's host.
|
|
13
|
+
*/
|
|
14
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
15
|
+
/** A typed refusal in the kit's error envelope - used for a missing field, BEFORE any IdP call. */
|
|
16
|
+
export declare function onboardingBadRequest(code: string, message: string): NextResponse<{
|
|
17
|
+
success: boolean;
|
|
18
|
+
message: string;
|
|
19
|
+
error: {
|
|
20
|
+
code: string;
|
|
21
|
+
};
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* POST a JSON body to `{IDP_URL}{idpPath}` with no credentials, and relay the
|
|
25
|
+
* IdP's status and body verbatim (the page acts on what the IdP actually
|
|
26
|
+
* returns, not on a shape this layer guesses). Never relays the IdP error TEXT
|
|
27
|
+
* for a non-2xx is NOT done here: the onboarding endpoints answer structured
|
|
28
|
+
* envelopes the page reads; the page owns the copy.
|
|
29
|
+
*/
|
|
30
|
+
export declare function onboardingProxy(req: NextRequest, idpPath: string): Promise<NextResponse>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.onboardingBadRequest = onboardingBadRequest;
|
|
4
|
+
exports.onboardingProxy = onboardingProxy;
|
|
5
|
+
/**
|
|
6
|
+
* PAY-2121: shared anonymous IDP proxy for the /api/onboarding/* handlers.
|
|
7
|
+
*
|
|
8
|
+
* The onboarding flow is TOKEN-DRIVEN: the invitee has no account or session
|
|
9
|
+
* yet, and the onboard_token IS the credential. So these calls must NOT go
|
|
10
|
+
* through idpFetchJSON (which injects a Bearer from a Redis session) - there is
|
|
11
|
+
* no session. They are plain server-to-server POSTs to the IdP, exactly as the
|
|
12
|
+
* VibeID portal's hand-built /api/onboarding/* routes do (card 182611).
|
|
13
|
+
*
|
|
14
|
+
* WHY A SERVER ROUTE AT ALL, AND NOT A BROWSER CALL: the IdP URL is not exposed
|
|
15
|
+
* to the browser, and the route is the one place that owns that. The token is
|
|
16
|
+
* forwarded, never the IdP's host.
|
|
17
|
+
*/
|
|
18
|
+
const server_1 = require("next/server");
|
|
19
|
+
const env_1 = require("../config/env");
|
|
20
|
+
const idp_url_guard_1 = require("./idp-url-guard");
|
|
21
|
+
/** A typed refusal in the kit's error envelope - used for a missing field, BEFORE any IdP call. */
|
|
22
|
+
function onboardingBadRequest(code, message) {
|
|
23
|
+
return server_1.NextResponse.json({ success: false, message, error: { code } }, { status: 400 });
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* POST a JSON body to `{IDP_URL}{idpPath}` with no credentials, and relay the
|
|
27
|
+
* IdP's status and body verbatim (the page acts on what the IdP actually
|
|
28
|
+
* returns, not on a shape this layer guesses). Never relays the IdP error TEXT
|
|
29
|
+
* for a non-2xx is NOT done here: the onboarding endpoints answer structured
|
|
30
|
+
* envelopes the page reads; the page owns the copy.
|
|
31
|
+
*/
|
|
32
|
+
async function onboardingProxy(req, idpPath) {
|
|
33
|
+
const idpBase = env_1.ENV_CONFIG.IDP_URL;
|
|
34
|
+
if (!idpBase) {
|
|
35
|
+
return server_1.NextResponse.json({ success: false, message: 'IDP_URL is not configured', error: { code: 'NO_IDP_URL' } }, { status: 500 });
|
|
36
|
+
}
|
|
37
|
+
const url = `${idpBase}${idpPath}`;
|
|
38
|
+
(0, idp_url_guard_1.assertAbsoluteIdpUrl)(url);
|
|
39
|
+
let bodyText = '{}';
|
|
40
|
+
try {
|
|
41
|
+
const raw = await req.text();
|
|
42
|
+
if (raw && raw.trim().length > 0)
|
|
43
|
+
bodyText = raw;
|
|
44
|
+
}
|
|
45
|
+
catch { /* empty body is fine */ }
|
|
46
|
+
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
47
|
+
const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
|
|
48
|
+
if (xfwd)
|
|
49
|
+
headers['X-Forwarded-For'] = xfwd;
|
|
50
|
+
const ua = req.headers.get('user-agent');
|
|
51
|
+
if (ua)
|
|
52
|
+
headers['User-Agent'] = ua;
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await fetch(url, { method: 'POST', headers, body: bodyText, cache: 'no-store' });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return server_1.NextResponse.json({ success: false, message: 'The identity service is unreachable.', error: { code: 'UPSTREAM_UNREACHABLE' } }, { status: 502 });
|
|
59
|
+
}
|
|
60
|
+
const json = await res.json().catch(() => null);
|
|
61
|
+
return server_1.NextResponse.json(json ?? { success: res.ok }, { status: res.status });
|
|
62
|
+
}
|
|
@@ -65,3 +65,11 @@ export declare function projectResetTokenInvalid(json: unknown): boolean;
|
|
|
65
65
|
* (the caller must be able to tell a refused password from a dead recovery session).
|
|
66
66
|
*/
|
|
67
67
|
export declare function projectPasswordPolicyRules(json: unknown): string[] | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* PAY-2103 (AUTH-009 breached-password check): the IdP refuses a password found in a breach corpus with
|
|
70
|
+
* the typed code `password_breached` in its 400 validation envelope (error.code, or error.details.code,
|
|
71
|
+
* or a value[] entry while it settles). The reset-password route is ANONYMOUS, so only a BOOLEAN crosses
|
|
72
|
+
* (`password_breached: true`), never the code text: the page maps the boolean to its own sentence. Reads
|
|
73
|
+
* only the code, never IdP text.
|
|
74
|
+
*/
|
|
75
|
+
export declare function projectPasswordBreached(json: unknown): boolean;
|
|
@@ -21,6 +21,7 @@ exports.projectPasswordPolicyDetails = projectPasswordPolicyDetails;
|
|
|
21
21
|
exports.idpErrorEnvelope = idpErrorEnvelope;
|
|
22
22
|
exports.projectResetTokenInvalid = projectResetTokenInvalid;
|
|
23
23
|
exports.projectPasswordPolicyRules = projectPasswordPolicyRules;
|
|
24
|
+
exports.projectPasswordBreached = projectPasswordBreached;
|
|
24
25
|
const password_rules_1 = require("./password-rules");
|
|
25
26
|
const str = (v) => (typeof v === 'string' ? v : undefined);
|
|
26
27
|
/** Log an exception with a fresh correlationId and return the id for the response body. */
|
|
@@ -128,3 +129,29 @@ function projectPasswordPolicyRules(json) {
|
|
|
128
129
|
return undefined;
|
|
129
130
|
return (0, password_rules_1.knownPasswordRules)(details.rules);
|
|
130
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* PAY-2103 (AUTH-009 breached-password check): the IdP refuses a password found in a breach corpus with
|
|
134
|
+
* the typed code `password_breached` in its 400 validation envelope (error.code, or error.details.code,
|
|
135
|
+
* or a value[] entry while it settles). The reset-password route is ANONYMOUS, so only a BOOLEAN crosses
|
|
136
|
+
* (`password_breached: true`), never the code text: the page maps the boolean to its own sentence. Reads
|
|
137
|
+
* only the code, never IdP text.
|
|
138
|
+
*/
|
|
139
|
+
function projectPasswordBreached(json) {
|
|
140
|
+
if (!json || typeof json !== 'object')
|
|
141
|
+
return false;
|
|
142
|
+
const top = json;
|
|
143
|
+
const err = top.error && typeof top.error === 'object' ? top.error : {};
|
|
144
|
+
const direct = str(err.code) ?? str(top.code) ?? str(top.error_code);
|
|
145
|
+
if (direct?.toLowerCase() === 'password_breached')
|
|
146
|
+
return true;
|
|
147
|
+
const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null);
|
|
148
|
+
if (str(details?.code)?.toLowerCase() === 'password_breached')
|
|
149
|
+
return true;
|
|
150
|
+
// THE SHAPE THE IdP ACTUALLY SENDS (DotNetPert-Scout, PAY-2103 PR 850caa07a): error.details =
|
|
151
|
+
// { field:'new_password', code:'password_policy', rules:['password_breached'], requirements:[...] }.
|
|
152
|
+
// The breach is a member of rules[], not a code - the house envelope carries every password rule
|
|
153
|
+
// that way. Reading only a code would MISS the real refusal.
|
|
154
|
+
if (Array.isArray(details?.rules) && details.rules.some((r) => typeof r === 'string' && r.toLowerCase() === 'password_breached'))
|
|
155
|
+
return true;
|
|
156
|
+
return Array.isArray(details?.value) && details.value.some((v) => !!v && typeof v === 'object' && str(v.code)?.toLowerCase() === 'password_breached');
|
|
157
|
+
}
|
|
@@ -99,7 +99,48 @@ export interface MvpMiddlewareOptions {
|
|
|
99
99
|
bypassPaths?: string[];
|
|
100
100
|
/** Paths exempt from RBAC checks (auth still enforced, just no page-permission check) */
|
|
101
101
|
rbacExemptPaths?: string[];
|
|
102
|
+
/**
|
|
103
|
+
* PAY-1882 step 4 - how this app treats a producer that reports the session's
|
|
104
|
+
* revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
|
|
105
|
+
* throw - the status could not be determined).
|
|
106
|
+
*
|
|
107
|
+
* 'closed' (DEFAULT) - an unknown revocation status is NOT viable; the
|
|
108
|
+
* session is force-invalidated and the user re-auths.
|
|
109
|
+
* This is Jon's PAY-1880 ruling (option A, fail closed),
|
|
110
|
+
* 2026-09-27, and BAPert 66316/66320 restates it for
|
|
111
|
+
* step 4. Consequence, accepted explicitly: a Vibe
|
|
112
|
+
* outage signs this app's users out. 'not_configured'
|
|
113
|
+
* also fails closed here - both live consumers ARE
|
|
114
|
+
* configured (BAPert 66356), so for them it means a
|
|
115
|
+
* misconfiguration, not "no revocation source".
|
|
116
|
+
* 'open' - an unknown OR not-configured status stays viable
|
|
117
|
+
* (availability over fail-closed). Per-app opt-out for a
|
|
118
|
+
* deployment with no revocation source, or the two live
|
|
119
|
+
* consumers while Jon decides.
|
|
120
|
+
*
|
|
121
|
+
* Neither mode changes `'revoked'` (always force-invalid) or an ABSENT
|
|
122
|
+
* `revocationStatus` (an older producer - unchanged behaviour, so no consumer
|
|
123
|
+
* is signed out for not implementing the check).
|
|
124
|
+
*/
|
|
125
|
+
revocationFailMode?: 'closed' | 'open';
|
|
102
126
|
}
|
|
103
127
|
/** Login error code for a pre-2FA session that expired before the code was verified (PAY-884). */
|
|
104
128
|
export declare const TWO_FACTOR_TIMEOUT_ERROR = "TwoFactorTimeout";
|
|
105
129
|
export declare function createMvpMiddleware(options?: MvpMiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;
|
|
130
|
+
/**
|
|
131
|
+
* The revocation decision, as a pure function (PAY-1882 step 4, NightHawk 66392).
|
|
132
|
+
*
|
|
133
|
+
* Separate + exported so the B1 defect is testable with a REAL mutant: the bug was
|
|
134
|
+
* that `data.forceInvalid === true` was read BEFORE revocationFailMode, so a
|
|
135
|
+
* producer that folds 'unknown' into forceInvalid made the 'open' lever dead.
|
|
136
|
+
*
|
|
137
|
+
* RULE: when the producer supplies `revocationStatus`, it is AUTHORITATIVE - the
|
|
138
|
+
* decision is made from it plus failMode, and the folded booleans are ignored for
|
|
139
|
+
* 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
|
|
140
|
+
* absent (idealresume.online's older boolean-only route).
|
|
141
|
+
*/
|
|
142
|
+
export declare function decideForceInvalid(data: {
|
|
143
|
+
revocationStatus?: unknown;
|
|
144
|
+
forceInvalid?: unknown;
|
|
145
|
+
revoked?: unknown;
|
|
146
|
+
}, revocationFailMode: 'closed' | 'open'): boolean;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
21
|
exports.TWO_FACTOR_TIMEOUT_ERROR = void 0;
|
|
22
22
|
exports.createMvpMiddleware = createMvpMiddleware;
|
|
23
|
+
exports.decideForceInvalid = decideForceInvalid;
|
|
23
24
|
const server_1 = require("next/server");
|
|
24
25
|
const route_config_1 = require("../auth/route-config");
|
|
25
26
|
const safe_callback_1 = require("../auth/safe-callback");
|
|
@@ -317,6 +318,8 @@ function createMvpMiddleware(options = {}) {
|
|
|
317
318
|
const refreshEndpoint = options.refreshEndpoint || '/api/auth/refresh';
|
|
318
319
|
const bypassPaths = options.bypassPaths || [];
|
|
319
320
|
const rbacExemptPaths = options.rbacExemptPaths || [];
|
|
321
|
+
// PAY-1882 step 4: default 'closed' (Jon's option A / BAPert 66316).
|
|
322
|
+
const revocationFailMode = options.revocationFailMode || 'closed';
|
|
320
323
|
return async function middleware(request) {
|
|
321
324
|
const { pathname, searchParams } = request.nextUrl;
|
|
322
325
|
// =========================================================================
|
|
@@ -347,7 +350,7 @@ function createMvpMiddleware(options = {}) {
|
|
|
347
350
|
// Has cookie - continue to viability check for 2FA enforcement
|
|
348
351
|
}
|
|
349
352
|
// --- Fetch session viability from internal API ---
|
|
350
|
-
const { sessionStatus, sessionPointer } = await checkViability(request, viabilityEndpoint, log);
|
|
353
|
+
const { sessionStatus, sessionPointer } = await checkViability(request, viabilityEndpoint, log, revocationFailMode);
|
|
351
354
|
// --- Stale session (cookie exists but session doesn't): OBSERVE ONLY, DO NOT DECIDE ---
|
|
352
355
|
//
|
|
353
356
|
// *** CARD 349607. THIS BLOCK USED TO `return` HERE, AND THAT RETURN WAS A BYPASS. ***
|
|
@@ -409,8 +412,37 @@ function createMvpMiddleware(options = {}) {
|
|
|
409
412
|
});
|
|
410
413
|
};
|
|
411
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* The revocation decision, as a pure function (PAY-1882 step 4, NightHawk 66392).
|
|
417
|
+
*
|
|
418
|
+
* Separate + exported so the B1 defect is testable with a REAL mutant: the bug was
|
|
419
|
+
* that `data.forceInvalid === true` was read BEFORE revocationFailMode, so a
|
|
420
|
+
* producer that folds 'unknown' into forceInvalid made the 'open' lever dead.
|
|
421
|
+
*
|
|
422
|
+
* RULE: when the producer supplies `revocationStatus`, it is AUTHORITATIVE - the
|
|
423
|
+
* decision is made from it plus failMode, and the folded booleans are ignored for
|
|
424
|
+
* 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
|
|
425
|
+
* absent (idealresume.online's older boolean-only route).
|
|
426
|
+
*/
|
|
427
|
+
function decideForceInvalid(data, revocationFailMode) {
|
|
428
|
+
const status = data.revocationStatus;
|
|
429
|
+
if (typeof status === 'string') {
|
|
430
|
+
if (status === 'revoked')
|
|
431
|
+
return true;
|
|
432
|
+
if (status === 'active')
|
|
433
|
+
return false;
|
|
434
|
+
// S1 (NightHawk 66409): ANY other string - 'unknown', 'not_configured', a
|
|
435
|
+
// typo like 'Revoked', 'error', or '' - is treated as 'unknown' (fail mode
|
|
436
|
+
// applies), NEVER as 'active'. Returning false here would let the first
|
|
437
|
+
// producer that emits an unexpected state silently switch revocation OFF -
|
|
438
|
+
// the exact PAY-1880 fail-open class. Only an explicit 'active' is viable.
|
|
439
|
+
return revocationFailMode === 'closed';
|
|
440
|
+
}
|
|
441
|
+
// No revocationStatus: an older boolean producer. Absence of both stays false.
|
|
442
|
+
return data.forceInvalid === true || data.revoked === true;
|
|
443
|
+
}
|
|
412
444
|
/** Check session viability via internal API */
|
|
413
|
-
async function checkViability(request, endpoint, log) {
|
|
445
|
+
async function checkViability(request, endpoint, log, revocationFailMode) {
|
|
414
446
|
const sessionStatus = {
|
|
415
447
|
exists: false,
|
|
416
448
|
forceInvalid: false,
|
|
@@ -443,6 +475,46 @@ async function checkViability(request, endpoint, log) {
|
|
|
443
475
|
sessionStatus.exists = data.authenticated ?? (!!data.sessionTokenId && data.reason !== 'session_not_found');
|
|
444
476
|
sessionStatus.requires2FA = data.requires2FA || false;
|
|
445
477
|
sessionStatus.twoFactorComplete = data.twoFactorComplete || false;
|
|
478
|
+
// --- Session revocation (PAY-1882 Step 4) ---
|
|
479
|
+
// Two producer spellings are honoured, because both exist on the wire:
|
|
480
|
+
// - boolean `forceInvalid` / `revoked` (idealresume.online's route today)
|
|
481
|
+
// - the three-state `revocationStatus` the package's own viability
|
|
482
|
+
// handlers now emit (PAY-1882 step 4).
|
|
483
|
+
//
|
|
484
|
+
// RULING (Jon, via BAPert 66316): 'revoked' ALWAYS fails closed. 'unknown'
|
|
485
|
+
// (a FAILED Vibe read) fails closed by default and is the ONLY state that
|
|
486
|
+
// the per-app `revocationFailMode` option can relax to viable.
|
|
487
|
+
//
|
|
488
|
+
// Four producer states (BAPert 66320), with the fail-closed ruling for
|
|
489
|
+
// 'not_configured' added by BAPert 66356:
|
|
490
|
+
// 'active' - Vibe read OK, not revoked -> viable
|
|
491
|
+
// 'revoked' - Vibe says revoked -> force-invalid, ALWAYS
|
|
492
|
+
// 'unknown' - read FAILED -> force-invalid if 'closed'
|
|
493
|
+
// 'not_configured' - no revocation source -> force-invalid if 'closed'
|
|
494
|
+
//
|
|
495
|
+
// WHY 'not_configured' FAILS CLOSED WHEN 'closed': both live consumers of
|
|
496
|
+
// this middleware (portal.keelauth.com / keelauth-portal, idealvibe.online)
|
|
497
|
+
// DO set IDP_URL + VIBE_CLIENT_ID + VIBE_HMAC_KEY (BAPert 66356, measured in
|
|
498
|
+
// their committed manifests + the graft pipeline's own secret check), so for
|
|
499
|
+
// them 'not_configured' can only mean a MISCONFIGURATION - revocation
|
|
500
|
+
// silently switched off is exactly the PAY-1880 fail-open bug. An app with
|
|
501
|
+
// no revocation source that genuinely wants availability sets
|
|
502
|
+
// revocationFailMode 'open'.
|
|
503
|
+
//
|
|
504
|
+
// A MISSING revocationStatus (an older producer) keeps today's behaviour,
|
|
505
|
+
// i.e. NOT force-invalid: absence must never sign out a population that
|
|
506
|
+
// never opted into the check.
|
|
507
|
+
//
|
|
508
|
+
// *** revocationStatus IS AUTHORITATIVE WHEN PRESENT (NightHawk 66392). ***
|
|
509
|
+
// The package's own producers emit `forceInvalid: revocationStatus ===
|
|
510
|
+
// 'revoked' || revocationStatus === 'unknown'`. If the boolean were read
|
|
511
|
+
// FIRST, `forceInvalid: true` would short-circuit before revocationFailMode
|
|
512
|
+
// was consulted, so `'open'` could never keep an 'unknown' session viable -
|
|
513
|
+
// the opt-out lever would be DEAD. So: when a producer supplies a
|
|
514
|
+
// revocations status, decide from it + failMode and ignore the folded
|
|
515
|
+
// booleans for unknown/not_configured. The booleans are read ONLY when
|
|
516
|
+
// revocationStatus is ABSENT (idealresume.online's older boolean route).
|
|
517
|
+
sessionStatus.forceInvalid = decideForceInvalid(data, revocationFailMode);
|
|
446
518
|
sessionPointer.exists = sessionStatus.exists ?? false;
|
|
447
519
|
sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
|
|
448
520
|
sessionPointer.expired = data.accessTokenExpired || false;
|
package/dist/pages/login/page.js
CHANGED
|
@@ -206,7 +206,7 @@ function LoginForm({ altAuthSlot }) {
|
|
|
206
206
|
.themed-status-submitting svg {
|
|
207
207
|
color: ${buttonColor} !important;
|
|
208
208
|
}
|
|
209
|
-
` } }), (0, jsx_runtime_1.jsx)("div", { className: `min-h-screen flex items-center justify-center px-4 ${colors.background}`, children: (0, jsx_runtime_1.
|
|
209
|
+
` } }), (0, jsx_runtime_1.jsx)("div", { className: `min-h-screen flex items-center justify-center px-4 ${colors.background}`, children: (0, jsx_runtime_1.jsx)("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" }) })] }) }) })] }));
|
|
210
210
|
}
|
|
211
211
|
function LoginPageFallback() {
|
|
212
212
|
const colors = (0, useTheme_1.useColors)();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2121: the onboarding surface.
|
|
3
|
+
*
|
|
4
|
+
* default - the /account-auth/onboarding page (mount from the scaffold at
|
|
5
|
+
* app/account-auth/onboarding/page.tsx).
|
|
6
|
+
* PasswordResetBridge - mount once on `/` to honour the IdP's post-verify
|
|
7
|
+
* `#password_reset_required=true` fragment.
|
|
8
|
+
*/
|
|
9
|
+
export { default } from './page';
|
|
10
|
+
export { default as OnboardingPage } from './page';
|
|
11
|
+
export { PasswordResetBridge } from '../../components/auth/PasswordResetBridge';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.PasswordResetBridge = exports.OnboardingPage = exports.default = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* PAY-2121: the onboarding surface.
|
|
9
|
+
*
|
|
10
|
+
* default - the /account-auth/onboarding page (mount from the scaffold at
|
|
11
|
+
* app/account-auth/onboarding/page.tsx).
|
|
12
|
+
* PasswordResetBridge - mount once on `/` to honour the IdP's post-verify
|
|
13
|
+
* `#password_reset_required=true` fragment.
|
|
14
|
+
*/
|
|
15
|
+
var page_1 = require("./page");
|
|
16
|
+
Object.defineProperty(exports, "default", { enumerable: true, get: function () { return __importDefault(page_1).default; } });
|
|
17
|
+
var page_2 = require("./page");
|
|
18
|
+
Object.defineProperty(exports, "OnboardingPage", { enumerable: true, get: function () { return __importDefault(page_2).default; } });
|
|
19
|
+
var PasswordResetBridge_1 = require("../../components/auth/PasswordResetBridge");
|
|
20
|
+
Object.defineProperty(exports, "PasswordResetBridge", { enumerable: true, get: function () { return PasswordResetBridge_1.PasswordResetBridge; } });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function OnboardingPage(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.default = OnboardingPage;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const navigation_1 = require("next/navigation");
|
|
8
|
+
const H = { primary: 'var(--text-primary)', secondary: 'var(--text-secondary)', muted: 'var(--text-muted)' };
|
|
9
|
+
function OnboardingContent() {
|
|
10
|
+
const params = (0, navigation_1.useSearchParams)();
|
|
11
|
+
const onboardToken = params?.get('onboard_token') ?? '';
|
|
12
|
+
const [step, setStep] = (0, react_1.useState)('code');
|
|
13
|
+
const [maskedEmail, setMaskedEmail] = (0, react_1.useState)(null);
|
|
14
|
+
const [digits, setDigits] = (0, react_1.useState)('');
|
|
15
|
+
const [password, setPassword] = (0, react_1.useState)('');
|
|
16
|
+
const [confirm, setConfirm] = (0, react_1.useState)('');
|
|
17
|
+
const [resetToken, setResetToken] = (0, react_1.useState)('');
|
|
18
|
+
const [email, setEmail] = (0, react_1.useState)('');
|
|
19
|
+
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
20
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
21
|
+
// Step 1: tell the invitee which address the code went to (masked). Best
|
|
22
|
+
// effort - a failure here does not block entering a code they were emailed.
|
|
23
|
+
(0, react_1.useEffect)(() => {
|
|
24
|
+
if (!onboardToken)
|
|
25
|
+
return;
|
|
26
|
+
let alive = true;
|
|
27
|
+
(async () => {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch('/api/onboarding/verify-welcome-email', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({ onboard_token: onboardToken }),
|
|
33
|
+
});
|
|
34
|
+
const body = await res.json().catch(() => null);
|
|
35
|
+
const data = body?.data?.data || body?.data || {};
|
|
36
|
+
const masked = data?.masked_user_info?.masked_email || data?.masked_email;
|
|
37
|
+
if (alive && typeof masked === 'string' && masked)
|
|
38
|
+
setMaskedEmail(masked);
|
|
39
|
+
}
|
|
40
|
+
catch { /* non-fatal */ }
|
|
41
|
+
})();
|
|
42
|
+
return () => { alive = false; };
|
|
43
|
+
}, [onboardToken]);
|
|
44
|
+
if (!onboardToken) {
|
|
45
|
+
return ((0, jsx_runtime_1.jsxs)(Shell, { children: [(0, jsx_runtime_1.jsx)("h1", { style: titleStyle, children: "This invitation link is missing its token." }), (0, jsx_runtime_1.jsx)("p", { style: { color: H.secondary, fontSize: 14, marginTop: 8 }, children: "The link may have been truncated or copied incompletely. Ask for a new invitation, or sign in if you already have an account." }), (0, jsx_runtime_1.jsx)("p", { style: { marginTop: 16 }, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/login", style: linkStyle, children: "Go to sign in" }) })] }));
|
|
46
|
+
}
|
|
47
|
+
async function submitCode(e) {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
setBusy(true);
|
|
50
|
+
setError(null);
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch('/api/onboarding/verify-code', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ onboard_token: onboardToken, code: digits }),
|
|
56
|
+
});
|
|
57
|
+
const body = await res.json().catch(() => null);
|
|
58
|
+
const data = body?.data?.data || body?.data || body;
|
|
59
|
+
if (!res.ok || !data?.reset_token || !data?.email)
|
|
60
|
+
throw new Error();
|
|
61
|
+
setResetToken(data.reset_token);
|
|
62
|
+
setEmail(data.email);
|
|
63
|
+
setStep('password');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
setError('That code was not accepted. Check it and try again.');
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
setBusy(false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function submitPassword(e) {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
if (password !== confirm) {
|
|
75
|
+
setError('The two passwords do not match.');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setBusy(true);
|
|
79
|
+
setError(null);
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch('/api/onboarding/set-password', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ onboard_token: onboardToken, email, reset_token: resetToken, new_password: password }),
|
|
85
|
+
});
|
|
86
|
+
const body = await res.json().catch(() => null);
|
|
87
|
+
if (!res.ok || (body && body.success === false))
|
|
88
|
+
throw new Error(body?.message || '');
|
|
89
|
+
setStep('done');
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
setError(err instanceof Error && err.message ? err.message : 'We could not set that password. Try again.');
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
setBusy(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return ((0, jsx_runtime_1.jsxs)(Shell, { children: [step === 'code' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h1", { style: titleStyle, children: "Finish setting up your account" }), (0, jsx_runtime_1.jsx)("p", { style: { color: H.secondary, fontSize: 14, marginTop: 8 }, children: maskedEmail
|
|
99
|
+
? `We emailed a 6-digit code to ${maskedEmail}. Enter it below.`
|
|
100
|
+
: 'Enter the 6-digit code from your invitation email.' }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: submitCode, noValidate: true, style: { marginTop: 16 }, children: [(0, jsx_runtime_1.jsx)("input", { value: digits, onChange: (e) => setDigits(e.target.value.replace(/\D/g, '').slice(0, 6)), inputMode: "numeric", autoComplete: "one-time-code", "aria-label": "6-digit code", placeholder: "000000", style: { ...inputStyle, letterSpacing: 6, textAlign: 'center', fontFamily: 'monospace', fontSize: 20 } }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy || digits.length < 6, style: ctaStyle(busy || digits.length < 6), children: busy ? 'Verifying…' : 'Verify' })] })] })), step === 'password' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h1", { style: titleStyle, children: "Choose a password" }), (0, jsx_runtime_1.jsx)("p", { style: { color: H.secondary, fontSize: 14, marginTop: 8 }, children: "Set the password you will sign in with." }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: submitPassword, noValidate: true, style: { marginTop: 16 }, children: [(0, jsx_runtime_1.jsx)("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), "aria-label": "New password", placeholder: "New password", style: inputStyle }), (0, jsx_runtime_1.jsx)("input", { type: "password", value: confirm, onChange: (e) => setConfirm(e.target.value), "aria-label": "Confirm password", placeholder: "Confirm password", style: { ...inputStyle, marginTop: 10 } }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy || !password || !confirm, style: ctaStyle(busy || !password || !confirm), children: busy ? 'Saving…' : 'Set password' })] })] })), step === 'done' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h1", { style: titleStyle, children: "You're all set." }), (0, jsx_runtime_1.jsx)("p", { style: { color: H.secondary, fontSize: 14, marginTop: 8 }, children: "Your account is ready. You can sign in now." }), (0, jsx_runtime_1.jsx)("p", { style: { marginTop: 16 }, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/login", style: linkStyle, children: "Go to sign in" }) })] })), error && (0, jsx_runtime_1.jsx)("p", { style: { color: '#b00020', fontSize: 13, marginTop: 12 }, children: error })] }));
|
|
101
|
+
}
|
|
102
|
+
const titleStyle = { color: H.primary, fontSize: 20, fontWeight: 600, margin: 0 };
|
|
103
|
+
const linkStyle = { color: H.primary, fontWeight: 600, textDecoration: 'underline' };
|
|
104
|
+
const inputStyle = {
|
|
105
|
+
width: '100%', padding: '10px 12px', border: '1px solid var(--border-default, #d1d5db)',
|
|
106
|
+
borderRadius: 10, color: H.primary, fontSize: 15, background: 'var(--bg-card, #fff)',
|
|
107
|
+
};
|
|
108
|
+
const ctaStyle = (disabled) => ({
|
|
109
|
+
marginTop: 14, width: '100%', padding: '10px 12px', borderRadius: 10, border: 'none',
|
|
110
|
+
background: H.primary, color: '#fff', fontWeight: 600, fontSize: 15, cursor: disabled ? 'default' : 'pointer',
|
|
111
|
+
opacity: disabled ? 0.5 : 1,
|
|
112
|
+
});
|
|
113
|
+
function Shell({ children }) {
|
|
114
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16, background: 'var(--bg-default, #f9fafb)' }, children: (0, jsx_runtime_1.jsx)("div", { style: { width: '100%', maxWidth: 420, background: 'var(--bg-card, #fff)', border: '1px solid var(--border-default, #e5e7eb)', borderRadius: 16, padding: 28 }, children: children }) }));
|
|
115
|
+
}
|
|
116
|
+
function OnboardingPage() {
|
|
117
|
+
return ((0, jsx_runtime_1.jsx)(react_1.Suspense, { fallback: (0, jsx_runtime_1.jsx)("div", { style: { minHeight: '100vh' } }), children: (0, jsx_runtime_1.jsx)(OnboardingContent, {}) }));
|
|
118
|
+
}
|
|
@@ -6,6 +6,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
6
6
|
const react_1 = require("react");
|
|
7
7
|
const navigation_1 = require("next/navigation");
|
|
8
8
|
const api_1 = require("../../utils/api");
|
|
9
|
+
const password_reset_bridge_1 = require("../../auth/password-reset-bridge");
|
|
9
10
|
const InitiateRecoveryStep_1 = require("../../components/recovery/InitiateRecoveryStep");
|
|
10
11
|
const SelectMethodStep_1 = require("../../components/recovery/SelectMethodStep");
|
|
11
12
|
const VerifyCodeStep_1 = require("../../components/recovery/VerifyCodeStep");
|
|
@@ -16,14 +17,18 @@ const useTheme_1 = require("../../theme/useTheme");
|
|
|
16
17
|
function RecoveryContent() {
|
|
17
18
|
const router = (0, navigation_1.useRouter)();
|
|
18
19
|
const searchParams = (0, navigation_1.useSearchParams)();
|
|
19
|
-
|
|
20
|
+
// PAY-2121 (BAPert 70576): a VERIFIED passwordless user arrives here with a real
|
|
21
|
+
// reset token (?token=) from the IdP's post-verify fragment, via the bridge. In
|
|
22
|
+
// that case skip the request-a-code step and go straight to set-password - the
|
|
23
|
+
// token is the credential; asking for a code would throw it away.
|
|
24
|
+
const entry = (0, password_reset_bridge_1.recoveryEntry)(searchParams?.get('token'), searchParams?.get('email'));
|
|
20
25
|
const colors = (0, useTheme_1.useColors)();
|
|
21
|
-
const [currentStep, setCurrentStep] = (0, react_1.useState)(
|
|
22
|
-
const [email, setEmail] = (0, react_1.useState)(
|
|
26
|
+
const [currentStep, setCurrentStep] = (0, react_1.useState)(entry.step);
|
|
27
|
+
const [email, setEmail] = (0, react_1.useState)(entry.email);
|
|
23
28
|
const [recoverySession, setRecoverySession] = (0, react_1.useState)(null);
|
|
24
29
|
const [selectedMethod, setSelectedMethod] = (0, react_1.useState)(null);
|
|
25
30
|
const [verificationCode, setVerificationCode] = (0, react_1.useState)('');
|
|
26
|
-
const [passwordResetToken, setPasswordResetToken] = (0, react_1.useState)(null);
|
|
31
|
+
const [passwordResetToken, setPasswordResetToken] = (0, react_1.useState)(entry.resetToken ? { token: entry.resetToken, expiresAt: '' } : null);
|
|
27
32
|
const [error, setError] = (0, react_1.useState)(null);
|
|
28
33
|
// PAY-1591: true when the reset failed because the recovery itself is dead; the error box then offers a way back.
|
|
29
34
|
const [recoveryExpired, setRecoveryExpired] = (0, react_1.useState)(false);
|
|
@@ -108,13 +113,19 @@ function RecoveryContent() {
|
|
|
108
113
|
}
|
|
109
114
|
};
|
|
110
115
|
const handleResetPassword = async (password, confirmPassword) => {
|
|
111
|
-
|
|
116
|
+
// PAY-2121 (BAPert 70582): the ONE place that decides whether a submit may proceed.
|
|
117
|
+
// No token (or no email anywhere) -> refuse BEFORE any request; a guard-less submit
|
|
118
|
+
// would otherwise call the API with an empty token.
|
|
119
|
+
const target = (0, password_reset_bridge_1.resetSubmitTarget)(passwordResetToken?.token, recoverySession?.email, email);
|
|
120
|
+
if (!target) {
|
|
121
|
+
setError(passwordResetToken ? 'We could not tell which account to reset. Start recovery again.' : 'This reset link is missing its token. Start recovery again.');
|
|
112
122
|
return;
|
|
123
|
+
}
|
|
113
124
|
setLoading(true);
|
|
114
125
|
setError(null);
|
|
115
126
|
setRecoveryExpired(false);
|
|
116
127
|
try {
|
|
117
|
-
const response = await api_1.accountApi.resetPasswordWithToken(
|
|
128
|
+
const response = await api_1.accountApi.resetPasswordWithToken(target.email, target.token, password, confirmPassword);
|
|
118
129
|
if (response.success) {
|
|
119
130
|
setResetSucceeded(true);
|
|
120
131
|
setCurrentStep('complete');
|
|
@@ -136,8 +147,10 @@ function RecoveryContent() {
|
|
|
136
147
|
setCurrentStep('initiate');
|
|
137
148
|
else if (currentStep === 'verify-code')
|
|
138
149
|
setCurrentStep('select-method');
|
|
150
|
+
// PAY-2121: a deep-linked set-password has no verify-code to go back to; return to
|
|
151
|
+
// the request-code step so a bad token still leaves a way forward.
|
|
139
152
|
else if (currentStep === 'set-password')
|
|
140
|
-
setCurrentStep('verify-code');
|
|
153
|
+
setCurrentStep(recoverySession ? 'verify-code' : 'initiate');
|
|
141
154
|
};
|
|
142
155
|
return ((0, jsx_runtime_1.jsx)("div", { className: "w-full flex items-center justify-center p-6", style: { background: 'var(--bg-default)' }, children: (0, jsx_runtime_1.jsxs)("div", { className: "max-w-md w-full", children: [error && ((0, jsx_runtime_1.jsxs)("div", { className: "mb-4 bg-red-50 border border-red-200 rounded-2xl p-3", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-red-700 text-sm", children: error }), recoveryExpired && ((0, jsx_runtime_1.jsx)("a", { href: "/account-auth/recovery", className: "text-sm font-medium underline", children: "Start password recovery again" }))] })), loading && ((0, jsx_runtime_1.jsx)("div", { className: "mb-4 rounded-2xl p-3", style: { background: 'var(--bg-card)', borderColor: 'var(--border-default)' }, children: (0, jsx_runtime_1.jsx)("p", { className: "text-sm", style: { color: 'var(--text-secondary)' }, children: "Processing..." }) })), currentStep === 'initiate' && ((0, jsx_runtime_1.jsx)(InitiateRecoveryStep_1.InitiateRecoveryStep, { email: email, setEmail: setEmail, onSubmit: handleInitiateRecovery, loading: loading })), currentStep === 'select-method' && recoverySession && ((0, jsx_runtime_1.jsx)(SelectMethodStep_1.SelectMethodStep, { session: recoverySession, onSelectMethod: handleSendCode, loading: loading })), currentStep === 'verify-code' && ((0, jsx_runtime_1.jsx)(VerifyCodeStep_1.VerifyCodeStep, { code: verificationCode, setCode: setVerificationCode, onSubmit: handleVerifyCode, onResend: () => selectedMethod && handleSendCode(selectedMethod), loading: loading, maskedDestination: selectedMethod === 'sms'
|
|
143
156
|
? recoverySession?.maskedPhone
|