@payez/next-mvp 4.4.9 → 4.5.1
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/auth-decision.d.ts +5 -1
- package/dist/auth/auth-decision.js +11 -1
- 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/edge/middleware.d.ts +1 -1
- package/dist/edge/middleware.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/onboarding-proxy.d.ts +30 -0
- package/dist/lib/onboarding-proxy.js +62 -0
- package/dist/lib/session-store.d.ts +17 -0
- package/dist/lib/session-store.js +33 -0
- package/dist/middleware/create-middleware.d.ts +10 -0
- package/dist/middleware/create-middleware.js +30 -1
- 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/dist/server/auth.js +87 -3
- package/dist/vibe/sessions.d.ts +10 -22
- package/dist/vibe/sessions.js +159 -24
- 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/auth-decision.ts +14 -1
- 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/edge/middleware.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/onboarding-proxy.ts +57 -0
- package/src/lib/session-store.ts +42 -0
- package/src/middleware/create-middleware.ts +26 -1
- 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/src/server/auth.ts +90 -3
- package/src/vibe/document-unwrap.ts +103 -103
- package/src/vibe/sessions.ts +172 -27
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig } from '.
|
|
|
27
27
|
// The ONE callback-target guard (N7 / PAY-1989): apps import this instead of
|
|
28
28
|
// copying it. Exposed from the main index AND as the subpath ./auth/safe-callback.
|
|
29
29
|
export { safeCallbackPath, isSafeCallbackPath, resolveCallbackDestination, DEFAULT_CALLBACK } from './auth/safe-callback';
|
|
30
|
-
export { createMvpMiddleware } from './middleware/create-middleware';
|
|
30
|
+
export { createMvpMiddleware, isPrefetchRequest } from './middleware/create-middleware';
|
|
31
31
|
|
|
32
32
|
// Account Components
|
|
33
33
|
export { UserAvatarMenu, MobileNavDrawer } from './components/account';
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
import { ENV_CONFIG } from '../config/env';
|
|
16
|
+
import { assertAbsoluteIdpUrl } from './idp-url-guard';
|
|
17
|
+
|
|
18
|
+
/** A typed refusal in the kit's error envelope - used for a missing field, BEFORE any IdP call. */
|
|
19
|
+
export function onboardingBadRequest(code: string, message: string) {
|
|
20
|
+
return NextResponse.json({ success: false, message, error: { code } }, { status: 400 });
|
|
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 async function onboardingProxy(req: NextRequest, idpPath: string): Promise<NextResponse> {
|
|
31
|
+
const idpBase = ENV_CONFIG.IDP_URL;
|
|
32
|
+
if (!idpBase) {
|
|
33
|
+
return NextResponse.json({ success: false, message: 'IDP_URL is not configured', error: { code: 'NO_IDP_URL' } }, { status: 500 });
|
|
34
|
+
}
|
|
35
|
+
const url = `${idpBase}${idpPath}`;
|
|
36
|
+
assertAbsoluteIdpUrl(url);
|
|
37
|
+
let bodyText = '{}';
|
|
38
|
+
try {
|
|
39
|
+
const raw = await req.text();
|
|
40
|
+
if (raw && raw.trim().length > 0) bodyText = raw;
|
|
41
|
+
} catch { /* empty body is fine */ }
|
|
42
|
+
|
|
43
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
44
|
+
const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
|
|
45
|
+
if (xfwd) headers['X-Forwarded-For'] = xfwd;
|
|
46
|
+
const ua = req.headers.get('user-agent');
|
|
47
|
+
if (ua) headers['User-Agent'] = ua;
|
|
48
|
+
|
|
49
|
+
let res: Response;
|
|
50
|
+
try {
|
|
51
|
+
res = await fetch(url, { method: 'POST', headers, body: bodyText, cache: 'no-store' });
|
|
52
|
+
} catch {
|
|
53
|
+
return NextResponse.json({ success: false, message: 'The identity service is unreachable.', error: { code: 'UPSTREAM_UNREACHABLE' } }, { status: 502 });
|
|
54
|
+
}
|
|
55
|
+
const json = await res.json().catch(() => null);
|
|
56
|
+
return NextResponse.json(json ?? { success: res.ok }, { status: res.status });
|
|
57
|
+
}
|
package/src/lib/session-store.ts
CHANGED
|
@@ -1048,3 +1048,45 @@ export async function cleanupRefreshLocks(): Promise<number> {
|
|
|
1048
1048
|
console.warn('[SESSION-STORE] cleanupRefreshLocks called - scanning for expired locks');
|
|
1049
1049
|
return 0;
|
|
1050
1050
|
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* PAY-2136 DIAGNOSTIC (BAPert 70873): which of the session keys EXIST for a token, right now.
|
|
1054
|
+
*
|
|
1055
|
+
* Names/presence only - this returns booleans and the key PREFIX (an app slug, not a secret), never a value and
|
|
1056
|
+
* never any part of the token. It exists so a NO_SESSION can be read for what it was: canonical row gone with the
|
|
1057
|
+
* ba: record alive (the :330 asymmetry), both gone (expired/deleted), or the store could not be asked ('error').
|
|
1058
|
+
*
|
|
1059
|
+
* EXISTS, not GET: it cannot parse, cannot throw on a malformed blob, and cannot return a credential. Each key is
|
|
1060
|
+
* asked separately so one timeout does not hide the other two. Never throws.
|
|
1061
|
+
*/
|
|
1062
|
+
export type KeyPresence = boolean | 'error';
|
|
1063
|
+
export async function describeSessionKeyPresence(
|
|
1064
|
+
sessionToken: string
|
|
1065
|
+
): Promise<{ prefix: string; canonical: KeyPresence; ba: KeyPresence; ver: KeyPresence }> {
|
|
1066
|
+
const ask = async (key: string): Promise<KeyPresence> => {
|
|
1067
|
+
try {
|
|
1068
|
+
return (await redis.exists(key)) > 0;
|
|
1069
|
+
} catch {
|
|
1070
|
+
return 'error';
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
let prefix = '';
|
|
1074
|
+
try {
|
|
1075
|
+
prefix = getSessionPrefix();
|
|
1076
|
+
} catch {
|
|
1077
|
+
prefix = '';
|
|
1078
|
+
}
|
|
1079
|
+
if (!sessionToken) return { prefix, canonical: false, ba: false, ver: false };
|
|
1080
|
+
let canonicalKey = '';
|
|
1081
|
+
let baKey = '';
|
|
1082
|
+
let verKey = '';
|
|
1083
|
+
try {
|
|
1084
|
+
canonicalKey = getSessionKey(sessionToken);
|
|
1085
|
+
baKey = getBetterAuthSessionKey(sessionToken, getAppSlug());
|
|
1086
|
+
verKey = getSessionVersionKey(sessionToken);
|
|
1087
|
+
} catch {
|
|
1088
|
+
return { prefix, canonical: 'error', ba: 'error', ver: 'error' };
|
|
1089
|
+
}
|
|
1090
|
+
const [canonical, ba, ver] = await Promise.all([ask(canonicalKey), ask(baKey), ask(verKey)]);
|
|
1091
|
+
return { prefix, canonical, ba, ver };
|
|
1092
|
+
}
|
|
@@ -197,8 +197,30 @@ function getSafeCallbackUrl(pathname: string, searchParams?: URLSearchParams): s
|
|
|
197
197
|
return resolveCallbackDestination(pathname, searchParams);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* PAY-2136 Rule A: a speculative request (router prefetch) must NEVER mutate cookies.
|
|
202
|
+
* A prefetch of /account/security was answered 307 + Set-Cookie (session cookies expired), the browser
|
|
203
|
+
* applied it, and the user's next real request (e.g. Approve) was signed out. Detected by any of:
|
|
204
|
+
* `Next-Router-Prefetch: 1|2` (Next sets it on EVERY Link prefetch; 2 = runtime prefetch) or `Sec-Purpose`/`Purpose`
|
|
205
|
+
* containing "prefetch".
|
|
206
|
+
* NOT `RSC: 1` + `_rsc`: a real client-side navigation carries both too (Nextpert-Scout 70976), so that shape
|
|
207
|
+
* would swallow in-app navigations and stop the middleware refreshing/clearing for them.
|
|
208
|
+
*/
|
|
209
|
+
export function isPrefetchRequest(request: NextRequest): boolean {
|
|
210
|
+
const h = request.headers;
|
|
211
|
+
const nrp = h.get('next-router-prefetch');
|
|
212
|
+
if (nrp === '1' || nrp === '2') return true;
|
|
213
|
+
if (/prefetch/i.test(h.get('sec-purpose') ?? '') || /prefetch/i.test(h.get('purpose') ?? '')) return true;
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Clear all session cookies on a response. The ONE site that writes the expiry Set-Cookie for the middleware,
|
|
219
|
+
* so the prefetch guard lives here: on a prefetch the redirect is still returned (routing unchanged) but
|
|
220
|
+
* carries no Set-Cookie; the next real navigation reaches the same decision and clears then.
|
|
221
|
+
*/
|
|
201
222
|
function clearSessionCookies(response: NextResponse, request: NextRequest): void {
|
|
223
|
+
if (isPrefetchRequest(request)) return;
|
|
202
224
|
const sessionCookie = getSessionCookieName();
|
|
203
225
|
const secureCookie = getSecureSessionCookieName();
|
|
204
226
|
|
|
@@ -736,6 +758,9 @@ async function executeDecision(
|
|
|
736
758
|
return NextResponse.redirect(new URL('/service-unavailable', request.url));
|
|
737
759
|
|
|
738
760
|
case 'refresh_needed':
|
|
761
|
+
// PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
|
|
762
|
+
// clear the session. Let it through; the real navigation refreshes.
|
|
763
|
+
if (isPrefetchRequest(request)) return NextResponse.next();
|
|
739
764
|
return handleRefresh(request, safeCallback, opts);
|
|
740
765
|
}
|
|
741
766
|
}
|
package/src/pages/login/page.tsx
CHANGED
|
@@ -311,13 +311,10 @@ function LoginForm({ altAuthSlot }: { altAuthSlot?: React.ReactNode }) {
|
|
|
311
311
|
</div>
|
|
312
312
|
</div>
|
|
313
313
|
|
|
314
|
-
{/*
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
Sign up
|
|
319
|
-
</a>
|
|
320
|
-
</p>
|
|
314
|
+
{/* PAY-2121: the kit ships no sign-up PAGE (registration is the owner-initiated
|
|
315
|
+
invite/onboarding flow, not a self-serve screen), so the old "Sign up" link to
|
|
316
|
+
the register path was a dead end - it 404'd at the developer's site. Removed;
|
|
317
|
+
the README says how registration is reached. */}
|
|
321
318
|
</div>
|
|
322
319
|
</div>
|
|
323
320
|
</>
|
|
@@ -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,212 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { Suspense, useEffect, useState } from 'react';
|
|
4
|
+
import { useSearchParams } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PAY-2121: the /account-auth/onboarding landing page.
|
|
8
|
+
*
|
|
9
|
+
* The IdP's welcome/invite email links to
|
|
10
|
+
* {site}/account-auth/onboarding?client_id=<numeric>&onboard_token=<token>
|
|
11
|
+
* (OnboardingLinkService.BuildOnboardingLink, PayEz-Core). A fresh
|
|
12
|
+
* @payez/next-mvp app had NO page at that path, so the invitee 404'd at the
|
|
13
|
+
* developer's own site. This page mounts it.
|
|
14
|
+
*
|
|
15
|
+
* It is TOKEN-DRIVEN: the invitee has no account or session; the onboard_token
|
|
16
|
+
* IS the credential. The page forwards { onboard_token } to the kit's onboarding
|
|
17
|
+
* API (the /api/onboarding/* handlers), which proxy the IdP. Two steps:
|
|
18
|
+
* 1. verify-welcome-email -> the masked contact info the invitee recognises;
|
|
19
|
+
* 2. verify-code -> reset_token + email; then set-password -> onboarding done.
|
|
20
|
+
*
|
|
21
|
+
* MOUNTING (what README section 5 asks a consumer to write; the CLI scaffold
|
|
22
|
+
* writes it too):
|
|
23
|
+
* app/account-auth/onboarding/page.tsx:
|
|
24
|
+
* export { default } from '@payez/next-mvp/pages/onboarding';
|
|
25
|
+
*
|
|
26
|
+
* A missing onboard_token is a typed state, never a crash: it renders a plain
|
|
27
|
+
* "this link is missing its token" with a way back to sign-in.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
type Step = 'code' | 'password' | 'done';
|
|
31
|
+
|
|
32
|
+
const H = { primary: 'var(--text-primary)', secondary: 'var(--text-secondary)', muted: 'var(--text-muted)' } as const;
|
|
33
|
+
|
|
34
|
+
function OnboardingContent() {
|
|
35
|
+
const params = useSearchParams();
|
|
36
|
+
const onboardToken = params?.get('onboard_token') ?? '';
|
|
37
|
+
|
|
38
|
+
const [step, setStep] = useState<Step>('code');
|
|
39
|
+
const [maskedEmail, setMaskedEmail] = useState<string | null>(null);
|
|
40
|
+
const [digits, setDigits] = useState('');
|
|
41
|
+
const [password, setPassword] = useState('');
|
|
42
|
+
const [confirm, setConfirm] = useState('');
|
|
43
|
+
const [resetToken, setResetToken] = useState('');
|
|
44
|
+
const [email, setEmail] = useState('');
|
|
45
|
+
const [busy, setBusy] = useState(false);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
// Step 1: tell the invitee which address the code went to (masked). Best
|
|
49
|
+
// effort - a failure here does not block entering a code they were emailed.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!onboardToken) return;
|
|
52
|
+
let alive = true;
|
|
53
|
+
(async () => {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch('/api/onboarding/verify-welcome-email', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ onboard_token: onboardToken }),
|
|
59
|
+
});
|
|
60
|
+
const body = await res.json().catch(() => null);
|
|
61
|
+
const data = body?.data?.data || body?.data || {};
|
|
62
|
+
const masked = data?.masked_user_info?.masked_email || data?.masked_email;
|
|
63
|
+
if (alive && typeof masked === 'string' && masked) setMaskedEmail(masked);
|
|
64
|
+
} catch { /* non-fatal */ }
|
|
65
|
+
})();
|
|
66
|
+
return () => { alive = false; };
|
|
67
|
+
}, [onboardToken]);
|
|
68
|
+
|
|
69
|
+
if (!onboardToken) {
|
|
70
|
+
return (
|
|
71
|
+
<Shell>
|
|
72
|
+
<h1 style={titleStyle}>This invitation link is missing its token.</h1>
|
|
73
|
+
<p style={{ color: H.secondary, fontSize: 14, marginTop: 8 }}>
|
|
74
|
+
The link may have been truncated or copied incompletely. Ask for a new invitation, or sign in if you already have an
|
|
75
|
+
account.
|
|
76
|
+
</p>
|
|
77
|
+
<p style={{ marginTop: 16 }}>
|
|
78
|
+
<a href="/account-auth/login" style={linkStyle}>Go to sign in</a>
|
|
79
|
+
</p>
|
|
80
|
+
</Shell>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function submitCode(e: React.FormEvent) {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
setBusy(true);
|
|
87
|
+
setError(null);
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch('/api/onboarding/verify-code', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({ onboard_token: onboardToken, code: digits }),
|
|
93
|
+
});
|
|
94
|
+
const body = await res.json().catch(() => null);
|
|
95
|
+
const data = body?.data?.data || body?.data || body;
|
|
96
|
+
if (!res.ok || !data?.reset_token || !data?.email) throw new Error();
|
|
97
|
+
setResetToken(data.reset_token);
|
|
98
|
+
setEmail(data.email);
|
|
99
|
+
setStep('password');
|
|
100
|
+
} catch {
|
|
101
|
+
setError('That code was not accepted. Check it and try again.');
|
|
102
|
+
} finally {
|
|
103
|
+
setBusy(false);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function submitPassword(e: React.FormEvent) {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
if (password !== confirm) { setError('The two passwords do not match.'); return; }
|
|
110
|
+
setBusy(true);
|
|
111
|
+
setError(null);
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch('/api/onboarding/set-password', {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({ onboard_token: onboardToken, email, reset_token: resetToken, new_password: password }),
|
|
117
|
+
});
|
|
118
|
+
const body = await res.json().catch(() => null);
|
|
119
|
+
if (!res.ok || (body && body.success === false)) throw new Error(body?.message || '');
|
|
120
|
+
setStep('done');
|
|
121
|
+
} catch (err) {
|
|
122
|
+
setError(err instanceof Error && err.message ? err.message : 'We could not set that password. Try again.');
|
|
123
|
+
} finally {
|
|
124
|
+
setBusy(false);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Shell>
|
|
130
|
+
{step === 'code' && (
|
|
131
|
+
<>
|
|
132
|
+
<h1 style={titleStyle}>Finish setting up your account</h1>
|
|
133
|
+
<p style={{ color: H.secondary, fontSize: 14, marginTop: 8 }}>
|
|
134
|
+
{maskedEmail
|
|
135
|
+
? `We emailed a 6-digit code to ${maskedEmail}. Enter it below.`
|
|
136
|
+
: 'Enter the 6-digit code from your invitation email.'}
|
|
137
|
+
</p>
|
|
138
|
+
<form onSubmit={submitCode} noValidate style={{ marginTop: 16 }}>
|
|
139
|
+
<input
|
|
140
|
+
value={digits}
|
|
141
|
+
onChange={(e) => setDigits(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
142
|
+
inputMode="numeric"
|
|
143
|
+
autoComplete="one-time-code"
|
|
144
|
+
aria-label="6-digit code"
|
|
145
|
+
placeholder="000000"
|
|
146
|
+
style={{ ...inputStyle, letterSpacing: 6, textAlign: 'center', fontFamily: 'monospace', fontSize: 20 }}
|
|
147
|
+
/>
|
|
148
|
+
<button type="submit" disabled={busy || digits.length < 6} style={ctaStyle(busy || digits.length < 6)}>
|
|
149
|
+
{busy ? 'Verifying…' : 'Verify'}
|
|
150
|
+
</button>
|
|
151
|
+
</form>
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{step === 'password' && (
|
|
156
|
+
<>
|
|
157
|
+
<h1 style={titleStyle}>Choose a password</h1>
|
|
158
|
+
<p style={{ color: H.secondary, fontSize: 14, marginTop: 8 }}>Set the password you will sign in with.</p>
|
|
159
|
+
<form onSubmit={submitPassword} noValidate style={{ marginTop: 16 }}>
|
|
160
|
+
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} aria-label="New password" placeholder="New password" style={inputStyle} />
|
|
161
|
+
<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} aria-label="Confirm password" placeholder="Confirm password" style={{ ...inputStyle, marginTop: 10 }} />
|
|
162
|
+
<button type="submit" disabled={busy || !password || !confirm} style={ctaStyle(busy || !password || !confirm)}>
|
|
163
|
+
{busy ? 'Saving…' : 'Set password'}
|
|
164
|
+
</button>
|
|
165
|
+
</form>
|
|
166
|
+
</>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{step === 'done' && (
|
|
170
|
+
<>
|
|
171
|
+
<h1 style={titleStyle}>You're all set.</h1>
|
|
172
|
+
<p style={{ color: H.secondary, fontSize: 14, marginTop: 8 }}>Your account is ready. You can sign in now.</p>
|
|
173
|
+
<p style={{ marginTop: 16 }}>
|
|
174
|
+
<a href="/account-auth/login" style={linkStyle}>Go to sign in</a>
|
|
175
|
+
</p>
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{error && <p style={{ color: '#b00020', fontSize: 13, marginTop: 12 }}>{error}</p>}
|
|
180
|
+
</Shell>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const titleStyle: React.CSSProperties = { color: H.primary, fontSize: 20, fontWeight: 600, margin: 0 };
|
|
185
|
+
const linkStyle: React.CSSProperties = { color: H.primary, fontWeight: 600, textDecoration: 'underline' };
|
|
186
|
+
const inputStyle: React.CSSProperties = {
|
|
187
|
+
width: '100%', padding: '10px 12px', border: '1px solid var(--border-default, #d1d5db)',
|
|
188
|
+
borderRadius: 10, color: H.primary, fontSize: 15, background: 'var(--bg-card, #fff)',
|
|
189
|
+
};
|
|
190
|
+
const ctaStyle = (disabled: boolean): React.CSSProperties => ({
|
|
191
|
+
marginTop: 14, width: '100%', padding: '10px 12px', borderRadius: 10, border: 'none',
|
|
192
|
+
background: H.primary, color: '#fff', fontWeight: 600, fontSize: 15, cursor: disabled ? 'default' : 'pointer',
|
|
193
|
+
opacity: disabled ? 0.5 : 1,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
function Shell({ children }: { children: React.ReactNode }) {
|
|
197
|
+
return (
|
|
198
|
+
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16, background: 'var(--bg-default, #f9fafb)' }}>
|
|
199
|
+
<div style={{ width: '100%', maxWidth: 420, background: 'var(--bg-card, #fff)', border: '1px solid var(--border-default, #e5e7eb)', borderRadius: 16, padding: 28 }}>
|
|
200
|
+
{children}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default function OnboardingPage() {
|
|
207
|
+
return (
|
|
208
|
+
<Suspense fallback={<div style={{ minHeight: '100vh' }} />}>
|
|
209
|
+
<OnboardingContent />
|
|
210
|
+
</Suspense>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense } from 'react';
|
|
|
4
4
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
5
5
|
import { RecoveryStep, RecoverySession, PasswordResetToken } from '../../types/recovery';
|
|
6
6
|
import { accountApi } from '../../utils/api';
|
|
7
|
+
import { recoveryEntry, resetSubmitTarget } from '../../auth/password-reset-bridge';
|
|
7
8
|
import { InitiateRecoveryStep } from '../../components/recovery/InitiateRecoveryStep';
|
|
8
9
|
import { SelectMethodStep } from '../../components/recovery/SelectMethodStep';
|
|
9
10
|
import { VerifyCodeStep } from '../../components/recovery/VerifyCodeStep';
|
|
@@ -15,15 +16,21 @@ import { useColors } from '../../theme/useTheme';
|
|
|
15
16
|
function RecoveryContent() {
|
|
16
17
|
const router = useRouter();
|
|
17
18
|
const searchParams = useSearchParams();
|
|
18
|
-
|
|
19
|
+
// PAY-2121 (BAPert 70576): a VERIFIED passwordless user arrives here with a real
|
|
20
|
+
// reset token (?token=) from the IdP's post-verify fragment, via the bridge. In
|
|
21
|
+
// that case skip the request-a-code step and go straight to set-password - the
|
|
22
|
+
// token is the credential; asking for a code would throw it away.
|
|
23
|
+
const entry = recoveryEntry(searchParams?.get('token'), searchParams?.get('email'));
|
|
19
24
|
const colors = useColors();
|
|
20
25
|
|
|
21
|
-
const [currentStep, setCurrentStep] = useState<RecoveryStep>(
|
|
22
|
-
const [email, setEmail] = useState(
|
|
26
|
+
const [currentStep, setCurrentStep] = useState<RecoveryStep>(entry.step);
|
|
27
|
+
const [email, setEmail] = useState(entry.email);
|
|
23
28
|
const [recoverySession, setRecoverySession] = useState<RecoverySession | null>(null);
|
|
24
29
|
const [selectedMethod, setSelectedMethod] = useState<'email' | 'sms' | 'authenticator' | null>(null);
|
|
25
30
|
const [verificationCode, setVerificationCode] = useState('');
|
|
26
|
-
const [passwordResetToken, setPasswordResetToken] = useState<PasswordResetToken | null>(
|
|
31
|
+
const [passwordResetToken, setPasswordResetToken] = useState<PasswordResetToken | null>(
|
|
32
|
+
entry.resetToken ? { token: entry.resetToken, expiresAt: '' } : null,
|
|
33
|
+
);
|
|
27
34
|
const [error, setError] = useState<string | null>(null);
|
|
28
35
|
// PAY-1591: true when the reset failed because the recovery itself is dead; the error box then offers a way back.
|
|
29
36
|
const [recoveryExpired, setRecoveryExpired] = useState(false);
|
|
@@ -113,7 +120,14 @@ function RecoveryContent() {
|
|
|
113
120
|
};
|
|
114
121
|
|
|
115
122
|
const handleResetPassword = async (password: string, confirmPassword: string) => {
|
|
116
|
-
|
|
123
|
+
// PAY-2121 (BAPert 70582): the ONE place that decides whether a submit may proceed.
|
|
124
|
+
// No token (or no email anywhere) -> refuse BEFORE any request; a guard-less submit
|
|
125
|
+
// would otherwise call the API with an empty token.
|
|
126
|
+
const target = resetSubmitTarget(passwordResetToken?.token, recoverySession?.email, email);
|
|
127
|
+
if (!target) {
|
|
128
|
+
setError(passwordResetToken ? 'We could not tell which account to reset. Start recovery again.' : 'This reset link is missing its token. Start recovery again.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
117
131
|
|
|
118
132
|
setLoading(true);
|
|
119
133
|
setError(null);
|
|
@@ -121,8 +135,8 @@ function RecoveryContent() {
|
|
|
121
135
|
|
|
122
136
|
try {
|
|
123
137
|
const response = await accountApi.resetPasswordWithToken(
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
target.email,
|
|
139
|
+
target.token,
|
|
126
140
|
password,
|
|
127
141
|
confirmPassword
|
|
128
142
|
);
|
|
@@ -144,7 +158,9 @@ function RecoveryContent() {
|
|
|
144
158
|
const handleGoBack = () => {
|
|
145
159
|
if (currentStep === 'select-method') setCurrentStep('initiate');
|
|
146
160
|
else if (currentStep === 'verify-code') setCurrentStep('select-method');
|
|
147
|
-
|
|
161
|
+
// PAY-2121: a deep-linked set-password has no verify-code to go back to; return to
|
|
162
|
+
// the request-code step so a bad token still leaves a way forward.
|
|
163
|
+
else if (currentStep === 'set-password') setCurrentStep(recoverySession ? 'verify-code' : 'initiate');
|
|
148
164
|
};
|
|
149
165
|
|
|
150
166
|
return (
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ready-to-use onboarding route handlers (PAY-2121).
|
|
3
|
+
*
|
|
4
|
+
* Import directly into your app's API routes with zero configuration:
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* // app/api/onboarding/verify-code/route.ts
|
|
9
|
+
* export { POST } from '@payez/next-mvp/routes/onboarding/verify-code';
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export { POST as verifyCodePOST } from '../../api-handlers/onboarding/verify-code';
|
|
13
|
+
export { POST as sendEmailCodePOST } from '../../api-handlers/onboarding/send-email-code';
|
|
14
|
+
export { POST as verifyWelcomeEmailPOST } from '../../api-handlers/onboarding/verify-welcome-email';
|
|
15
|
+
export { POST as setPasswordPOST } from '../../api-handlers/onboarding/set-password';
|
package/src/server/auth.ts
CHANGED
|
@@ -10,8 +10,10 @@ import { getBetterAuthInstance } from '../auth/better-auth';
|
|
|
10
10
|
import {
|
|
11
11
|
getSession as getRedisSession,
|
|
12
12
|
getBetterAuthSession as getBetterAuthRedisSession,
|
|
13
|
+
describeSessionKeyPresence,
|
|
13
14
|
type SessionData,
|
|
14
15
|
} from '../lib/session-store';
|
|
16
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
15
17
|
import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
|
|
16
18
|
|
|
17
19
|
export type IdpTokenResult =
|
|
@@ -125,6 +127,82 @@ export async function getAuthInstance() {
|
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
|
|
130
|
+
/* ----------------------------------------------------------------------------------------------------------------
|
|
131
|
+
* PAY-2136 DIAGNOSTICS (BAPert 70873, Nextpert-Scout 70872/70875) - LOG ONLY, NO BEHAVIOUR CHANGE.
|
|
132
|
+
*
|
|
133
|
+
* A getFreshIdpToken NO_SESSION used to carry no origin: three different returns produced the same
|
|
134
|
+
* `{error:'NO_SESSION'}`, and getSession() swallowed every Redis error into a bare `catch {}`. These helpers name
|
|
135
|
+
* the origin and make a swallowed read audible. Hard rules: every helper is wrapped so it can NEVER throw or change
|
|
136
|
+
* a return value; nothing here ever logs a token, a cookie VALUE or a Redis value - only a tokenLogId() fingerprint,
|
|
137
|
+
* cookie NAMES, booleans, and an error name/message with any long opaque run redacted.
|
|
138
|
+
* -------------------------------------------------------------------------------------------------------------- */
|
|
139
|
+
|
|
140
|
+
/** An error name/message, first 80 chars, with any 24+ char opaque run (a token, a key) replaced. */
|
|
141
|
+
function scrubForLog(v: unknown): string {
|
|
142
|
+
const raw = v instanceof Error ? v.message : String(v ?? '');
|
|
143
|
+
return raw.replace(/[A-Za-z0-9_\-.+/=]{24,}/g, '[redacted]').slice(0, 80);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `catch {}` sites on the session read path: still swallowed, now audible. */
|
|
147
|
+
function logSessionReadSwallowed(path: 'primary' | 'A' | 'B', err: unknown): void {
|
|
148
|
+
try {
|
|
149
|
+
const name = scrubForLog(err instanceof Error ? err.name : typeof err);
|
|
150
|
+
console.warn(`[server-auth] SESSION_READ_SWALLOWED path=${path} err=${name} msg=${scrubForLog(err)}`);
|
|
151
|
+
} catch {
|
|
152
|
+
/* a diagnostic must never change the outcome */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Origin a: getSession(request) produced no token. Cookie NAMES only, never a value. */
|
|
157
|
+
function logNoSessionOriginA(request: Request | undefined): void {
|
|
158
|
+
try {
|
|
159
|
+
const header = request?.headers?.get('cookie') ?? '';
|
|
160
|
+
// Only segments of the form name=value contribute a NAME. A segment with no '=' is not a cookie pair, and
|
|
161
|
+
// logging it whole could emit a bare token (Nextpert-Scout 70901 finding 1).
|
|
162
|
+
const cookieNames = header
|
|
163
|
+
.split(';')
|
|
164
|
+
// Structural, alphabet-independent (Nextpert-Scout 70945): base64 padding can only sit at the END of a bare
|
|
165
|
+
// value, so a bare padded token splits as name=<token>, value='' or '='. A real pair has a non-empty value that
|
|
166
|
+
// is not only '=' characters. (The old includes('=') let 74% of base64url tokens through as a "name".)
|
|
167
|
+
.filter((c) => {
|
|
168
|
+
const i = c.indexOf('=');
|
|
169
|
+
if (i < 0) return false;
|
|
170
|
+
const v = c.slice(i + 1);
|
|
171
|
+
return v !== '' && !/^=+$/.test(v);
|
|
172
|
+
})
|
|
173
|
+
.map((c) => c.split('=')[0].trim())
|
|
174
|
+
.filter((n) => n !== '')
|
|
175
|
+
// A bare token that merely ENDS in base64 padding ('=') still passes the filter above and its "name" would be
|
|
176
|
+
// the token itself (Nextpert-Scout 70913). Real cookie names carry a separator ('.', '-', '_'); a long name with
|
|
177
|
+
// none is opaque, so it is logged as a placeholder, never as itself.
|
|
178
|
+
.map((n) => (n.length >= 24 && !/[._-]/.test(n) ? '[opaque-name]' : n))
|
|
179
|
+
.slice(0, 20);
|
|
180
|
+
console.warn('[server-auth] NO_SESSION origin=a', { hasCookieHeader: header !== '', cookieNames });
|
|
181
|
+
} catch {
|
|
182
|
+
/* a diagnostic must never change the outcome */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Origins b and c: a token WAS resolved. Log which of its three keys exist (presence only), fire-and-forget. */
|
|
187
|
+
function logNoSessionWithKeys(origin: 'b' | 'c', sessionToken: string): void {
|
|
188
|
+
try {
|
|
189
|
+
void describeSessionKeyPresence(sessionToken).then(
|
|
190
|
+
(keys) => {
|
|
191
|
+
try {
|
|
192
|
+
console.warn(`[server-auth] NO_SESSION origin=${origin}`, { token: tokenLogId(sessionToken), ...keys });
|
|
193
|
+
} catch {
|
|
194
|
+
/* never throws */
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
() => {
|
|
198
|
+
/* never throws */
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
} catch {
|
|
202
|
+
/* a diagnostic must never change the outcome */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
128
206
|
/**
|
|
129
207
|
* Get the current session from a request.
|
|
130
208
|
*
|
|
@@ -153,7 +231,9 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
153
231
|
let betterAuthSession: any = null;
|
|
154
232
|
try {
|
|
155
233
|
betterAuthSession = await auth.api.getSession({ headers: request.headers });
|
|
156
|
-
} catch {
|
|
234
|
+
} catch (err) {
|
|
235
|
+
logSessionReadSwallowed('primary', err); // fall through to canonical Redis path
|
|
236
|
+
}
|
|
157
237
|
|
|
158
238
|
if (betterAuthSession?.session?.token && betterAuthSession?.user) {
|
|
159
239
|
const sessionToken = betterAuthSession.session.token as string;
|
|
@@ -161,7 +241,9 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
161
241
|
try {
|
|
162
242
|
sessionData = await getRedisSession(sessionToken);
|
|
163
243
|
if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
164
|
-
} catch {
|
|
244
|
+
} catch (err) {
|
|
245
|
+
logSessionReadSwallowed('A', err); // Redis unavailable
|
|
246
|
+
}
|
|
165
247
|
if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
|
|
166
248
|
return attachSessionData(betterAuthSession, sessionData, sessionToken);
|
|
167
249
|
}
|
|
@@ -184,7 +266,9 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
184
266
|
try {
|
|
185
267
|
sessionData = await getRedisSession(sessionToken);
|
|
186
268
|
if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
187
|
-
} catch {
|
|
269
|
+
} catch (err) {
|
|
270
|
+
logSessionReadSwallowed('B', err); // Redis unavailable
|
|
271
|
+
}
|
|
188
272
|
if (!sessionData) return null;
|
|
189
273
|
|
|
190
274
|
// Synthesize a minimal session shape compatible with downstream callers.
|
|
@@ -296,6 +380,7 @@ export async function getFreshIdpToken(
|
|
|
296
380
|
const session = await getSession(request);
|
|
297
381
|
const sessionToken = session?.session?.token as string | undefined;
|
|
298
382
|
if (!sessionToken) {
|
|
383
|
+
logNoSessionOriginA(request);
|
|
299
384
|
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
300
385
|
}
|
|
301
386
|
|
|
@@ -314,6 +399,7 @@ export async function getFreshIdpToken(
|
|
|
314
399
|
);
|
|
315
400
|
|
|
316
401
|
if (!result.ok) {
|
|
402
|
+
if (result.code === 'NO_SESSION') logNoSessionWithKeys('b', sessionToken);
|
|
317
403
|
return {
|
|
318
404
|
success: false,
|
|
319
405
|
error: result.code,
|
|
@@ -327,6 +413,7 @@ export async function getFreshIdpToken(
|
|
|
327
413
|
|
|
328
414
|
const sessionData = await getRedisSession(sessionToken);
|
|
329
415
|
if (!sessionData) {
|
|
416
|
+
logNoSessionWithKeys('c', sessionToken);
|
|
330
417
|
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
331
418
|
}
|
|
332
419
|
|