@payez/next-mvp 4.4.9 → 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/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/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/PasswordResetBridge.d.ts +17 -0
- package/dist/components/auth/PasswordResetBridge.js +32 -0
- package/dist/lib/onboarding-proxy.d.ts +30 -0
- package/dist/lib/onboarding-proxy.js +62 -0
- 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/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/package.json +56 -1
- 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/auth/password-reset-bridge.ts +98 -0
- package/src/auth/route-config.ts +17 -0
- package/src/components/auth/PasswordResetBridge.tsx +30 -0
- package/src/lib/onboarding-proxy.ts +57 -0
- 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/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
|
@@ -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
|
|
@@ -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';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.setPasswordPOST = exports.verifyWelcomeEmailPOST = exports.sendEmailCodePOST = exports.verifyCodePOST = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Ready-to-use onboarding route handlers (PAY-2121).
|
|
6
|
+
*
|
|
7
|
+
* Import directly into your app's API routes with zero configuration:
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* // app/api/onboarding/verify-code/route.ts
|
|
12
|
+
* export { POST } from '@payez/next-mvp/routes/onboarding/verify-code';
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
var verify_code_1 = require("../../api-handlers/onboarding/verify-code");
|
|
16
|
+
Object.defineProperty(exports, "verifyCodePOST", { enumerable: true, get: function () { return verify_code_1.POST; } });
|
|
17
|
+
var send_email_code_1 = require("../../api-handlers/onboarding/send-email-code");
|
|
18
|
+
Object.defineProperty(exports, "sendEmailCodePOST", { enumerable: true, get: function () { return send_email_code_1.POST; } });
|
|
19
|
+
var verify_welcome_email_1 = require("../../api-handlers/onboarding/verify-welcome-email");
|
|
20
|
+
Object.defineProperty(exports, "verifyWelcomeEmailPOST", { enumerable: true, get: function () { return verify_welcome_email_1.POST; } });
|
|
21
|
+
var set_password_1 = require("../../api-handlers/onboarding/set-password");
|
|
22
|
+
Object.defineProperty(exports, "setPasswordPOST", { enumerable: true, get: function () { return set_password_1.POST; } });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.POST = void 0;
|
|
4
|
+
/** POST /api/onboarding/send-email-code -> IdP (PAY-2121). */
|
|
5
|
+
var send_email_code_1 = require("../../api-handlers/onboarding/send-email-code");
|
|
6
|
+
Object.defineProperty(exports, "POST", { enumerable: true, get: function () { return send_email_code_1.POST; } });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.POST = void 0;
|
|
4
|
+
/** POST /api/onboarding/set-password -> IdP (PAY-2121). */
|
|
5
|
+
var set_password_1 = require("../../api-handlers/onboarding/set-password");
|
|
6
|
+
Object.defineProperty(exports, "POST", { enumerable: true, get: function () { return set_password_1.POST; } });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.POST = void 0;
|
|
4
|
+
/** POST /api/onboarding/verify-code -> IdP (PAY-2121). */
|
|
5
|
+
var verify_code_1 = require("../../api-handlers/onboarding/verify-code");
|
|
6
|
+
Object.defineProperty(exports, "POST", { enumerable: true, get: function () { return verify_code_1.POST; } });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.POST = void 0;
|
|
4
|
+
/** POST /api/onboarding/verify-welcome-email -> IdP (PAY-2121). */
|
|
5
|
+
var verify_welcome_email_1 = require("../../api-handlers/onboarding/verify-welcome-email");
|
|
6
|
+
Object.defineProperty(exports, "POST", { enumerable: true, get: function () { return verify_welcome_email_1.POST; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -575,6 +575,61 @@
|
|
|
575
575
|
"require": "./dist/pages/verify-code/page.js",
|
|
576
576
|
"default": "./dist/pages/verify-code/page.js"
|
|
577
577
|
},
|
|
578
|
+
"./dist/pages/onboarding": {
|
|
579
|
+
"types": "./dist/pages/onboarding/index.d.ts",
|
|
580
|
+
"require": "./dist/pages/onboarding/index.js",
|
|
581
|
+
"default": "./dist/pages/onboarding/index.js"
|
|
582
|
+
},
|
|
583
|
+
"./pages/onboarding": {
|
|
584
|
+
"types": "./dist/pages/onboarding/index.d.ts",
|
|
585
|
+
"require": "./dist/pages/onboarding/index.js",
|
|
586
|
+
"default": "./dist/pages/onboarding/index.js"
|
|
587
|
+
},
|
|
588
|
+
"./components/auth/PasswordResetBridge": {
|
|
589
|
+
"types": "./dist/components/auth/PasswordResetBridge.d.ts",
|
|
590
|
+
"require": "./dist/components/auth/PasswordResetBridge.js",
|
|
591
|
+
"default": "./dist/components/auth/PasswordResetBridge.js"
|
|
592
|
+
},
|
|
593
|
+
"./api-handlers/onboarding/verify-code": {
|
|
594
|
+
"types": "./dist/api-handlers/onboarding/verify-code.d.ts",
|
|
595
|
+
"require": "./dist/api-handlers/onboarding/verify-code.js",
|
|
596
|
+
"default": "./dist/api-handlers/onboarding/verify-code.js"
|
|
597
|
+
},
|
|
598
|
+
"./api-handlers/onboarding/send-email-code": {
|
|
599
|
+
"types": "./dist/api-handlers/onboarding/send-email-code.d.ts",
|
|
600
|
+
"require": "./dist/api-handlers/onboarding/send-email-code.js",
|
|
601
|
+
"default": "./dist/api-handlers/onboarding/send-email-code.js"
|
|
602
|
+
},
|
|
603
|
+
"./api-handlers/onboarding/verify-welcome-email": {
|
|
604
|
+
"types": "./dist/api-handlers/onboarding/verify-welcome-email.d.ts",
|
|
605
|
+
"require": "./dist/api-handlers/onboarding/verify-welcome-email.js",
|
|
606
|
+
"default": "./dist/api-handlers/onboarding/verify-welcome-email.js"
|
|
607
|
+
},
|
|
608
|
+
"./api-handlers/onboarding/set-password": {
|
|
609
|
+
"types": "./dist/api-handlers/onboarding/set-password.d.ts",
|
|
610
|
+
"require": "./dist/api-handlers/onboarding/set-password.js",
|
|
611
|
+
"default": "./dist/api-handlers/onboarding/set-password.js"
|
|
612
|
+
},
|
|
613
|
+
"./routes/onboarding/verify-code": {
|
|
614
|
+
"types": "./dist/routes/onboarding/verify-code.d.ts",
|
|
615
|
+
"require": "./dist/routes/onboarding/verify-code.js",
|
|
616
|
+
"default": "./dist/routes/onboarding/verify-code.js"
|
|
617
|
+
},
|
|
618
|
+
"./routes/onboarding/send-email-code": {
|
|
619
|
+
"types": "./dist/routes/onboarding/send-email-code.d.ts",
|
|
620
|
+
"require": "./dist/routes/onboarding/send-email-code.js",
|
|
621
|
+
"default": "./dist/routes/onboarding/send-email-code.js"
|
|
622
|
+
},
|
|
623
|
+
"./routes/onboarding/verify-welcome-email": {
|
|
624
|
+
"types": "./dist/routes/onboarding/verify-welcome-email.d.ts",
|
|
625
|
+
"require": "./dist/routes/onboarding/verify-welcome-email.js",
|
|
626
|
+
"default": "./dist/routes/onboarding/verify-welcome-email.js"
|
|
627
|
+
},
|
|
628
|
+
"./routes/onboarding/set-password": {
|
|
629
|
+
"types": "./dist/routes/onboarding/set-password.d.ts",
|
|
630
|
+
"require": "./dist/routes/onboarding/set-password.js",
|
|
631
|
+
"default": "./dist/routes/onboarding/set-password.js"
|
|
632
|
+
},
|
|
578
633
|
"./pages/admin-login": {
|
|
579
634
|
"types": "./dist/pages/admin-login/page.d.ts",
|
|
580
635
|
"require": "./dist/pages/admin-login/page.js",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/onboarding/send-email-code (PAY-2121).
|
|
3
|
+
*
|
|
4
|
+
* Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
|
|
5
|
+
* POST /api/Onboarding/send-email-code. The IdP derives the recipient from the
|
|
6
|
+
* token's BOUND email only, never from client input, so a tampered link cannot
|
|
7
|
+
* redirect the code. Also the resend leg the verify card's cooldown fires
|
|
8
|
+
* against.
|
|
9
|
+
*/
|
|
10
|
+
import { NextRequest } from 'next/server';
|
|
11
|
+
import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
|
|
12
|
+
|
|
13
|
+
export async function POST(req: NextRequest) {
|
|
14
|
+
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
|
15
|
+
const onboardToken = (body as Record<string, unknown>)?.onboard_token;
|
|
16
|
+
if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
17
|
+
return onboardingProxy(req, '/api/Onboarding/send-email-code');
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/onboarding/set-password (PAY-2121).
|
|
3
|
+
*
|
|
4
|
+
* Body: { onboard_token, email, reset_token, new_password } - the locked
|
|
5
|
+
* contract (IdP OnboardingDtos.cs SetPasswordRequest, all four snake_case).
|
|
6
|
+
* email + reset_token come from the verify-code response; the IdP re-checks
|
|
7
|
+
* email against the token's bound user before consuming the single-use token.
|
|
8
|
+
* On success the IdP sets the password AND completes onboarding (issues tokens).
|
|
9
|
+
* Thin proxy - status and body pass through verbatim.
|
|
10
|
+
*/
|
|
11
|
+
import { NextRequest } from 'next/server';
|
|
12
|
+
import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
|
|
13
|
+
|
|
14
|
+
export async function POST(req: NextRequest) {
|
|
15
|
+
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
|
16
|
+
const b = body as Record<string, unknown>;
|
|
17
|
+
if (!b?.onboard_token) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
18
|
+
if (!b?.email || !b?.reset_token) return onboardingBadRequest('MISSING_RESET_CONTEXT', 'email and reset_token (from verify-code) are required');
|
|
19
|
+
if (!b?.new_password) return onboardingBadRequest('MISSING_PASSWORD', 'new_password is required');
|
|
20
|
+
return onboardingProxy(req, '/api/Onboarding/set-password');
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/onboarding/verify-code (PAY-2121).
|
|
3
|
+
*
|
|
4
|
+
* Body: { onboard_token, code } - token-driven, no session. Thin proxy to the
|
|
5
|
+
* IdP's POST /api/Onboarding/verify-code, which answers
|
|
6
|
+
* { success, reset_token, email, client_key } on success (OnboardingDtos.cs
|
|
7
|
+
* VerifyCodeResponse). reset_token + email are what the set-password step needs
|
|
8
|
+
* next. The onboard_token IS the auth; there is no session to attach.
|
|
9
|
+
*/
|
|
10
|
+
import { NextRequest } from 'next/server';
|
|
11
|
+
import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
|
|
12
|
+
|
|
13
|
+
export async function POST(req: NextRequest) {
|
|
14
|
+
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
|
15
|
+
const onboardToken = (body as Record<string, unknown>)?.onboard_token;
|
|
16
|
+
const code = (body as Record<string, unknown>)?.code;
|
|
17
|
+
if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
18
|
+
if (!code) return onboardingBadRequest('MISSING_CODE', 'code is required');
|
|
19
|
+
return onboardingProxy(req, '/api/Onboarding/verify-code');
|
|
20
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/onboarding/verify-welcome-email (PAY-2121).
|
|
3
|
+
*
|
|
4
|
+
* Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
|
|
5
|
+
* POST /api/Onboarding/verify-welcome-email, which answers the masked contact
|
|
6
|
+
* info + expiry the verify card displays. The onboarding page calls this to
|
|
7
|
+
* render its "check your email / here is what we know" state.
|
|
8
|
+
*/
|
|
9
|
+
import { NextRequest } from 'next/server';
|
|
10
|
+
import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
|
|
11
|
+
|
|
12
|
+
export async function POST(req: NextRequest) {
|
|
13
|
+
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
|
14
|
+
const onboardToken = (body as Record<string, unknown>)?.onboard_token;
|
|
15
|
+
if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
16
|
+
return onboardingProxy(req, '/api/Onboarding/verify-welcome-email');
|
|
17
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2121: the fragment bridge on `/`.
|
|
3
|
+
*
|
|
4
|
+
* The IdP's post-verify redirect for a passwordless account lands on
|
|
5
|
+
* ORIGIN/#password_reset_required=true&reset_token=<token>&email=<addr>
|
|
6
|
+
* - a FRAGMENT, never a query (ProgressiveAuthController.cs:1204-1222). A fresh
|
|
7
|
+
* @payez/next-mvp app has no fragment handling at all, so the user lands on `/`
|
|
8
|
+
* and nothing happens; their reset is lost.
|
|
9
|
+
*
|
|
10
|
+
* This is the PURE half (data in, data out) so a test can execute it without a
|
|
11
|
+
* DOM. The client component (PasswordResetBridge) calls it on mount with
|
|
12
|
+
* `window.location.hash`. The kit's reset surface is the existing
|
|
13
|
+
* `/account-auth/recovery` page, which reads `?email=` and a reset token - so the
|
|
14
|
+
* bridge rewrites the fragment into that surface's OWN query shape and navigates
|
|
15
|
+
* once. It invents no new reset flow.
|
|
16
|
+
*
|
|
17
|
+
* CONTRACT:
|
|
18
|
+
* '#password_reset_required=true&reset_token=T&email=a%40b.co' -> '/account-auth/recovery?token=T&email=a%40b.co'
|
|
19
|
+
* any other fragment (no flag, flag != true, no token) -> null (leave `/` alone)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The reset surface the bridge hands off to, by its existing path. */
|
|
23
|
+
export const RECOVERY_PATH = '/account-auth/recovery';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* PAY-2121 (BAPert 70576): the recovery page's ENTRY decision, as a pure function.
|
|
27
|
+
*
|
|
28
|
+
* The bridge now sends a VERIFIED passwordless user to
|
|
29
|
+
* /account-auth/recovery?token=<reset_token>&email=<addr>
|
|
30
|
+
* with a REAL reset token in hand. If the page then showed its request-a-code
|
|
31
|
+
* step, it would throw that token away and the flow would still dead-end - just
|
|
32
|
+
* one page later. So when a token is present the page must start at the
|
|
33
|
+
* set-password step and submit straight through resetPasswordWithToken.
|
|
34
|
+
*
|
|
35
|
+
* token present -> { step: 'set-password', resetToken: <token> }
|
|
36
|
+
* no token -> { step: 'initiate', resetToken: null } (today's behaviour)
|
|
37
|
+
*/
|
|
38
|
+
export function recoveryEntry(
|
|
39
|
+
token: string | null | undefined,
|
|
40
|
+
email: string | null | undefined,
|
|
41
|
+
): { step: 'set-password' | 'initiate'; resetToken: string | null; email: string } {
|
|
42
|
+
const tok = (token ?? '').trim();
|
|
43
|
+
return {
|
|
44
|
+
step: tok ? 'set-password' : 'initiate',
|
|
45
|
+
resetToken: tok || null,
|
|
46
|
+
email: (email ?? '').trim(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* PAY-2121 (BAPert 70582): the set-password SUBMIT precondition, as a pure
|
|
52
|
+
* function. The old guard was `if (!passwordResetToken || !recoverySession) return;`.
|
|
53
|
+
* Removing the session half (so a deep-linked reset can submit) must NOT let a
|
|
54
|
+
* submit with NO token reach the API carrying an empty token - which is exactly
|
|
55
|
+
* what a guard-less submit would do. This decides it, in one testable place:
|
|
56
|
+
*
|
|
57
|
+
* resetSubmitTarget(resetToken, sessionEmail, queryEmail):
|
|
58
|
+
* - no token (after trim) -> null (REFUSE: never call the API with an empty token)
|
|
59
|
+
* - token, session email present -> { token, email: sessionEmail }
|
|
60
|
+
* - token, only a query email -> { token, email: queryEmail } (the deep-link case)
|
|
61
|
+
* - token, but NO email anywhere -> null (REFUSE: nothing to reset)
|
|
62
|
+
*
|
|
63
|
+
* Returns the exact { token, email } the caller must pass to
|
|
64
|
+
* accountApi.resetPasswordWithToken, or null to refuse BEFORE any request.
|
|
65
|
+
*/
|
|
66
|
+
export function resetSubmitTarget(
|
|
67
|
+
resetToken: string | null | undefined,
|
|
68
|
+
sessionEmail: string | null | undefined,
|
|
69
|
+
queryEmail: string | null | undefined,
|
|
70
|
+
): { token: string; email: string } | null {
|
|
71
|
+
const tok = (resetToken ?? '').trim();
|
|
72
|
+
if (!tok) return null;
|
|
73
|
+
const email = ((sessionEmail ?? '').trim() || (queryEmail ?? '').trim());
|
|
74
|
+
if (!email) return null;
|
|
75
|
+
return { token: tok, email };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The redirect the fragment asks for, or null when the fragment is not a reset
|
|
80
|
+
* request. `hash` is `window.location.hash` (leading '#' optional).
|
|
81
|
+
*
|
|
82
|
+
* Returns null - leaves `/` untouched - when:
|
|
83
|
+
* - the fragment is empty;
|
|
84
|
+
* - `password_reset_required` is absent or not exactly 'true';
|
|
85
|
+
* - no `reset_token` is present (nothing to reset with; not a real link).
|
|
86
|
+
*/
|
|
87
|
+
export function passwordResetRedirect(hash: string | null | undefined): string | null {
|
|
88
|
+
const raw = (hash ?? '').replace(/^#/, '');
|
|
89
|
+
if (!raw) return null;
|
|
90
|
+
const params = new URLSearchParams(raw);
|
|
91
|
+
if (params.get('password_reset_required') !== 'true') return null;
|
|
92
|
+
const token = params.get('reset_token') ?? '';
|
|
93
|
+
if (!token) return null;
|
|
94
|
+
const email = params.get('email') ?? '';
|
|
95
|
+
const qs = new URLSearchParams({ token });
|
|
96
|
+
if (email) qs.set('email', email);
|
|
97
|
+
return `${RECOVERY_PATH}?${qs.toString()}`;
|
|
98
|
+
}
|
package/src/auth/route-config.ts
CHANGED
|
@@ -63,6 +63,23 @@ const defaultUnauthenticatedRoutes: UnauthenticatedRouteConfig[] = [
|
|
|
63
63
|
allowDuringCircuitBreakerOpen: true,
|
|
64
64
|
requiresRateLimit: false
|
|
65
65
|
},
|
|
66
|
+
// PAY-2121: the IdP emails an invite link to /account-auth/onboarding and a
|
|
67
|
+
// post-verify reset FRAGMENT on '/'. Both must be reachable unauthenticated -
|
|
68
|
+
// the invitee has no session (the onboard_token IS the credential), and the
|
|
69
|
+
// fragment lands on the already-public root. Listed explicitly, not left to
|
|
70
|
+
// the /account-auth/* wildcard, so the two conventional paths are visible here.
|
|
71
|
+
{
|
|
72
|
+
pattern: '/account-auth/onboarding',
|
|
73
|
+
description: 'IdP invite/onboarding landing page (token-driven, no session)',
|
|
74
|
+
allowDuringCircuitBreakerOpen: true,
|
|
75
|
+
requiresRateLimit: false
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
pattern: '/api/onboarding/*',
|
|
79
|
+
description: 'Onboarding API (token-driven, no session)',
|
|
80
|
+
allowDuringCircuitBreakerOpen: true,
|
|
81
|
+
requiresRateLimit: true
|
|
82
|
+
},
|
|
66
83
|
{
|
|
67
84
|
pattern: '/api/auth/*',
|
|
68
85
|
description: 'Authentication API endpoints',
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { passwordResetRedirect } from '../../auth/password-reset-bridge';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PAY-2121: mount this once on your app's `/` page. When the IdP's post-verify
|
|
8
|
+
* redirect lands on `/#password_reset_required=true&reset_token=<t>&email=<e>`
|
|
9
|
+
* (a FRAGMENT), this performs ONE replace to the kit's reset surface and clears
|
|
10
|
+
* the hash. With any other fragment it renders nothing and leaves `/` alone.
|
|
11
|
+
*
|
|
12
|
+
* It renders nothing and reads no user data - it only inspects the fragment.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // app/page.tsx
|
|
16
|
+
* import { PasswordResetBridge } from '@payez/next-mvp/components/auth/PasswordResetBridge';
|
|
17
|
+
* export default function Home() {
|
|
18
|
+
* return (<><PasswordResetBridge /></>);
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
export function PasswordResetBridge() {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (typeof window === 'undefined') return;
|
|
24
|
+
const target = passwordResetRedirect(window.location.hash);
|
|
25
|
+
if (target) window.location.replace(target);
|
|
26
|
+
}, []);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default PasswordResetBridge;
|