@payez/next-mvp 3.5.0 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/api-handlers/admin/index.d.ts +1 -0
  2. package/dist/api-handlers/admin/index.js +3 -1
  3. package/dist/api-handlers/admin/stats.d.ts +21 -0
  4. package/dist/api-handlers/admin/stats.js +240 -0
  5. package/dist/auth/utils/idp-client.js +1 -0
  6. package/dist/components/account/MobileNavDrawer.d.ts +32 -0
  7. package/dist/components/account/MobileNavDrawer.js +81 -0
  8. package/dist/components/account/UserAvatarMenu.js +5 -1
  9. package/dist/components/account/index.d.ts +2 -0
  10. package/dist/components/account/index.js +5 -2
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.js +2 -1
  13. package/dist/pages/admin-page-permissions/PagePermissionsAdminPage.d.ts +18 -0
  14. package/dist/pages/admin-page-permissions/PagePermissionsAdminPage.js +276 -0
  15. package/dist/pages/admin-page-permissions/index.d.ts +6 -0
  16. package/dist/pages/admin-page-permissions/index.js +13 -0
  17. package/dist/pages/admin-roles/RolesAdminPage.d.ts +12 -11
  18. package/dist/pages/admin-roles/RolesAdminPage.js +249 -66
  19. package/dist/routes/auth/session.d.ts +1 -30
  20. package/dist/routes/auth/session.js +3 -4
  21. package/package.json +6 -1
  22. package/src/api-handlers/admin/index.ts +5 -0
  23. package/src/api-handlers/admin/stats.ts +240 -0
  24. package/src/auth/utils/idp-client.ts +1 -0
  25. package/src/components/account/MobileNavDrawer.tsx +305 -0
  26. package/src/components/account/UserAvatarMenu.tsx +47 -17
  27. package/src/components/account/index.ts +5 -0
  28. package/src/index.ts +2 -2
  29. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -0
  30. package/src/pages/admin-page-permissions/index.ts +7 -0
  31. package/src/pages/admin-roles/RolesAdminPage.tsx +494 -318
  32. package/src/routes/auth/session.ts +3 -4
@@ -1,356 +1,532 @@
1
1
  /**
2
- * Roles Admin Page for @payez/next-mvp
2
+ * Role Management Admin Page (/admin/roles)
3
3
  *
4
- * Read-only admin interface for viewing roles and permissions (/admin/roles).
5
- * MVP scope: View IDP roles and their page permissions only.
6
- * Role creation/editing deferred to post-MVP.
4
+ * Design: Aurum (DESIGN_SPEC.md)
5
+ * Three sections:
6
+ * 1. Available Roles Cards showing SiteAdmin, ClientAdmin
7
+ * 2. User Assignments — Table with inline role dropdowns
8
+ * 3. Change History — Audit log of role changes
7
9
  *
8
- * @see docs/specs/ROLES_MANAGEMENT_SPEC.md
10
+ * Design Principles:
11
+ * - No shadows, gradients, or animation
12
+ * - One accent color (blue #0066cc)
13
+ * - Inline interactions (no modals)
14
+ * - Scan-friendly tables and lists
9
15
  */
10
16
 
11
17
  'use client';
12
18
 
13
- import React, { useState, useEffect, useCallback } from 'react';
14
- import { useLayout, useColors } from '../../theme/useTheme';
15
- import { RoleBadge } from '../roles/components';
19
+ import React, { useState } from 'react';
16
20
 
17
- // Types
18
- interface PagePermission {
19
- page_permission_id: number;
20
- route_pattern: string;
21
- display_name: string;
22
- requires_2fa: boolean;
23
- }
24
-
25
- interface Role {
26
- role_name: string;
27
- display_name?: string;
28
- description?: string;
29
- permission_count?: number;
30
- permissions?: PagePermission[];
31
- }
21
+ // Mock data
22
+ const MOCK_ROLES = [
23
+ {
24
+ id: 1,
25
+ name: 'SiteAdmin',
26
+ description: 'System-wide administrator. Manages all vibe_app features.',
27
+ userCount: 1,
28
+ lastChanged: '3/10/2026 by Admin',
29
+ },
30
+ {
31
+ id: 2,
32
+ name: 'ClientAdmin',
33
+ description: 'Resume admin for Ideal Resume. Manages users, resumes, audit logs.',
34
+ userCount: 2,
35
+ lastChanged: '3/9/2026 by Admin',
36
+ },
37
+ ];
32
38
 
