@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
package/README.md CHANGED
@@ -256,6 +256,59 @@ export default function VerifyCodePageWrapper() {
256
256
  }
257
257
  ```
258
258
 
259
+ #### What your app must serve (PAY-2121)
260
+
261
+ The IdP emails links that land on paths your app must host. Two of them are
262
+ conventions the kit mounts for you; wire them or the link 404s at your site:
263
+
264
+ **Invitation / onboarding** - `app/account-auth/onboarding/page.tsx`:
265
+
266
+ ```typescript
267
+ // The IdP's welcome email links to
268
+ // {your site}/account-auth/onboarding?client_id=&onboard_token=
269
+ export { default } from '@payez/next-mvp/pages/onboarding';
270
+ ```
271
+
272
+ and the API it posts to (token-driven, no session) - one mount file each:
273
+
274
+ ```typescript
275
+ // app/api/onboarding/verify-code/route.ts
276
+ export { POST } from '@payez/next-mvp/routes/onboarding/verify-code';
277
+ // app/api/onboarding/send-email-code/route.ts
278
+ export { POST } from '@payez/next-mvp/routes/onboarding/send-email-code';
279
+ // app/api/onboarding/verify-welcome-email/route.ts
280
+ export { POST } from '@payez/next-mvp/routes/onboarding/verify-welcome-email';
281
+ // app/api/onboarding/set-password/route.ts
282
+ export { POST } from '@payez/next-mvp/routes/onboarding/set-password';
283
+ ```
284
+
285
+ **Post-verify reset fragment on `/`** - mount the bridge on your home page:
286
+
287
+ ```typescript
288
+ // app/page.tsx
289
+ import { PasswordResetBridge } from '@payez/next-mvp/components/auth/PasswordResetBridge';
290
+
291
+ export default function Home() {
292
+ return (<><PasswordResetBridge />{/* your home page */}</>);
293
+ }
294
+ ```
295
+
296
+ When the IdP's verification redirect lands on `/#password_reset_required=true&reset_token=…&email=…`
297
+ (a fragment, for a passwordless account), the bridge performs one replace to
298
+ `/account-auth/recovery?token=…&email=…` and clears the hash. With any other
299
+ fragment it renders nothing. `npx @payez/create-next-app` writes all of the above.
300
+
301
+ **Registration** has two doors, and the kit ships neither as a page:
302
+
303
+ - **Owner-initiated invite** (shipped here): an existing owner invites a user; the invite
304
+ email lands on `/account-auth/onboarding` (above), which the kit now serves.
305
+ - **Self-serve sign-up**: the kit exposes the IdP's ProgressiveAuth flow as API endpoints
306
+ (`/api/ProgressiveAuth/step1`..`step5`, then `/api/ProgressiveAuth/verify-email`;
307
+ `API_ENDPOINTS.progressiveAuth` in `config/env.ts`). The kit ships **no** registration
308
+ page, so a developer who wants self-serve sign-up authors a page that drives that flow,
309
+ respecting the tenant's `allow_public_registration` / waitlist state. The login page no
310
+ longer links a register page, because the kit does not ship one.
311
+
259
312
  ### 6. Protect Routes with Authentication
260
313
 
261
314
  **Server-side protection:**
