@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.
- package/README.md +53 -0
- package/dist/api-handlers/onboarding/send-email-code.d.ts +11 -0
- package/dist/api-handlers/onboarding/send-email-code.js +11 -0
- package/dist/api-handlers/onboarding/set-password.d.ts +12 -0
- package/dist/api-handlers/onboarding/set-password.js +15 -0
- package/dist/api-handlers/onboarding/verify-code.d.ts +11 -0
- package/dist/api-handlers/onboarding/verify-code.js +14 -0
- package/dist/api-handlers/onboarding/verify-welcome-email.d.ts +10 -0
- package/dist/api-handlers/onboarding/verify-welcome-email.js +11 -0
- package/dist/auth/password-reset-bridge.d.ts +70 -0
- package/dist/auth/password-reset-bridge.js +99 -0
- package/dist/auth/route-config.js +17 -0
- package/dist/components/auth/PasswordResetBridge.d.ts +17 -0
- package/dist/components/auth/PasswordResetBridge.js +32 -0
- package/dist/lib/onboarding-proxy.d.ts +30 -0
- package/dist/lib/onboarding-proxy.js +62 -0
- package/dist/pages/login/page.js +1 -1
- package/dist/pages/onboarding/index.d.ts +11 -0
- package/dist/pages/onboarding/index.js +20 -0
- package/dist/pages/onboarding/page.d.ts +1 -0
- package/dist/pages/onboarding/page.js +118 -0
- package/dist/pages/recovery/page.js +20 -7
- package/dist/routes/onboarding/index.d.ts +15 -0
- package/dist/routes/onboarding/index.js +22 -0
- package/dist/routes/onboarding/send-email-code.d.ts +2 -0
- package/dist/routes/onboarding/send-email-code.js +6 -0
- package/dist/routes/onboarding/set-password.d.ts +2 -0
- package/dist/routes/onboarding/set-password.js +6 -0
- package/dist/routes/onboarding/verify-code.d.ts +2 -0
- package/dist/routes/onboarding/verify-code.js +6 -0
- package/dist/routes/onboarding/verify-welcome-email.d.ts +2 -0
- package/dist/routes/onboarding/verify-welcome-email.js +6 -0
- package/package.json +56 -1
- package/src/api-handlers/onboarding/send-email-code.ts +18 -0
- package/src/api-handlers/onboarding/set-password.ts +21 -0
- package/src/api-handlers/onboarding/verify-code.ts +20 -0
- package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -0
- package/src/auth/password-reset-bridge.ts +98 -0
- package/src/auth/route-config.ts +17 -0
- package/src/components/auth/PasswordResetBridge.tsx +30 -0
- package/src/lib/onboarding-proxy.ts +57 -0
- package/src/pages/login/page.tsx +4 -7
- package/src/pages/onboarding/index.ts +11 -0
- package/src/pages/onboarding/page.tsx +212 -0
- package/src/pages/recovery/page.tsx +24 -8
- package/src/routes/onboarding/index.ts +15 -0
- package/src/routes/onboarding/send-email-code.ts +2 -0
- package/src/routes/onboarding/set-password.ts +2 -0
- package/src/routes/onboarding/verify-code.ts +2 -0
- 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 (<><PasswordResetBridge /></>);
|
|
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 (<><PasswordResetBridge /></>);
|
|
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
|
+
}
|
package/dist/pages/login/page.js
CHANGED
|
@@ -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.
|
|
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;
|