@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.
Files changed (50) hide show
  1. package/README.md +53 -0
  2. package/dist/api-handlers/onboarding/send-email-code.d.ts +11 -0
  3. package/dist/api-handlers/onboarding/send-email-code.js +11 -0
  4. package/dist/api-handlers/onboarding/set-password.d.ts +12 -0
  5. package/dist/api-handlers/onboarding/set-password.js +15 -0
  6. package/dist/api-handlers/onboarding/verify-code.d.ts +11 -0
  7. package/dist/api-handlers/onboarding/verify-code.js +14 -0
  8. package/dist/api-handlers/onboarding/verify-welcome-email.d.ts +10 -0
  9. package/dist/api-handlers/onboarding/verify-welcome-email.js +11 -0
  10. package/dist/auth/password-reset-bridge.d.ts +70 -0
  11. package/dist/auth/password-reset-bridge.js +99 -0
  12. package/dist/auth/route-config.js +17 -0
  13. package/dist/components/auth/PasswordResetBridge.d.ts +17 -0
  14. package/dist/components/auth/PasswordResetBridge.js +32 -0
  15. package/dist/lib/onboarding-proxy.d.ts +30 -0
  16. package/dist/lib/onboarding-proxy.js +62 -0
  17. package/dist/pages/login/page.js +1 -1
  18. package/dist/pages/onboarding/index.d.ts +11 -0
  19. package/dist/pages/onboarding/index.js +20 -0
  20. package/dist/pages/onboarding/page.d.ts +1 -0
  21. package/dist/pages/onboarding/page.js +118 -0
  22. package/dist/pages/recovery/page.js +20 -7
  23. package/dist/routes/onboarding/index.d.ts +15 -0
  24. package/dist/routes/onboarding/index.js +22 -0
  25. package/dist/routes/onboarding/send-email-code.d.ts +2 -0
  26. package/dist/routes/onboarding/send-email-code.js +6 -0
  27. package/dist/routes/onboarding/set-password.d.ts +2 -0
  28. package/dist/routes/onboarding/set-password.js +6 -0
  29. package/dist/routes/onboarding/verify-code.d.ts +2 -0
  30. package/dist/routes/onboarding/verify-code.js +6 -0
  31. package/dist/routes/onboarding/verify-welcome-email.d.ts +2 -0
  32. package/dist/routes/onboarding/verify-welcome-email.js +6 -0
  33. package/package.json +56 -1
  34. package/src/api-handlers/onboarding/send-email-code.ts +18 -0
  35. package/src/api-handlers/onboarding/set-password.ts +21 -0
  36. package/src/api-handlers/onboarding/verify-code.ts +20 -0
  37. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -0
  38. package/src/auth/password-reset-bridge.ts +98 -0
  39. package/src/auth/route-config.ts +17 -0
  40. package/src/components/auth/PasswordResetBridge.tsx +30 -0
  41. package/src/lib/onboarding-proxy.ts +57 -0
  42. package/src/pages/login/page.tsx +4 -7
  43. package/src/pages/onboarding/index.ts +11 -0
  44. package/src/pages/onboarding/page.tsx +212 -0
  45. package/src/pages/recovery/page.tsx +24 -8
  46. package/src/routes/onboarding/index.ts +15 -0
  47. package/src/routes/onboarding/send-email-code.ts +2 -0
  48. package/src/routes/onboarding/set-password.ts +2 -0
  49. package/src/routes/onboarding/verify-code.ts +2 -0
  50. 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
- const prefilledEmail = searchParams?.get('email') || '';
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)('initiate');
22
- const [email, setEmail] = (0, react_1.useState)(prefilledEmail);
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
- if (!passwordResetToken || !recoverySession)
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(recoverySession.email, passwordResetToken.token, password, confirmPassword);
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,2 @@
1
+ /** POST /api/onboarding/send-email-code -> IdP (PAY-2121). */
2
+ export { POST } from '../../api-handlers/onboarding/send-email-code';
@@ -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,2 @@
1
+ /** POST /api/onboarding/set-password -> IdP (PAY-2121). */
2
+ export { POST } from '../../api-handlers/onboarding/set-password';
@@ -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,2 @@
1
+ /** POST /api/onboarding/verify-code -> IdP (PAY-2121). */
2
+ export { POST } from '../../api-handlers/onboarding/verify-code';
@@ -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,2 @@
1
+ /** POST /api/onboarding/verify-welcome-email -> IdP (PAY-2121). */
2
+ export { POST } from '../../api-handlers/onboarding/verify-welcome-email';
@@ -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.4.9",
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
+ }
@@ -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 (&lt;&gt;&lt;PasswordResetBridge /&gt;&lt;/&gt;);
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;