@@ -0,0 +1,11 @@
1
+ /**
2
+ * POST /api/onboarding/send-email-code (PAY-2121).
3
+ *
4
+ * Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
5
+ * POST /api/Onboarding/send-email-code. The IdP derives the recipient from the
6
+ * token's BOUND email only, never from client input, so a tampered link cannot
7
+ * redirect the code. Also the resend leg the verify card's cooldown fires
8
+ * against.
9
+ */
10
+ import { NextRequest } from 'next/server';
11
+ export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<unknown>>;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = POST;
4
+ const onboarding_proxy_1 = require("../../lib/onboarding-proxy");
5
+ async function POST(req) {
6
+ const body = await req.json().catch(() => ({}));
7
+ const onboardToken = body?.onboard_token;
8
+ if (!onboardToken)
9
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_TOKEN', 'onboard_token is required');
10
+ return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/send-email-code');
11
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * POST /api/onboarding/set-password (PAY-2121).
3
+ *
4
+ * Body: { onboard_token, email, reset_token, new_password } - the locked
5
+ * contract (IdP OnboardingDtos.cs SetPasswordRequest, all four snake_case).
6
+ * email + reset_token come from the verify-code response; the IdP re-checks
7
+ * email against the token's bound user before consuming the single-use token.
8
+ * On success the IdP sets the password AND completes onboarding (issues tokens).
9
+ * Thin proxy - status and body pass through verbatim.
10
+ */
11
+ import { NextRequest } from 'next/server';
12
+ export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<unknown>>;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = POST;
4
+ const onboarding_proxy_1 = require("../../lib/onboarding-proxy");
5
+ async function POST(req) {
6
+ const body = await req.json().catch(() => ({}));
7
+ const b = body;
8
+ if (!b?.onboard_token)
9
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_TOKEN', 'onboard_token is required');
10
+ if (!b?.email || !b?.reset_token)
11
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_RESET_CONTEXT', 'email and reset_token (from verify-code) are required');
12
+ if (!b?.new_password)
13
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_PASSWORD', 'new_password is required');
14
+ return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/set-password');
15
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * POST /api/onboarding/verify-code (PAY-2121).
3
+ *
4
+ * Body: { onboard_token, code } - token-driven, no session. Thin proxy to the
5
+ * IdP's POST /api/Onboarding/verify-code, which answers
6
+ * { success, reset_token, email, client_key } on success (OnboardingDtos.cs
7
+ * VerifyCodeResponse). reset_token + email are what the set-password step needs
8
+ * next. The onboard_token IS the auth; there is no session to attach.
9
+ */
10
+ import { NextRequest } from 'next/server';
11
+ export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<unknown>>;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = POST;
4
+ const onboarding_proxy_1 = require("../../lib/onboarding-proxy");
5
+ async function POST(req) {
6
+ const body = await req.json().catch(() => ({}));
7
+ const onboardToken = body?.onboard_token;
8
+ const code = body?.code;
9
+ if (!onboardToken)
10
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_TOKEN', 'onboard_token is required');
11
+ if (!code)
12
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_CODE', 'code is required');
13
+ return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/verify-code');
14
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * POST /api/onboarding/verify-welcome-email (PAY-2121).
3
+ *
4
+ * Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
5
+ * POST /api/Onboarding/verify-welcome-email, which answers the masked contact
6
+ * info + expiry the verify card displays. The onboarding page calls this to
7
+ * render its "check your email / here is what we know" state.
8
+ */
9
+ import { NextRequest } from 'next/server';
10
+ export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<unknown>>;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = POST;
4
+ const onboarding_proxy_1 = require("../../lib/onboarding-proxy");
5
+ async function POST(req) {
6
+ const body = await req.json().catch(() => ({}));
7
+ const onboardToken = body?.onboard_token;
8
+ if (!onboardToken)
9
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_TOKEN', 'onboard_token is required');
10
+ return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/verify-welcome-email');
11
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * PAY-2121: the fragment bridge on `/`.
3
+ *
4
+ * The IdP's post-verify redirect for a passwordless account lands on
5
+ * ORIGIN/#password_reset_required=true&reset_token=<token>&email=<addr>
6
+ * - a FRAGMENT, never a query (ProgressiveAuthController.cs:1204-1222). A fresh
7
+ * @payez/next-mvp app has no fragment handling at all, so the user lands on `/`
8
+ * and nothing happens; their reset is lost.
9
+ *
10
+ * This is the PURE half (data in, data out) so a test can execute it without a
11
+ * DOM. The client component (PasswordResetBridge) calls it on mount with
12
+ * `window.location.hash`. The kit's reset surface is the existing
13
+ * `/account-auth/recovery` page, which reads `?email=` and a reset token - so the
14
+ * bridge rewrites the fragment into that surface's OWN query shape and navigates
15
+ * once. It invents no new reset flow.
16
+ *
17
+ * CONTRACT:
18
+ * '#password_reset_required=true&reset_token=T&email=a%40b.co' -> '/account-auth/recovery?token=T&email=a%40b.co'
19
+ * any other fragment (no flag, flag != true, no token) -> null (leave `/` alone)
20
+ */
21
+ /** The reset surface the bridge hands off to, by its existing path. */
22
+ export declare const RECOVERY_PATH = "/account-auth/recovery";
23
+ /**
24
+ * PAY-2121 (BAPert 70576): the recovery page's ENTRY decision, as a pure function.
25
+ *
26
+ * The bridge now sends a VERIFIED passwordless user to
27
+ * /account-auth/recovery?token=<reset_token>&email=<addr>
28
+ * with a REAL reset token in hand. If the page then showed its request-a-code
29
+ * step, it would throw that token away and the flow would still dead-end - just
30
+ * one page later. So when a token is present the page must start at the
31
+ * set-password step and submit straight through resetPasswordWithToken.
32
+ *
33
+ * token present -> { step: 'set-password', resetToken: <token> }
34
+ * no token -> { step: 'initiate', resetToken: null } (today's behaviour)
35
+ */
36
+ export declare function recoveryEntry(token: string | null | undefined, email: string | null | undefined): {
37
+ step: 'set-password' | 'initiate';
38
+ resetToken: string | null;
39
+ email: string;
40
+ };
41
+ /**
42
+ * PAY-2121 (BAPert 70582): the set-password SUBMIT precondition, as a pure
43
+ * function. The old guard was `if (!passwordResetToken || !recoverySession) return;`.
44
+ * Removing the session half (so a deep-linked reset can submit) must NOT let a
45
+ * submit with NO token reach the API carrying an empty token - which is exactly
46
+ * what a guard-less submit would do. This decides it, in one testable place:
47
+ *
48
+ * resetSubmitTarget(resetToken, sessionEmail, queryEmail):
49
+ * - no token (after trim) -> null (REFUSE: never call the API with an empty token)
50
+ * - token, session email present -> { token, email: sessionEmail }
51
+ * - token, only a query email -> { token, email: queryEmail } (the deep-link case)
52
+ * - token, but NO email anywhere -> null (REFUSE: nothing to reset)
53
+ *
54
+ * Returns the exact { token, email } the caller must pass to
55
+ * accountApi.resetPasswordWithToken, or null to refuse BEFORE any request.
56
+ */
57
+ export declare function resetSubmitTarget(resetToken: string | null | undefined, sessionEmail: string | null | undefined, queryEmail: string | null | undefined): {
58
+ token: string;
59
+ email: string;
60
+ } | null;
61
+ /**
62
+ * The redirect the fragment asks for, or null when the fragment is not a reset
63
+ * request. `hash` is `window.location.hash` (leading '#' optional).
64
+ *
65
+ * Returns null - leaves `/` untouched - when:
66
+ * - the fragment is empty;
67
+ * - `password_reset_required` is absent or not exactly 'true';
68
+ * - no `reset_token` is present (nothing to reset with; not a real link).
69
+ */
70
+ export declare function passwordResetRedirect(hash: string | null | undefined): string | null;
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ /**
3
+ * PAY-2121: the fragment bridge on `/`.
4
+ *
5
+ * The IdP's post-verify redirect for a passwordless account lands on
6
+ * ORIGIN/#password_reset_required=true&reset_token=<token>&email=<addr>
7
+ * - a FRAGMENT, never a query (ProgressiveAuthController.cs:1204-1222). A fresh
8
+ * @payez/next-mvp app has no fragment handling at all, so the user lands on `/`
9
+ * and nothing happens; their reset is lost.
10
+ *
11
+ * This is the PURE half (data in, data out) so a test can execute it without a
12
+ * DOM. The client component (PasswordResetBridge) calls it on mount with
13
+ * `window.location.hash`. The kit's reset surface is the existing
14
+ * `/account-auth/recovery` page, which reads `?email=` and a reset token - so the
15
+ * bridge rewrites the fragment into that surface's OWN query shape and navigates
16
+ * once. It invents no new reset flow.
17
+ *
18
+ * CONTRACT:
19
+ * '#password_reset_required=true&reset_token=T&email=a%40b.co' -> '/account-auth/recovery?token=T&email=a%40b.co'
20
+ * any other fragment (no flag, flag != true, no token) -> null (leave `/` alone)
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.RECOVERY_PATH = void 0;
24
+ exports.recoveryEntry = recoveryEntry;
25
+ exports.resetSubmitTarget = resetSubmitTarget;
26
+ exports.passwordResetRedirect = passwordResetRedirect;
27
+ /** The reset surface the bridge hands off to, by its existing path. */
28
+ exports.RECOVERY_PATH = '/account-auth/recovery';
29
+ /**
30
+ * PAY-2121 (BAPert 70576): the recovery page's ENTRY decision, as a pure function.
31
+ *
32
+ * The bridge now sends a VERIFIED passwordless user to
33
+ * /account-auth/recovery?token=<reset_token>&email=<addr>
34
+ * with a REAL reset token in hand. If the page then showed its request-a-code
35
+ * step, it would throw that token away and the flow would still dead-end - just
36
+ * one page later. So when a token is present the page must start at the
37
+ * set-password step and submit straight through resetPasswordWithToken.
38
+ *
39
+ * token present -> { step: 'set-password', resetToken: <token> }
40
+ * no token -> { step: 'initiate', resetToken: null } (today's behaviour)
41
+ */
42
+ function recoveryEntry(token, email) {
43
+ const tok = (token ?? '').trim();
44
+ return {
45
+ step: tok ? 'set-password' : 'initiate',
46
+ resetToken: tok || null,
47
+ email: (email ?? '').trim(),
48
+ };
49
+ }
50
+ /**
51
+ * PAY-2121 (BAPert 70582): the set-password SUBMIT precondition, as a pure
52
+ * function. The old guard was `if (!passwordResetToken || !recoverySession) return;`.
53
+ * Removing the session half (so a deep-linked reset can submit) must NOT let a
54
+ * submit with NO token reach the API carrying an empty token - which is exactly
55
+ * what a guard-less submit would do. This decides it, in one testable place:
56
+ *
57
+ * resetSubmitTarget(resetToken, sessionEmail, queryEmail):
58
+ * - no token (after trim) -> null (REFUSE: never call the API with an empty token)
59
+ * - token, session email present -> { token, email: sessionEmail }
60
+ * - token, only a query email -> { token, email: queryEmail } (the deep-link case)
61
+ * - token, but NO email anywhere -> null (REFUSE: nothing to reset)
62
+ *
63
+ * Returns the exact { token, email } the caller must pass to
64
+ * accountApi.resetPasswordWithToken, or null to refuse BEFORE any request.
65
+ */
66
+ function resetSubmitTarget(resetToken, sessionEmail, queryEmail) {
67
+ const tok = (resetToken ?? '').trim();
68
+ if (!tok)
69
+ return null;
70
+ const email = ((sessionEmail ?? '').trim() || (queryEmail ?? '').trim());
71
+ if (!email)
72
+ return null;
73
+ return { token: tok, email };
74
+ }
75
+ /**
76
+ * The redirect the fragment asks for, or null when the fragment is not a reset
77
+ * request. `hash` is `window.location.hash` (leading '#' optional).
78
+ *
79
+ * Returns null - leaves `/` untouched - when:
80
+ * - the fragment is empty;
81
+ * - `password_reset_required` is absent or not exactly 'true';
82
+ * - no `reset_token` is present (nothing to reset with; not a real link).
83
+ */
84
+ function passwordResetRedirect(hash) {
85
+ const raw = (hash ?? '').replace(/^#/, '');
86
+ if (!raw)
87
+ return null;
88
+ const params = new URLSearchParams(raw);
89
+ if (params.get('password_reset_required') !== 'true')
90
+ return null;
91
+ const token = params.get('reset_token') ?? '';
92
+ if (!token)
93
+ return null;
94
+ const email = params.get('email') ?? '';
95
+ const qs = new URLSearchParams({ token });
96
+ if (email)
97
+ qs.set('email', email);
98
+ return `${exports.RECOVERY_PATH}?${qs.toString()}`;
99
+ }
@@ -46,6 +46,23 @@ const defaultUnauthenticatedRoutes = [
46
46
  allowDuringCircuitBreakerOpen: true,
47
47
  requiresRateLimit: false
48
48
  },
49
+ // PAY-2121: the IdP emails an invite link to /account-auth/onboarding and a
50
+ // post-verify reset FRAGMENT on '/'. Both must be reachable unauthenticated -
51
+ // the invitee has no session (the onboard_token IS the credential), and the
52
+ // fragment lands on the already-public root. Listed explicitly, not left to
53
+ // the /account-auth/* wildcard, so the two conventional paths are visible here.
54
+ {
55
+ pattern: '/account-auth/onboarding',
56
+ description: 'IdP invite/onboarding landing page (token-driven, no session)',
57
+ allowDuringCircuitBreakerOpen: true,
58
+ requiresRateLimit: false
59
+ },
60
+ {
61
+ pattern: '/api/onboarding/*',
62
+ description: 'Onboarding API (token-driven, no session)',
63
+ allowDuringCircuitBreakerOpen: true,
64
+ requiresRateLimit: true
65
+ },
49
66
  {
50
67
  pattern: '/api/auth/*',
51
68
  description: 'Authentication API endpoints',
@@ -0,0 +1,17 @@
1
+ /**
2
+ * PAY-2121: mount this once on your app's `/` page. When the IdP's post-verify
3
+ * redirect lands on `/#password_reset_required=true&reset_token=<t>&email=<e>`
4
+ * (a FRAGMENT), this performs ONE replace to the kit's reset surface and clears
5
+ * the hash. With any other fragment it renders nothing and leaves `/` alone.
6
+ *
7
+ * It renders nothing and reads no user data - it only inspects the fragment.
8
+ *
9
+ * @example
10
+ * // app/page.tsx
11
+ * import { PasswordResetBridge } from '@payez/next-mvp/components/auth/PasswordResetBridge';
12
+ * export default function Home() {
13
+ * return (&lt;&gt;&lt;PasswordResetBridge /&gt;&lt;/&gt;);
14
+ * }
15
+ */
16
+ export declare function PasswordResetBridge(): null;
17
+ export default PasswordResetBridge;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.PasswordResetBridge = PasswordResetBridge;
5
+ const react_1 = require("react");
6
+ const password_reset_bridge_1 = require("../../auth/password-reset-bridge");
7
+ /**
8
+ * PAY-2121: mount this once on your app's `/` page. When the IdP's post-verify
9
+ * redirect lands on `/#password_reset_required=true&reset_token=<t>&email=<e>`
10
+ * (a FRAGMENT), this performs ONE replace to the kit's reset surface and clears
11
+ * the hash. With any other fragment it renders nothing and leaves `/` alone.
12
+ *
13
+ * It renders nothing and reads no user data - it only inspects the fragment.
14
+ *
15
+ * @example
16
+ * // app/page.tsx
17
+ * import { PasswordResetBridge } from '@payez/next-mvp/components/auth/PasswordResetBridge';
18
+ * export default function Home() {
19
+ * return (&lt;&gt;&lt;PasswordResetBridge /&gt;&lt;/&gt;);
20
+ * }
21
+ */
22
+ function PasswordResetBridge() {
23
+ (0, react_1.useEffect)(() => {
24
+ if (typeof window === 'undefined')
25
+ return;
26
+ const target = (0, password_reset_bridge_1.passwordResetRedirect)(window.location.hash);
27
+ if (target)
28
+ window.location.replace(target);
29
+ }, []);
30
+ return null;
31
+ }
32
+ exports.default = PasswordResetBridge;
@@ -0,0 +1,30 @@
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
+ /** A typed refusal in the kit's error envelope - used for a missing field, BEFORE any IdP call. */
16
+ export declare function onboardingBadRequest(code: string, message: string): NextResponse<{
17
+ success: boolean;
18
+ message: string;
19
+ error: {
20
+ code: string;
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 declare function onboardingProxy(req: NextRequest, idpPath: string): Promise<NextResponse>;
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.onboardingBadRequest = onboardingBadRequest;
4
+ exports.onboardingProxy = onboardingProxy;
5
+ /**
6
+ * PAY-2121: shared anonymous IDP proxy for the /api/onboarding/* handlers.
7
+ *
8
+ * The onboarding flow is TOKEN-DRIVEN: the invitee has no account or session
9
+ * yet, and the onboard_token IS the credential. So these calls must NOT go
10
+ * through idpFetchJSON (which injects a Bearer from a Redis session) - there is
11
+ * no session. They are plain server-to-server POSTs to the IdP, exactly as the
12
+ * VibeID portal's hand-built /api/onboarding/* routes do (card 182611).
13
+ *
14
+ * WHY A SERVER ROUTE AT ALL, AND NOT A BROWSER CALL: the IdP URL is not exposed
15
+ * to the browser, and the route is the one place that owns that. The token is
16
+ * forwarded, never the IdP's host.
17
+ */
18
+ const server_1 = require("next/server");
19
+ const env_1 = require("../config/env");
20
+ const idp_url_guard_1 = require("./idp-url-guard");
21
+ /** A typed refusal in the kit's error envelope - used for a missing field, BEFORE any IdP call. */
22
+ function onboardingBadRequest(code, message) {
23
+ return server_1.NextResponse.json({ success: false, message, error: { code } }, { status: 400 });
24
+ }
25
+ /**
26
+ * POST a JSON body to `{IDP_URL}{idpPath}` with no credentials, and relay the
27
+ * IdP's status and body verbatim (the page acts on what the IdP actually
28
+ * returns, not on a shape this layer guesses). Never relays the IdP error TEXT
29
+ * for a non-2xx is NOT done here: the onboarding endpoints answer structured
30
+ * envelopes the page reads; the page owns the copy.
31
+ */
32
+ async function onboardingProxy(req, idpPath) {
33
+ const idpBase = env_1.ENV_CONFIG.IDP_URL;
34
+ if (!idpBase) {
35
+ return server_1.NextResponse.json({ success: false, message: 'IDP_URL is not configured', error: { code: 'NO_IDP_URL' } }, { status: 500 });
36
+ }
37
+ const url = `${idpBase}${idpPath}`;
38
+ (0, idp_url_guard_1.assertAbsoluteIdpUrl)(url);
39
+ let bodyText = '{}';
40
+ try {
41
+ const raw = await req.text();
42
+ if (raw && raw.trim().length > 0)
43
+ bodyText = raw;
44
+ }
45
+ catch { /* empty body is fine */ }
46
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
47
+ const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
48
+ if (xfwd)
49
+ headers['X-Forwarded-For'] = xfwd;
50
+ const ua = req.headers.get('user-agent');
51
+ if (ua)
52
+ headers['User-Agent'] = ua;
53
+ let res;
54
+ try {
55
+ res = await fetch(url, { method: 'POST', headers, body: bodyText, cache: 'no-store' });
56
+ }
57
+ catch {
58
+ return server_1.NextResponse.json({ success: false, message: 'The identity service is unreachable.', error: { code: 'UPSTREAM_UNREACHABLE' } }, { status: 502 });
59
+ }
60
+ const json = await res.json().catch(() => null);
61
+ return server_1.NextResponse.json(json ?? { success: res.ok }, { status: res.status });
62
+ }
@@ -206,7 +206,7 @@ function LoginForm({ altAuthSlot }) {
206
206
  .themed-status-submitting svg {
207
207
  color: ${buttonColor} !important;
208
208
  }
209
- ` } }), (0, jsx_runtime_1.jsx)("div", { className: `min-h-screen flex items-center justify-center px-4 ${colors.background}`, children: (0, jsx_runtime_1.jsxs)("div", { className: "max-w-md w-full", children: [(0, jsx_runtime_1.jsxs)("div", { className: `rounded-2xl shadow-xl p-8 ${colors.card} ${colors.border} text-gray-800`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "text-center mb-8", children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-3xl font-bold mb-2", style: { color: 'inherit' }, children: branding.tagline || 'Welcome Back' }), (0, jsx_runtime_1.jsx)("p", { className: colors.muted, children: "Sign in to your account" })] }), googleEnabled && ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => void handleGoogleSignIn(), disabled: googleLoading || isSubmitting, "data-testid": "google-sign-in", className: "w-full px-4 py-3 mb-6 rounded-lg border border-gray-300 font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50", style: { backgroundColor: 'white', color: '#1f2937' }, children: [googleLoading ? ((0, jsx_runtime_1.jsxs)("svg", { className: "animate-spin w-5 h-5", fill: "none", viewBox: "0 0 24 24", children: [(0, jsx_runtime_1.jsx)("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), (0, jsx_runtime_1.jsx)("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" })] })) : ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: [(0, jsx_runtime_1.jsx)("path", { d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z", fill: "#4285F4" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z", fill: "#34A853" }), (0, jsx_runtime_1.jsx)("path", { d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z", fill: "#FBBC05" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z", fill: "#EA4335" })] })), googleLoading ? 'Redirecting to Google…' : 'Continue with Google'] }) })), altAuthSlot && (0, jsx_runtime_1.jsx)("div", { className: "mt-4", children: altAuthSlot }), (0, jsx_runtime_1.jsx)("div", { className: `mt-6 text-center text-sm ${colors.muted}`, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/recovery", className: "themed-link hover:underline", children: "Start account recovery" }) })] }), (0, jsx_runtime_1.jsxs)("p", { className: `mt-4 text-center text-sm ${colors.muted}`, children: ["Don't have an account?", ' ', (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/register", className: "themed-link font-medium hover:underline", children: "Sign up" })] })] }) })] }));
209
+ ` } }), (0, jsx_runtime_1.jsx)("div", { className: `min-h-screen flex items-center justify-center px-4 ${colors.background}`, children: (0, jsx_runtime_1.jsx)("div", { className: "max-w-md w-full", children: (0, jsx_runtime_1.jsxs)("div", { className: `rounded-2xl shadow-xl p-8 ${colors.card} ${colors.border} text-gray-800`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "text-center mb-8", children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-3xl font-bold mb-2", style: { color: 'inherit' }, children: branding.tagline || 'Welcome Back' }), (0, jsx_runtime_1.jsx)("p", { className: colors.muted, children: "Sign in to your account" })] }), googleEnabled && ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => void handleGoogleSignIn(), disabled: googleLoading || isSubmitting, "data-testid": "google-sign-in", className: "w-full px-4 py-3 mb-6 rounded-lg border border-gray-300 font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50", style: { backgroundColor: 'white', color: '#1f2937' }, children: [googleLoading ? ((0, jsx_runtime_1.jsxs)("svg", { className: "animate-spin w-5 h-5", fill: "none", viewBox: "0 0 24 24", children: [(0, jsx_runtime_1.jsx)("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), (0, jsx_runtime_1.jsx)("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" })] })) : ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: [(0, jsx_runtime_1.jsx)("path", { d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z", fill: "#4285F4" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z", fill: "#34A853" }), (0, jsx_runtime_1.jsx)("path", { d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z", fill: "#FBBC05" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z", fill: "#EA4335" })] })), googleLoading ? 'Redirecting to Google…' : 'Continue with Google'] }) })), altAuthSlot && (0, jsx_runtime_1.jsx)("div", { className: "mt-4", children: altAuthSlot }), (0, jsx_runtime_1.jsx)("div", { className: `mt-6 text-center text-sm ${colors.muted}`, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/recovery", className: "themed-link hover:underline", children: "Start account recovery" }) })] }) }) })] }));
210
210
  }
211
211
  function LoginPageFallback() {
212
212
  const colors = (0, useTheme_1.useColors)();
@@ -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,20 @@
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.PasswordResetBridge = exports.OnboardingPage = exports.default = void 0;
7
+ /**
8
+ * PAY-2121: the onboarding surface.
9
+ *
10
+ * default - the /account-auth/onboarding page (mount from the scaffold at
11
+ * app/account-auth/onboarding/page.tsx).
12
+ * PasswordResetBridge - mount once on `/` to honour the IdP's post-verify
13
+ * `#password_reset_required=true` fragment.
14
+ */
15
+ var page_1 = require("./page");
16
+ Object.defineProperty(exports, "default", { enumerable: true, get: function () { return __importDefault(page_1).default; } });
17
+ var page_2 = require("./page");
18
+ Object.defineProperty(exports, "OnboardingPage", { enumerable: true, get: function () { return __importDefault(page_2).default; } });
19
+ var PasswordResetBridge_1 = require("../../components/auth/PasswordResetBridge");
20
+ Object.defineProperty(exports, "PasswordResetBridge", { enumerable: true, get: function () { return PasswordResetBridge_1.PasswordResetBridge; } });
@@ -0,0 +1 @@
1
+ export default function OnboardingPage(): import("react/jsx-runtime").JSX.Element;