@payez/next-mvp 4.8.4 → 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.
Files changed (32) hide show
  1. package/dist/api-handlers/onboarding/signup.d.ts +15 -0
  2. package/dist/api-handlers/onboarding/signup.js +44 -0
  3. package/dist/components/auth/CredentialSignInForm.js +6 -1
  4. package/dist/components/auth/DevCodeNotice.d.ts +10 -0
  5. package/dist/components/auth/DevCodeNotice.js +16 -0
  6. package/dist/components/auth/InvitedEntryLink.d.ts +3 -0
  7. package/dist/components/auth/InvitedEntryLink.js +9 -0
  8. package/dist/lib/credential-sign-in-client.d.ts +1 -0
  9. package/dist/lib/credential-sign-in-client.js +6 -2
  10. package/dist/lib/onboarding-code-client.d.ts +10 -0
  11. package/dist/lib/onboarding-code-client.js +22 -0
  12. package/dist/pages/invited/index.d.ts +5 -0
  13. package/dist/pages/invited/index.js +13 -0
  14. package/dist/pages/invited/page.d.ts +7 -0
  15. package/dist/pages/invited/page.js +41 -0
  16. package/dist/pages/onboarding/page.js +20 -1
  17. package/dist/routes/onboarding/index.d.ts +1 -0
  18. package/dist/routes/onboarding/index.js +3 -1
  19. package/dist/routes/onboarding/signup.d.ts +2 -0
  20. package/dist/routes/onboarding/signup.js +6 -0
  21. package/package.json +21 -1
  22. package/src/api-handlers/onboarding/signup.ts +41 -0
  23. package/src/components/auth/CredentialSignInForm.tsx +6 -0
  24. package/src/components/auth/DevCodeNotice.tsx +17 -0
  25. package/src/components/auth/InvitedEntryLink.tsx +13 -0
  26. package/src/lib/credential-sign-in-client.ts +8 -3
  27. package/src/lib/onboarding-code-client.ts +31 -0
  28. package/src/pages/invited/index.ts +5 -0
  29. package/src/pages/invited/page.tsx +60 -0
  30. package/src/pages/onboarding/page.tsx +28 -2
  31. package/src/routes/onboarding/index.ts +1 -0
  32. 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
+ }
@@ -9,6 +9,7 @@ const TraditionalAuthSection_1 = require("./TraditionalAuthSection");
9
9
  const safe_callback_1 = require("../../auth/safe-callback");
10
10
  const authorize_resume_1 = require("../../lib/authorize-resume");
11
11
  const code_step_copy_1 = require("../../lib/code-step-copy");
12
+ const DevCodeNotice_1 = require("./DevCodeNotice");
12
13
  const credential_sign_in_client_1 = require("../../lib/credential-sign-in-client");
