@payez/next-mvp 4.1.9 → 4.1.11
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 +792 -792
- package/dist/api-handlers/auth/refresh.js +38 -15
- package/dist/auth/better-auth.js +4 -3
- package/dist/components/account/MobileNavDrawer.js +18 -18
- package/dist/components/account/UserAvatarMenu.js +1 -1
- package/dist/components/admin/AlertSettingsTab.js +40 -40
- package/dist/components/admin/LoggingSettingsTab.js +28 -28
- package/dist/lib/ensure-fresh-access-token.js +42 -8
- package/dist/lib/idp-fetch.js +25 -9
- package/dist/lib/session-store.d.ts +1 -0
- package/dist/lib/session-store.js +12 -8
- package/dist/lib/token-lifecycle.d.ts +2 -0
- package/dist/lib/token-lifecycle.js +21 -3
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/package.json +893 -893
- package/scripts/dev-broker.ps1 +35 -35
- package/scripts/dev-local.ps1 +45 -45
- package/src/api/auth-handler.ts +548 -548
- package/src/api-handlers/account/change-password.ts +142 -142
- package/src/api-handlers/account/update-phone.ts +79 -79
- package/src/api-handlers/account/verify-email.ts +125 -125
- package/src/api-handlers/account/verify-sms.ts +125 -125
- package/src/api-handlers/admin/analytics.ts +443 -443
- package/src/api-handlers/admin/audit.ts +223 -223
- package/src/api-handlers/admin/index.ts +63 -63
- package/src/api-handlers/admin/redis-sessions.ts +251 -251
- package/src/api-handlers/admin/sessions.ts +318 -318
- package/src/api-handlers/admin/site-logs.ts +365 -365
- package/src/api-handlers/admin/users.ts +242 -242
- package/src/api-handlers/admin/vibe-data.ts +324 -324
- package/src/api-handlers/auth/refresh.ts +705 -682
- package/src/api-handlers/auth/signout.ts +199 -199
- package/src/api-handlers/auth/status.ts +20 -20
- package/src/api-handlers/auth/update-session.ts +110 -110
- package/src/api-handlers/auth/verify-code.ts +112 -112
- package/src/api-handlers/session/refresh-viability.ts +36 -36
- package/src/api-handlers/test/force-expire.ts +60 -60
- package/src/auth/auth-decision.ts +229 -229
- package/src/auth/better-auth.ts +467 -466
- package/src/auth/route-config.ts +219 -219
- package/src/auth/types/auth-types.ts +555 -555
- package/src/auth/types/index.ts +7 -7
- package/src/auth/utils/idp-client.ts +445 -445
- package/src/auth/utils/index.ts +6 -6
- package/src/auth/utils/token-utils.ts +243 -243
- package/src/client/AuthContext.tsx +143 -143
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/components/SessionSync.tsx +138 -138
- package/src/components/SignalRHealthCheck.tsx +131 -131
- package/src/components/account/MobileNavDrawer.tsx +305 -305
- package/src/components/account/UserAvatarMenu.tsx +266 -266
- package/src/components/account/index.ts +13 -13
- package/src/components/admin/AlertSettingsTab.tsx +728 -728
- package/src/components/admin/AnalyticsTab.tsx +703 -703
- package/src/components/admin/DataBrowserTab.tsx +505 -505
- package/src/components/admin/LoggingSettingsTab.tsx +665 -665
- package/src/components/admin/SessionsTab.tsx +414 -414
- package/src/components/admin/StatsTab.tsx +379 -379
- package/src/components/admin/VibeAdminLayout.tsx +187 -187
- package/src/components/admin/index.ts +59 -59
- package/src/components/auth/FederatedAuthSection.tsx +95 -95
- package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
- package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
- package/src/components/auth/TraditionalAuthSection.tsx +99 -99
- package/src/components/recovery/CompleteStep.tsx +36 -36
- package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
- package/src/components/recovery/SelectMethodStep.tsx +73 -73
- package/src/components/recovery/SetPasswordStep.tsx +97 -97
- package/src/components/recovery/VerifyCodeStep.tsx +90 -90
- package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
- package/src/components/reserved/ReservedStatusBox.tsx +118 -118
- package/src/components/ui/BetaBadge.tsx +58 -58
- package/src/components/ui/Footer.tsx +93 -93
- package/src/config/env.ts +57 -57
- package/src/config/logger.ts +62 -62
- package/src/config/vibe-log-transport.ts +264 -264
- package/src/hooks/useAuth.ts +117 -117
- package/src/hooks/useAuthSettings.ts +97 -97
- package/src/hooks/useAvailableProviders.ts +120 -120
- package/src/hooks/usePasswordValidation.ts +127 -127
- package/src/hooks/usePublicAuthSettings.ts +149 -149
- package/src/hooks/useSessionExpiration.ts +101 -101
- package/src/hooks/useViabilitySession.ts +336 -336
- package/src/index.ts +60 -60
- package/src/lib/api-handler.ts +643 -643
- package/src/lib/app-slug.ts +178 -178
- package/src/lib/ensure-fresh-access-token.ts +369 -335
- package/src/lib/geolocation.ts +265 -265
- package/src/lib/idp-fetch.ts +26 -10
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/roles.ts +177 -177
- package/src/lib/session-store.ts +4 -1
- package/src/lib/site-logger.ts +245 -245
- package/src/lib/standardized-client-api.ts +901 -901
- package/src/lib/test-aware-get-token.ts +41 -41
- package/src/lib/token-lifecycle.ts +30 -3
- package/src/lib/user-agent-parser.ts +252 -252
- package/src/logging/api/admin-analytics.ts +51 -51
- package/src/logging/api/audit-log.ts +53 -53
- package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
- package/src/logging/components/AuditLogViewer.tsx +125 -125
- package/src/logging/components/ErrorMetricsCard.tsx +98 -98
- package/src/logging/components/HealthMetricsCard.tsx +70 -70
- package/src/logging/hooks/useAdminAnalytics.ts +22 -22
- package/src/logging/hooks/useAuditLog.ts +24 -24
- package/src/logging/hooks/useErrorMetrics.ts +40 -40
- package/src/logging/hooks/useHealthMetrics.ts +44 -44
- package/src/logging/index.ts +18 -18
- package/src/logging/types/analytics.ts +81 -81
- package/src/logging/types/audit.ts +31 -31
- package/src/logging/types/index.ts +3 -3
- package/src/middleware/create-middleware.ts +663 -663
- package/src/middleware/rbac-check.ts +279 -279
- package/src/middleware/twofa-presets.ts +224 -224
- package/src/pages/admin-login/page.tsx +221 -221
- package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
- package/src/pages/admin-page-permissions/index.ts +7 -7
- package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
- package/src/pages/admin-roles/modals.tsx +469 -469
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
- package/src/pages/client-admin/index.ts +33 -33
- package/src/pages/coming-soon/page.tsx +83 -83
- package/src/pages/login/page.tsx +456 -456
- package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
- package/src/pages/profile/index.ts +9 -9
- package/src/pages/profile/page.tsx +166 -166
- package/src/pages/recovery/page.tsx +234 -234
- package/src/pages/roles/MyRolesPage.tsx +211 -211
- package/src/pages/roles/components.tsx +294 -294
- package/src/pages/roles/index.ts +17 -17
- package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
- package/src/pages/security/index.ts +9 -9
- package/src/pages/security/page.tsx +507 -507
- package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
- package/src/pages/settings/index.ts +9 -9
- package/src/pages/settings/page.tsx +47 -47
- package/src/pages/showcase/ShowcasePage.tsx +532 -532
- package/src/pages/showcase/index.ts +13 -13
- package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
- package/src/pages/test-env/JwtInspectPage.tsx +456 -456
- package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
- package/src/pages/test-env/TestEnvPage.tsx +118 -118
- package/src/pages/test-env/index.ts +25 -25
- package/src/pages/verify-code/page.tsx +652 -652
- package/src/routes/account/update-phone.ts +13 -13
- package/src/routes/auth/logout.ts +108 -108
- package/src/routes/auth/nextauth.ts +45 -45
- package/src/routes/auth/settings.ts +75 -75
- package/src/routes/auth/viability.ts +224 -224
- package/src/server/auth-guard.ts +170 -170
- package/src/server/slim-middleware.ts +117 -117
- package/src/server/with-auth.ts +93 -93
- package/src/stores/authStore.ts +1899 -1899
- package/src/templates/instrumentation.ts +41 -41
- package/src/utils/logout.ts +30 -30
- package/src/vibe/client.ts +590 -590
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +185 -185
- package/src/vibe/generic.ts +431 -431
- package/src/vibe/hooks/index.ts +367 -367
- package/src/vibe/index.ts +132 -132
- package/src/vibe/sessions.ts +551 -551
- package/src/vibe/types.ts +577 -577
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
import { NextRequest } from 'next/server';
|
|
2
|
-
import { logger } from '../config/logger';
|
|
3
|
-
import { getSession } from '../server/auth';
|
|
4
|
-
import { getSessionCookieName } from './app-slug';
|
|
5
|
-
|
|
6
|
-
export async function getTokenTestAware(req: NextRequest): Promise<any> {
|
|
7
|
-
if (process.env.TEST_MODE === 'true') {
|
|
8
|
-
try {
|
|
9
|
-
let secret = process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET;
|
|
10
|
-
if (!secret || secret.trim() === '') {
|
|
11
|
-
const { getIDPClientConfig } = await import('./idp-client-config');
|
|
12
|
-
const idpConfig = await getIDPClientConfig();
|
|
13
|
-
secret = idpConfig.authSecret;
|
|
14
|
-
}
|
|
15
|
-
// Use app-slug prefixed cookie name
|
|
16
|
-
const cookieName = getSessionCookieName();
|
|
17
|
-
const cookies = req.headers.get('cookie'); if (!cookies) { logger.debug('[GET_TOKEN] No cookies in request'); return null; }
|
|
18
|
-
const cookieValue = cookies.split(';').find(c => c.trim().startsWith(`${cookieName}=`))?.split('=')[1];
|
|
19
|
-
if (!cookieValue) { logger.debug('[GET_TOKEN] Session token cookie not found'); return null; }
|
|
20
|
-
const { jwtVerify } = await import('jose');
|
|
21
|
-
const secretKey = new TextEncoder().encode(secret);
|
|
22
|
-
const { payload } = await jwtVerify(cookieValue, secretKey);
|
|
23
|
-
logger.debug('[GET_TOKEN] TEST_MODE token decoded:', { hasPayload: !!payload, redisSessionId: (payload as any).redisSessionId, sub: (payload as any).sub });
|
|
24
|
-
return payload;
|
|
25
|
-
} catch (error) { logger.error('[GET_TOKEN] TEST_MODE token decode error:', { error: error instanceof Error ? error.message : String(error) }); return null; }
|
|
26
|
-
}
|
|
27
|
-
// Production path: Better Auth session
|
|
28
|
-
const session = await getSession(req);
|
|
29
|
-
if (session?.user && session?.session?.token) {
|
|
30
|
-
return {
|
|
31
|
-
sub: session.user.id,
|
|
32
|
-
email: session.user.email,
|
|
33
|
-
name: session.user.name,
|
|
34
|
-
sessionToken: session.session.token,
|
|
35
|
-
roles: session.user.roles || [],
|
|
36
|
-
...session.user,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
import { logger } from '../config/logger';
|
|
3
|
+
import { getSession } from '../server/auth';
|
|
4
|
+
import { getSessionCookieName } from './app-slug';
|
|
5
|
+
|
|
6
|
+
export async function getTokenTestAware(req: NextRequest): Promise<any> {
|
|
7
|
+
if (process.env.TEST_MODE === 'true') {
|
|
8
|
+
try {
|
|
9
|
+
let secret = process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET;
|
|
10
|
+
if (!secret || secret.trim() === '') {
|
|
11
|
+
const { getIDPClientConfig } = await import('./idp-client-config');
|
|
12
|
+
const idpConfig = await getIDPClientConfig();
|
|
13
|
+
secret = idpConfig.authSecret;
|
|
14
|
+
}
|
|
15
|
+
// Use app-slug prefixed cookie name
|
|
16
|
+
const cookieName = getSessionCookieName();
|
|
17
|
+
const cookies = req.headers.get('cookie'); if (!cookies) { logger.debug('[GET_TOKEN] No cookies in request'); return null; }
|
|
18
|
+
const cookieValue = cookies.split(';').find(c => c.trim().startsWith(`${cookieName}=`))?.split('=')[1];
|
|
19
|
+
if (!cookieValue) { logger.debug('[GET_TOKEN] Session token cookie not found'); return null; }
|
|
20
|
+
const { jwtVerify } = await import('jose');
|
|
21
|
+
const secretKey = new TextEncoder().encode(secret);
|
|
22
|
+
const { payload } = await jwtVerify(cookieValue, secretKey);
|
|
23
|
+
logger.debug('[GET_TOKEN] TEST_MODE token decoded:', { hasPayload: !!payload, redisSessionId: (payload as any).redisSessionId, sub: (payload as any).sub });
|
|
24
|
+
return payload;
|
|
25
|
+
} catch (error) { logger.error('[GET_TOKEN] TEST_MODE token decode error:', { error: error instanceof Error ? error.message : String(error) }); return null; }
|
|
26
|
+
}
|
|
27
|
+
// Production path: Better Auth session
|
|
28
|
+
const session = await getSession(req);
|
|
29
|
+
if (session?.user && session?.session?.token) {
|
|
30
|
+
return {
|
|
31
|
+
sub: session.user.id,
|
|
32
|
+
email: session.user.email,
|
|
33
|
+
name: session.user.name,
|
|
34
|
+
sessionToken: session.session.token,
|
|
35
|
+
roles: session.user.roles || [],
|
|
36
|
+
...session.user,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
@@ -46,6 +46,13 @@ export interface TokenError {
|
|
|
46
46
|
error: 'NO_SESSION' | 'NO_TOKEN' | 'EXPIRED' | 'REFRESH_FAILED' | 'SESSION_EXPIRED_NO_REFRESH';
|
|
47
47
|
message: string;
|
|
48
48
|
terminal?: boolean; // If true, don't retry - redirect to login
|
|
49
|
+
// WO-1 (genuine-dead refresh): the VERBATIM upstream reason from the IDP, preserved
|
|
50
|
+
// end-to-end so the consumer can surface an honest error + halt a futile re-auth loop
|
|
51
|
+
// (e.g. an unapproved user whose login mints a bearer but whose refresh terminally
|
|
52
|
+
// fails). `error` stays the stable category for existing consumers; `code`/`resolution`
|
|
53
|
+
// carry the real cause. Never soften — surface it.
|
|
54
|
+
code?: string; // Upstream IDP error code (e.g. UNAUTHORIZED, USER_NOT_APPROVED)
|
|
55
|
+
resolution?: string; // Upstream human-readable resolution hint, if any
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
export type EnsureFreshTokenResult = TokenResult | TokenError;
|
|
@@ -123,6 +130,8 @@ interface RefreshResult {
|
|
|
123
130
|
success: boolean;
|
|
124
131
|
terminal?: boolean; // If true, session is dead - don't retry, redirect to login
|
|
125
132
|
code?: string; // Error code from refresh endpoint
|
|
133
|
+
message?: string; // WO-1: verbatim human message from the refresh endpoint
|
|
134
|
+
resolution?: string; // WO-1: verbatim resolution hint from the refresh endpoint
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
/**
|
|
@@ -201,7 +210,16 @@ async function triggerRefresh(
|
|
|
201
210
|
// Don't retry - user must re-authenticate
|
|
202
211
|
if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
|
|
203
212
|
console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
|
|
204
|
-
|
|
213
|
+
// WO-1: preserve the verbatim upstream reason. refresh.ts puts the human
|
|
214
|
+
// message in `error` and the code in `code`; carry both up so the consumer
|
|
215
|
+
// can tell the user WHY (not just "session expired") and stop a futile loop.
|
|
216
|
+
return {
|
|
217
|
+
success: false,
|
|
218
|
+
terminal: true,
|
|
219
|
+
code: responseData.code,
|
|
220
|
+
message: responseData.error,
|
|
221
|
+
resolution: responseData.resolution,
|
|
222
|
+
};
|
|
205
223
|
}
|
|
206
224
|
|
|
207
225
|
// For other 401s, check if maybe session was refreshed by another request
|
|
@@ -388,12 +406,21 @@ export async function ensureFreshToken(
|
|
|
388
406
|
if (!refreshResult.success) {
|
|
389
407
|
// Check for terminal state - session cannot be recovered
|
|
390
408
|
if (refreshResult.terminal) {
|
|
391
|
-
console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired
|
|
409
|
+
console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired - redirect to login', {
|
|
410
|
+
code: refreshResult.code,
|
|
411
|
+
message: refreshResult.message,
|
|
412
|
+
});
|
|
413
|
+
// WO-1: keep `error` as the stable terminal category (existing consumers
|
|
414
|
+
// branch on SESSION_EXPIRED_NO_REFRESH) but surface the verbatim upstream
|
|
415
|
+
// reason via code/message/resolution so the user learns WHY and a genuinely
|
|
416
|
+
// dead session (e.g. unapproved account) doesn't loop login→bounce→login.
|
|
392
417
|
return {
|
|
393
418
|
success: false,
|
|
394
419
|
error: 'SESSION_EXPIRED_NO_REFRESH',
|
|
395
|
-
message: 'Session expired. Please sign in again.',
|
|
420
|
+
message: refreshResult.message || 'Session expired. Please sign in again.',
|
|
396
421
|
terminal: true,
|
|
422
|
+
code: refreshResult.code,
|
|
423
|
+
resolution: refreshResult.resolution,
|
|
397
424
|
};
|
|
398
425
|
}
|
|
399
426
|
|
|
@@ -1,252 +1,252 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* =============================================================================
|
|
3
|
-
* USER AGENT PARSER
|
|
4
|
-
* =============================================================================
|
|
5
|
-
*
|
|
6
|
-
* Lightweight user agent parsing without external dependencies.
|
|
7
|
-
* Extracts device type, browser, and OS information from user agent strings.
|
|
8
|
-
*
|
|
9
|
-
* USAGE:
|
|
10
|
-
* ------
|
|
11
|
-
* import { parseUserAgent, DeviceInfo } from '@payez/next-mvp/lib/user-agent-parser';
|
|
12
|
-
*
|
|
13
|
-
* const info = parseUserAgent(request.headers.get('user-agent'));
|
|
14
|
-
* console.log(info.browser); // 'Chrome'
|
|
15
|
-
* console.log(info.deviceType); // 'desktop'
|
|
16
|
-
*
|
|
17
|
-
* =============================================================================
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
// -----------------------------------------------------------------------------
|
|
21
|
-
// TYPES
|
|
22
|
-
// -----------------------------------------------------------------------------
|
|
23
|
-
|
|
24
|
-
export type DeviceType = 'desktop' | 'mobile' | 'tablet' | 'unknown';
|
|
25
|
-
|
|
26
|
-
export interface DeviceInfo {
|
|
27
|
-
deviceType: DeviceType;
|
|
28
|
-
browser: string;
|
|
29
|
-
browserVersion?: string;
|
|
30
|
-
os: string;
|
|
31
|
-
osVersion?: string;
|
|
32
|
-
isMobile: boolean;
|
|
33
|
-
isTablet: boolean;
|
|
34
|
-
isDesktop: boolean;
|
|
35
|
-
isBot: boolean;
|
|
36
|
-
raw: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// -----------------------------------------------------------------------------
|
|
40
|
-
// DETECTION PATTERNS
|
|
41
|
-
// -----------------------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
const MOBILE_KEYWORDS = [
|
|
44
|
-
'Mobile',
|
|
45
|
-
'Android',
|
|
46
|
-
'iPhone',
|
|
47
|
-
'iPod',
|
|
48
|
-
'BlackBerry',
|
|
49
|
-
'IEMobile',
|
|
50
|
-
'Opera Mini',
|
|
51
|
-
'Opera Mobi',
|
|
52
|
-
'Windows Phone',
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
const TABLET_KEYWORDS = [
|
|
56
|
-
'iPad',
|
|
57
|
-
'Tablet',
|
|
58
|
-
'PlayBook',
|
|
59
|
-
'Silk',
|
|
60
|
-
'Kindle',
|
|
61
|
-
];
|
|
62
|
-
|
|
63
|
-
const BOT_KEYWORDS = [
|
|
64
|
-
'bot',
|
|
65
|
-
'spider',
|
|
66
|
-
'crawler',
|
|
67
|
-
'slurp',
|
|
68
|
-
'googlebot',
|
|
69
|
-
'bingbot',
|
|
70
|
-
'yandex',
|
|
71
|
-
'baidu',
|
|
72
|
-
'duckduckbot',
|
|
73
|
-
'facebookexternalhit',
|
|
74
|
-
'twitterbot',
|
|
75
|
-
'linkedinbot',
|
|
76
|
-
'pinterest',
|
|
77
|
-
'semrush',
|
|
78
|
-
'ahref',
|
|
79
|
-
];
|
|
80
|
-
|
|
81
|
-
// Browser detection patterns (order matters - more specific first)
|
|
82
|
-
const BROWSER_PATTERNS: Array<{ name: string; pattern: RegExp }> = [
|
|
83
|
-
{ name: 'Edge', pattern: /Edg(?:e|A|iOS)?\/(\d+(?:\.\d+)*)/ },
|
|
84
|
-
{ name: 'Opera', pattern: /(?:OPR|Opera)\/(\d+(?:\.\d+)*)/ },
|
|
85
|
-
{ name: 'Samsung Browser', pattern: /SamsungBrowser\/(\d+(?:\.\d+)*)/ },
|
|
86
|
-
{ name: 'UC Browser', pattern: /UCBrowser\/(\d+(?:\.\d+)*)/ },
|
|
87
|
-
{ name: 'Firefox', pattern: /Firefox\/(\d+(?:\.\d+)*)/ },
|
|
88
|
-
{ name: 'Chrome', pattern: /Chrome\/(\d+(?:\.\d+)*)/ },
|
|
89
|
-
{ name: 'Safari', pattern: /Version\/(\d+(?:\.\d+)*).*Safari/ },
|
|
90
|
-
{ name: 'Safari', pattern: /Safari\/(\d+(?:\.\d+)*)/ },
|
|
91
|
-
{ name: 'IE', pattern: /(?:MSIE |rv:)(\d+(?:\.\d+)*)/ },
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
// OS detection patterns
|
|
95
|
-
const OS_PATTERNS: Array<{ name: string; pattern: RegExp; versionPattern?: RegExp }> = [
|
|
96
|
-
{ name: 'iOS', pattern: /iPhone|iPad|iPod/, versionPattern: /OS (\d+[_\.]\d+(?:[_\.]\d+)?)/ },
|
|
97
|
-
{ name: 'Android', pattern: /Android/, versionPattern: /Android (\d+(?:\.\d+)*)/ },
|
|
98
|
-
{ name: 'Windows', pattern: /Windows/, versionPattern: /Windows NT (\d+(?:\.\d+)*)/ },
|
|
99
|
-
{ name: 'macOS', pattern: /Mac OS X/, versionPattern: /Mac OS X (\d+[_\.]\d+(?:[_\.]\d+)?)/ },
|
|
100
|
-
{ name: 'Linux', pattern: /Linux/, versionPattern: undefined },
|
|
101
|
-
{ name: 'Chrome OS', pattern: /CrOS/, versionPattern: undefined },
|
|
102
|
-
];
|
|
103
|
-
|
|
104
|
-
// Windows NT version mapping
|
|
105
|
-
const WINDOWS_VERSIONS: Record<string, string> = {
|
|
106
|
-
'10.0': '10/11',
|
|
107
|
-
'6.3': '8.1',
|
|
108
|
-
'6.2': '8',
|
|
109
|
-
'6.1': '7',
|
|
110
|
-
'6.0': 'Vista',
|
|
111
|
-
'5.1': 'XP',
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
// -----------------------------------------------------------------------------
|
|
115
|
-
// PARSER FUNCTION
|
|
116
|
-
// -----------------------------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Parse a user agent string to extract device, browser, and OS information.
|
|
120
|
-
*
|
|
121
|
-
* @param userAgent - The user agent string from request headers
|
|
122
|
-
* @returns Parsed device information
|
|
123
|
-
*/
|
|
124
|
-
export function parseUserAgent(userAgent?: string | null): DeviceInfo {
|
|
125
|
-
const ua = userAgent || '';
|
|
126
|
-
const uaLower = ua.toLowerCase();
|
|
127
|
-
|
|
128
|
-
// Default result
|
|
129
|
-
const result: DeviceInfo = {
|
|
130
|
-
deviceType: 'unknown',
|
|
131
|
-
browser: 'Unknown',
|
|
132
|
-
os: 'Unknown',
|
|
133
|
-
isMobile: false,
|
|
134
|
-
isTablet: false,
|
|
135
|
-
isDesktop: false,
|
|
136
|
-
isBot: false,
|
|
137
|
-
raw: ua,
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
if (!ua) return result;
|
|
141
|
-
|
|
142
|
-
// Check for bots first
|
|
143
|
-
result.isBot = BOT_KEYWORDS.some(keyword => uaLower.includes(keyword));
|
|
144
|
-
|
|
145
|
-
// Detect device type
|
|
146
|
-
result.isTablet = TABLET_KEYWORDS.some(keyword => ua.includes(keyword));
|
|
147
|
-
result.isMobile = !result.isTablet && MOBILE_KEYWORDS.some(keyword => ua.includes(keyword));
|
|
148
|
-
result.isDesktop = !result.isMobile && !result.isTablet && !result.isBot;
|
|
149
|
-
|
|
150
|
-
if (result.isTablet) {
|
|
151
|
-
result.deviceType = 'tablet';
|
|
152
|
-
} else if (result.isMobile) {
|
|
153
|
-
result.deviceType = 'mobile';
|
|
154
|
-
} else if (result.isDesktop) {
|
|
155
|
-
result.deviceType = 'desktop';
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Detect browser
|
|
159
|
-
for (const { name, pattern } of BROWSER_PATTERNS) {
|
|
160
|
-
const match = ua.match(pattern);
|
|
161
|
-
if (match) {
|
|
162
|
-
result.browser = name;
|
|
163
|
-
result.browserVersion = match[1];
|
|
164
|
-
break;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Detect OS
|
|
169
|
-
for (const { name, pattern, versionPattern } of OS_PATTERNS) {
|
|
170
|
-
if (pattern.test(ua)) {
|
|
171
|
-
result.os = name;
|
|
172
|
-
if (versionPattern) {
|
|
173
|
-
const versionMatch = ua.match(versionPattern);
|
|
174
|
-
if (versionMatch) {
|
|
175
|
-
let version = versionMatch[1].replace(/_/g, '.');
|
|
176
|
-
|
|
177
|
-
// Map Windows NT versions to friendly names
|
|
178
|
-
if (name === 'Windows' && WINDOWS_VERSIONS[version]) {
|
|
179
|
-
version = WINDOWS_VERSIONS[version];
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
result.osVersion = version;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
break;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return result;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// -----------------------------------------------------------------------------
|
|
193
|
-
// HELPER FUNCTIONS
|
|
194
|
-
// -----------------------------------------------------------------------------
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Get a short, human-readable description of the device
|
|
198
|
-
*/
|
|
199
|
-
export function getDeviceDescription(info: DeviceInfo): string {
|
|
200
|
-
const parts: string[] = [];
|
|
201
|
-
|
|
202
|
-
if (info.browser !== 'Unknown') {
|
|
203
|
-
parts.push(info.browser);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (info.os !== 'Unknown') {
|
|
207
|
-
let osDesc = info.os;
|
|
208
|
-
if (info.osVersion) {
|
|
209
|
-
osDesc += ` ${info.osVersion}`;
|
|
210
|
-
}
|
|
211
|
-
parts.push(osDesc);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (parts.length === 0) {
|
|
215
|
-
if (info.isBot) return 'Bot';
|
|
216
|
-
return info.deviceType.charAt(0).toUpperCase() + info.deviceType.slice(1);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return parts.join(' / ');
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Get device type icon (emoji)
|
|
224
|
-
*/
|
|
225
|
-
export function getDeviceIcon(deviceType: DeviceType): string {
|
|
226
|
-
switch (deviceType) {
|
|
227
|
-
case 'mobile':
|
|
228
|
-
return '📱';
|
|
229
|
-
case 'tablet':
|
|
230
|
-
return '📲';
|
|
231
|
-
case 'desktop':
|
|
232
|
-
return '💻';
|
|
233
|
-
default:
|
|
234
|
-
return '🔌';
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Get browser icon (emoji)
|
|
240
|
-
*/
|
|
241
|
-
export function getBrowserIcon(browser: string): string {
|
|
242
|
-
const browserLower = browser.toLowerCase();
|
|
243
|
-
|
|
244
|
-
if (browserLower.includes('chrome')) return '🌐';
|
|
245
|
-
if (browserLower.includes('firefox')) return '🦊';
|
|
246
|
-
if (browserLower.includes('safari')) return '🧭';
|
|
247
|
-
if (browserLower.includes('edge')) return '🌐';
|
|
248
|
-
if (browserLower.includes('opera')) return '🔴';
|
|
249
|
-
if (browserLower.includes('ie') || browserLower.includes('internet explorer')) return '🌐';
|
|
250
|
-
|
|
251
|
-
return '🌐';
|
|
252
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* USER AGENT PARSER
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Lightweight user agent parsing without external dependencies.
|
|
7
|
+
* Extracts device type, browser, and OS information from user agent strings.
|
|
8
|
+
*
|
|
9
|
+
* USAGE:
|
|
10
|
+
* ------
|
|
11
|
+
* import { parseUserAgent, DeviceInfo } from '@payez/next-mvp/lib/user-agent-parser';
|
|
12
|
+
*
|
|
13
|
+
* const info = parseUserAgent(request.headers.get('user-agent'));
|
|
14
|
+
* console.log(info.browser); // 'Chrome'
|
|
15
|
+
* console.log(info.deviceType); // 'desktop'
|
|
16
|
+
*
|
|
17
|
+
* =============================================================================
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// -----------------------------------------------------------------------------
|
|
21
|
+
// TYPES
|
|
22
|
+
// -----------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export type DeviceType = 'desktop' | 'mobile' | 'tablet' | 'unknown';
|
|
25
|
+
|
|
26
|
+
export interface DeviceInfo {
|
|
27
|
+
deviceType: DeviceType;
|
|
28
|
+
browser: string;
|
|
29
|
+
browserVersion?: string;
|
|
30
|
+
os: string;
|
|
31
|
+
osVersion?: string;
|
|
32
|
+
isMobile: boolean;
|
|
33
|
+
isTablet: boolean;
|
|
34
|
+
isDesktop: boolean;
|
|
35
|
+
isBot: boolean;
|
|
36
|
+
raw: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// -----------------------------------------------------------------------------
|
|
40
|
+
// DETECTION PATTERNS
|
|
41
|
+
// -----------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const MOBILE_KEYWORDS = [
|
|
44
|
+
'Mobile',
|
|
45
|
+
'Android',
|
|
46
|
+
'iPhone',
|
|
47
|
+
'iPod',
|
|
48
|
+
'BlackBerry',
|
|
49
|
+
'IEMobile',
|
|
50
|
+
'Opera Mini',
|
|
51
|
+
'Opera Mobi',
|
|
52
|
+
'Windows Phone',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const TABLET_KEYWORDS = [
|
|
56
|
+
'iPad',
|
|
57
|
+
'Tablet',
|
|
58
|
+
'PlayBook',
|
|
59
|
+
'Silk',
|
|
60
|
+
'Kindle',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const BOT_KEYWORDS = [
|
|
64
|
+
'bot',
|
|
65
|
+
'spider',
|
|
66
|
+
'crawler',
|
|
67
|
+
'slurp',
|
|
68
|
+
'googlebot',
|
|
69
|
+
'bingbot',
|
|
70
|
+
'yandex',
|
|
71
|
+
'baidu',
|
|
72
|
+
'duckduckbot',
|
|
73
|
+
'facebookexternalhit',
|
|
74
|
+
'twitterbot',
|
|
75
|
+
'linkedinbot',
|
|
76
|
+
'pinterest',
|
|
77
|
+
'semrush',
|
|
78
|
+
'ahref',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// Browser detection patterns (order matters - more specific first)
|
|
82
|
+
const BROWSER_PATTERNS: Array<{ name: string; pattern: RegExp }> = [
|
|
83
|
+
{ name: 'Edge', pattern: /Edg(?:e|A|iOS)?\/(\d+(?:\.\d+)*)/ },
|
|
84
|
+
{ name: 'Opera', pattern: /(?:OPR|Opera)\/(\d+(?:\.\d+)*)/ },
|
|
85
|
+
{ name: 'Samsung Browser', pattern: /SamsungBrowser\/(\d+(?:\.\d+)*)/ },
|
|
86
|
+
{ name: 'UC Browser', pattern: /UCBrowser\/(\d+(?:\.\d+)*)/ },
|
|
87
|
+
{ name: 'Firefox', pattern: /Firefox\/(\d+(?:\.\d+)*)/ },
|
|
88
|
+
{ name: 'Chrome', pattern: /Chrome\/(\d+(?:\.\d+)*)/ },
|
|
89
|
+
{ name: 'Safari', pattern: /Version\/(\d+(?:\.\d+)*).*Safari/ },
|
|
90
|
+
{ name: 'Safari', pattern: /Safari\/(\d+(?:\.\d+)*)/ },
|
|
91
|
+
{ name: 'IE', pattern: /(?:MSIE |rv:)(\d+(?:\.\d+)*)/ },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
// OS detection patterns
|
|
95
|
+
const OS_PATTERNS: Array<{ name: string; pattern: RegExp; versionPattern?: RegExp }> = [
|
|
96
|
+
{ name: 'iOS', pattern: /iPhone|iPad|iPod/, versionPattern: /OS (\d+[_\.]\d+(?:[_\.]\d+)?)/ },
|
|
97
|
+
{ name: 'Android', pattern: /Android/, versionPattern: /Android (\d+(?:\.\d+)*)/ },
|
|
98
|
+
{ name: 'Windows', pattern: /Windows/, versionPattern: /Windows NT (\d+(?:\.\d+)*)/ },
|
|
99
|
+
{ name: 'macOS', pattern: /Mac OS X/, versionPattern: /Mac OS X (\d+[_\.]\d+(?:[_\.]\d+)?)/ },
|
|
100
|
+
{ name: 'Linux', pattern: /Linux/, versionPattern: undefined },
|
|
101
|
+
{ name: 'Chrome OS', pattern: /CrOS/, versionPattern: undefined },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
// Windows NT version mapping
|
|
105
|
+
const WINDOWS_VERSIONS: Record<string, string> = {
|
|
106
|
+
'10.0': '10/11',
|
|
107
|
+
'6.3': '8.1',
|
|
108
|
+
'6.2': '8',
|
|
109
|
+
'6.1': '7',
|
|
110
|
+
'6.0': 'Vista',
|
|
111
|
+
'5.1': 'XP',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// -----------------------------------------------------------------------------
|
|
115
|
+
// PARSER FUNCTION
|
|
116
|
+
// -----------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse a user agent string to extract device, browser, and OS information.
|
|
120
|
+
*
|
|
121
|
+
* @param userAgent - The user agent string from request headers
|
|
122
|
+
* @returns Parsed device information
|
|
123
|
+
*/
|
|
124
|
+
export function parseUserAgent(userAgent?: string | null): DeviceInfo {
|
|
125
|
+
const ua = userAgent || '';
|
|
126
|
+
const uaLower = ua.toLowerCase();
|
|
127
|
+
|
|
128
|
+
// Default result
|
|
129
|
+
const result: DeviceInfo = {
|
|
130
|
+
deviceType: 'unknown',
|
|
131
|
+
browser: 'Unknown',
|
|
132
|
+
os: 'Unknown',
|
|
133
|
+
isMobile: false,
|
|
134
|
+
isTablet: false,
|
|
135
|
+
isDesktop: false,
|
|
136
|
+
isBot: false,
|
|
137
|
+
raw: ua,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (!ua) return result;
|
|
141
|
+
|
|
142
|
+
// Check for bots first
|
|
143
|
+
result.isBot = BOT_KEYWORDS.some(keyword => uaLower.includes(keyword));
|
|
144
|
+
|
|
145
|
+
// Detect device type
|
|
146
|
+
result.isTablet = TABLET_KEYWORDS.some(keyword => ua.includes(keyword));
|
|
147
|
+
result.isMobile = !result.isTablet && MOBILE_KEYWORDS.some(keyword => ua.includes(keyword));
|
|
148
|
+
result.isDesktop = !result.isMobile && !result.isTablet && !result.isBot;
|
|
149
|
+
|
|
150
|
+
if (result.isTablet) {
|
|
151
|
+
result.deviceType = 'tablet';
|
|
152
|
+
} else if (result.isMobile) {
|
|
153
|
+
result.deviceType = 'mobile';
|
|
154
|
+
} else if (result.isDesktop) {
|
|
155
|
+
result.deviceType = 'desktop';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Detect browser
|
|
159
|
+
for (const { name, pattern } of BROWSER_PATTERNS) {
|
|
160
|
+
const match = ua.match(pattern);
|
|
161
|
+
if (match) {
|
|
162
|
+
result.browser = name;
|
|
163
|
+
result.browserVersion = match[1];
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Detect OS
|
|
169
|
+
for (const { name, pattern, versionPattern } of OS_PATTERNS) {
|
|
170
|
+
if (pattern.test(ua)) {
|
|
171
|
+
result.os = name;
|
|
172
|
+
if (versionPattern) {
|
|
173
|
+
const versionMatch = ua.match(versionPattern);
|
|
174
|
+
if (versionMatch) {
|
|
175
|
+
let version = versionMatch[1].replace(/_/g, '.');
|
|
176
|
+
|
|
177
|
+
// Map Windows NT versions to friendly names
|
|
178
|
+
if (name === 'Windows' && WINDOWS_VERSIONS[version]) {
|
|
179
|
+
version = WINDOWS_VERSIONS[version];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
result.osVersion = version;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// -----------------------------------------------------------------------------
|
|
193
|
+
// HELPER FUNCTIONS
|
|
194
|
+
// -----------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get a short, human-readable description of the device
|
|
198
|
+
*/
|
|
199
|
+
export function getDeviceDescription(info: DeviceInfo): string {
|
|
200
|
+
const parts: string[] = [];
|
|
201
|
+
|
|
202
|
+
if (info.browser !== 'Unknown') {
|
|
203
|
+
parts.push(info.browser);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (info.os !== 'Unknown') {
|
|
207
|
+
let osDesc = info.os;
|
|
208
|
+
if (info.osVersion) {
|
|
209
|
+
osDesc += ` ${info.osVersion}`;
|
|
210
|
+
}
|
|
211
|
+
parts.push(osDesc);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (parts.length === 0) {
|
|
215
|
+
if (info.isBot) return 'Bot';
|
|
216
|
+
return info.deviceType.charAt(0).toUpperCase() + info.deviceType.slice(1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return parts.join(' / ');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get device type icon (emoji)
|
|
224
|
+
*/
|
|
225
|
+
export function getDeviceIcon(deviceType: DeviceType): string {
|
|
226
|
+
switch (deviceType) {
|
|
227
|
+
case 'mobile':
|
|
228
|
+
return '📱';
|
|
229
|
+
case 'tablet':
|
|
230
|
+
return '📲';
|
|
231
|
+
case 'desktop':
|
|
232
|
+
return '💻';
|
|
233
|
+
default:
|
|
234
|
+
return '🔌';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get browser icon (emoji)
|
|
240
|
+
*/
|
|
241
|
+
export function getBrowserIcon(browser: string): string {
|
|
242
|
+
const browserLower = browser.toLowerCase();
|
|
243
|
+
|
|
244
|
+
if (browserLower.includes('chrome')) return '🌐';
|
|
245
|
+
if (browserLower.includes('firefox')) return '🦊';
|
|
246
|
+
if (browserLower.includes('safari')) return '🧭';
|
|
247
|
+
if (browserLower.includes('edge')) return '🌐';
|
|
248
|
+
if (browserLower.includes('opera')) return '🔴';
|
|
249
|
+
if (browserLower.includes('ie') || browserLower.includes('internet explorer')) return '🌐';
|
|
250
|
+
|
|
251
|
+
return '🌐';
|
|
252
|
+
}
|