@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,57 @@
1
+ /**
2
+ * PAY-2121: shared anonymous IDP proxy for the /api/onboarding/* handlers.
3
+ *
4
+ * The onboarding flow is TOKEN-DRIVEN: the invitee has no account or session
5
+ * yet, and the onboard_token IS the credential. So these calls must NOT go
6
+ * through idpFetchJSON (which injects a Bearer from a Redis session) - there is
7
+ * no session. They are plain server-to-server POSTs to the IdP, exactly as the
8
+ * VibeID portal's hand-built /api/onboarding/* routes do (card 182611).
9
+ *
10
+ * WHY A SERVER ROUTE AT ALL, AND NOT A BROWSER CALL: the IdP URL is not exposed
11
+ * to the browser, and the route is the one place that owns that. The token is
12
+ * forwarded, never the IdP's host.
13
+ */
14
+ import { NextRequest, NextResponse } from 'next/server';
15
+ import { ENV_CONFIG } from '../config/env';
16
+ import { assertAbsoluteIdpUrl } from './idp-url-guard';
17
+
18
+ /** A typed refusal in the kit's error envelope - used for a missing field, BEFORE any IdP call. */
19
+ export function onboardingBadRequest(code: string, message: string) {
20
+ return NextResponse.json({ success: false, message, error: { code } }, { status: 400 });
21
+ }
22
+
23
+ /**
24
+ * POST a JSON body to `{IDP_URL}{idpPath}` with no credentials, and relay the
25
+ * IdP's status and body verbatim (the page acts on what the IdP actually
26
+ * returns, not on a shape this layer guesses). Never relays the IdP error TEXT
27
+ * for a non-2xx is NOT done here: the onboarding endpoints answer structured
28
+ * envelopes the page reads; the page owns the copy.
29
+ */
30
+ export async function onboardingProxy(req: NextRequest, idpPath: string): Promise<NextResponse> {
31
+ const idpBase = ENV_CONFIG.IDP_URL;
32
+ if (!idpBase) {
33
+ return NextResponse.json({ success: false, message: 'IDP_URL is not configured', error: { code: 'NO_IDP_URL' } }, { status: 500 });
34
+ }
35
+ const url = `${idpBase}${idpPath}`;
36
+ assertAbsoluteIdpUrl(url);
37
+ let bodyText = '{}';
38
+ try {
39
+ const raw = await req.text();
40
+ if (raw && raw.trim().length > 0) bodyText = raw;
41
+ } catch { /* empty body is fine */ }
42
+
43
+ const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
44
+ const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
45
+ if (xfwd) headers['X-Forwarded-For'] = xfwd;
46
+ const ua = req.headers.get('user-agent');
47
+ if (ua) headers['User-Agent'] = ua;
48
+
49
+ let res: Response;
50
+ try {
51
+ res = await fetch(url, { method: 'POST', headers, body: bodyText, cache: 'no-store' });
52
+ } catch {
53
+ return NextResponse.json({ success: false, message: 'The identity service is unreachable.', error: { code: 'UPSTREAM_UNREACHABLE' } }, { status: 502 });
54
+ }
55
+ const json = await res.json().catch(() => null);
56
+ return NextResponse.json(json ?? { success: res.ok }, { status: res.status });
57
+ }
@@ -311,13 +311,10 @@ function LoginForm({ altAuthSlot }: { altAuthSlot?: React.ReactNode }) {
311
311
  </div>
312
312
  </div>
313
313
 
314
- {/* Additional Info */}
315
- <p className={`mt-4 text-center text-sm ${colors.muted}`}>
316
- Don't have an account?{' '}
317
- <a href="/account-auth/register" className="themed-link font-medium hover:underline">
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&apos;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
- const prefilledEmail = searchParams?.get('email') || '';
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>('initiate');
22
- const [email, setEmail] = useState(prefilledEmail);
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>(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
- if (!passwordResetToken || !recoverySession) return;
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
- recoverySession.email,
125
- passwordResetToken.token,
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
- else if (currentStep === 'set-password') setCurrentStep('verify-code');
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 (
@@ -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,2 @@
1
+ /** POST /api/onboarding/send-email-code -> IdP (PAY-2121). */
2
+ export { POST } from '../../api-handlers/onboarding/send-email-code';
@@ -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,2 @@
1
+ /** POST /api/onboarding/verify-code -> IdP (PAY-2121). */
2
+ export { POST } from '../../api-handlers/onboarding/verify-code';
@@ -0,0 +1,2 @@
1
+ /** POST /api/onboarding/verify-welcome-email -> IdP (PAY-2121). */
2
+ export { POST } from '../../api-handlers/onboarding/verify-welcome-email';