13
14
  const inputStyle = {
14
15
  borderColor: 'var(--border-default, #e5e7eb)',
@@ -39,6 +40,7 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
39
40
  const [fieldError, setFieldError] = (0, react_1.useState)(null);
40
41
  const [codeRequest, setCodeRequest] = (0, react_1.useState)(code_step_copy_1.NO_CODE_REQUEST);
41
42
  const [offerEmailCode, setOfferEmailCode] = (0, react_1.useState)(false);
43
+ const [devCode, setDevCode] = (0, react_1.useState)(null);
42
44
  const [emailCodeMsg, setEmailCodeMsg] = (0, react_1.useState)(null);
43
45
  async function submit(withCode) {
44
46
  setBusy(true);
@@ -56,6 +58,7 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
56
58
  }
57
59
  if (r.kind === 'two_factor') {
58
60
  setCodeRequest(r.codeRequest);
61
+ setDevCode(r.devCode);
59
62
  setStep('code');
60
63
  return;
61
64
  }
@@ -90,6 +93,7 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
90
93
  function onBack() {
91
94
  setStep('credentials');
92
95
  setCodeRequest(code_step_copy_1.NO_CODE_REQUEST);
96
+ setDevCode(null);
93
97
  setCode('');
94
98
  setFieldError(null);
95
99
  setError(null);
@@ -103,6 +107,7 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
103
107
  const r = await (0, credential_sign_in_client_1.requestEmailCode)(fetchImpl);
104
108
  if (r.kind === 'requested') {
105
109
  setCodeRequest(r.codeRequest);
110
+ setDevCode(r.devCode);
106
111
  setError(null);
107
112
  setOfferEmailCode(false);
108
113
  setEmailCodeMsg('We asked for a code to be emailed to you. Enter it above when it arrives.');
@@ -121,7 +126,7 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
121
126
  return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "credential-sign-in-code-step", className: "text-left", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm mb-4", style: { color: 'var(--text-primary, #111827)' }, children: (0, code_step_copy_1.codeStepInstruction)(email, codeRequest) }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: onCodeSubmit, noValidate: true, className: "space-y-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "credential-code", className: "block text-sm font-medium mb-2", style: { color: 'var(--text-primary, #111827)' }, children: "Authentication code" }), (0, jsx_runtime_1.jsx)("input", { id: "credential-code", name: "code", type: "text", inputMode: "numeric", autoComplete: "one-time-code", required: true, placeholder: "000000", value: code, disabled: busy, onChange: (e) => {
122
127
  setCode(e.target.value);
123
128
  setFieldError(null);
124
- }, className: "w-full px-4 py-3 rounded-lg border transition-colors focus:outline-none focus:ring-2 disabled:opacity-50", style: inputStyle })] }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy, className: "w-full py-3 px-4 rounded-lg font-semibold focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors", style: { background: 'var(--btn-primary-bg, #11B588)', color: 'var(--btn-primary-text, white)' }, children: busy ? 'Verifying…' : 'Verify' })] }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onBack, disabled: busy, className: "text-sm underline mt-4", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-muted, #6b7280)' }, children: "Back" }), problemLine, (offerEmailCode || emailCodeMsg) && ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "email-code-offer", className: "mt-4", children: [offerEmailCode && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => void askForEmailCode(), disabled: busy, className: "text-sm font-semibold underline", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-primary, #111827)' }, children: "Email me a code instead" })), emailCodeMsg && ((0, jsx_runtime_1.jsx)("p", { role: "status", "data-testid": "email-code-offer-msg", className: "text-sm mt-2", style: { color: 'var(--text-primary, #111827)' }, children: emailCodeMsg }))] }))] }));
129
+ }, className: "w-full px-4 py-3 rounded-lg border transition-colors focus:outline-none focus:ring-2 disabled:opacity-50", style: inputStyle }), (0, jsx_runtime_1.jsx)(DevCodeNotice_1.DevCodeNotice, { code: devCode })] }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy, className: "w-full py-3 px-4 rounded-lg font-semibold focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors", style: { background: 'var(--btn-primary-bg, #11B588)', color: 'var(--btn-primary-text, white)' }, children: busy ? 'Verifying…' : 'Verify' })] }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onBack, disabled: busy, className: "text-sm underline mt-4", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-muted, #6b7280)' }, children: "Back" }), problemLine, (offerEmailCode || emailCodeMsg) && ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "email-code-offer", className: "mt-4", children: [offerEmailCode && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => void askForEmailCode(), disabled: busy, className: "text-sm font-semibold underline", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-primary, #111827)' }, children: "Email me a code instead" })), emailCodeMsg && ((0, jsx_runtime_1.jsx)("p", { role: "status", "data-testid": "email-code-offer-msg", className: "text-sm mt-2", style: { color: 'var(--text-primary, #111827)' }, children: emailCodeMsg }))] }))] }));
125
130
  }
