@payez/next-mvp 4.4.8 → 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 (75) hide show
  1. package/README.md +53 -0
  2. package/dist/api-handlers/account/reset-password.js +3 -1
  3. package/dist/api-handlers/onboarding/send-email-code.d.ts +11 -0
  4. package/dist/api-handlers/onboarding/send-email-code.js +11 -0
  5. package/dist/api-handlers/onboarding/set-password.d.ts +12 -0
  6. package/dist/api-handlers/onboarding/set-password.js +15 -0
  7. package/dist/api-handlers/onboarding/verify-code.d.ts +11 -0
  8. package/dist/api-handlers/onboarding/verify-code.js +14 -0
  9. package/dist/api-handlers/onboarding/verify-welcome-email.d.ts +10 -0
  10. package/dist/api-handlers/onboarding/verify-welcome-email.js +11 -0
  11. package/dist/api-handlers/session/viability.js +32 -0
  12. package/dist/auth/password-reset-bridge.d.ts +70 -0
  13. package/dist/auth/password-reset-bridge.js +99 -0
  14. package/dist/auth/route-config.js +17 -0
  15. package/dist/components/auth/FederatedAuthSection.js +50 -21
  16. package/dist/components/auth/PasswordResetBridge.d.ts +17 -0
  17. package/dist/components/auth/PasswordResetBridge.js +32 -0
  18. package/dist/hooks/useAvailableProviders.js +39 -17
  19. package/dist/lib/onboarding-proxy.d.ts +30 -0
  20. package/dist/lib/onboarding-proxy.js +62 -0
  21. package/dist/lib/safe-error-response.d.ts +8 -0
  22. package/dist/lib/safe-error-response.js +27 -0
  23. package/dist/middleware/create-middleware.d.ts +41 -0
  24. package/dist/middleware/create-middleware.js +74 -2
  25. package/dist/pages/login/page.js +1 -1
  26. package/dist/pages/onboarding/index.d.ts +11 -0
  27. package/dist/pages/onboarding/index.js +20 -0
  28. package/dist/pages/onboarding/page.d.ts +1 -0
  29. package/dist/pages/onboarding/page.js +118 -0
  30. package/dist/pages/recovery/page.js +20 -7
  31. package/dist/routes/auth/viability.d.ts +5 -0
  32. package/dist/routes/auth/viability.js +53 -0
  33. package/dist/routes/onboarding/index.d.ts +15 -0
  34. package/dist/routes/onboarding/index.js +22 -0
  35. package/dist/routes/onboarding/send-email-code.d.ts +2 -0
  36. package/dist/routes/onboarding/send-email-code.js +6 -0
  37. package/dist/routes/onboarding/set-password.d.ts +2 -0
  38. package/dist/routes/onboarding/set-password.js +6 -0
  39. package/dist/routes/onboarding/verify-code.d.ts +2 -0
  40. package/dist/routes/onboarding/verify-code.js +6 -0
  41. package/dist/routes/onboarding/verify-welcome-email.d.ts +2 -0
  42. package/dist/routes/onboarding/verify-welcome-email.js +6 -0
  43. package/dist/utils/api.d.ts +6 -0
  44. package/dist/utils/api.js +10 -2
  45. package/dist/vibe/sessions.d.ts +88 -1
  46. package/dist/vibe/sessions.js +260 -19
  47. package/package.json +58 -2
  48. package/scripts/check-publish-eol.mjs +135 -0
  49. package/src/api-handlers/account/reset-password.ts +4 -2
  50. package/src/api-handlers/onboarding/send-email-code.ts +18 -0
  51. package/src/api-handlers/onboarding/set-password.ts +21 -0
  52. package/src/api-handlers/onboarding/verify-code.ts +20 -0
  53. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -0
  54. package/src/api-handlers/session/viability.ts +31 -0
  55. package/src/auth/password-reset-bridge.ts +98 -0
  56. package/src/auth/route-config.ts +17 -0
  57. package/src/components/auth/FederatedAuthSection.tsx +56 -22
  58. package/src/components/auth/PasswordResetBridge.tsx +30 -0
  59. package/src/hooks/useAvailableProviders.ts +41 -18
  60. package/src/lib/onboarding-proxy.ts +57 -0
  61. package/src/lib/safe-error-response.ts +23 -0
  62. package/src/middleware/create-middleware.ts +106 -2
  63. package/src/pages/login/page.tsx +4 -7
  64. package/src/pages/onboarding/index.ts +11 -0
  65. package/src/pages/onboarding/page.tsx +212 -0
  66. package/src/pages/recovery/page.tsx +24 -8
  67. package/src/routes/auth/viability.ts +52 -0
  68. package/src/routes/onboarding/index.ts +15 -0
  69. package/src/routes/onboarding/send-email-code.ts +2 -0
  70. package/src/routes/onboarding/set-password.ts +2 -0
  71. package/src/routes/onboarding/verify-code.ts +2 -0
  72. package/src/routes/onboarding/verify-welcome-email.ts +2 -0
  73. package/src/utils/api.ts +10 -1
  74. package/src/vibe/document-unwrap.ts +103 -103
  75. package/src/vibe/sessions.ts +291 -24
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:**
@@ -72,7 +72,9 @@ async function POST(req) {
72
72
  if (!upstream.ok) {
73
73
  // PAY-1591: a password-policy refusal also carries the KNOWN rule codes so the page can say which rules failed.
74
74
  const rules = (0, safe_error_response_1.projectPasswordPolicyRules)(data);
75
- return server_1.NextResponse.json({ ...(0, safe_error_response_1.idpErrorEnvelope)(data, 'Failed to reset password'), ...(rules !== undefined ? { password_policy: { rules } } : {}), ...((0, safe_error_response_1.projectResetTokenInvalid)(data) ? { reset_token_invalid: true } : {}) }, { status: upstream.status });
75
+ // PAY-2103: a breached-password refusal is a distinct 400; project a BOOLEAN so the anonymous
76
+ // route carries no code text and the page picks its own sentence.
77
+ return server_1.NextResponse.json({ ...(0, safe_error_response_1.idpErrorEnvelope)(data, 'Failed to reset password'), ...(rules !== undefined ? { password_policy: { rules } } : {}), ...((0, safe_error_response_1.projectResetTokenInvalid)(data) ? { reset_token_invalid: true } : {}), ...((0, safe_error_response_1.projectPasswordBreached)(data) ? { password_breached: true } : {}) }, { status: upstream.status });
76
78
  }
77
79
  return server_1.NextResponse.json(data, { status: upstream.status });
78
80
  }
