@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.
Files changed (164) hide show
  1. package/README.md +792 -792
  2. package/dist/api-handlers/auth/refresh.js +38 -15
  3. package/dist/auth/better-auth.js +4 -3
  4. package/dist/components/account/MobileNavDrawer.js +18 -18
  5. package/dist/components/account/UserAvatarMenu.js +1 -1
  6. package/dist/components/admin/AlertSettingsTab.js +40 -40
  7. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  8. package/dist/lib/ensure-fresh-access-token.js +42 -8
  9. package/dist/lib/idp-fetch.js +25 -9
  10. package/dist/lib/session-store.d.ts +1 -0
  11. package/dist/lib/session-store.js +12 -8
  12. package/dist/lib/token-lifecycle.d.ts +2 -0
  13. package/dist/lib/token-lifecycle.js +21 -3
  14. package/dist/pages/login/page.js +28 -28
  15. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  16. package/package.json +893 -893
  17. package/scripts/dev-broker.ps1 +35 -35
  18. package/scripts/dev-local.ps1 +45 -45
  19. package/src/api/auth-handler.ts +548 -548
  20. package/src/api-handlers/account/change-password.ts +142 -142
  21. package/src/api-handlers/account/update-phone.ts +79 -79
  22. package/src/api-handlers/account/verify-email.ts +125 -125
  23. package/src/api-handlers/account/verify-sms.ts +125 -125
  24. package/src/api-handlers/admin/analytics.ts +443 -443
  25. package/src/api-handlers/admin/audit.ts +223 -223
  26. package/src/api-handlers/admin/index.ts +63 -63
  27. package/src/api-handlers/admin/redis-sessions.ts +251 -251
  28. package/src/api-handlers/admin/sessions.ts +318 -318
  29. package/src/api-handlers/admin/site-logs.ts +365 -365
  30. package/src/api-handlers/admin/users.ts +242 -242
  31. package/src/api-handlers/admin/vibe-data.ts +324 -324
  32. package/src/api-handlers/auth/refresh.ts +705 -682
  33. package/src/api-handlers/auth/signout.ts +199 -199
  34. package/src/api-handlers/auth/status.ts +20 -20
  35. package/src/api-handlers/auth/update-session.ts +110 -110
  36. package/src/api-handlers/auth/verify-code.ts +112 -112
  37. package/src/api-handlers/session/refresh-viability.ts +36 -36
  38. package/src/api-handlers/test/force-expire.ts +60 -60
  39. package/src/auth/auth-decision.ts +229 -229
  40. package/src/auth/better-auth.ts +467 -466
  41. package/src/auth/route-config.ts +219 -219
  42. package/src/auth/types/auth-types.ts +555 -555
  43. package/src/auth/types/index.ts +7 -7
  44. package/src/auth/utils/idp-client.ts +445 -445
  45. package/src/auth/utils/index.ts +6 -6
  46. package/src/auth/utils/token-utils.ts +243 -243
  47. package/src/client/AuthContext.tsx +143 -143
  48. package/src/client/fetch-with-auth.ts +47 -47
  49. package/src/components/SessionSync.tsx +138 -138
  50. package/src/components/SignalRHealthCheck.tsx +131 -131
  51. package/src/components/account/MobileNavDrawer.tsx +305 -305
  52. package/src/components/account/UserAvatarMenu.tsx +266 -266
  53. package/src/components/account/index.ts +13 -13
  54. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  55. package/src/components/admin/AnalyticsTab.tsx +703 -703
  56. package/src/components/admin/DataBrowserTab.tsx +505 -505
  57. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  58. package/src/components/admin/SessionsTab.tsx +414 -414
  59. package/src/components/admin/StatsTab.tsx +379 -379
  60. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  61. package/src/components/admin/index.ts +59 -59
  62. package/src/components/auth/FederatedAuthSection.tsx +95 -95
  63. package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
  64. package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
  65. package/src/components/auth/TraditionalAuthSection.tsx +99 -99
  66. package/src/components/recovery/CompleteStep.tsx +36 -36
  67. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  68. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  69. package/src/components/recovery/SetPasswordStep.tsx +97 -97
  70. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  71. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  72. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  73. package/src/components/ui/BetaBadge.tsx +58 -58
  74. package/src/components/ui/Footer.tsx +93 -93
  75. package/src/config/env.ts +57 -57
  76. package/src/config/logger.ts +62 -62
  77. package/src/config/vibe-log-transport.ts +264 -264
  78. package/src/hooks/useAuth.ts +117 -117
  79. package/src/hooks/useAuthSettings.ts +97 -97
  80. package/src/hooks/useAvailableProviders.ts +120 -120
  81. package/src/hooks/usePasswordValidation.ts +127 -127
  82. package/src/hooks/usePublicAuthSettings.ts +149 -149
  83. package/src/hooks/useSessionExpiration.ts +101 -101
  84. package/src/hooks/useViabilitySession.ts +336 -336
  85. package/src/index.ts +60 -60
  86. package/src/lib/api-handler.ts +643 -643
  87. package/src/lib/app-slug.ts +178 -178
  88. package/src/lib/ensure-fresh-access-token.ts +369 -335
  89. package/src/lib/geolocation.ts +265 -265
  90. package/src/lib/idp-fetch.ts +26 -10
  91. package/src/lib/jwt-decode.ts +82 -82
  92. package/src/lib/roles.ts +177 -177
  93. package/src/lib/session-store.ts +4 -1
  94. package/src/lib/site-logger.ts +245 -245
  95. package/src/lib/standardized-client-api.ts +901 -901
  96. package/src/lib/test-aware-get-token.ts +41 -41
  97. package/src/lib/token-lifecycle.ts +30 -3
  98. package/src/lib/user-agent-parser.ts +252 -252
  99. package/src/logging/api/admin-analytics.ts +51 -51
  100. package/src/logging/api/audit-log.ts +53 -53
  101. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  102. package/src/logging/components/AuditLogViewer.tsx +125 -125
  103. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  104. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  105. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  106. package/src/logging/hooks/useAuditLog.ts +24 -24
  107. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  108. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  109. package/src/logging/index.ts +18 -18
  110. package/src/logging/types/analytics.ts +81 -81
  111. package/src/logging/types/audit.ts +31 -31
  112. package/src/logging/types/index.ts +3 -3
  113. package/src/middleware/create-middleware.ts +663 -663
  114. package/src/middleware/rbac-check.ts +279 -279
  115. package/src/middleware/twofa-presets.ts +224 -224
  116. package/src/pages/admin-login/page.tsx +221 -221
  117. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  118. package/src/pages/admin-page-permissions/index.ts +7 -7
  119. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  120. package/src/pages/admin-roles/modals.tsx +469 -469
  121. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  122. package/src/pages/client-admin/index.ts +33 -33
  123. package/src/pages/coming-soon/page.tsx +83 -83
  124. package/src/pages/login/page.tsx +456 -456
  125. package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
  126. package/src/pages/profile/index.ts +9 -9
  127. package/src/pages/profile/page.tsx +166 -166
  128. package/src/pages/recovery/page.tsx +234 -234
  129. package/src/pages/roles/MyRolesPage.tsx +211 -211
  130. package/src/pages/roles/components.tsx +294 -294
  131. package/src/pages/roles/index.ts +17 -17
  132. package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
  133. package/src/pages/security/index.ts +9 -9
  134. package/src/pages/security/page.tsx +507 -507
  135. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  136. package/src/pages/settings/index.ts +9 -9
  137. package/src/pages/settings/page.tsx +47 -47
  138. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  139. package/src/pages/showcase/index.ts +13 -13
  140. package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
  141. package/src/pages/test-env/JwtInspectPage.tsx +456 -456
  142. package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
  143. package/src/pages/test-env/TestEnvPage.tsx +118 -118
  144. package/src/pages/test-env/index.ts +25 -25
  145. package/src/pages/verify-code/page.tsx +652 -652
  146. package/src/routes/account/update-phone.ts +13 -13
  147. package/src/routes/auth/logout.ts +108 -108
  148. package/src/routes/auth/nextauth.ts +45 -45
  149. package/src/routes/auth/settings.ts +75 -75
  150. package/src/routes/auth/viability.ts +224 -224
  151. package/src/server/auth-guard.ts +170 -170
  152. package/src/server/slim-middleware.ts +117 -117
  153. package/src/server/with-auth.ts +93 -93
  154. package/src/stores/authStore.ts +1899 -1899
  155. package/src/templates/instrumentation.ts +41 -41
  156. package/src/utils/logout.ts +30 -30
  157. package/src/vibe/client.ts +590 -590
  158. package/src/vibe/enterprise-auth.ts +208 -208
  159. package/src/vibe/errors.ts +185 -185
  160. package/src/vibe/generic.ts +431 -431
  161. package/src/vibe/hooks/index.ts +367 -367
  162. package/src/vibe/index.ts +132 -132
  163. package/src/vibe/sessions.ts +551 -551
  164. 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
- return { success: false, terminal: true, code: responseData.code };
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 with no refresh token - redirect to login');
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
+ }