126
131
  return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "credential-sign-in", className: "text-left", children: [(0, jsx_runtime_1.jsx)(TraditionalAuthSection_1.TraditionalAuthSection, { email: email, password: password, onEmailChange: (v) => {
127
132
  setEmail(v);
@@ -0,0 +1,10 @@
1
+ /**
2
+ * AUTH-045 (BAPert 75627): on a DEV identity service that echoes the emailed sign-in code in its own answer (Email:DevCodeEcho, an
3
+ * IdP setting that is on only in Development), the sign-in form shows that code under the code box, because on a dev rig the email
4
+ * goes to nobody. This component is a PURE PASSTHROUGH of the server's `devVerificationCode`: it renders nothing unless it is given
5
+ * a non-empty string, and CredentialSignInForm gives it nothing but the value the door returned. There is no environment flag in the
6
+ * app, so a production IdP (echo off, field absent) can never make it appear.
7
+ */
8
+ export declare function DevCodeNotice({ code }: {
9
+ code?: string | null;
10
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DevCodeNotice = DevCodeNotice;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ /**
6
+ * AUTH-045 (BAPert 75627): on a DEV identity service that echoes the emailed sign-in code in its own answer (Email:DevCodeEcho, an
7
+ * IdP setting that is on only in Development), the sign-in form shows that code under the code box, because on a dev rig the email
8
+ * goes to nobody. This component is a PURE PASSTHROUGH of the server's `devVerificationCode`: it renders nothing unless it is given
9
+ * a non-empty string, and CredentialSignInForm gives it nothing but the value the door returned. There is no environment flag in the
10
+ * app, so a production IdP (echo off, field absent) can never make it appear.
11
+ */
12
+ function DevCodeNotice({ code }) {
13
+ if (typeof code !== 'string' || code === '')
14
+ return null;
15
+ return ((0, jsx_runtime_1.jsxs)("p", { "data-testid": "dev-sign-in-code", className: "text-sm mt-3", style: { color: 'var(--text-muted, #6b7280)' }, children: ["Development sign-in code: ", (0, jsx_runtime_1.jsx)("strong", { children: code })] }));
16
+ }
@@ -0,0 +1,3 @@
1
+ /** 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). */
2
+ export declare function InvitedEntryLink(): import("react/jsx-runtime").JSX.Element;
3
+ export default InvitedEntryLink;
@@ -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;
@@ -22,6 +22,7 @@ export type CredentialSignInResult = {
22
22
  } | {
23
23
  kind: 'two_factor';
24
24
  codeRequest: CodeRequest;
25
+ devCode: string | null;
25
26
  } | {
26
27
  kind: 'rate_limited';
27
28
  message: string;
@@ -43,6 +43,10 @@ function validateCredentials(email, password) {
43
43
  errors.password = 'Enter your password.';
44
44
  return errors.email || errors.password ? errors : null;
45
45
  }
46
+ /** The dev echo: the door passes the IdP's DevVerificationCode through as `devVerificationCode`. Nothing else is ever a dev code. */
47
+ function readDevCode(body) {
48
+ return typeof body?.devVerificationCode === 'string' && body.devVerificationCode ? body.devVerificationCode : null;
49
+ }
46
50
  function asString(v) {
47
51
  return typeof v === 'string' && v ? v : undefined;
48
52
  }
@@ -58,7 +62,7 @@ async function credentialSignIn(args, fetchImpl = (input, init) => fetch(input,
58
62
  if (res.status === 429)
59
63
  return { kind: 'rate_limited', message: asString(body?.error) ?? exports.MSG_RATE_LIMITED };
60
64
  if (body?.requiresTwoFactor)
61
- return { kind: 'two_factor', codeRequest: (0, code_step_copy_1.readCodeRequest)(body) };
65
+ return { kind: 'two_factor', codeRequest: (0, code_step_copy_1.readCodeRequest)(body), devCode: readDevCode(body) };
62
66
  if (!res.ok || body?.success !== true) {
63
67
  return { kind: 'error', message: asString(body?.error) ?? exports.MSG_CANNOT_SIGN_IN, offerEmailCode: body?.offerEmailCode === true };
64
68
  }
@@ -78,7 +82,7 @@ async function requestEmailCode(fetchImpl = (input, init) => fetch(input, init))
78
82
  return {
79
83
  kind: 'requested',
80
84
  codeRequest: (0, code_step_copy_1.readCodeRequest)(body),
81
- devCode: typeof body?.devVerificationCode === 'string' && body.devVerificationCode ? body.devVerificationCode : null,
85
+ devCode: readDevCode(body),
82
86
  };
83
87
  }
84
88
  return { kind: 'error', message: asString(body?.error) ?? exports.MSG_EMAIL_CODE_FAILED };
@@ -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,5 @@
1
+ /**
2
+ * 4.8.6: the "I was invited" page. Mount at app/account-auth/signup/page.tsx:
3
+ * export { default } from '@payez/next-mvp/pages/invited';
4
+ */
5
+ export { default, INVITED_DONE_SENTENCE } from './page';
@@ -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
- : '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 })] }));
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,2 @@
1
+ /** POST /api/onboarding/signup -> IdP anonymous Onboarding/signup (4.8.6, the "I was invited" entry). */
2
+ export { POST } from '../../api-handlers/onboarding/signup';
@@ -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.4",
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
+ }
@@ -5,6 +5,7 @@ import { TraditionalAuthSection } from './TraditionalAuthSection';
5
5
  import { safeCallbackPath } from '../../auth/safe-callback';
6
6
  import { authorizeResumeHref } from '../../lib/authorize-resume';
7
7
  import { NO_CODE_REQUEST, codeStepInstruction, type CodeRequest } from '../../lib/code-step-copy';
8
+ import { DevCodeNotice } from './DevCodeNotice';
8
9
  import {
9
10
  credentialSignIn,
10
11
  requestEmailCode,
@@ -73,6 +74,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
73
74
  const [fieldError, setFieldError] = useState<string | null>(null);
74
75
  const [codeRequest, setCodeRequest] = useState<CodeRequest>(NO_CODE_REQUEST);
75
76
  const [offerEmailCode, setOfferEmailCode] = useState(false);
77
+ const [devCode, setDevCode] = useState<string | null>(null);
76
78
  const [emailCodeMsg, setEmailCodeMsg] = useState<string | null>(null);
77
79
 
78
80
  async function submit(withCode?: string) {
@@ -89,6 +91,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
89
91
  }
90
92
  if (r.kind === 'two_factor') {
91
93
  setCodeRequest(r.codeRequest);
94
+ setDevCode(r.devCode);
92
95
  setStep('code');
93
96
  return;
94
97
  }
@@ -124,6 +127,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
124
127
  function onBack() {
125
128
  setStep('credentials');
126
129
  setCodeRequest(NO_CODE_REQUEST);
130
+ setDevCode(null);
127
131
  setCode('');
128
132
  setFieldError(null);
129
133
  setError(null);
@@ -138,6 +142,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
138
142
  const r = await requestEmailCode(fetchImpl);
139
143
  if (r.kind === 'requested') {
140
144
  setCodeRequest(r.codeRequest);
145
+ setDevCode(r.devCode);
141
146
  setError(null);
142
147
  setOfferEmailCode(false);
143
148
  setEmailCodeMsg('We asked for a code to be emailed to you. Enter it above when it arrives.');
@@ -182,6 +187,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
182
187
  className="w-full px-4 py-3 rounded-lg border transition-colors focus:outline-none focus:ring-2 disabled:opacity-50"
183
188
  style={inputStyle}
184
189
  />
190
+ <DevCodeNotice code={devCode} />
185
191
  </div>
186
192
  <button
187
193
  type="submit"
@@ -0,0 +1,17 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * AUTH-045 (BAPert 75627): on a DEV identity service that echoes the emailed sign-in code in its own answer (Email:DevCodeEcho, an
5
+ * IdP setting that is on only in Development), the sign-in form shows that code under the code box, because on a dev rig the email
6
+ * goes to nobody. This component is a PURE PASSTHROUGH of the server's `devVerificationCode`: it renders nothing unless it is given
7
+ * a non-empty string, and CredentialSignInForm gives it nothing but the value the door returned. There is no environment flag in the
8
+ * app, so a production IdP (echo off, field absent) can never make it appear.
9
+ */
10
+ export function DevCodeNotice({ code }: { code?: string | null }) {
11
+ if (typeof code !== 'string' || code === '') return null;
12
+ return (
13
+ <p data-testid="dev-sign-in-code" className="text-sm mt-3" style={{ color: 'var(--text-muted, #6b7280)' }}>
14
+ Development sign-in code: <strong>{code}</strong>
15
+ </p>
16
+ );
17
+ }
@@ -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;
@@ -22,7 +22,7 @@ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
22
22
 
23
23
  export type CredentialSignInResult =
24
24
  | { kind: 'success' }
25
- | { kind: 'two_factor'; codeRequest: CodeRequest }
25
+ | { kind: 'two_factor'; codeRequest: CodeRequest; devCode: string | null }
26
26
  | { kind: 'rate_limited'; message: string }
27
27
  | { kind: 'error'; message: string; offerEmailCode: boolean };
28
28
 
@@ -46,6 +46,11 @@ export function validateCredentials(email: string, password: string): { email?:
46
46
  return errors.email || errors.password ? errors : null;
47
47
  }
48
48
 
49
+ /** The dev echo: the door passes the IdP's DevVerificationCode through as `devVerificationCode`. Nothing else is ever a dev code. */
50
+ function readDevCode(body: any): string | null {
51
+ return typeof body?.devVerificationCode === 'string' && body.devVerificationCode ? body.devVerificationCode : null;
52
+ }
53
+
49
54
  function asString(v: unknown): string | undefined {
50
55
  return typeof v === 'string' && v ? v : undefined;
51
56
  }
@@ -64,7 +69,7 @@ export async function credentialSignIn(
64
69
  const body: any = await res.json().catch(() => null);
65
70
 
66
71
  if (res.status === 429) return { kind: 'rate_limited', message: asString(body?.error) ?? MSG_RATE_LIMITED };
67
- if (body?.requiresTwoFactor) return { kind: 'two_factor', codeRequest: readCodeRequest(body) };
72
+ if (body?.requiresTwoFactor) return { kind: 'two_factor', codeRequest: readCodeRequest(body), devCode: readDevCode(body) };
68
73
  if (!res.ok || body?.success !== true) {
69
74
  return { kind: 'error', message: asString(body?.error) ?? MSG_CANNOT_SIGN_IN, offerEmailCode: body?.offerEmailCode === true };
70
75
  }
@@ -89,7 +94,7 @@ export async function requestEmailCode(fetchImpl: FetchLike = (input, init) => f
89
94
  return {
90
95
  kind: 'requested',
91
96
  codeRequest: readCodeRequest(body),
92
- devCode: typeof body?.devVerificationCode === 'string' && body.devVerificationCode ? body.devVerificationCode : null,
97
+ devCode: readDevCode(body),
93
98
  };
94
99
  }
95
100
  return { kind: 'error', message: asString(body?.error) ?? MSG_EMAIL_CODE_FAILED };
@@ -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,5 @@
1
+ /**
2
+ * 4.8.6: the "I was invited" page. Mount at app/account-auth/signup/page.tsx:
3
+ * export { default } from '@payez/next-mvp/pages/invited';
4
+ */
5
+ export { default, INVITED_DONE_SENTENCE } from './page';
@@ -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
- : 'Enter the 6-digit code from your invitation email.'}
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';
@@ -0,0 +1,2 @@
1
+ /** POST /api/onboarding/signup -> IdP anonymous Onboarding/signup (4.8.6, the "I was invited" entry). */
2
+ export { POST } from '../../api-handlers/onboarding/signup';