@payez/next-mvp 4.8.5 → 4.8.6
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/dist/api-handlers/onboarding/signup.d.ts +15 -0
- package/dist/api-handlers/onboarding/signup.js +44 -0
- package/dist/components/auth/InvitedEntryLink.d.ts +3 -0
- package/dist/components/auth/InvitedEntryLink.js +9 -0
- package/dist/lib/onboarding-code-client.d.ts +10 -0
- package/dist/lib/onboarding-code-client.js +22 -0
- package/dist/pages/invited/index.d.ts +5 -0
- package/dist/pages/invited/index.js +13 -0
- package/dist/pages/invited/page.d.ts +7 -0
- package/dist/pages/invited/page.js +41 -0
- package/dist/pages/onboarding/page.js +20 -1
- package/dist/routes/onboarding/index.d.ts +1 -0
- package/dist/routes/onboarding/index.js +3 -1
- package/dist/routes/onboarding/signup.d.ts +2 -0
- package/dist/routes/onboarding/signup.js +6 -0
- package/package.json +21 -1
- package/src/api-handlers/onboarding/signup.ts +41 -0
- package/src/components/auth/InvitedEntryLink.tsx +13 -0
- package/src/lib/onboarding-code-client.ts +31 -0
- package/src/pages/invited/index.ts +5 -0
- package/src/pages/invited/page.tsx +60 -0
- package/src/pages/onboarding/page.tsx +28 -2
- package/src/routes/onboarding/index.ts +1 -0
- package/src/routes/onboarding/signup.ts +2 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/onboarding/signup (4.8.6) - the "I was invited" entry's server half.
|
|
3
|
+
*
|
|
4
|
+
* Body: { email }. Forwards EXACTLY { email, client_id } to the IdP's anonymous POST /api/Onboarding/signup; the client id is this
|
|
5
|
+
* app's own (server env), never taken from the browser, and any other key in the request is dropped. The IdP answers a uniform 202
|
|
6
|
+
* whatever the address is (enumeration-safe) and emails the onboarding LINK only if the address was invited; so this route also
|
|
7
|
+
* answers ONE fixed body, whatever the IdP said (status, body, an outage) - the only signal is the email. A malformed address is
|
|
8
|
+
* refused here, locally, before any IdP call.
|
|
9
|
+
*/
|
|
10
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
11
|
+
export declare const INVITED_SIGNUP_SENTENCE = "If this address was invited, a link is on its way.";
|
|
12
|
+
export declare function POST(req: NextRequest): Promise<NextResponse<{
|
|
13
|
+
success: boolean;
|
|
14
|
+
message: string;
|
|
15
|
+
}>>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.INVITED_SIGNUP_SENTENCE = void 0;
|
|
4
|
+
exports.POST = POST;
|
|
5
|
+
/**
|
|
6
|
+
* POST /api/onboarding/signup (4.8.6) - the "I was invited" entry's server half.
|
|
7
|
+
*
|
|
8
|
+
* Body: { email }. Forwards EXACTLY { email, client_id } to the IdP's anonymous POST /api/Onboarding/signup; the client id is this
|
|
9
|
+
* app's own (server env), never taken from the browser, and any other key in the request is dropped. The IdP answers a uniform 202
|
|
10
|
+
* whatever the address is (enumeration-safe) and emails the onboarding LINK only if the address was invited; so this route also
|
|
11
|
+
* answers ONE fixed body, whatever the IdP said (status, body, an outage) - the only signal is the email. A malformed address is
|
|
12
|
+
* refused here, locally, before any IdP call.
|
|
13
|
+
*/
|
|
14
|
+
const server_1 = require("next/server");
|
|
15
|
+
const env_1 = require("../../config/env");
|
|
16
|
+
const idp_url_guard_1 = require("../../lib/idp-url-guard");
|
|
17
|
+
exports.INVITED_SIGNUP_SENTENCE = 'If this address was invited, a link is on its way.';
|
|
18
|
+
const FIXED = { success: true, message: exports.INVITED_SIGNUP_SENTENCE };
|
|
19
|
+
async function POST(req) {
|
|
20
|
+
const raw = (await req.json().catch(() => null));
|
|
21
|
+
const email = typeof raw?.email === 'string' ? raw.email.trim() : '';
|
|
22
|
+
if (!email || email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
23
|
+
return server_1.NextResponse.json({ success: false, message: 'Enter a valid email address.', error: { code: 'INVALID_EMAIL' } }, { status: 400 });
|
|
24
|
+
}
|
|
25
|
+
const idpBase = env_1.ENV_CONFIG.IDP_URL;
|
|
26
|
+
const clientId = env_1.ENV_CONFIG.IDP_CLIENT_ID ?? env_1.ENV_CONFIG.CLIENT_ID;
|
|
27
|
+
if (!idpBase || clientId === undefined || clientId === null || String(clientId) === '') {
|
|
28
|
+
return server_1.NextResponse.json({ success: false, message: 'Sign-up is not configured.', error: { code: 'NOT_CONFIGURED' } }, { status: 500 });
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const url = `${idpBase}/api/Onboarding/signup`;
|
|
32
|
+
(0, idp_url_guard_1.assertAbsoluteIdpUrl)(url);
|
|
33
|
+
await fetch(url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
36
|
+
body: JSON.stringify({ email, client_id: String(clientId) }),
|
|
37
|
+
cache: 'no-store',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* the answer must not vary with the IdP's state */
|
|
42
|
+
}
|
|
43
|
+
return server_1.NextResponse.json(FIXED, { status: 202 });
|
|
44
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InvitedEntryLink = InvitedEntryLink;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
/** 4.8.6: the sign-in page's way in for a person who was invited. A plain link to /account-auth/signup (the "I was invited" page). */
|
|
6
|
+
function InvitedEntryLink() {
|
|
7
|
+
return ((0, jsx_runtime_1.jsx)("p", { className: "text-sm mt-4", style: { textAlign: 'center', color: 'var(--text-secondary, #6b7280)' }, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/signup", "data-testid": "invited-entry", style: { color: 'var(--text-primary, #111827)', fontWeight: 600, textDecoration: 'underline' }, children: "I was invited" }) }));
|
|
8
|
+
}
|
|
9
|
+
exports.default = InvitedEntryLink;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.8.6: the onboarding page asks the IdP to email the invitee a code (POST /api/onboarding/send-email-code, token-driven) and shows
|
|
3
|
+
* the emailed code ONLY when the IdP itself echoed it in that answer (Email:DevCodeEcho, Development only). Same rule as the sign-in
|
|
4
|
+
* form (AUTH-045): a pure passthrough of the server's `dev_verification_code`, only a non-empty STRING counts, no environment flag.
|
|
5
|
+
*/
|
|
6
|
+
export type OnboardingCodeResult = {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
devCode: string | null;
|
|
9
|
+
};
|
|
10
|
+
export declare function requestOnboardingCode(onboardToken: string, fetchImpl?: typeof fetch): Promise<OnboardingCodeResult>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.requestOnboardingCode = requestOnboardingCode;
|
|
4
|
+
function pick(j) {
|
|
5
|
+
const o = j;
|
|
6
|
+
return o?.dev_verification_code ?? o?.data?.dev_verification_code ?? o?.data?.result?.dev_verification_code ?? o?.result?.dev_verification_code;
|
|
7
|
+
}
|
|
8
|
+
async function requestOnboardingCode(onboardToken, fetchImpl = fetch) {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetchImpl('/api/onboarding/send-email-code', {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
body: JSON.stringify({ onboard_token: onboardToken }),
|
|
14
|
+
});
|
|
15
|
+
const body = await res.json().catch(() => null);
|
|
16
|
+
const echoed = pick(body);
|
|
17
|
+
return { ok: res.ok, devCode: res.ok && typeof echoed === 'string' && echoed !== '' ? echoed : null };
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return { ok: false, devCode: null };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.INVITED_DONE_SENTENCE = exports.default = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* 4.8.6: the "I was invited" page. Mount at app/account-auth/signup/page.tsx:
|
|
9
|
+
* export { default } from '@payez/next-mvp/pages/invited';
|
|
10
|
+
*/
|
|
11
|
+
var page_1 = require("./page");
|
|
12
|
+
Object.defineProperty(exports, "default", { enumerable: true, get: function () { return __importDefault(page_1).default; } });
|
|
13
|
+
Object.defineProperty(exports, "INVITED_DONE_SENTENCE", { enumerable: true, get: function () { return page_1.INVITED_DONE_SENTENCE; } });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.8.6: /account-auth/signup - "I was invited". The invitee types the address the invitation went to; the page posts it to the app's
|
|
3
|
+
* own /api/onboarding/signup and ALWAYS shows the same sentence afterwards, whatever came back (the IdP's answer is uniform and the
|
|
4
|
+
* page must not become an oracle for which addresses were invited). The onboarding link arrives by email.
|
|
5
|
+
*/
|
|
6
|
+
export declare const INVITED_DONE_SENTENCE = "If this address was invited, a link is on its way.";
|
|
7
|
+
export default function InvitedSignupPage(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.INVITED_DONE_SENTENCE = void 0;
|
|
5
|
+
exports.default = InvitedSignupPage;
|
|
6
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const react_1 = require("react");
|
|
8
|
+
/**
|
|
9
|
+
* 4.8.6: /account-auth/signup - "I was invited". The invitee types the address the invitation went to; the page posts it to the app's
|
|
10
|
+
* own /api/onboarding/signup and ALWAYS shows the same sentence afterwards, whatever came back (the IdP's answer is uniform and the
|
|
11
|
+
* page must not become an oracle for which addresses were invited). The onboarding link arrives by email.
|
|
12
|
+
*/
|
|
13
|
+
exports.INVITED_DONE_SENTENCE = 'If this address was invited, a link is on its way.';
|
|
14
|
+
const INVITED_ERROR_SENTENCE = 'Enter the email address your invitation was sent to.';
|
|
15
|
+
function InvitedSignupPage() {
|
|
16
|
+
const [email, setEmail] = (0, react_1.useState)('');
|
|
17
|
+
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
18
|
+
const [done, setDone] = (0, react_1.useState)(false);
|
|
19
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
20
|
+
async function submit(e) {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
const value = email.trim();
|
|
23
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
24
|
+
setError(INVITED_ERROR_SENTENCE);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
setBusy(true);
|
|
28
|
+
setError(null);
|
|
29
|
+
try {
|
|
30
|
+
await fetch('/api/onboarding/signup', {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'Content-Type': 'application/json' },
|
|
33
|
+
body: JSON.stringify({ email: value }),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch { /* same sentence either way */ }
|
|
37
|
+
setBusy(false);
|
|
38
|
+
setDone(true);
|
|
39
|
+
}
|
|
40
|
+
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.jsxs)("div", { style: { width: '100%', maxWidth: 420, background: 'var(--bg-card, #fff)', border: '1px solid var(--border-default, #e5e7eb)', borderRadius: 16, padding: 28 }, children: [(0, jsx_runtime_1.jsx)("h1", { style: { color: 'var(--text-primary)', fontSize: 20, fontWeight: 600, margin: 0 }, children: "I was invited" }), done ? ((0, jsx_runtime_1.jsx)("p", { role: "status", "data-testid": "invited-done", style: { color: 'var(--text-secondary)', fontSize: 14, marginTop: 12 }, children: exports.INVITED_DONE_SENTENCE })) : ((0, jsx_runtime_1.jsxs)("form", { onSubmit: submit, noValidate: true, style: { marginTop: 16 }, children: [(0, jsx_runtime_1.jsx)("p", { style: { color: 'var(--text-secondary)', fontSize: 14, marginTop: 0 }, children: "Enter the email address your invitation was sent to." }), (0, jsx_runtime_1.jsx)("input", { type: "email", value: email, onChange: (e) => setEmail(e.target.value), autoComplete: "email", "aria-label": "Email address", placeholder: "you@example.com", "aria-invalid": error ? true : undefined, "aria-describedby": error ? 'invited-error' : undefined, style: { width: '100%', padding: '10px 12px', border: '1px solid var(--border-default, #d1d5db)', borderRadius: 10, fontSize: 15 } }), error && (0, jsx_runtime_1.jsx)("p", { id: "invited-error", role: "alert", style: { color: '#b00020', fontSize: 13, marginTop: 8 }, children: error }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy || !email, style: { marginTop: 14, width: '100%', padding: '10px 12px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: '#fff', fontWeight: 600, fontSize: 15, opacity: busy || !email ? 0.5 : 1 }, children: busy ? 'Sending…' : 'Send my link' })] })), (0, jsx_runtime_1.jsx)("p", { style: { marginTop: 16 }, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/login", style: { color: 'var(--text-primary)', fontWeight: 600, textDecoration: 'underline' }, children: "Back to sign in" }) })] }) }));
|
|
41
|
+
}
|
|
@@ -5,6 +5,8 @@ exports.default = OnboardingPage;
|
|
|
5
5
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
6
|
const react_1 = require("react");
|
|
7
7
|
const navigation_1 = require("next/navigation");
|
|
8
|
+
const DevCodeNotice_1 = require("../../components/auth/DevCodeNotice");
|
|
9
|
+
const onboarding_code_client_1 = require("../../lib/onboarding-code-client");
|
|
8
10
|
const H = { primary: 'var(--text-primary)', secondary: 'var(--text-secondary)', muted: 'var(--text-muted)' };
|
|
9
11
|
function OnboardingContent() {
|
|
10
12
|
const params = (0, navigation_1.useSearchParams)();
|
|
@@ -18,6 +20,23 @@ function OnboardingContent() {
|
|
|
18
20
|
const [email, setEmail] = (0, react_1.useState)('');
|
|
19
21
|
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
20
22
|
const [error, setError] = (0, react_1.useState)(null);
|
|
23
|
+
const [devCode, setDevCode] = (0, react_1.useState)(null);
|
|
24
|
+
const [codeNote, setCodeNote] = (0, react_1.useState)(null);
|
|
25
|
+
const sentFor = (0, react_1.useRef)(null);
|
|
26
|
+
// 4.8.6: the page asks for the code itself, once per token (nothing else ever sends it). The dev echo, if the IdP gave one in
|
|
27
|
+
// this answer, is shown under the code box; nothing else can make it appear.
|
|
28
|
+
async function sendCode() {
|
|
29
|
+
setCodeNote(null);
|
|
30
|
+
const r = await (0, onboarding_code_client_1.requestOnboardingCode)(onboardToken);
|
|
31
|
+
setDevCode(r.devCode);
|
|
32
|
+
setCodeNote(r.ok ? 'A new code was sent.' : 'We could not send a code. Try again in a moment.');
|
|
33
|
+
}
|
|
34
|
+
(0, react_1.useEffect)(() => {
|
|
35
|
+
if (!onboardToken || sentFor.current === onboardToken)
|
|
36
|
+
return;
|
|
37
|
+
sentFor.current = onboardToken;
|
|
38
|
+
void (0, onboarding_code_client_1.requestOnboardingCode)(onboardToken).then((r) => setDevCode(r.devCode));
|
|
39
|
+
}, [onboardToken]);
|
|
21
40
|
// Step 1: tell the invitee which address the code went to (masked). Best
|
|
22
41
|
// effort - a failure here does not block entering a code they were emailed.
|
|
23
42
|
(0, react_1.useEffect)(() => {
|
|
@@ -97,7 +116,7 @@ function OnboardingContent() {
|
|
|
97
116
|
}
|
|
98
117
|
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
118
|
? `We emailed a 6-digit code to ${maskedEmail}. Enter it below.`
|
|
100
|
-
: '
|
|
119
|
+
: 'We emailed you a 6-digit code. Enter it below.' }), (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' })] }), (0, jsx_runtime_1.jsx)(DevCodeNotice_1.DevCodeNotice, { code: devCode }), (0, jsx_runtime_1.jsxs)("p", { style: { marginTop: 12, fontSize: 13 }, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => void sendCode(), "data-testid": "resend-code", style: { background: 'none', border: 'none', padding: 0, color: H.primary, fontWeight: 600, textDecoration: 'underline', cursor: 'pointer' }, children: "Send a new code" }), codeNote && (0, jsx_runtime_1.jsx)("span", { role: "status", style: { marginLeft: 8, color: H.secondary }, children: codeNote })] })] })), 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
120
|
}
|
|
102
121
|
const titleStyle = { color: H.primary, fontSize: 20, fontWeight: 600, margin: 0 };
|
|
103
122
|
const linkStyle = { color: H.primary, fontWeight: 600, textDecoration: 'underline' };
|
|
@@ -13,3 +13,4 @@ export { POST as verifyCodePOST } from '../../api-handlers/onboarding/verify-cod
|
|
|
13
13
|
export { POST as sendEmailCodePOST } from '../../api-handlers/onboarding/send-email-code';
|
|
14
14
|
export { POST as verifyWelcomeEmailPOST } from '../../api-handlers/onboarding/verify-welcome-email';
|
|
15
15
|
export { POST as setPasswordPOST } from '../../api-handlers/onboarding/set-password';
|
|
16
|
+
export { POST as signupPOST } from '../../api-handlers/onboarding/signup';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.setPasswordPOST = exports.verifyWelcomeEmailPOST = exports.sendEmailCodePOST = exports.verifyCodePOST = void 0;
|
|
3
|
+
exports.signupPOST = exports.setPasswordPOST = exports.verifyWelcomeEmailPOST = exports.sendEmailCodePOST = exports.verifyCodePOST = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Ready-to-use onboarding route handlers (PAY-2121).
|
|
6
6
|
*
|
|
@@ -20,3 +20,5 @@ var verify_welcome_email_1 = require("../../api-handlers/onboarding/verify-welco
|
|
|
20
20
|
Object.defineProperty(exports, "verifyWelcomeEmailPOST", { enumerable: true, get: function () { return verify_welcome_email_1.POST; } });
|
|
21
21
|
var set_password_1 = require("../../api-handlers/onboarding/set-password");
|
|
22
22
|
Object.defineProperty(exports, "setPasswordPOST", { enumerable: true, get: function () { return set_password_1.POST; } });
|
|
23
|
+
var signup_1 = require("../../api-handlers/onboarding/signup");
|
|
24
|
+
Object.defineProperty(exports, "signupPOST", { enumerable: true, get: function () { return signup_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/signup -> IdP anonymous Onboarding/signup (4.8.6, the "I was invited" entry). */
|
|
5
|
+
var signup_1 = require("../../api-handlers/onboarding/signup");
|
|
6
|
+
Object.defineProperty(exports, "POST", { enumerable: true, get: function () { return signup_1.POST; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.6",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -1055,6 +1055,26 @@
|
|
|
1055
1055
|
"require": "./dist/lib/bearer-fetch.js",
|
|
1056
1056
|
"default": "./dist/lib/bearer-fetch.js"
|
|
1057
1057
|
},
|
|
1058
|
+
"./pages/invited": {
|
|
1059
|
+
"types": "./dist/pages/invited/index.d.ts",
|
|
1060
|
+
"require": "./dist/pages/invited/index.js",
|
|
1061
|
+
"default": "./dist/pages/invited/index.js"
|
|
1062
|
+
},
|
|
1063
|
+
"./routes/onboarding/signup": {
|
|
1064
|
+
"types": "./dist/routes/onboarding/signup.d.ts",
|
|
1065
|
+
"require": "./dist/routes/onboarding/signup.js",
|
|
1066
|
+
"default": "./dist/routes/onboarding/signup.js"
|
|
1067
|
+
},
|
|
1068
|
+
"./api-handlers/onboarding/signup": {
|
|
1069
|
+
"types": "./dist/api-handlers/onboarding/signup.d.ts",
|
|
1070
|
+
"require": "./dist/api-handlers/onboarding/signup.js",
|
|
1071
|
+
"default": "./dist/api-handlers/onboarding/signup.js"
|
|
1072
|
+
},
|
|
1073
|
+
"./components/auth/InvitedEntryLink": {
|
|
1074
|
+
"types": "./dist/components/auth/InvitedEntryLink.d.ts",
|
|
1075
|
+
"require": "./dist/components/auth/InvitedEntryLink.js",
|
|
1076
|
+
"default": "./dist/components/auth/InvitedEntryLink.js"
|
|
1077
|
+
},
|
|
1058
1078
|
"./components/auth/CredentialSignInForm": {
|
|
1059
1079
|
"types": "./dist/components/auth/CredentialSignInForm.d.ts",
|
|
1060
1080
|
"require": "./dist/components/auth/CredentialSignInForm.js",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/onboarding/signup (4.8.6) - the "I was invited" entry's server half.
|
|
3
|
+
*
|
|
4
|
+
* Body: { email }. Forwards EXACTLY { email, client_id } to the IdP's anonymous POST /api/Onboarding/signup; the client id is this
|
|
5
|
+
* app's own (server env), never taken from the browser, and any other key in the request is dropped. The IdP answers a uniform 202
|
|
6
|
+
* whatever the address is (enumeration-safe) and emails the onboarding LINK only if the address was invited; so this route also
|
|
7
|
+
* answers ONE fixed body, whatever the IdP said (status, body, an outage) - the only signal is the email. A malformed address is
|
|
8
|
+
* refused here, locally, before any IdP call.
|
|
9
|
+
*/
|
|
10
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
11
|
+
import { ENV_CONFIG } from '../../config/env';
|
|
12
|
+
import { assertAbsoluteIdpUrl } from '../../lib/idp-url-guard';
|
|
13
|
+
|
|
14
|
+
export const INVITED_SIGNUP_SENTENCE = 'If this address was invited, a link is on its way.';
|
|
15
|
+
const FIXED = { success: true, message: INVITED_SIGNUP_SENTENCE };
|
|
16
|
+
|
|
17
|
+
export async function POST(req: NextRequest) {
|
|
18
|
+
const raw = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
|
19
|
+
const email = typeof raw?.email === 'string' ? raw.email.trim() : '';
|
|
20
|
+
if (!email || email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
21
|
+
return NextResponse.json({ success: false, message: 'Enter a valid email address.', error: { code: 'INVALID_EMAIL' } }, { status: 400 });
|
|
22
|
+
}
|
|
23
|
+
const idpBase = ENV_CONFIG.IDP_URL;
|
|
24
|
+
const clientId = ENV_CONFIG.IDP_CLIENT_ID ?? ENV_CONFIG.CLIENT_ID;
|
|
25
|
+
if (!idpBase || clientId === undefined || clientId === null || String(clientId) === '') {
|
|
26
|
+
return NextResponse.json({ success: false, message: 'Sign-up is not configured.', error: { code: 'NOT_CONFIGURED' } }, { status: 500 });
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const url = `${idpBase}/api/Onboarding/signup`;
|
|
30
|
+
assertAbsoluteIdpUrl(url);
|
|
31
|
+
await fetch(url, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
34
|
+
body: JSON.stringify({ email, client_id: String(clientId) }),
|
|
35
|
+
cache: 'no-store',
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
/* the answer must not vary with the IdP's state */
|
|
39
|
+
}
|
|
40
|
+
return NextResponse.json(FIXED, { status: 202 });
|
|
41
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/** 4.8.6: the sign-in page's way in for a person who was invited. A plain link to /account-auth/signup (the "I was invited" page). */
|
|
4
|
+
export function InvitedEntryLink() {
|
|
5
|
+
return (
|
|
6
|
+
<p className="text-sm mt-4" style={{ textAlign: 'center', color: 'var(--text-secondary, #6b7280)' }}>
|
|
7
|
+
<a href="/account-auth/signup" data-testid="invited-entry" style={{ color: 'var(--text-primary, #111827)', fontWeight: 600, textDecoration: 'underline' }}>
|
|
8
|
+
I was invited
|
|
9
|
+
</a>
|
|
10
|
+
</p>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
export default InvitedEntryLink;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.8.6: the onboarding page asks the IdP to email the invitee a code (POST /api/onboarding/send-email-code, token-driven) and shows
|
|
3
|
+
* the emailed code ONLY when the IdP itself echoed it in that answer (Email:DevCodeEcho, Development only). Same rule as the sign-in
|
|
4
|
+
* form (AUTH-045): a pure passthrough of the server's `dev_verification_code`, only a non-empty STRING counts, no environment flag.
|
|
5
|
+
*/
|
|
6
|
+
export type OnboardingCodeResult = { ok: boolean; devCode: string | null };
|
|
7
|
+
|
|
8
|
+
type Echo = { dev_verification_code?: unknown };
|
|
9
|
+
|
|
10
|
+
function pick(j: unknown): unknown {
|
|
11
|
+
const o = j as (Echo & { data?: Echo & { result?: Echo }; result?: Echo }) | null;
|
|
12
|
+
return o?.dev_verification_code ?? o?.data?.dev_verification_code ?? o?.data?.result?.dev_verification_code ?? o?.result?.dev_verification_code;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function requestOnboardingCode(
|
|
16
|
+
onboardToken: string,
|
|
17
|
+
fetchImpl: typeof fetch = fetch,
|
|
18
|
+
): Promise<OnboardingCodeResult> {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetchImpl('/api/onboarding/send-email-code', {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({ onboard_token: onboardToken }),
|
|
24
|
+
});
|
|
25
|
+
const body = await res.json().catch(() => null);
|
|
26
|
+
const echoed = pick(body);
|
|
27
|
+
return { ok: res.ok, devCode: res.ok && typeof echoed === 'string' && echoed !== '' ? echoed : null };
|
|
28
|
+
} catch {
|
|
29
|
+
return { ok: false, devCode: null };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 4.8.6: /account-auth/signup - "I was invited". The invitee types the address the invitation went to; the page posts it to the app's
|
|
7
|
+
* own /api/onboarding/signup and ALWAYS shows the same sentence afterwards, whatever came back (the IdP's answer is uniform and the
|
|
8
|
+
* page must not become an oracle for which addresses were invited). The onboarding link arrives by email.
|
|
9
|
+
*/
|
|
10
|
+
export const INVITED_DONE_SENTENCE = 'If this address was invited, a link is on its way.';
|
|
11
|
+
const INVITED_ERROR_SENTENCE = 'Enter the email address your invitation was sent to.';
|
|
12
|
+
|
|
13
|
+
export default function InvitedSignupPage() {
|
|
14
|
+
const [email, setEmail] = useState('');
|
|
15
|
+
const [busy, setBusy] = useState(false);
|
|
16
|
+
const [done, setDone] = useState(false);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
async function submit(e: React.FormEvent) {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
const value = email.trim();
|
|
22
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { setError(INVITED_ERROR_SENTENCE); return; }
|
|
23
|
+
setBusy(true);
|
|
24
|
+
setError(null);
|
|
25
|
+
try {
|
|
26
|
+
await fetch('/api/onboarding/signup', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ email: value }),
|
|
30
|
+
});
|
|
31
|
+
} catch { /* same sentence either way */ }
|
|
32
|
+
setBusy(false);
|
|
33
|
+
setDone(true);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16, background: 'var(--bg-default, #f9fafb)' }}>
|
|
38
|
+
<div style={{ width: '100%', maxWidth: 420, background: 'var(--bg-card, #fff)', border: '1px solid var(--border-default, #e5e7eb)', borderRadius: 16, padding: 28 }}>
|
|
39
|
+
<h1 style={{ color: 'var(--text-primary)', fontSize: 20, fontWeight: 600, margin: 0 }}>I was invited</h1>
|
|
40
|
+
{done ? (
|
|
41
|
+
<p role="status" data-testid="invited-done" style={{ color: 'var(--text-secondary)', fontSize: 14, marginTop: 12 }}>{INVITED_DONE_SENTENCE}</p>
|
|
42
|
+
) : (
|
|
43
|
+
<form onSubmit={submit} noValidate style={{ marginTop: 16 }}>
|
|
44
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: 14, marginTop: 0 }}>Enter the email address your invitation was sent to.</p>
|
|
45
|
+
<input
|
|
46
|
+
type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" aria-label="Email address" placeholder="you@example.com"
|
|
47
|
+
aria-invalid={error ? true : undefined} aria-describedby={error ? 'invited-error' : undefined}
|
|
48
|
+
style={{ width: '100%', padding: '10px 12px', border: '1px solid var(--border-default, #d1d5db)', borderRadius: 10, fontSize: 15 }}
|
|
49
|
+
/>
|
|
50
|
+
{error && <p id="invited-error" role="alert" style={{ color: '#b00020', fontSize: 13, marginTop: 8 }}>{error}</p>}
|
|
51
|
+
<button type="submit" disabled={busy || !email} style={{ marginTop: 14, width: '100%', padding: '10px 12px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: '#fff', fontWeight: 600, fontSize: 15, opacity: busy || !email ? 0.5 : 1 }}>
|
|
52
|
+
{busy ? 'Sending…' : 'Send my link'}
|
|
53
|
+
</button>
|
|
54
|
+
</form>
|
|
55
|
+
)}
|
|
56
|
+
<p style={{ marginTop: 16 }}><a href="/account-auth/login" style={{ color: 'var(--text-primary)', fontWeight: 600, textDecoration: 'underline' }}>Back to sign in</a></p>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { Suspense, useEffect, useState } from 'react';
|
|
3
|
+
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
|
4
4
|
import { useSearchParams } from 'next/navigation';
|
|
5
|
+
import { DevCodeNotice } from '../../components/auth/DevCodeNotice';
|
|
6
|
+
import { requestOnboardingCode } from '../../lib/onboarding-code-client';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* PAY-2121: the /account-auth/onboarding landing page.
|
|
@@ -44,6 +46,23 @@ function OnboardingContent() {
|
|
|
44
46
|
const [email, setEmail] = useState('');
|
|
45
47
|
const [busy, setBusy] = useState(false);
|
|
46
48
|
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
const [devCode, setDevCode] = useState<string | null>(null);
|
|
50
|
+
const [codeNote, setCodeNote] = useState<string | null>(null);
|
|
51
|
+
const sentFor = useRef<string | null>(null);
|
|
52
|
+
|
|
53
|
+
// 4.8.6: the page asks for the code itself, once per token (nothing else ever sends it). The dev echo, if the IdP gave one in
|
|
54
|
+
// this answer, is shown under the code box; nothing else can make it appear.
|
|
55
|
+
async function sendCode() {
|
|
56
|
+
setCodeNote(null);
|
|
57
|
+
const r = await requestOnboardingCode(onboardToken);
|
|
58
|
+
setDevCode(r.devCode);
|
|
59
|
+
setCodeNote(r.ok ? 'A new code was sent.' : 'We could not send a code. Try again in a moment.');
|
|
60
|
+
}
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!onboardToken || sentFor.current === onboardToken) return;
|
|
63
|
+
sentFor.current = onboardToken;
|
|
64
|
+
void requestOnboardingCode(onboardToken).then((r) => setDevCode(r.devCode));
|
|
65
|
+
}, [onboardToken]);
|
|
47
66
|
|
|
48
67
|
// Step 1: tell the invitee which address the code went to (masked). Best
|
|
49
68
|
// effort - a failure here does not block entering a code they were emailed.
|
|
@@ -133,7 +152,7 @@ function OnboardingContent() {
|
|
|
133
152
|
<p style={{ color: H.secondary, fontSize: 14, marginTop: 8 }}>
|
|
134
153
|
{maskedEmail
|
|
135
154
|
? `We emailed a 6-digit code to ${maskedEmail}. Enter it below.`
|
|
136
|
-
: '
|
|
155
|
+
: 'We emailed you a 6-digit code. Enter it below.'}
|
|
137
156
|
</p>
|
|
138
157
|
<form onSubmit={submitCode} noValidate style={{ marginTop: 16 }}>
|
|
139
158
|
<input
|
|
@@ -149,6 +168,13 @@ function OnboardingContent() {
|
|
|
149
168
|
{busy ? 'Verifying…' : 'Verify'}
|
|
150
169
|
</button>
|
|
151
170
|
</form>
|
|
171
|
+
<DevCodeNotice code={devCode} />
|
|
172
|
+
<p style={{ marginTop: 12, fontSize: 13 }}>
|
|
173
|
+
<button type="button" onClick={() => void sendCode()} data-testid="resend-code" style={{ background: 'none', border: 'none', padding: 0, color: H.primary, fontWeight: 600, textDecoration: 'underline', cursor: 'pointer' }}>
|
|
174
|
+
Send a new code
|
|
175
|
+
</button>
|
|
176
|
+
{codeNote && <span role="status" style={{ marginLeft: 8, color: H.secondary }}>{codeNote}</span>}
|
|
177
|
+
</p>
|
|
152
178
|
</>
|
|
153
179
|
)}
|
|
154
180
|
|
|
@@ -13,3 +13,4 @@ export { POST as verifyCodePOST } from '../../api-handlers/onboarding/verify-cod
|
|
|
13
13
|
export { POST as sendEmailCodePOST } from '../../api-handlers/onboarding/send-email-code';
|
|
14
14
|
export { POST as verifyWelcomeEmailPOST } from '../../api-handlers/onboarding/verify-welcome-email';
|
|
15
15
|
export { POST as setPasswordPOST } from '../../api-handlers/onboarding/set-password';
|
|
16
|
+
export { POST as signupPOST } from '../../api-handlers/onboarding/signup';
|