33
- interface MatrixData {
34
- roles: string[];
35
- pages: {
36
- page_permission_id: number;
37
- route_pattern: string;
38
- display_name: string;
39
- requires_2fa: boolean;
40
- role_access: Record<string, boolean>;
41
- }[];
42
- }
39
+ const MOCK_USERS = [
40
+ { id: 1, email: 'alice@example.com', role: 'ClientAdmin', assigned: '3/10/2026' },
41
+ { id: 2, email: 'bob@example.com', role: 'SiteAdmin', assigned: '2/28/2026' },
42
+ { id: 3, email: 'carol@example.com', role: null, assigned: null },
43
+ { id: 4, email: 'dave@example.com', role: 'ClientAdmin', assigned: '3/5/2026' },
44
+ { id: 5, email: 'eve@example.com', role: 'ClientAdmin', assigned: '3/8/2026' },
45
+ ];
43
46
 
44
- type ViewMode = 'list' | 'matrix';
47
+ const MOCK_CHANGES = [
48
+ { timestamp: '3/10/2026, 10:15 AM', event: 'Alice assigned to ClientAdmin by Admin User' },
49
+ { timestamp: '3/9/2026, 2:30 PM', event: 'Bob assigned to SiteAdmin by Admin User' },
50
+ { timestamp: '3/8/2026, 4:45 PM', event: 'Carol removed from ClientAdmin by Admin User' },
51
+ { timestamp: '3/8/2026, 3:00 PM', event: 'SiteAdmin edited: Description changed by Admin User' },
52
+ ];
45
53
 