@@ -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
+ }
@@ -12,6 +12,7 @@ const auth_1 = require("../../server/auth");
12
12
  const startup_init_1 = require("../../lib/startup-init");
13
13
  const idp_client_config_1 = require("../../lib/idp-client-config");
14
14
  const token_log_id_1 = require("../../lib/token-log-id");
15
+ const sessions_1 = require("../../vibe/sessions");
15
16
  async function GET(req) {
16
17
  try {
17
18
  // Ensure initialization is complete (auth signing secret resolved from IDP)
@@ -124,12 +125,43 @@ async function GET(req) {
124
125
  hoursExpiredAgo: ((Date.now() - mfaExpiresAt) / (1000 * 60 * 60)).toFixed(1)
125
126
  });
126
127
  }
128
+ // --- Session revocation status (PAY-1882 step 4 producer half) ---
129
+ // The four-state status (BAPert 66320). The middleware fails CLOSED by
130
+ // default on 'unknown' (a failed Vibe read) and always on 'revoked';
131
+ // 'active' and 'not_configured' stay viable. A consumer that does not
132
+ // read revocationStatus is unaffected - absent is not revoked.
133
+ let revocationStatus = 'not_configured';
134
+ // BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
135
+ // the token's own `sub` claim FIRST (session.userId can be an email).
136
+ const revocationUserId = (0, sessions_1.resolveNumericIdpUserId)(sessionData, sessionData.idpAccessToken);
137
+ if (revocationUserId > 0) {
138
+ try {
139
+ revocationStatus = await (0, sessions_1.getRevocationStatus)(revocationUserId, sessionToken);
140
+ }
141
+ catch {
142
+ // getRevocationStatus does not throw by contract; fail closed if it ever does.
143
+ revocationStatus = 'unknown';
144
+ }
145
+ }
146
+ else {
147
+ // N1 (NightHawk 66392): a session EXISTS but its userId is not a usable
148
+ // numeric IdP id, so the check could not be run. That is 'unknown'
149
+ // (could not determine), NOT 'not_configured' (no revocation source) -
150
+ // the two must not be conflated.
151
+ // BAPert 66480 condition 1: emit ONE WARN (shape only) per session.
152
+ revocationStatus = (0, sessions_1.warnNonNumericUserId)(sessionToken, sessionData.userId);
153
+ }
127
154
  const response = {
128
155
  authenticated: true,
129
156
  sessionTokenId: (0, token_log_id_1.tokenLogId)(sessionToken), // PAY-1481: correlation id for middleware tracking, never the token
130
157
  // 2FA fields - critical for middleware redirect logic
131
158
  requires2FA, // From cached client config (client-wide setting)
132
159
  twoFactorComplete: effectiveTwoFactorComplete, // From session, BUT respects MFA TTL
160
+ // Session revocation (PAY-1882 step 4): the four-state status, plus the
161
+ // boolean the middleware's existing force-invalid branch already reads.
162
+ revocationStatus,
163
+ forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
164
+ revoked: revocationStatus === 'revoked',
133
165
  // Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
134
166
  //
135
167
  // THIS IS THE SECOND IMPLEMENTATION OF VIABILITY. The other is
@@ -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',
@@ -3,31 +3,54 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.FederatedAuthSection = FederatedAuthSection;
5
5
  const jsx_runtime_1 = require("react/jsx-runtime");
6
+ /**
7
+ * PAY-2104 — PER-PROVIDER BRAND TREATMENT, NOT ONE GENERIC BUTTON.
8
+ *
9
+ * Apple and Facebook both publish binding rules for their sign-in buttons and
10
+ * neither is satisfied by the generic "Continue with X" control this section
11
+ * used to render for every provider:
12
+ * - APPLE (App Store Review Guidelines 4.8 / "Sign in with Apple" HIG): the
13
+ * button MUST carry the Apple logo and one of Apple's own wordings. The
14
+ * approved primary wording is "Sign in with Apple" ("Continue with Apple"
15
+ * is the permitted alternative). "Continue with Apple" with no logo, which
16
+ * is what the generic path produced, is non-compliant and is what App Store
17
+ * review rejects. Black or white only.
18
+ * - FACEBOOK (Meta Platform Terms / brand guidelines): "Continue with
19
+ * Facebook", the Facebook "f" mark, and the brand blue (#1877F2) or white
20
+ * are the sanctioned treatments.
21
+ * Google/Microsoft/GitHub keep the neutral control — they impose no such rule,
22
+ * and inventing brand colours for them would be a change nobody asked for.
23
+ *
24
+ * The mark inherits `currentColor` so it is legible on both the white neutral
25
+ * buttons and the black/blue branded ones without a second SVG per provider.
26
+ */
27
+ const PROVIDER_BRAND = {
28
+ google: { label: 'Continue with Google', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
29
+ microsoft: { label: 'Continue with Microsoft', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
30
+ github: { label: 'Continue with GitHub', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
31
+ // Meta brand blue; white mark on blue, per the brand guidelines.
32
+ facebook: { label: 'Continue with Facebook', background: '#1877F2', color: '#ffffff', border: '#1877F2' },
33
+ // Apple HIG: black (or white) field, "Sign in with Apple", Apple logo.
34
+ apple: { label: 'Sign in with Apple', background: '#000000', color: '#ffffff', border: '#000000' },
35
+ };
6
36
  const providerIcons = {
7
37
  google: {
8
- label: 'Google',
9
- color: '#4285F4',
10
- svg: ((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" })] })),
38
+ svg: ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", 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" })] })),
11
39
  },
12
40
  microsoft: {
13
- label: 'Microsoft',
14
- color: '#0078D4',
15
- svg: ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: [(0, jsx_runtime_1.jsx)("rect", { x: "1", y: "1", width: "9", height: "9", fill: "#F25022" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "1", width: "9", height: "9", fill: "#7FBA00" }), (0, jsx_runtime_1.jsx)("rect", { x: "1", y: "12", width: "9", height: "9", fill: "#00A4EF" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "12", width: "9", height: "9", fill: "#FFB900" })] })),
41
+ svg: ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: [(0, jsx_runtime_1.jsx)("rect", { x: "1", y: "1", width: "9", height: "9", fill: "#F25022" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "1", width: "9", height: "9", fill: "#7FBA00" }), (0, jsx_runtime_1.jsx)("rect", { x: "1", y: "12", width: "9", height: "9", fill: "#00A4EF" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "12", width: "9", height: "9", fill: "#FFB900" })] })),
16
42
  },
