@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,485 +1,485 @@
1
- /**
2
- * Enhanced Profile Page for @payez/next-mvp
3
- *
4
- * Implements BAPert's Member Self-Service spec with:
5
- * - Identity header (avatar, name, email, phone, member since)
6
- * - Personal info section (first name, last name, title, bio)
7
- * - Contact info section (secondary email/phone, website)
8
- * - Address section with state/country dropdowns
9
- * - Edit capabilities with inline forms
10
- *
11
- * @see docs/specs/MEMBER_SELF_SERVICE_SPEC.md
12
- */
13
-
14
- 'use client';
15
-
16
- import React, { useState, useEffect } from 'react';
17
- import { useProfile } from '../../hooks/useProfile';
18
- import { useLayout, useColors } from '../../theme/useTheme';
19
-
20
- // Types matching BAPert's spec
21
- interface ProfileData {
22
- user_id?: number | string; // Can be either depending on source
23
- account_id?: string;
24
-
25
- identity?: {
26
- email: string;
27
- email_confirmed?: boolean;
28
- phone_number?: string;
29
- phone_confirmed?: boolean;
30
- full_name?: string;
31
- avatar_url?: string | null;
32
- created_at?: string;
33
- last_sign_in?: string;
34
- last_activity?: string;
35
- };
36
-
37
- personal_info?: {
38
- first_name?: string;
39
- last_name?: string;
40
- title?: string;
41
- bio?: string;
42
- };
43
-
44
- contact_info?: {
45
- secondary_email?: string;
46
- secondary_phone?: string;
47
- website?: string;
48
- preferred_contact_method?: 'email' | 'sms' | 'both';
49
- };
50
-
51
- address?: {
52
- address_line_1?: string;
53
- address_line_2?: string;
54
- city?: string;
55
- state_id?: number;
56
- state_name?: string;
57
- postal_code?: string;
58
- country_code?: string;
59
- country_name?: string;
60
- };
61
-
62
- // Legacy flat fields for backwards compatibility
63
- email?: string;
64
- email_confirmed?: boolean;
65
- phone_number?: string;
66
- phone_confirmed?: boolean;
67
- first_name?: string;
68
- last_name?: string;
69
- full_name?: string;
70
- two_factor_enabled?: boolean;
71
- roles?: string[];
72
- contact_information?: any;
73
- }
74
-
75
- interface EditableSectionProps {
76
- title: string;
77
- children: React.ReactNode;
78
- onEdit?: () => void;
79
- isEditing?: boolean;
80
- onSave?: () => void;
81
- onCancel?: () => void;
82
- isDarkMode: boolean;
83
- }
84
-
85
- function EditableSection({
86
- title,
87
- children,
88
- onEdit,
89
- isEditing,
90
- onSave,
91
- onCancel,
92
- isDarkMode
93
- }: EditableSectionProps) {
94
- const cardBg = isDarkMode ? 'bg-slate-800' : 'bg-white';
95
- const borderColor = isDarkMode ? 'border-slate-700' : 'border-gray-200';
96
- const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
97
- const textSecondary = isDarkMode ? 'text-slate-300' : 'text-gray-600';
98
-
99
- return (
100
- <div className={`rounded-lg border ${cardBg} ${borderColor}`}>
101
- <div className={`flex items-center justify-between px-6 py-4 border-b ${borderColor}`}>
102
- <h2 className={`text-lg font-semibold ${textPrimary}`}>{title}</h2>
103
- {!isEditing && onEdit && (
104
- <button
105
- onClick={onEdit}
106
- className={`text-sm font-medium ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
107
- >
108
- Edit
109
- </button>
110
- )}
111
- {isEditing && (
112
- <div className="flex items-center gap-2">
113
- <button
114
- onClick={onCancel}
115
- className={`text-sm font-medium ${textSecondary} hover:${textPrimary}`}
116
- >
117
- Cancel
118
- </button>
119
- <button
120
- onClick={onSave}
121
- className="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded"
122
- >
123
- Save
124
- </button>
125
- </div>
126
- )}
127
- </div>
128
- <div className="px-6 py-4">
129
- {children}
130
- </div>
131
- </div>
132
- );
133
- }
134
-
135
- interface FieldRowProps {
136
- label: string;
137
- value?: string | React.ReactNode;
138
- verified?: boolean;
139
- action?: React.ReactNode;
140
- isDarkMode: boolean;
141
- }
142
-
143
- function FieldRow({ label, value, verified, action, isDarkMode }: FieldRowProps) {
144
- const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
145
- const textSecondary = isDarkMode ? 'text-slate-400' : 'text-gray-500';
146
-
147
- return (
148
- <div className="flex items-center justify-between py-3 border-b last:border-b-0 border-slate-700/30">
149
- <div className="flex-1">
150
- <p className={`text-sm ${textSecondary}`}>{label}</p>
151
- <div className="flex items-center gap-2 mt-1">
152
- <span className={textPrimary}>{value || '—'}</span>
153
- {verified !== undefined && (
154
- <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
155
- verified ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
156
- }`}>
157
- {verified ? '✓ Verified' : 'Not Verified'}
158
- </span>
159
- )}
160
- </div>
161
- </div>
162
- {action && <div className="ml-4">{action}</div>}
163
- </div>
164
- );
165
- }
166
-
167
- function formatDate(dateString?: string): string {
168
- if (!dateString) return '—';
169
- try {
170
- const date = new Date(dateString);
171
- return date.toLocaleDateString('en-US', {
172
- year: 'numeric',
173
- month: 'long',
174
- day: 'numeric'
175
- });
176
- } catch {
177
- return dateString;
178
- }
179
- }
180
-
181
- function formatRelativeTime(dateString?: string): string {
182
- if (!dateString) return '—';
183
- try {
184
- const date = new Date(dateString);
185
- const now = new Date();
186
- const diffMs = now.getTime() - date.getTime();
187
- const diffMins = Math.floor(diffMs / 60000);
188
- const diffHours = Math.floor(diffMs / 3600000);
189
- const diffDays = Math.floor(diffMs / 86400000);
190
-
191
- if (diffMins < 1) return 'Just now';
192
- if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
193
- if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
194
- if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
195
- return formatDate(dateString);
196
- } catch {
197
- return dateString;
198
- }
199
- }
200
-
201
- export default function EnhancedProfilePage() {
202
- const { data: rawProfile, isLoading, error, refetch } = useProfile();
203
- const layout = useLayout();
204
- const colors = useColors();
205
-
206
- // Normalize profile data (handles both new structured and legacy flat formats)
207
- const profile: ProfileData = React.useMemo(() => {
208
- if (!rawProfile) return {};
209
-
210
- // If data already has identity section, use as-is
211
- if (rawProfile.identity) {
212
- return rawProfile as ProfileData;
213
- }
214
-
215
- // Convert legacy flat format to structured format
216
- return {
217
- user_id: rawProfile.user_id,
218
- identity: {
219
- email: rawProfile.email || '',
220
- email_confirmed: rawProfile.email_confirmed,
221
- phone_number: rawProfile.phone_number,
222
- phone_confirmed: rawProfile.phone_confirmed,
223
- full_name: rawProfile.full_name ||
224
- [rawProfile.first_name, rawProfile.last_name].filter(Boolean).join(' ') ||
225
- rawProfile.email,
226
- avatar_url: null,
227
- created_at: undefined,
228
- last_sign_in: undefined,
229
- },
230
- personal_info: {
231
- first_name: rawProfile.first_name,
232
- last_name: rawProfile.last_name,
233
- title: undefined,
234
- bio: undefined,
235
- },
236
- contact_info: rawProfile.contact_information ? {
237
- secondary_email: rawProfile.contact_information.secondary_email,
238
- secondary_phone: rawProfile.contact_information.secondary_phone,
239
- website: rawProfile.contact_information.website,
240
- } : undefined,
241
- address: rawProfile.contact_information ? {
242
- address_line_1: rawProfile.contact_information.addressLine1,
243
- address_line_2: rawProfile.contact_information.addressLine2,
244
- city: rawProfile.contact_information.city,
245
- state_id: rawProfile.contact_information.stateId,
246
- state_name: rawProfile.contact_information.stateName,
247
- postal_code: rawProfile.contact_information.postalCode,
248
- country_code: rawProfile.contact_information.countryCode,
249
- country_name: rawProfile.contact_information.countryName,
250
- } : undefined,
251
- two_factor_enabled: rawProfile.two_factor_enabled,
252
- roles: rawProfile.roles,
253
- };
254
- }, [rawProfile]);
255
-
256
- // Determine dark mode
257
- const isDarkMode = colors?.background?.includes('slate-9') ||
258
- colors?.background?.includes('gray-9') ||
259
- colors?.background?.includes('dark') ||
260
- colors?.card?.includes('slate-8');
261
-
262
- // Theme classes
263
- const bgClass = isDarkMode ? 'bg-slate-900' : 'bg-gray-50';
264
- const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
265
- const textSecondary = isDarkMode ? 'text-slate-300' : 'text-gray-600';
266
- const textMuted = isDarkMode ? 'text-slate-400' : 'text-gray-500';
267
- const cardBg = isDarkMode ? 'bg-slate-800' : 'bg-white';
268
- const borderColor = isDarkMode ? 'border-slate-700' : 'border-gray-200';
269
-
270
- // Loading state
271
- if (isLoading || !profile.identity) {
272
- return (
273
- <div className={`min-h-screen ${bgClass}`}>
274
- <div className="flex items-center justify-center min-h-[400px]">
275
- <div className="flex flex-col items-center space-y-4">
276
- <svg className={`animate-spin h-8 w-8 ${textMuted}`} viewBox="0 0 24 24" fill="none">
277
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
278
- <path className="opacity-75" fill="currentColor" d="M4 12 a 8 8 0 0 1 8 -8 v 4 a 4 4 0 0 0 -4 4 H4 z" />
279
- </svg>
280
- <p className={textMuted}>Loading profile...</p>
281
- {error && (
282
- <p className="text-red-500 text-sm mt-2">{(error as Error).message}</p>
283
- )}
284
- </div>
285
- </div>
286
- </div>
287
- );
288
- }
289
-
290
- const { identity, personal_info, contact_info, address } = profile;
291
- const userInitial = identity?.email?.charAt(0).toUpperCase() || 'U';
292
-
293
- return (
294
- <div className={`min-h-screen ${bgClass}`}>
295
- <div className={`${layout?.maxWidth || 'max-w-3xl'} mx-auto ${layout?.padding || 'p-6'} space-y-6`}>
296
- {/* Page Header */}
297
- <div className="flex items-center justify-between">
298
- <h1 className={`text-2xl font-bold ${textPrimary}`}>Profile</h1>
299
- </div>
300
-
301
- {/* Identity Header Card */}
302
- <div className={`rounded-lg border ${cardBg} ${borderColor} p-6`}>
303
- <div className="flex items-start gap-6">
304
- {/* Avatar */}
305
- <div className="flex-shrink-0">
306
- {identity?.avatar_url ? (
307
- <img
308
- src={identity.avatar_url}
309
- alt="Profile"
310
- className="w-24 h-24 rounded-full object-cover"
311
- />
312
- ) : (
313
- <div className="w-24 h-24 rounded-full bg-blue-600 flex items-center justify-center">
314
- <span className="text-white font-semibold text-3xl">{userInitial}</span>
315
- </div>
316
- )}
317
- <button className={`mt-2 text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'} w-full text-center`}>
318
- Change Photo
319
- </button>
320
- </div>
321
-
322
- {/* Identity Info */}
323
- <div className="flex-1">
324
- <h2 className={`text-2xl font-bold ${textPrimary}`}>
325
- {identity?.full_name || identity?.email}
326
- </h2>
327
-
328
- <div className="mt-2 space-y-1">
329
- <div className="flex items-center gap-2">
330
- <span className={textSecondary}>{identity?.email}</span>
331
- {identity?.email_confirmed && (
332
- <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
333
- ✓
334
- </span>
335
- )}
336
- </div>
337
-
338
- {identity?.phone_number && (
339
- <div className="flex items-center gap-2">
340
- <span className={textSecondary}>{identity.phone_number}</span>
341
- {identity?.phone_confirmed && (
342
- <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
343
- ✓
344
- </span>
345
- )}
346
- </div>
347
- )}
348
- </div>
349
-
350
- {/* Account metadata */}
351
- <div className={`mt-4 pt-4 border-t ${borderColor} text-sm ${textMuted} space-y-1`}>
352
- {identity?.created_at && (
353
- <p>Member since {formatDate(identity.created_at)}</p>
354
- )}
355
- {identity?.last_activity && (
356
- <p>Last active {formatRelativeTime(identity.last_activity)}</p>
357
- )}
358
- {profile.user_id && (
359
- <p>Account ID: USR-{profile.user_id}</p>
360
- )}
361
- </div>
362
- </div>
363
- </div>
364
- </div>
365
-
366
- {/* Personal Information */}
367
- <EditableSection title="Personal Information" isDarkMode={isDarkMode}>
368
- <div className="space-y-0">
369
- <FieldRow
370
- label="First Name"
371
- value={personal_info?.first_name}
372
- isDarkMode={isDarkMode}
373
- />
374
- <FieldRow
375
- label="Last Name"
376
- value={personal_info?.last_name}
377
- isDarkMode={isDarkMode}
378
- />
379
- <FieldRow
380
- label="Title"
381
- value={personal_info?.title}
382
- isDarkMode={isDarkMode}
383
- />
384
- <FieldRow
385
- label="Bio"
386
- value={personal_info?.bio}
387
- isDarkMode={isDarkMode}
388
- />
389
- </div>
390
- </EditableSection>
391
-
392
- {/* Contact Information */}
393
- <EditableSection title="Contact Information" isDarkMode={isDarkMode}>
394
- <div className="space-y-0">
395
- <FieldRow
396
- label="Primary Email"
397
- value={identity?.email}
398
- verified={identity?.email_confirmed}
399
- action={
400
- <button className={`text-sm ${isDarkMode ? 'text-blue-400' : 'text-blue-600'}`}>
401
- Change
402
- </button>
403
- }
404
- isDarkMode={isDarkMode}
405
- />
406
- <FieldRow
407
- label="Primary Phone"
408
- value={identity?.phone_number}
409
- verified={identity?.phone_confirmed}
410
- action={
411
- <button className={`text-sm ${isDarkMode ? 'text-blue-400' : 'text-blue-600'}`}>
412
- Change
413
- </button>
414
- }
415
- isDarkMode={isDarkMode}
416
- />
417
- <FieldRow
418
- label="Secondary Email"
419
- value={contact_info?.secondary_email}
420
- isDarkMode={isDarkMode}
421
- />
422
- <FieldRow
423
- label="Secondary Phone"
424
- value={contact_info?.secondary_phone}
425
- isDarkMode={isDarkMode}
426
- />
427
- <FieldRow
428
- label="Website"
429
- value={contact_info?.website}
430
- isDarkMode={isDarkMode}
431
- />
432
- <FieldRow
433
- label="Preferred Contact"
434
- value={contact_info?.preferred_contact_method ?
435
- contact_info.preferred_contact_method.charAt(0).toUpperCase() +
436
- contact_info.preferred_contact_method.slice(1) : undefined
437
- }
438
- isDarkMode={isDarkMode}
439
- />
440
- </div>
441
- </EditableSection>
442
-
443
- {/* Address */}
444
- <EditableSection title="Address" isDarkMode={isDarkMode}>
445
- {address?.address_line_1 ? (
446
- <div className={textPrimary}>
447
- <p>{address.address_line_1}</p>
448
- {address.address_line_2 && <p>{address.address_line_2}</p>}
449
- <p>
450
- {[address.city, address.state_name, address.postal_code]
451
- .filter(Boolean)
452
- .join(', ')}
453
- </p>
454
- <p>{address.country_name || address.country_code}</p>
455
- </div>
456
- ) : (
457
- <p className={textMuted}>No address on file</p>
458
- )}
459
- </EditableSection>
460
-
461
- {/* Quick Links */}
462
- <div className="flex flex-wrap justify-center gap-6 pt-4">
463
- <a
464
- href="/account/subscription"
465
- className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
466
- >
467
- Subscription & Billing →
468
- </a>
469
- <a
470
- href="/account/security"
471
- className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
472
- >
473
- Security Settings →
474
- </a>
475
- <a
476
- href="/account/settings"
477
- className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
478
- >
479
- Preferences →
480
- </a>
481
- </div>
482
- </div>
483
- </div>
484
- );
485
- }
1
+ /**
2
+ * Enhanced Profile Page for @payez/next-mvp
3
+ *
4
+ * Implements BAPert's Member Self-Service spec with:
5
+ * - Identity header (avatar, name, email, phone, member since)
6
+ * - Personal info section (first name, last name, title, bio)
7
+ * - Contact info section (secondary email/phone, website)
8
+ * - Address section with state/country dropdowns
9
+ * - Edit capabilities with inline forms
10
+ *
11
+ * @see docs/specs/MEMBER_SELF_SERVICE_SPEC.md
12
+ */
13
+
14
+ 'use client';
15
+
16
+ import React, { useState, useEffect } from 'react';
17
+ import { useProfile } from '../../hooks/useProfile';
18
+ import { useLayout, useColors } from '../../theme/useTheme';
19
+
20
+ // Types matching BAPert's spec
21
+ interface ProfileData {
22
+ user_id?: number | string; // Can be either depending on source
23
+ account_id?: string;
24
+
25
+ identity?: {
26
+ email: string;
27
+ email_confirmed?: boolean;
28
+ phone_number?: string;
29
+ phone_confirmed?: boolean;
30
+ full_name?: string;
31
+ avatar_url?: string | null;
32
+ created_at?: string;
33
+ last_sign_in?: string;
34
+ last_activity?: string;
35
+ };
36
+
37
+ personal_info?: {
38
+ first_name?: string;
39
+ last_name?: string;
40
+ title?: string;
41
+ bio?: string;
42
+ };
43
+
44
+ contact_info?: {
45
+ secondary_email?: string;
46
+ secondary_phone?: string;
47
+ website?: string;
48
+ preferred_contact_method?: 'email' | 'sms' | 'both';
49
+ };
50
+
51
+ address?: {
52
+ address_line_1?: string;
53
+ address_line_2?: string;
54
+ city?: string;
55
+ state_id?: number;
56
+ state_name?: string;
57
+ postal_code?: string;
58
+ country_code?: string;
59
+ country_name?: string;
60
+ };
61
+
62
+ // Legacy flat fields for backwards compatibility
63
+ email?: string;
64
+ email_confirmed?: boolean;
65
+ phone_number?: string;
66
+ phone_confirmed?: boolean;
67
+ first_name?: string;
68
+ last_name?: string;
69
+ full_name?: string;
70
+ two_factor_enabled?: boolean;
71
+ roles?: string[];
72
+ contact_information?: any;
73
+ }
74
+
75
+ interface EditableSectionProps {
76
+ title: string;
77
+ children: React.ReactNode;
78
+ onEdit?: () => void;
79
+ isEditing?: boolean;
80
+ onSave?: () => void;
81
+ onCancel?: () => void;
82
+ isDarkMode: boolean;
83
+ }
84
+
85
+ function EditableSection({
86
+ title,
87
+ children,
88
+ onEdit,
89
+ isEditing,
90
+ onSave,
91
+ onCancel,
92
+ isDarkMode
93
+ }: EditableSectionProps) {
94
+ const cardBg = isDarkMode ? 'bg-slate-800' : 'bg-white';
95
+ const borderColor = isDarkMode ? 'border-slate-700' : 'border-gray-200';
96
+ const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
97
+ const textSecondary = isDarkMode ? 'text-slate-300' : 'text-gray-600';
98
+
99
+ return (
100
+ <div className={`rounded-lg border ${cardBg} ${borderColor}`}>
101
+ <div className={`flex items-center justify-between px-6 py-4 border-b ${borderColor}`}>
102
+ <h2 className={`text-lg font-semibold ${textPrimary}`}>{title}</h2>
103
+ {!isEditing && onEdit && (
104
+ <button
105
+ onClick={onEdit}
106
+ className={`text-sm font-medium ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
107
+ >
108
+ Edit
109
+ </button>
110
+ )}
111
+ {isEditing && (
112
+ <div className="flex items-center gap-2">
113
+ <button
114
+ onClick={onCancel}
115
+ className={`text-sm font-medium ${textSecondary} hover:${textPrimary}`}
116
+ >
117
+ Cancel
118
+ </button>
119
+ <button
120
+ onClick={onSave}
121
+ className="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded"
122
+ >
123
+ Save
124
+ </button>
125
+ </div>
126
+ )}
127
+ </div>
128
+ <div className="px-6 py-4">
129
+ {children}
130
+ </div>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ interface FieldRowProps {
136
+ label: string;
137
+ value?: string | React.ReactNode;
138
+ verified?: boolean;
139
+ action?: React.ReactNode;
140
+ isDarkMode: boolean;
141
+ }
142
+
143
+ function FieldRow({ label, value, verified, action, isDarkMode }: FieldRowProps) {
144
+ const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
145
+ const textSecondary = isDarkMode ? 'text-slate-400' : 'text-gray-500';
146
+
147
+ return (
148
+ <div className="flex items-center justify-between py-3 border-b last:border-b-0 border-slate-700/30">
149
+ <div className="flex-1">
150
+ <p className={`text-sm ${textSecondary}`}>{label}</p>
151
+ <div className="flex items-center gap-2 mt-1">
152
+ <span className={textPrimary}>{value || '—'}</span>
153
+ {verified !== undefined && (
154
+ <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
155
+ verified ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
156
+ }`}>
157
+ {verified ? '✓ Verified' : 'Not Verified'}
158
+ </span>
159
+ )}
160
+ </div>
161
+ </div>
162
+ {action && <div className="ml-4">{action}</div>}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ function formatDate(dateString?: string): string {
168
+ if (!dateString) return '—';
169
+ try {
170
+ const date = new Date(dateString);
171
+ return date.toLocaleDateString('en-US', {
172
+ year: 'numeric',
173
+ month: 'long',
174
+ day: 'numeric'
175
+ });
176
+ } catch {
177
+ return dateString;
178
+ }
179
+ }
180
+
181
+ function formatRelativeTime(dateString?: string): string {
182
+ if (!dateString) return '—';
183
+ try {
184
+ const date = new Date(dateString);
185
+ const now = new Date();
186
+ const diffMs = now.getTime() - date.getTime();
187
+ const diffMins = Math.floor(diffMs / 60000);
188
+ const diffHours = Math.floor(diffMs / 3600000);
189
+ const diffDays = Math.floor(diffMs / 86400000);
190
+
191
+ if (diffMins < 1) return 'Just now';
192
+ if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
193
+ if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
194
+ if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
195
+ return formatDate(dateString);
196
+ } catch {
197
+ return dateString;
198
+ }
199
+ }
200
+
201
+ export default function EnhancedProfilePage() {
202
+ const { data: rawProfile, isLoading, error, refetch } = useProfile();
203
+ const layout = useLayout();
204
+ const colors = useColors();
205
+
206
+ // Normalize profile data (handles both new structured and legacy flat formats)
207
+ const profile: ProfileData = React.useMemo(() => {
208
+ if (!rawProfile) return {};
209
+
210
+ // If data already has identity section, use as-is
211
+ if (rawProfile.identity) {
212
+ return rawProfile as ProfileData;
213
+ }
214
+
215
+ // Convert legacy flat format to structured format
216
+ return {
217
+ user_id: rawProfile.user_id,
218
+ identity: {
219
+ email: rawProfile.email || '',
220
+ email_confirmed: rawProfile.email_confirmed,
221
+ phone_number: rawProfile.phone_number,
222
+ phone_confirmed: rawProfile.phone_confirmed,
223
+ full_name: rawProfile.full_name ||
224
+ [rawProfile.first_name, rawProfile.last_name].filter(Boolean).join(' ') ||
225
+ rawProfile.email,
226
+ avatar_url: null,
227
+ created_at: undefined,
228
+ last_sign_in: undefined,
229
+ },
230
+ personal_info: {
231
+ first_name: rawProfile.first_name,
232
+ last_name: rawProfile.last_name,
233
+ title: undefined,
234
+ bio: undefined,
235
+ },
236
+ contact_info: rawProfile.contact_information ? {
237
+ secondary_email: rawProfile.contact_information.secondary_email,
238
+ secondary_phone: rawProfile.contact_information.secondary_phone,
239
+ website: rawProfile.contact_information.website,
240
+ } : undefined,
241
+ address: rawProfile.contact_information ? {
242
+ address_line_1: rawProfile.contact_information.addressLine1,
243
+ address_line_2: rawProfile.contact_information.addressLine2,
244
+ city: rawProfile.contact_information.city,
245
+ state_id: rawProfile.contact_information.stateId,
246
+ state_name: rawProfile.contact_information.stateName,
247
+ postal_code: rawProfile.contact_information.postalCode,
248
+ country_code: rawProfile.contact_information.countryCode,
249
+ country_name: rawProfile.contact_information.countryName,
250
+ } : undefined,
251
+ two_factor_enabled: rawProfile.two_factor_enabled,
252
+ roles: rawProfile.roles,
253
+ };
254
+ }, [rawProfile]);
255
+
256
+ // Determine dark mode
257
+ const isDarkMode = colors?.background?.includes('slate-9') ||
258
+ colors?.background?.includes('gray-9') ||
259
+ colors?.background?.includes('dark') ||
260
+ colors?.card?.includes('slate-8');
261
+
262
+ // Theme classes
263
+ const bgClass = isDarkMode ? 'bg-slate-900' : 'bg-gray-50';
264
+ const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
265
+ const textSecondary = isDarkMode ? 'text-slate-300' : 'text-gray-600';
266
+ const textMuted = isDarkMode ? 'text-slate-400' : 'text-gray-500';
267
+ const cardBg = isDarkMode ? 'bg-slate-800' : 'bg-white';
268
+ const borderColor = isDarkMode ? 'border-slate-700' : 'border-gray-200';
269
+
270
+ // Loading state
271
+ if (isLoading || !profile.identity) {
272
+ return (
273
+ <div className={`min-h-screen ${bgClass}`}>
274
+ <div className="flex items-center justify-center min-h-[400px]">
275
+ <div className="flex flex-col items-center space-y-4">
276
+ <svg className={`animate-spin h-8 w-8 ${textMuted}`} viewBox="0 0 24 24" fill="none">
277
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
278
+ <path className="opacity-75" fill="currentColor" d="M4 12 a 8 8 0 0 1 8 -8 v 4 a 4 4 0 0 0 -4 4 H4 z" />
279
+ </svg>
280
+ <p className={textMuted}>Loading profile...</p>
281
+ {error && (
282
+ <p className="text-red-500 text-sm mt-2">{(error as Error).message}</p>
283
+ )}
284
+ </div>
285
+ </div>
286
+ </div>
287
+ );
288
+ }
289
+
290
+ const { identity, personal_info, contact_info, address } = profile;
291
+ const userInitial = identity?.email?.charAt(0).toUpperCase() || 'U';
292
+
293
+ return (
294
+ <div className={`min-h-screen ${bgClass}`}>
295
+ <div className={`${layout?.maxWidth || 'max-w-3xl'} mx-auto ${layout?.padding || 'p-6'} space-y-6`}>
296
+ {/* Page Header */}
297
+ <div className="flex items-center justify-between">
298
+ <h1 className={`text-2xl font-bold ${textPrimary}`}>Profile</h1>
299
+ </div>
300
+
301
+ {/* Identity Header Card */}
302
+ <div className={`rounded-lg border ${cardBg} ${borderColor} p-6`}>
303
+ <div className="flex items-start gap-6">
304
+ {/* Avatar */}
305
+ <div className="flex-shrink-0">
306
+ {identity?.avatar_url ? (
307
+ <img
308
+ src={identity.avatar_url}
309
+ alt="Profile"
310
+ className="w-24 h-24 rounded-full object-cover"
311
+ />
312
+ ) : (
313
+ <div className="w-24 h-24 rounded-full bg-blue-600 flex items-center justify-center">
314
+ <span className="text-white font-semibold text-3xl">{userInitial}</span>
315
+ </div>
316
+ )}
317
+ <button className={`mt-2 text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'} w-full text-center`}>
318
+ Change Photo
319
+ </button>
320
+ </div>
321
+
322
+ {/* Identity Info */}
323
+ <div className="flex-1">
324
+ <h2 className={`text-2xl font-bold ${textPrimary}`}>
325
+ {identity?.full_name || identity?.email}
326
+ </h2>
327
+
328
+ <div className="mt-2 space-y-1">
329
+ <div className="flex items-center gap-2">
330
+ <span className={textSecondary}>{identity?.email}</span>
331
+ {identity?.email_confirmed && (
332
+ <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
333
+ ✓
334
+ </span>
335
+ )}
336
+ </div>
337
+
338
+ {identity?.phone_number && (
339
+ <div className="flex items-center gap-2">
340
+ <span className={textSecondary}>{identity.phone_number}</span>
341
+ {identity?.phone_confirmed && (
342
+ <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
343
+ ✓
344
+ </span>
345
+ )}
346
+ </div>
347
+ )}
348
+ </div>
349
+
350
+ {/* Account metadata */}
351
+ <div className={`mt-4 pt-4 border-t ${borderColor} text-sm ${textMuted} space-y-1`}>
352
+ {identity?.created_at && (
353
+ <p>Member since {formatDate(identity.created_at)}</p>
354
+ )}
355
+ {identity?.last_activity && (
356
+ <p>Last active {formatRelativeTime(identity.last_activity)}</p>
357
+ )}
358
+ {profile.user_id && (
359
+ <p>Account ID: USR-{profile.user_id}</p>
360
+ )}
361
+ </div>
362
+ </div>
363
+ </div>
364
+ </div>
365
+
366
+ {/* Personal Information */}
367
+ <EditableSection title="Personal Information" isDarkMode={isDarkMode}>
368
+ <div className="space-y-0">
369
+ <FieldRow
370
+ label="First Name"
371
+ value={personal_info?.first_name}
372
+ isDarkMode={isDarkMode}
373
+ />
374
+ <FieldRow
375
+ label="Last Name"
376
+ value={personal_info?.last_name}
377
+ isDarkMode={isDarkMode}
378
+ />
379
+ <FieldRow
380
+ label="Title"
381
+ value={personal_info?.title}
382
+ isDarkMode={isDarkMode}
383
+ />
384
+ <FieldRow
385
+ label="Bio"
386
+ value={personal_info?.bio}
387
+ isDarkMode={isDarkMode}
388
+ />
389
+ </div>
390
+ </EditableSection>
391
+
392
+ {/* Contact Information */}
393
+ <EditableSection title="Contact Information" isDarkMode={isDarkMode}>
394
+ <div className="space-y-0">
395
+ <FieldRow
396
+ label="Primary Email"
397
+ value={identity?.email}
398
+ verified={identity?.email_confirmed}
399
+ action={
400
+ <button className={`text-sm ${isDarkMode ? 'text-blue-400' : 'text-blue-600'}`}>
401
+ Change
402
+ </button>
403
+ }
404
+ isDarkMode={isDarkMode}
405
+ />
406
+ <FieldRow
407
+ label="Primary Phone"
408
+ value={identity?.phone_number}
409
+ verified={identity?.phone_confirmed}
410
+ action={
411
+ <button className={`text-sm ${isDarkMode ? 'text-blue-400' : 'text-blue-600'}`}>
412
+ Change
413
+ </button>
414
+ }
415
+ isDarkMode={isDarkMode}
416
+ />
417
+ <FieldRow
418
+ label="Secondary Email"
419
+ value={contact_info?.secondary_email}
420
+ isDarkMode={isDarkMode}
421
+ />
422
+ <FieldRow
423
+ label="Secondary Phone"
424
+ value={contact_info?.secondary_phone}
425
+ isDarkMode={isDarkMode}
426
+ />
427
+ <FieldRow
428
+ label="Website"
429
+ value={contact_info?.website}
430
+ isDarkMode={isDarkMode}
431
+ />
432
+ <FieldRow
433
+ label="Preferred Contact"
434
+ value={contact_info?.preferred_contact_method ?
435
+ contact_info.preferred_contact_method.charAt(0).toUpperCase() +
436
+ contact_info.preferred_contact_method.slice(1) : undefined
437
+ }
438
+ isDarkMode={isDarkMode}
439
+ />
440
+ </div>
441
+ </EditableSection>
442
+
443
+ {/* Address */}
444
+ <EditableSection title="Address" isDarkMode={isDarkMode}>
445
+ {address?.address_line_1 ? (
446
+ <div className={textPrimary}>
447
+ <p>{address.address_line_1}</p>
448
+ {address.address_line_2 && <p>{address.address_line_2}</p>}
449
+ <p>
450
+ {[address.city, address.state_name, address.postal_code]
451
+ .filter(Boolean)
452
+ .join(', ')}
453
+ </p>
454
+ <p>{address.country_name || address.country_code}</p>
455
+ </div>
456
+ ) : (
457
+ <p className={textMuted}>No address on file</p>
458
+ )}
459
+ </EditableSection>
460
+
461
+ {/* Quick Links */}
462
+ <div className="flex flex-wrap justify-center gap-6 pt-4">
463
+ <a
464
+ href="/account/subscription"
465
+ className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
466
+ >
467
+ Subscription & Billing →
468
+ </a>
469
+ <a
470
+ href="/account/security"
471
+ className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
472
+ >
473
+ Security Settings →
474
+ </a>
475
+ <a
476
+ href="/account/settings"
477
+ className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
478
+ >
479
+ Preferences →
480
+ </a>
481
+ </div>
482
+ </div>
483
+ </div>
484
+ );
485
+ }