46
- interface RolesAdminPageProps {
47
- rolesEndpoint?: string;
48
- matrixEndpoint?: string;
54
+ interface User {
55
+ id: number;
56
+ email: string;
57
+ role: string | null;
58
+ assigned: string | null;
49
59
  }
50
60
 
51
- export default function RolesAdminPage({
52
- rolesEndpoint = '/api/v1/admin/roles',
53
- matrixEndpoint = '/api/v1/admin/permissions-matrix',
54
- }: RolesAdminPageProps) {
55
- const layout = useLayout();
56
- const colors = useColors();
57
-
58
- const isDark = colors?.background?.includes('slate-9') ||
59
- colors?.background?.includes('gray-9') ||
60
- colors?.card?.includes('slate-8');
61
-
62
- // State
63
- const [viewMode, setViewMode] = useState<ViewMode>('list');
64
- const [roles, setRoles] = useState<Role[]>([]);
65
- const [matrixData, setMatrixData] = useState<MatrixData | null>(null);
66
- const [loading, setLoading] = useState(true);
67
- const [expandedRole, setExpandedRole] = useState<string | null>(null);
68
-
69
- // Theme colors
70
- const bgColor = isDark ? 'bg-slate-900' : 'bg-gray-50';
71
- const cardBg = isDark ? 'bg-slate-800' : 'bg-white';
72
- const borderColor = isDark ? 'border-slate-700' : 'border-gray-200';
73
- const textPrimary = isDark ? 'text-white' : 'text-gray-900';
74
- const textMuted = isDark ? 'text-slate-400' : 'text-gray-500';
75
- const hoverBg = isDark ? 'hover:bg-slate-700' : 'hover:bg-gray-50';
61
+ export default function RolesAdminPage() {
62
+ const [users, setUsers] = useState<User[]>(MOCK_USERS);
63
+ const [searchQuery, setSearchQuery] = useState('');
64
+ const [editingUserId, setEditingUserId] = useState<number | null>(null);
65
+ const [tempRole, setTempRole] = useState<string | null>(null);
66
+ const [message, setMessage] = useState<string | null>(null);
76
67
 
77
- // Fetch data
78
- const fetchRoles = useCallback(async () => {
79
- try {
80
- const res = await fetch(rolesEndpoint, { credentials: 'include' });
81
- if (res.ok) {
82
- const data = await res.json();
83
- setRoles(data.roles || data.idp_roles || []);
84
- }
85
- } catch (err) {
86
- console.error('Failed to fetch roles:', err);
87
- }
88
- }, [rolesEndpoint]);
89
-
90
- const fetchMatrix = useCallback(async () => {
91
- try {
92
- const res = await fetch(matrixEndpoint, { credentials: 'include' });
93
- if (res.ok) {
94
- const data = await res.json();
95
- setMatrixData(data);
96
- }
97
- } catch (err) {
98
- console.error('Failed to fetch matrix:', err);
99
- }
100
- }, [matrixEndpoint]);
101
-
102
- useEffect(() => {
103
- setLoading(true);
104
- Promise.all([fetchRoles(), fetchMatrix()])
105
- .finally(() => setLoading(false));
106
- }, [fetchRoles, fetchMatrix]);
68
+ const filteredUsers = users.filter(
69
+ (u) => u.email.toLowerCase().includes(searchQuery.toLowerCase())
70
+ );
107
71
 
108
- const toggleRoleExpanded = (roleName: string) => {
109
- setExpandedRole(expandedRole === roleName ? null : roleName);
72
+ const handleEditRole = (userId: number, currentRole: string | null) => {
73
+ setEditingUserId(userId);
74
+ setTempRole(currentRole);
110
75
  };
111
76
 
112
- if (loading) {
113
- return (
114
- <div className={`min-h-screen ${bgColor} flex items-center justify-center`}>
115
- <div className="flex flex-col items-center space-y-4">
116
- <svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
117
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
118
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
119
- </svg>
120
- <p className={textMuted}>Loading roles...</p>
121
- </div>
122
- </div>
77
+ const handleSaveRole = (userId: number) => {
78
+ setUsers((prev) =>
79
+ prev.map((u) =>
80
+ u.id === userId ? { ...u, role: tempRole, assigned: '3/10/2026' } : u
81
+ )
123
82
  );
124
- }
83
+ setMessage(`Role updated`);
84
+ setEditingUserId(null);
85
+ setTimeout(() => setMessage(null), 3000);
86
+ };
87
+
88
+ const handleRemoveRole = (userId: number) => {
89
+ setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role: null, assigned: null } : u)));
90
+ setMessage(`Role removed`);
91
+ setTimeout(() => setMessage(null), 3000);
92
+ };
125
93
 
126
94
  return (
127
- <div className={`min-h-screen ${bgColor}`}>
128
- <div className={`max-w-6xl mx-auto ${layout?.padding || 'p-6'}`}>
95
+ <div style={{ background: '#f8f8f8', minHeight: '100vh', padding: '40px 20px' }}>
96
+ <div style={{ maxWidth: '1200px', margin: '0 auto' }}>
129
97
  {/* Header */}
130
- <div className="flex items-center justify-between mb-6">
131
- <div>
132
- <h1 className={`text-3xl font-bold ${textPrimary}`}>Role Permissions</h1>
133
- <p className={`mt-1 ${textMuted}`}>View IDP roles and their page access permissions</p>
134
- </div>
135
- <a href="/admin" className={`text-sm hover:underline ${textMuted}`}>
136
- Back to Admin
137
- </a>
98
+ <div style={{ marginBottom: '40px' }}>
99
+ <h1
100
+ style={{
101
+ fontSize: '32px',
102
+ fontWeight: 400,
103
+ color: '#333',
104
+ marginBottom: '8px',
105
+ }}
106
+ >
107
+ Role Management
108
+ </h1>
109
+ <p style={{ fontSize: '16px', color: '#666', fontWeight: 400 }}>
110
+ Manage who has access to what role
111
+ </p>
138
112
  </div>
139
113
 
140
- {/* View Toggle */}
141
- <div className={`flex border-b ${borderColor} mb-6`}>
142
- <button
143
- onClick={() => setViewMode('list')}
144
- className={`px-4 py-3 font-medium text-sm border-b-2 transition-colors ${
145
- viewMode === 'list'
146
- ? 'border-blue-500 text-blue-500'
147
- : `border-transparent ${textMuted} hover:text-blue-400`
148
- }`}
114
+ <div style={{ height: '1px', background: '#e0e0e0', margin: '24px 0' }} />
115
+
116
+ {/* Section 1: Available Roles */}
117
+ <section style={{ marginBottom: '60px' }}>
118
+ <h2
119
+ style={{
120
+ fontSize: '18px',
121
+ fontWeight: 400,
122
+ color: '#666',
123
+ marginBottom: '24px',
124
+ textTransform: 'uppercase',
125
+ letterSpacing: '1px',
126
+ }}
149
127
  >
150
- Role List
151
- </button>
152
- <button
153
- onClick={() => setViewMode('matrix')}
154
- className={`px-4 py-3 font-medium text-sm border-b-2 transition-colors ${
155
- viewMode === 'matrix'
156
- ? 'border-blue-500 text-blue-500'
157
- : `border-transparent ${textMuted} hover:text-blue-400`
158
- }`}
128
+ Available Roles
129
+ </h2>
130
+ <div
131
+ style={{
132
+ display: 'grid',
133
+ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
134
+ gap: '24px',
135
+ }}
159
136
  >
160
- Permissions Matrix
161
- </button>
162
- </div>
163
-
164
- {/* Role List View */}
165
- {viewMode === 'list' && (
166
- <div className={`${cardBg} border ${borderColor} rounded-lg overflow-hidden`}>
167
- <div className={`px-4 py-3 border-b ${borderColor}`}>
168
- <p className={`text-sm ${textMuted}`}>
169
- Click a role to see what pages it grants access to
170
- </p>
171
- </div>
172
- <div className={`divide-y ${borderColor}`}>
173
- {roles.length === 0 ? (
174
- <div className={`p-8 text-center ${textMuted}`}>
175
- No roles found
137
+ {MOCK_ROLES.map((role) => (
138
+ <div
139
+ key={role.id}
140
+ style={{
141
+ background: 'white',
142
+ border: '1px solid #e0e0e0',
143
+ borderRadius: '6px',
144
+ padding: '20px',
145
+ display: 'flex',
146
+ flexDirection: 'column',
147
+ transition: 'all 0.2s ease',
148
+ cursor: 'default',
149
+ }}
150
+ onMouseEnter={(e) => {
151
+ e.currentTarget.style.background = '#f9f9f9';
152
+ e.currentTarget.style.borderColor = '#d0d0d0';
153
+ }}
154
+ onMouseLeave={(e) => {
155
+ e.currentTarget.style.background = 'white';
156
+ e.currentTarget.style.borderColor = '#e0e0e0';
157
+ }}
158
+ >
159
+ <h3
160
+ style={{
161
+ fontSize: '18px',
162
+ fontWeight: 600,
163
+ color: '#333',
164
+ marginBottom: '8px',
165
+ }}
166
+ >
167
+ {role.name}
168
+ </h3>
169
+ <p
170
+ style={{
171
+ fontSize: '14px',
172
+ color: '#666',
173
+ marginBottom: '16px',
174
+ flex: 1,
175
+ lineHeight: 1.6,
176
+ }}
177
+ >
178
+ {role.description}
179
+ </p>
180
+ <div style={{ marginBottom: '16px' }}>
181
+ <div style={{ fontSize: '12px', color: '#999' }}>Users: {role.userCount}</div>
182
+ <div style={{ fontSize: '12px', color: '#999' }}>
183
+ Last changed: {role.lastChanged}
184
+ </div>
176
185
  </div>
177
- ) : (
178
- roles.map((role) => (
179
- <div key={role.role_name}>
180
- <button
181
- onClick={() => toggleRoleExpanded(role.role_name)}
182
- className={`w-full px-4 py-4 flex items-center justify-between ${hoverBg} transition-colors`}
183
- >
184
- <div className="flex items-center gap-3">
185
- <svg
186
- className={`w-5 h-5 ${textMuted} transition-transform ${expandedRole === role.role_name ? 'rotate-90' : ''}`}
187
- fill="none"
188
- viewBox="0 0 24 24"
189
- stroke="currentColor"
190
- >
191
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
192
- </svg>
193
- <div className="text-left">
194
- <div className={`font-medium ${textPrimary}`}>
195
- {role.display_name || role.role_name}
196
- </div>
197
- <div className={`text-sm ${textMuted}`}>
198
- {role.role_name}
199
- </div>
200
- </div>
201
- </div>
202
- <div className="flex items-center gap-3">
203
- <RoleBadge roleName="IDP" source="idp" size="sm" isDark={isDark} />
204
- <span className={`text-sm ${textMuted}`}>
205
- {role.permission_count ?? role.permissions?.length ?? 0} pages
206
- </span>
207
- </div>
208
- </button>
186
+ <div style={{ display: 'flex', gap: '8px' }}>
187
+ <button
188
+ style={{
189
+ padding: '8px 16px',
190
+ fontSize: '13px',
191
+ background: '#0066cc',
192
+ color: 'white',
193
+ border: 'none',
194
+ borderRadius: '4px',
195
+ cursor: 'pointer',
196
+ }}
197
+ onMouseEnter={(e) => (e.currentTarget.style.background = '#0052a3')}
198
+ onMouseLeave={(e) => (e.currentTarget.style.background = '#0066cc')}
199
+ >
200
+ Edit
201
+ </button>
202
+ <button
203
+ style={{
204
+ padding: '8px 16px',
205
+ fontSize: '13px',
206
+ background: 'white',
207
+ color: '#333',
208
+ border: '1px solid #e0e0e0',
209
+ borderRadius: '4px',
210
+ cursor: 'pointer',
211
+ }}
212
+ onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
213
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'white')}
214
+ >
215
+ Remove
216
+ </button>
217
+ </div>
218
+ </div>
219
+ ))}
220
+ </div>
221
+ </section>
209
222
 
210
- {expandedRole === role.role_name && (
211
- <div className={`px-4 pb-4 ${isDark ? 'bg-slate-800/50' : 'bg-gray-50'}`}>
212
- <div className="pl-8">
213
- {role.permissions && role.permissions.length > 0 ? (
214
- <div className="space-y-2 pt-2">
215
- {role.permissions.map((perm) => (
216
- <div
217
- key={perm.page_permission_id}
218
- className={`flex items-center justify-between py-2 px-3 rounded ${isDark ? 'bg-slate-700/50' : 'bg-white'} border ${borderColor}`}
219
- >
220
- <div>
221
- <span className={`font-mono text-sm ${textPrimary}`}>
222
- {perm.route_pattern}
223
- </span>
224
- {perm.display_name && (
225
- <span className={`ml-2 text-sm ${textMuted}`}>
226
- - {perm.display_name}
227
- </span>
228
- )}
229
- </div>
230
- {perm.requires_2fa && (
231
- <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
232
- 2FA Required
233
- </span>
234
- )}
235
- </div>
236
- ))}
237
- </div>
238
- ) : (
239
- <p className={`py-4 text-sm ${textMuted}`}>
240
- No page permissions assigned to this role
241
- </p>
242
- )}
243
- </div>
244
- </div>
245
- )}
246
- </div>
247
- ))
248
- )}
249
- </div>
250
- <div className={`px-4 py-3 border-t ${borderColor} ${textMuted} text-sm`}>
251
- {roles.length} roles total
252
- </div>
223
+ <div style={{ height: '1px', background: '#e0e0e0', margin: '24px 0' }} />
224
+
225
+ {/* Section 2: User Assignments */}
226
+ <section style={{ marginBottom: '60px' }}>
227
+ <h2
228
+ style={{
229
+ fontSize: '18px',
230
+ fontWeight: 400,
231
+ color: '#666',
232
+ marginBottom: '24px',
233
+ textTransform: 'uppercase',
234
+ letterSpacing: '1px',
235
+ }}
236
+ >
237
+ User Assignments
238
+ </h2>
239
+
240
+ {/* Search */}
241
+ <div style={{ marginBottom: '24px' }}>
242
+ <input
243
+ type="text"
244
+ placeholder="Search users..."
245
+ value={searchQuery}
246
+ onChange={(e) => setSearchQuery(e.target.value)}
247
+ style={{
248
+ width: '100%',
249
+ padding: '10px 14px',
250
+ fontSize: '14px',
251
+ border: '1px solid #e0e0e0',
252
+ borderRadius: '4px',
253
+ background: 'white',
254
+ boxSizing: 'border-box',
255
+ }}
256
+ />
253
257
  </div>
254
- )}
255
258
 
