@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
package/src/pages/login/page.tsx
CHANGED
|
@@ -311,13 +311,10 @@ function LoginForm({ altAuthSlot }: { altAuthSlot?: React.ReactNode }) {
|
|
|
311
311
|
</div>
|
|
312
312
|
</div>
|
|
313
313
|
|
|
314
|
-
{/*
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
Sign up
|
|
319
|
-
</a>
|
|
320
|
-
</p>
|
|
314
|
+
{/* PAY-2121: the kit ships no sign-up PAGE (registration is the owner-initiated
|
|
315
|
+
invite/onboarding flow, not a self-serve screen), so the old "Sign up" link to
|
|
316
|
+
the register path was a dead end - it 404'd at the developer's site. Removed;
|
|
317
|
+
the README says how registration is reached. */}
|
|
321
318
|
</div>
|
|
322
319
|
</div>
|
|
323
320
|
</>
|
|
@@ -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,212 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { Suspense, useEffect, useState } from 'react';
|
|
4
|
+
import { useSearchParams } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PAY-2121: the /account-auth/onboarding landing page.
|
|
8
|
+
*
|
|
9
|
+
* The IdP's welcome/invite email links to
|
|
10
|
+
* {site}/account-auth/onboarding?client_id=<numeric>&onboard_token=<token>
|
|
11
|
+
* (OnboardingLinkService.BuildOnboardingLink, PayEz-Core). A fresh
|
|
12
|
+
* @payez/next-mvp app had NO page at that path, so the invitee 404'd at the
|
|
13
|
+
* developer's own site. This page mounts it.
|
|
14
|
+
*
|
|
15
|
+
* It is TOKEN-DRIVEN: the invitee has no account or session; the onboard_token
|
|
16
|
+
* IS the credential. The page forwards { onboard_token } to the kit's onboarding
|
|
17
|
+
* API (the /api/onboarding/* handlers), which proxy the IdP. Two steps:
|
|
18
|
+
* 1. verify-welcome-email -> the masked contact info the invitee recognises;
|
|
19
|
+
* 2. verify-code -> reset_token + email; then set-password -> onboarding done.
|
|
20
|
+
*
|
|
21
|
+
* MOUNTING (what README section 5 asks a consumer to write; the CLI scaffold
|
|
22
|
+
* writes it too):
|
|
23
|
+
* app/account-auth/onboarding/page.tsx:
|
|
24
|
+
* export { default } from '@payez/next-mvp/pages/onboarding';
|
|
25
|
+
*
|
|
26
|
+
* A missing onboard_token is a typed state, never a crash: it renders a plain
|
|
27
|
+
* "this link is missing its token" with a way back to sign-in.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
type Step = 'code' | 'password' | 'done';
|
|
31
|
+
|
|
32
|
+
const H = { primary: 'var(--text-primary)', secondary: 'var(--text-secondary)', muted: 'var(--text-muted)' } as const;
|
|
33
|
+
|
|
34
|
+
function OnboardingContent() {
|
|
35
|
+
const params = useSearchParams();
|
|
36
|
+
const onboardToken = params?.get('onboard_token') ?? '';
|
|
37
|
+
|
|
38
|
+
const [step, setStep] = useState<Step>('code');
|
|
39
|
+
const [maskedEmail, setMaskedEmail] = useState<string | null>(null);
|
|
40
|
+
const [digits, setDigits] = useState('');
|
|
41
|
+
const [password, setPassword] = useState('');
|
|
42
|
+
const [confirm, setConfirm] = useState('');
|
|
43
|
+
const [resetToken, setResetToken] = useState('');
|
|
44
|
+
const [email, setEmail] = useState('');
|
|
45
|
+
const [busy, setBusy] = useState(false);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
// Step 1: tell the invitee which address the code went to (masked). Best
|
|
49
|
+
// effort - a failure here does not block entering a code they were emailed.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!onboardToken) return;
|
|
52
|
+
let alive = true;
|
|
53
|
+
(async () => {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch('/api/onboarding/verify-welcome-email', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ onboard_token: onboardToken }),
|
|
59
|
+
});
|
|
60
|
+
const body = await res.json().catch(() => null);
|
|
61
|
+
const data = body?.data?.data || body?.data || {};
|
|
62
|
+
const masked = data?.masked_user_info?.masked_email || data?.masked_email;
|
|
63
|
+
if (alive && typeof masked === 'string' && masked) setMaskedEmail(masked);
|
|
64
|
+
} catch { /* non-fatal */ }
|
|
65
|
+
})();
|
|
66
|
+
return () => { alive = false; };
|
|
67
|
+
}, [onboardToken]);
|
|
68
|
+
|
|
69
|
+
if (!onboardToken) {
|
|
70
|
+
return (
|
|
71
|
+
<Shell>
|
|
72
|
+
<h1 style={titleStyle}>This invitation link is missing its token.</h1>
|
|
73
|
+
<p style={{ color: H.secondary, fontSize: 14, marginTop: 8 }}>
|
|
74
|
+
The link may have been truncated or copied incompletely. Ask for a new invitation, or sign in if you already have an
|
|
75
|
+
account.
|
|
76
|
+
</p>
|
|
77
|
+
<p style={{ marginTop: 16 }}>
|
|
78
|
+
<a href="/account-auth/login" style={linkStyle}>Go to sign in</a>
|
|
79
|
+
</p>
|
|
80
|
+
</Shell>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function submitCode(e: React.FormEvent) {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
setBusy(true);
|
|
87
|
+
setError(null);
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch('/api/onboarding/verify-code', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({ onboard_token: onboardToken, code: digits }),
|
|
93
|
+
});
|
|
94
|
+
const body = await res.json().catch(() => null);
|
|
95
|
+
const data = body?.data?.data || body?.data || body;
|
|
96
|
+
if (!res.ok || !data?.reset_token || !data?.email) throw new Error();
|
|
97
|
+
setResetToken(data.reset_token);
|
|
98
|
+
setEmail(data.email);
|
|
99
|
+
setStep('password');
|
|
100
|
+
} catch {
|
|
101
|
+
setError('That code was not accepted. Check it and try again.');
|
|
102
|
+
} finally {
|
|
103
|
+
setBusy(false);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function submitPassword(e: React.FormEvent) {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
if (password !== confirm) { setError('The two passwords do not match.'); return; }
|
|
110
|
+
setBusy(true);
|
|
111
|
+
setError(null);
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch('/api/onboarding/set-password', {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({ onboard_token: onboardToken, email, reset_token: resetToken, new_password: password }),
|
|
117
|
+
});
|
|
118
|
+
const body = await res.json().catch(() => null);
|
|
119
|
+
if (!res.ok || (body && body.success === false)) throw new Error(body?.message || '');
|
|
120
|
+
setStep('done');
|
|
121
|
+
} catch (err) {
|
|
122
|
+
setError(err instanceof Error && err.message ? err.message : 'We could not set that password. Try again.');
|
|
123
|
+
} finally {
|
|
124
|
+
setBusy(false);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Shell>
|
|
130
|
+
{step === 'code' && (
|
|
131
|
+
<>
|
|
132
|
+
<h1 style={titleStyle}>Finish setting up your account</h1>
|
|
133
|
+
<p style={{ color: H.secondary, fontSize: 14, marginTop: 8 }}>
|
|
134
|
+
{maskedEmail
|
|
135
|
+
? `We emailed a 6-digit code to ${maskedEmail}. Enter it below.`
|
|
136
|
+
: 'Enter the 6-digit code from your invitation email.'}
|
|
137
|
+
</p>
|
|
138
|
+
<form onSubmit={submitCode} noValidate style={{ marginTop: 16 }}>
|
|
139
|
+
<input
|
|
140
|
+
value={digits}
|
|
141
|
+
onChange={(e) => setDigits(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
142
|
+
inputMode="numeric"
|
|
143
|
+
autoComplete="one-time-code"
|
|
144
|
+
aria-label="6-digit code"
|
|
145
|
+
placeholder="000000"
|
|
146
|
+
style={{ ...inputStyle, letterSpacing: 6, textAlign: 'center', fontFamily: 'monospace', fontSize: 20 }}
|
|
147
|
+
/>
|
|
148
|
+
<button type="submit" disabled={busy || digits.length < 6} style={ctaStyle(busy || digits.length < 6)}>
|
|
149
|
+
{busy ? 'Verifying…' : 'Verify'}
|
|
150
|
+
</button>
|
|
151
|
+
</form>
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{step === 'password' && (
|
|
156
|
+
<>
|
|
157
|
+
<h1 style={titleStyle}>Choose a password</h1>
|
|
158
|
+
<p style={{ color: H.secondary, fontSize: 14, marginTop: 8 }}>Set the password you will sign in with.</p>
|
|
159
|
+
<form onSubmit={submitPassword} noValidate style={{ marginTop: 16 }}>
|
|
160
|
+
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} aria-label="New password" placeholder="New password" style={inputStyle} />
|
|
161
|
+
<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} aria-label="Confirm password" placeholder="Confirm password" style={{ ...inputStyle, marginTop: 10 }} />
|
|
162
|
+
<button type="submit" disabled={busy || !password || !confirm} style={ctaStyle(busy || !password || !confirm)}>
|
|
163
|
+
{busy ? 'Saving…' : 'Set password'}
|
|
164
|
+
</button>
|
|
165
|
+
</form>
|
|
166
|
+
</>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{step === 'done' && (
|
|
170
|
+
<>
|
|
171
|
+
<h1 style={titleStyle}>You're all set.</h1>
|
|
172
|
+
<p style={{ color: H.secondary, fontSize: 14, marginTop: 8 }}>Your account is ready. You can sign in now.</p>
|
|
173
|
+
<p style={{ marginTop: 16 }}>
|
|
174
|
+
<a href="/account-auth/login" style={linkStyle}>Go to sign in</a>
|
|
175
|
+
</p>
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{error && <p style={{ color: '#b00020', fontSize: 13, marginTop: 12 }}>{error}</p>}
|
|
180
|
+
</Shell>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const titleStyle: React.CSSProperties = { color: H.primary, fontSize: 20, fontWeight: 600, margin: 0 };
|
|
185
|
+
const linkStyle: React.CSSProperties = { color: H.primary, fontWeight: 600, textDecoration: 'underline' };
|
|
186
|
+
const inputStyle: React.CSSProperties = {
|
|
187
|
+
width: '100%', padding: '10px 12px', border: '1px solid var(--border-default, #d1d5db)',
|
|
188
|
+
borderRadius: 10, color: H.primary, fontSize: 15, background: 'var(--bg-card, #fff)',
|
|
189
|
+
};
|
|
190
|
+
const ctaStyle = (disabled: boolean): React.CSSProperties => ({
|
|
191
|
+
marginTop: 14, width: '100%', padding: '10px 12px', borderRadius: 10, border: 'none',
|
|
192
|
+
background: H.primary, color: '#fff', fontWeight: 600, fontSize: 15, cursor: disabled ? 'default' : 'pointer',
|
|
193
|
+
opacity: disabled ? 0.5 : 1,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
function Shell({ children }: { children: React.ReactNode }) {
|
|
197
|
+
return (
|
|
198
|
+
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16, background: 'var(--bg-default, #f9fafb)' }}>
|
|
199
|
+
<div style={{ width: '100%', maxWidth: 420, background: 'var(--bg-card, #fff)', border: '1px solid var(--border-default, #e5e7eb)', borderRadius: 16, padding: 28 }}>
|
|
200
|
+
{children}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default function OnboardingPage() {
|
|
207
|
+
return (
|
|
208
|
+
<Suspense fallback={<div style={{ minHeight: '100vh' }} />}>
|
|
209
|
+
<OnboardingContent />
|
|
210
|
+
</Suspense>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense } from 'react';
|
|
|
4
4
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
5
5
|
import { RecoveryStep, RecoverySession, PasswordResetToken } from '../../types/recovery';
|
|
6
6
|
import { accountApi } from '../../utils/api';
|
|
7
|
+
import { recoveryEntry, resetSubmitTarget } from '../../auth/password-reset-bridge';
|
|
7
8
|
import { InitiateRecoveryStep } from '../../components/recovery/InitiateRecoveryStep';
|
|
8
9
|
import { SelectMethodStep } from '../../components/recovery/SelectMethodStep';
|
|
9
10
|
import { VerifyCodeStep } from '../../components/recovery/VerifyCodeStep';
|
|
@@ -15,15 +16,21 @@ import { useColors } from '../../theme/useTheme';
|
|
|
15
16
|
function RecoveryContent() {
|
|
16
17
|
const router = useRouter();
|
|
17
18
|
const searchParams = useSearchParams();
|
|
18
|
-
|
|
19
|
+
// PAY-2121 (BAPert 70576): a VERIFIED passwordless user arrives here with a real
|
|
20
|
+
// reset token (?token=) from the IdP's post-verify fragment, via the bridge. In
|
|
21
|
+
// that case skip the request-a-code step and go straight to set-password - the
|
|
22
|
+
// token is the credential; asking for a code would throw it away.
|
|
23
|
+
const entry = recoveryEntry(searchParams?.get('token'), searchParams?.get('email'));
|
|
19
24
|
const colors = useColors();
|
|
20
25
|
|
|
21
|
-
const [currentStep, setCurrentStep] = useState<RecoveryStep>(
|
|
22
|
-
const [email, setEmail] = useState(
|
|
26
|
+
const [currentStep, setCurrentStep] = useState<RecoveryStep>(entry.step);
|
|
27
|
+
const [email, setEmail] = useState(entry.email);
|
|
23
28
|
const [recoverySession, setRecoverySession] = useState<RecoverySession | null>(null);
|
|
24
29
|
const [selectedMethod, setSelectedMethod] = useState<'email' | 'sms' | 'authenticator' | null>(null);
|
|
25
30
|
const [verificationCode, setVerificationCode] = useState('');
|
|
26
|
-
const [passwordResetToken, setPasswordResetToken] = useState<PasswordResetToken | null>(
|
|
31
|
+
const [passwordResetToken, setPasswordResetToken] = useState<PasswordResetToken | null>(
|
|
32
|
+
entry.resetToken ? { token: entry.resetToken, expiresAt: '' } : null,
|
|
33
|
+
);
|
|
27
34
|
const [error, setError] = useState<string | null>(null);
|
|
28
35
|
// PAY-1591: true when the reset failed because the recovery itself is dead; the error box then offers a way back.
|
|
29
36
|
const [recoveryExpired, setRecoveryExpired] = useState(false);
|
|
@@ -113,7 +120,14 @@ function RecoveryContent() {
|
|
|
113
120
|
};
|
|
114
121
|
|
|
115
122
|
const handleResetPassword = async (password: string, confirmPassword: string) => {
|
|
116
|
-
|
|
123
|
+
// PAY-2121 (BAPert 70582): the ONE place that decides whether a submit may proceed.
|
|
124
|
+
// No token (or no email anywhere) -> refuse BEFORE any request; a guard-less submit
|
|
125
|
+
// would otherwise call the API with an empty token.
|
|
126
|
+
const target = resetSubmitTarget(passwordResetToken?.token, recoverySession?.email, email);
|
|
127
|
+
if (!target) {
|
|
128
|
+
setError(passwordResetToken ? 'We could not tell which account to reset. Start recovery again.' : 'This reset link is missing its token. Start recovery again.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
117
131
|
|
|
118
132
|
setLoading(true);
|
|
119
133
|
setError(null);
|
|
@@ -121,8 +135,8 @@ function RecoveryContent() {
|
|
|
121
135
|
|
|
122
136
|
try {
|
|
123
137
|
const response = await accountApi.resetPasswordWithToken(
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
target.email,
|
|
139
|
+
target.token,
|
|
126
140
|
password,
|
|
127
141
|
confirmPassword
|
|
128
142
|
);
|
|
@@ -144,7 +158,9 @@ function RecoveryContent() {
|
|
|
144
158
|
const handleGoBack = () => {
|
|
145
159
|
if (currentStep === 'select-method') setCurrentStep('initiate');
|
|
146
160
|
else if (currentStep === 'verify-code') setCurrentStep('select-method');
|
|
147
|
-
|
|
161
|
+
// PAY-2121: a deep-linked set-password has no verify-code to go back to; return to
|
|
162
|
+
// the request-code step so a bad token still leaves a way forward.
|
|
163
|
+
else if (currentStep === 'set-password') setCurrentStep(recoverySession ? 'verify-code' : 'initiate');
|
|
148
164
|
};
|
|
149
165
|
|
|
150
166
|
return (
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
24
24
|
import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
|
|
25
25
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
26
|
+
import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Get tenant-wide 2FA requirement from cached client config (from broker handshake)
|
|
@@ -262,6 +263,40 @@ export async function GET(req: NextRequest) {
|
|
|
262
263
|
session?.mfaVerified === false &&
|
|
263
264
|
!(session.idpRefreshToken || (session as any)?.refreshToken);
|
|
264
265
|
|
|
266
|
+
// --- Session revocation status (PAY-1882 step 4 producer half) ---
|
|
267
|
+
/*
|
|
268
|
+
* Resolve the four-state revocation status for this user (BAPert 66320).
|
|
269
|
+
* Read it HERE, above every authenticated return, so all three responses
|
|
270
|
+
* carry the SAME value - the same discipline as nextStep above.
|
|
271
|
+
*
|
|
272
|
+
* The middleware fails CLOSED by default on 'unknown' (a failed Vibe read)
|
|
273
|
+
* and always on 'revoked'; 'active' and 'not_configured' stay viable. A
|
|
274
|
+
* consumer that does NOT read revocationStatus is unaffected - absent is not
|
|
275
|
+
* revoked - so shipping this changes nothing for them until they opt in.
|
|
276
|
+
*/
|
|
277
|
+
let revocationStatus: RevocationStatus = 'not_configured';
|
|
278
|
+
if (session) {
|
|
279
|
+
// BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
|
|
280
|
+
// the token's own `sub` claim FIRST (session.userId can be an email).
|
|
281
|
+
const revocationUserId = resolveNumericIdpUserId(session, (session as any).idpAccessToken);
|
|
282
|
+
if (revocationUserId > 0) {
|
|
283
|
+
try {
|
|
284
|
+
revocationStatus = await getRevocationStatus(revocationUserId, sessionToken);
|
|
285
|
+
} catch {
|
|
286
|
+
// getRevocationStatus does not throw by contract, but never let a
|
|
287
|
+
// revocation probe take down the viability answer: fail closed.
|
|
288
|
+
revocationStatus = 'unknown';
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
// N1 (NightHawk 66392): a session exists but carries no usable numeric
|
|
292
|
+
// IdP id, so the check could not run -> 'unknown' (could not determine),
|
|
293
|
+
// NOT 'not_configured' (no revocation source).
|
|
294
|
+
// BAPert 66480 condition 1: emit ONE WARN (shape only) so a non-numeric
|
|
295
|
+
// id surfaces as a log line, not an unexplained sign-out.
|
|
296
|
+
revocationStatus = warnNonNumericUserId(sessionToken, session.userId);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
265
300
|
// Check access token expiry
|
|
266
301
|
const now = Math.floor(Date.now() / 1000);
|
|
267
302
|
// 240654 addendum 2: fall back to the Redis session's own expiry.
|
|
@@ -312,6 +347,11 @@ export async function GET(req: NextRequest) {
|
|
|
312
347
|
needsRefresh: true,
|
|
313
348
|
authenticated: true,
|
|
314
349
|
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
350
|
+
// Session revocation (PAY-1882 step 4): the four-state status, plus the
|
|
351
|
+
// boolean the middleware's existing force-invalid branch already reads.
|
|
352
|
+
revocationStatus,
|
|
353
|
+
forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
|
|
354
|
+
revoked: revocationStatus === 'revoked',
|
|
315
355
|
// Clear names for middleware decision-making
|
|
316
356
|
tenantRequiresTwoFactor,
|
|
317
357
|
userHasCompletedTenantTwoFactorRequirements,
|
|
@@ -393,6 +433,10 @@ export async function GET(req: NextRequest) {
|
|
|
393
433
|
twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
|
|
394
434
|
accessTokenExpired: true,
|
|
395
435
|
reason: 'Token expired',
|
|
436
|
+
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
437
|
+
revocationStatus,
|
|
438
|
+
forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
|
|
439
|
+
revoked: revocationStatus === 'revoked',
|
|
396
440
|
// RBAC fields
|
|
397
441
|
roles: session?.roles || [],
|
|
398
442
|
clientId: resolveClientId(session?.idpClientId),
|
|
@@ -406,6 +450,10 @@ export async function GET(req: NextRequest) {
|
|
|
406
450
|
hasRefreshToken,
|
|
407
451
|
authenticated: true,
|
|
408
452
|
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
453
|
+
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
454
|
+
revocationStatus,
|
|
455
|
+
forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
|
|
456
|
+
revoked: revocationStatus === 'revoked',
|
|
409
457
|
// Clear names
|
|
410
458
|
tenantRequiresTwoFactor,
|
|
411
459
|
userHasCompletedTenantTwoFactorRequirements,
|
|
@@ -428,6 +476,10 @@ export async function GET(req: NextRequest) {
|
|
|
428
476
|
viable: false,
|
|
429
477
|
needsRefresh: false,
|
|
430
478
|
authenticated: false,
|
|
479
|
+
// PAY-1882 step 4: an exception here means the status could not be read.
|
|
480
|
+
// Report 'unknown' (not a bare 500 with no revocation signal) so the
|
|
481
|
+
// middleware's fail-closed default applies rather than reading it as viable.
|
|
482
|
+
revocationStatus: 'unknown' as RevocationStatus,
|
|
431
483
|
error: 'Failed to check session',
|
|
432
484
|
code: 'INTERNAL_ERROR',
|
|
433
485
|
correlationId
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ready-to-use onboarding route handlers (PAY-2121).
|
|
3
|
+
*
|
|
4
|
+
* Import directly into your app's API routes with zero configuration:
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* // app/api/onboarding/verify-code/route.ts
|
|
9
|
+
* export { POST } from '@payez/next-mvp/routes/onboarding/verify-code';
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export { POST as verifyCodePOST } from '../../api-handlers/onboarding/verify-code';
|
|
13
|
+
export { POST as sendEmailCodePOST } from '../../api-handlers/onboarding/send-email-code';
|
|
14
|
+
export { POST as verifyWelcomeEmailPOST } from '../../api-handlers/onboarding/verify-welcome-email';
|
|
15
|
+
export { POST as setPasswordPOST } from '../../api-handlers/onboarding/set-password';
|
package/src/utils/api.ts
CHANGED
|
@@ -107,6 +107,13 @@ export const RECOVERY_EXPIRED_TEXT = 'This reset link has expired or was already
|
|
|
107
107
|
|
|
108
108
|
export const RESET_NEUTRAL_TEXT = "We couldn't set that password. Check it meets the requirements shown and try again.";
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* PAY-2103 (AUTH-009): the IdP refuses a password found in a breach corpus; the route projects a
|
|
112
|
+
* `password_breached: true` boolean, and the user reads this. Distinct from the policy sentence,
|
|
113
|
+
* which would wrongly tell them to re-read the requirements.
|
|
114
|
+
*/
|
|
115
|
+
export const RESET_BREACHED_TEXT = 'This password appears in a known data breach. Choose a different one.';
|
|
116
|
+
|
|
110
117
|
/**
|
|
111
118
|
* Three tiers for a 400/422 from reset (rigpert 60822, QAPert 60820), decided ONLY on codes the IdP actually sends:
|
|
112
119
|
* policy details.code password_policy (projected by the handler as password_policy) -> the rule sentences
|
|
@@ -233,7 +240,9 @@ export class AccountApi {
|
|
|
233
240
|
const policy = !!body?.password_policy;
|
|
234
241
|
const rules = knownPasswordRules(body?.password_policy?.rules);
|
|
235
242
|
const tokenInvalid = body?.reset_token_invalid === true;
|
|
236
|
-
|
|
243
|
+
// PAY-2103: a breached password is its own outcome, not a policy refusal.
|
|
244
|
+
const breached = body?.password_breached === true;
|
|
245
|
+
const err = new Error(breached ? RESET_BREACHED_TEXT : resetFailureText(res.status, rules, policy, tokenInvalid)) as Error & { rules?: string[]; code?: string };
|
|
237
246
|
if (rules.length) err.rules = rules;
|
|
238
247
|
if ((res.status === 400 || res.status === 422) && tokenInvalid && !policy) err.code = 'RECOVERY_EXPIRED';
|
|
239
248
|
throw err;
|