17
43
  facebook: {
18
- label: 'Facebook',
19
- color: '#1877F2',
20
- svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "#1877F2", children: (0, jsx_runtime_1.jsx)("path", { d: "M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" }) })),
44
+ // currentColor: white on the Meta-blue button. A hardcoded #1877F2 fill on a
45
+ // #1877F2 field is an invisible mark — the exact way a branded button ships
46
+ // looking like a blank one.
47
+ svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("path", { d: "M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" }) })),
21
48
  },
22
49
  github: {
23
- label: 'GitHub',
24
- color: '#000000',
25
- svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: (0, jsx_runtime_1.jsx)("path", { d: "M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" }) })),
50
+ svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("path", { d: "M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" }) })),
26
51
  },
27
52
  apple: {
28
- label: 'Apple',
29
- color: '#000000',
30
- svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: (0, jsx_runtime_1.jsx)("path", { d: "M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" }) })),
53
+ svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("path", { d: "M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" }) })),
31
54
  },
32
55
  };
33
56
  function FederatedAuthSection({ providers, onProviderClick, isLoading }) {
@@ -35,11 +58,17 @@ function FederatedAuthSection({ providers, onProviderClick, isLoading }) {
35
58
  return null;
36
59
  }
37
60
  return ((0, jsx_runtime_1.jsx)("div", { className: "space-y-3", children: providers.map((provider) => {
38
- const config = providerIcons[provider];
39
- return ((0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => onProviderClick(provider), disabled: isLoading, className: `w-full px-4 py-3 rounded-lg border-2 font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed`, style: {
40
- border: '1px solid var(--border-default)',
41
- color: 'var(--text-primary)',
42
- background: 'var(--bg-default)',
43
- }, children: [config.svg, "Continue with ", config.label] }, provider));
61
+ const icon = providerIcons[provider];
62
+ const brand = PROVIDER_BRAND[provider] ?? {
63
+ label: `Continue with ${provider}`,
64
+ background: 'var(--bg-default, #ffffff)',
65
+ color: 'var(--text-primary, #111827)',
66
+ border: 'var(--border-default, #e5e7eb)',
67
+ };
68
+ return ((0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => onProviderClick(provider), disabled: isLoading, "data-provider": provider, className: `w-full px-4 py-3 rounded-lg border-2 font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed`, style: {
69
+ border: `1px solid ${brand.border}`,
70
+ color: brand.color,
71
+ background: brand.background,
72
+ }, children: [icon.svg, brand.label] }, provider));
44
73
  }) }));
45
74
  }
@@ -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;