256
- {/* Matrix View */}
257
- {viewMode === 'matrix' && matrixData && (
258
- <div className={`${cardBg} border ${borderColor} rounded-lg overflow-hidden`}>
259
- <div className={`px-4 py-3 border-b ${borderColor}`}>
260
- <p className={`text-sm ${textMuted}`}>
261
- Read-only view of which roles have access to which pages
262
- </p>
259
+ {/* Message */}
260
+ {message && (
261
+ <div
262
+ style={{
263
+ padding: '8px 12px',
264
+ background: '#e8f5e9',
265
+ color: '#2e7d32',
266
+ borderRadius: '4px',
267
+ marginBottom: '12px',
268
+ fontSize: '13px',
269
+ }}
270
+ >
271
+ ✓ {message}
263
272
  </div>
264
- <div className="overflow-x-auto">
265
- <table className="w-full">
266
- <thead className={isDark ? 'bg-slate-700/50' : 'bg-gray-50'}>
267
- <tr>
268
- <th className={`px-4 py-3 text-left text-sm font-medium ${textMuted} sticky left-0 ${isDark ? 'bg-slate-700' : 'bg-gray-50'}`}>
269
- Page
270
- </th>
271
- {matrixData.roles.map((role) => (
272
- <th key={role} className={`px-4 py-3 text-center text-sm font-medium ${textMuted} whitespace-nowrap`}>
273
- {role}
274
- </th>
275
- ))}
276
- <th className={`px-4 py-3 text-center text-sm font-medium ${textMuted}`}>2FA</th>
277
- </tr>
278
- </thead>
279
- <tbody className={`divide-y ${borderColor}`}>
280
- {matrixData.pages.map((page) => (
281
- <tr key={page.page_permission_id} className={hoverBg}>
282
- <td className={`px-4 py-3 ${textPrimary} sticky left-0 ${cardBg}`}>
283
- <div>
284
- <span className="font-mono text-sm">{page.route_pattern}</span>
285
- {page.display_name && (
286
- <span className={`block text-xs ${textMuted}`}>{page.display_name}</span>
287
- )}
288
- </div>
289
- </td>
290
- {matrixData.roles.map((role) => (
291
- <td key={role} className="px-4 py-3 text-center">
292
- {page.role_access[role] ? (
293
- <svg className="w-5 h-5 mx-auto text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
294
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
295
- </svg>
296
- ) : (
297
- <svg className={`w-5 h-5 mx-auto ${textMuted}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
298
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
299
- </svg>
300
- )}
301
- </td>
302
- ))}
303
- <td className="px-4 py-3 text-center">
304
- {page.requires_2fa && (
305
- <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
306
- 2FA
307
- </span>
273
+ )}
274
+
275
+ {/* Table */}
276
+ <table
277
+ style={{
278
+ width: '100%',
279
+ borderCollapse: 'collapse',
280
+ background: 'white',
281
+ border: '1px solid #e0e0e0',
282
+ borderRadius: '4px',
283
+ overflow: 'hidden',
284
+ }}
285
+ >
286
+ <thead>
287
+ <tr style={{ background: '#f8f8f8', borderBottom: '1px solid #e0e0e0' }}>
288
+ <th
289
+ style={{
290
+ padding: '16px',
291
+ textAlign: 'left',
292
+ fontSize: '12px',
293
+ color: '#999',
294
+ textTransform: 'uppercase',
295
+ letterSpacing: '0.5px',
296
+ fontWeight: 'normal',
297
+ }}
298
+ >
299
+ User
300
+ </th>
301
+ <th
302
+ style={{
303
+ padding: '16px',
304
+ textAlign: 'left',
305
+ fontSize: '12px',
306
+ color: '#999',
307
+ textTransform: 'uppercase',
308
+ letterSpacing: '0.5px',
309
+ fontWeight: 'normal',
310
+ }}
311
+ >
312
+ Role
313
+ </th>
314
+ <th
315
+ style={{
316
+ padding: '16px',
317
+ textAlign: 'left',
318
+ fontSize: '12px',
319
+ color: '#999',
320
+ textTransform: 'uppercase',
321
+ letterSpacing: '0.5px',
322
+ fontWeight: 'normal',
323
+ }}
324
+ >
325
+ Assigned
326
+ </th>
327
+ <th
328
+ style={{
329
+ padding: '16px',
330
+ textAlign: 'left',
331
+ fontSize: '12px',
332
+ color: '#999',
333
+ textTransform: 'uppercase',
334
+ letterSpacing: '0.5px',
335
+ fontWeight: 'normal',
336
+ }}
337
+ >
338
+ Actions
339
+ </th>
340
+ </tr>
341
+ </thead>
342
+ <tbody>
343
+ {filteredUsers.map((user) => (
344
+ <tr
345
+ key={user.id}
346
+ style={{
347
+ borderBottom: '1px solid #e0e0e0',
348
+ height: '48px',
349
+ }}
350
+ onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
351
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'white')}
352
+ >
353
+ <td style={{ padding: '16px', fontSize: '14px', color: '#333' }}>
354
+ {user.email}
355
+ </td>
356
+ <td style={{ padding: '16px', fontSize: '14px' }}>
357
+ {editingUserId === user.id ? (
358
+ <select
359
+ value={tempRole || ''}
360
+ onChange={(e) => setTempRole(e.target.value || null)}
361
+ style={{
362
+ padding: '6px 10px',
363
+ fontSize: '13px',
364
+ border: '1px solid #0066cc',
365
+ borderRadius: '4px',
366
+ background: 'white',
367
+ color: '#333',
368
+ }}
369
+ >
370
+ <option value="">— Remove role —</option>
371
+ <option value="SiteAdmin">SiteAdmin</option>
372
+ <option value="ClientAdmin">ClientAdmin</option>
373
+ </select>
374
+ ) : user.role ? (
375
+ <span
376
+ style={{
377
+ background: '#e3f2fd',
378
+ color: '#0066cc',
379
+ padding: '6px 10px',
380
+ borderRadius: '4px',
381
+ fontSize: '13px',
382
+ display: 'inline-block',
383
+ }}
384
+ >
385
+ {user.role}
386
+ </span>
387
+ ) : (
388
+ <span style={{ color: '#999', fontStyle: 'italic' }}>(none)</span>
389
+ )}
390
+ </td>
391
+ <td style={{ padding: '16px', fontSize: '12px', color: '#999' }}>
392
+ {user.assigned || '—'}
393
+ </td>
394
+ <td style={{ padding: '16px', fontSize: '13px' }}>
395
+ {editingUserId === user.id ? (
396
+ <div style={{ display: 'flex', gap: '8px' }}>
397
+ <button
398
+ onClick={() => handleSaveRole(user.id)}
399
+ style={{
400
+ padding: '6px 12px',
401
+ background: '#0066cc',
402
+ color: 'white',
403
+ border: 'none',
404
+ borderRadius: '4px',
405
+ cursor: 'pointer',
406
+ fontSize: '12px',
407
+ }}
408
+ onMouseEnter={(e) => (e.currentTarget.style.background = '#0052a3')}
409
+ onMouseLeave={(e) => (e.currentTarget.style.background = '#0066cc')}
410
+ >
411
+ Save
412
+ </button>
413
+ <button
414
+ onClick={() => setEditingUserId(null)}
415
+ style={{
416
+ padding: '6px 12px',
417
+ background: 'white',
418
+ color: '#333',
419
+ border: '1px solid #e0e0e0',
420
+ borderRadius: '4px',
421
+ cursor: 'pointer',
422
+ fontSize: '12px',
423
+ }}
424
+ onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
425
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'white')}
426
+ >
427
+ Cancel
428
+ </button>
429
+ </div>
430
+ ) : (
431
+ <div style={{ display: 'flex', gap: '8px' }}>
432
+ {user.role && (
433
+ <button
434
+ onClick={() => handleEditRole(user.id, user.role)}
435
+ style={{
436
+ padding: '6px 10px',
437
+ background: 'white',
438
+ color: '#0066cc',
439
+ border: '1px solid #e0e0e0',
440
+ borderRadius: '4px',
441
+ cursor: 'pointer',
442
+ fontSize: '12px',
443
+ }}
444
+ onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
445
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'white')}
446
+ >
447
+
448
+ </button>
308
449
  )}
309
- </td>
310
- </tr>
311
- ))}
312
- </tbody>
313
- </table>
314
- </div>
315
- <div className={`px-4 py-3 border-t ${borderColor} ${textMuted} text-sm flex items-center gap-4`}>
316
- <span className="flex items-center gap-2">
317
- <svg className="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
318
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
319
- </svg>
320
- Access granted
321
- </span>
322
- <span className="flex items-center gap-2">
323
- <svg className={`w-4 h-4 ${textMuted}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
324
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
325
- </svg>
326
- No access
327
- </span>
328
- </div>
450
+ {!user.role ? (
451
+ <button
452
+ onClick={() => handleEditRole(user.id, null)}
453
+ style={{
454
+ padding: '6px 10px',
455
+ background: 'white',
456
+ color: '#0066cc',
457
+ border: '1px solid #e0e0e0',
458
+ borderRadius: '4px',
459
+ cursor: 'pointer',
460
+ fontSize: '12px',
461
+ }}
462
+ onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
463
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'white')}
464
+ >
465
+ +
466
+ </button>
467
+ ) : (
468
+ <button
469
+ onClick={() => handleRemoveRole(user.id)}
470
+ style={{
471
+ padding: '6px 10px',
472
+ background: 'white',
473
+ color: '#cc0000',
474
+ border: '1px solid #e0e0e0',
475
+ borderRadius: '4px',
476
+ cursor: 'pointer',
477
+ fontSize: '12px',
478
+ }}
479
+ onMouseEnter={(e) => (e.currentTarget.style.background = '#fff0f0')}
480
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'white')}
481
+ >
482
+
483
+ </button>
484
+ )}
485
+ </div>
486
+ )}
487
+ </td>
488
+ </tr>
489
+ ))}
490
+ </tbody>
491
+ </table>
492
+ <div style={{ marginTop: '12px', fontSize: '12px', color: '#999' }}>
493
+ {filteredUsers.length} of {users.length} users shown
329
494
  </div>
330
- )}
495
+ </section>
331
496
 
332
- {viewMode === 'matrix' && !matrixData && (
333
- <div className={`${cardBg} border ${borderColor} rounded-lg p-8 text-center ${textMuted}`}>
334
- Unable to load permissions matrix
335
- </div>
336
- )}
497
+ <div style={{ height: '1px', background: '#e0e0e0', margin: '24px 0' }} />
337
498
 
338
- {/* Info Banner */}
339
- <div className={`mt-6 p-4 rounded-lg ${isDark ? 'bg-blue-900/20 border border-blue-700' : 'bg-blue-50 border border-blue-200'}`}>
340
- <div className="flex items-start gap-3">
341
- <svg className={`w-5 h-5 mt-0.5 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
342
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
343
- </svg>
344
- <div>
345
- <p className={`font-medium ${isDark ? 'text-blue-300' : 'text-blue-800'}`}>
346
- Roles are managed by your Identity Provider
347
- </p>
348
- <p className={`text-sm mt-1 ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
349
- Role assignments are controlled through your organization&apos;s IDP. Contact your system administrator to request role changes.
350
- </p>
351
- </div>
499
+ {/* Section 3: Change History */}
500
+ <section>
501
+ <h2
502
+ style={{
503
+ fontSize: '18px',
504
+ fontWeight: 400,
505
+ color: '#666',
506
+ marginBottom: '24px',
507
+ textTransform: 'uppercase',
508
+ letterSpacing: '1px',
509
+ }}
510
+ >
511
+ Recent Changes
512
+ </h2>
513
+ <div style={{ background: 'white', border: '1px solid #e0e0e0', borderRadius: '4px' }}>
514
+ {MOCK_CHANGES.map((change, idx) => (
515
+ <div
516
+ key={idx}
517
+ style={{
518
+ padding: '16px',
519
+ borderBottom: idx < MOCK_CHANGES.length - 1 ? '1px solid #e0e0e0' : 'none',
520
+ }}
521
+ >
522
+ <div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>
523
+ {change.timestamp}
524
+ </div>
525
+ <div style={{ fontSize: '14px', color: '#333' }}>{change.event}</div>
526
+ </div>
527
+ ))}
352
528
  </div>
353
- </div>
529
+ </section>
354
530
  </div>
355
531
  </div>
356
532
  );