larxius commited on
Commit
f229803
·
verified ·
1 Parent(s): d719482

Update frontend/src/pages/AdminPage.jsx

Browse files
Files changed (1) hide show
  1. frontend/src/pages/AdminPage.jsx +570 -525
frontend/src/pages/AdminPage.jsx CHANGED
@@ -1,525 +1,570 @@
1
- import React, { useState, useEffect, useCallback } from 'react';
2
- import { useAuth } from '../components/AuthContext';
3
- import { CustomModal } from '../components/CustomModal';
4
-
5
- const AdminPageContent = () => {
6
- const { token } = useAuth();
7
- const [users, setUsers] = useState([]);
8
- const [organizations, setOrganizations] = useState([]);
9
- const [scanAccess, setScanAccess] = useState([]);
10
- const [loading, setLoading] = useState(true);
11
- const [error, setError] = useState('');
12
- const [message, setMessage] = useState('');
13
-
14
- const [sortUserCol, setSortUserCol] = useState('Email');
15
- const [sortUserDir, setSortUserDir] = useState('asc');
16
-
17
- const [sortOrgCol, setSortOrgCol] = useState('Created');
18
- const [sortOrgDir, setSortOrgDir] = useState('desc');
19
-
20
- const [promptModal, setPromptModal] = useState({ isOpen: false, title: '', desc: '', inputs: [], onConfirm: null });
21
- const [promptValues, setPromptValues] = useState({});
22
-
23
- const closePrompt = () => {
24
- setPromptModal({ ...promptModal, isOpen: false });
25
- setPromptValues({});
26
- };
27
-
28
- const handlePromptChange = (key, val) => {
29
- setPromptValues(prev => ({ ...prev, [key]: val }));
30
- };
31
-
32
- const fetchUsers = useCallback(async () => {
33
- try {
34
- const res = await fetch('/api/auth/users', {
35
- headers: { 'Authorization': `Bearer ${token}` },
36
- });
37
- if (res.ok) {
38
- const data = await res.json();
39
- setUsers(data.users);
40
- } else {
41
- setError('Failed to load users. Admin privileges required.');
42
- }
43
- } catch {
44
- setError('Could not connect to API.');
45
- }
46
- }, [token]);
47
-
48
- const fetchOrganizations = useCallback(async () => {
49
- try {
50
- const res = await fetch('/api/auth/organizations', {
51
- headers: { 'Authorization': `Bearer ${token}` },
52
- });
53
- if (res.ok) {
54
- const data = await res.json();
55
- setOrganizations(data.organizations || []);
56
- }
57
- } catch {
58
- console.error('Could not load organizations');
59
- }
60
- }, [token]);
61
-
62
- const fetchScanAccess = useCallback(async () => {
63
- try {
64
- const res = await fetch('/api/admin/scan-access', {
65
- headers: { 'Authorization': `Bearer ${token}` },
66
- });
67
- if (res.ok) {
68
- const data = await res.json();
69
- setScanAccess(data.controls || []);
70
- }
71
- } catch {
72
- console.error('Could not load scan access config');
73
- }
74
- }, [token]);
75
-
76
- const updateScanAccess = async (scanType, requiredTier, isEnabled) => {
77
- setMessage('');
78
- setError('');
79
- try {
80
- const res = await fetch(`/api/admin/scan-access/${scanType}`, {
81
- method: 'PUT',
82
- headers: {
83
- 'Content-Type': 'application/json',
84
- 'Authorization': `Bearer ${token}`,
85
- },
86
- body: JSON.stringify({ required_tier: requiredTier, is_enabled: isEnabled }),
87
- });
88
- if (res.ok) {
89
- setMessage(`${scanType} access updated successfully.`);
90
- fetchScanAccess();
91
- } else {
92
- const data = await res.json();
93
- setError(data.message || 'Failed to update access control.');
94
- }
95
- } catch {
96
- setError('Could not connect to API.');
97
- }
98
- };
99
-
100
- useEffect(() => {
101
- const fetchAll = () => {
102
- Promise.all([fetchUsers(), fetchScanAccess(), fetchOrganizations()]).finally(() => setLoading(false));
103
- };
104
- fetchAll();
105
- const interval = setInterval(fetchAll, 5000);
106
- return () => clearInterval(interval);
107
- }, [fetchUsers, fetchScanAccess, fetchOrganizations]);
108
-
109
- const handleRoleChange = async (userId, newRole) => {
110
- setMessage('');
111
- setError('');
112
- try {
113
- const res = await fetch(`/api/auth/users/${userId}/role`, {
114
- method: 'PUT',
115
- headers: {
116
- 'Content-Type': 'application/json',
117
- 'Authorization': `Bearer ${token}`,
118
- },
119
- body: JSON.stringify({ role: newRole }),
120
- });
121
- if (res.ok) {
122
- setMessage(`User role updated to ${newRole}.`);
123
- fetchUsers();
124
- } else {
125
- const data = await res.json();
126
- setError(data.message || 'Failed to update role.');
127
- }
128
- } catch {
129
- setError('Could not connect to API.');
130
- }
131
- };
132
-
133
- const handleUnlock = async (userId) => {
134
- setMessage('');
135
- setError('');
136
- try {
137
- const res = await fetch(`/api/auth/users/${userId}/unlock`, {
138
- method: 'POST',
139
- headers: { 'Authorization': `Bearer ${token}` },
140
- });
141
- if (res.ok) {
142
- setMessage('User account unlocked.');
143
- fetchUsers();
144
- } else {
145
- const data = await res.json();
146
- setError(data.message || 'Failed to unlock user.');
147
- }
148
- } catch {
149
- setError('Could not connect to API.');
150
- }
151
- };
152
-
153
- const handleAssignScans = (org) => {
154
- setPromptValues({ scan_type: 'Deep', count: '1' });
155
- setPromptModal({
156
- isOpen: true,
157
- title: 'Assign Custom Scans',
158
- desc: `Grant specific scan limits for ${org.name}`,
159
- inputs: [
160
- {
161
- key: 'scan_type',
162
- label: 'Scan Type',
163
- type: 'select',
164
- options: ['Quick', 'Advanced', 'Deep']
165
- },
166
- { key: 'count', label: 'Number of Scans', placeholder: 'e.g., 5' }
167
- ],
168
- onConfirm: async (values) => {
169
- try {
170
- const res = await fetch(`/api/auth/organizations/${org.id}/quotas`, {
171
- method: 'POST',
172
- headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
173
- body: JSON.stringify({ scan_type: values.scan_type, count: parseInt(values.count) })
174
- });
175
- if (res.ok) {
176
- setMessage(`${values.count} ${values.scan_type} scans assigned to ${org.name}.`);
177
- } else {
178
- const data = await res.json();
179
- setError(data.message || 'Failed to assign scans.');
180
- }
181
- } catch {
182
- setError('Could not connect to API for assigning scans.');
183
- }
184
- closePrompt();
185
- }
186
- });
187
- };
188
-
189
- const handleImpersonate = async (orgId, orgName) => {
190
- try {
191
- const res = await fetch(`/api/auth/impersonate/${orgId}`, {
192
- method: 'POST',
193
- headers: { 'Authorization': `Bearer ${token}` }
194
- });
195
- if (res.ok) {
196
- const data = await res.json();
197
- localStorage.setItem('original_admin_token', token);
198
- localStorage.setItem('wss_token', data.access_token);
199
- window.location.href = '/dashboard';
200
- } else {
201
- const data = await res.json();
202
- setError(data.message || 'Failed to impersonate organization.');
203
- }
204
- } catch {
205
- setError('Could not connect to API for impersonation.');
206
- }
207
- };
208
-
209
- if (loading) {
210
- return (
211
- <div className="flex items-center justify-center py-2xl font-label-md text-label-md text-on-surface-variant">
212
- <span className="material-symbols-outlined animate-spin mr-sm">sync</span>
213
- Loading users...
214
- </div>
215
- );
216
- }
217
-
218
- const handleUserSort = (column) => {
219
- if (column === 'Actions' || column === 'Role') return;
220
- if (sortUserCol === column) {
221
- setSortUserDir(sortUserDir === 'asc' ? 'desc' : 'asc');
222
- } else {
223
- setSortUserCol(column);
224
- setSortUserDir('asc');
225
- }
226
- };
227
-
228
- const handleOrgSort = (column) => {
229
- if (column === 'Actions') return;
230
- if (sortOrgCol === column) {
231
- setSortOrgDir(sortOrgDir === 'asc' ? 'desc' : 'asc');
232
- } else {
233
- setSortOrgCol(column);
234
- setSortOrgDir('asc');
235
- }
236
- };
237
-
238
- const getSortedUsers = () => {
239
- return [...users].sort((a, b) => {
240
- let aVal, bVal;
241
- switch (sortUserCol) {
242
- case 'Email': aVal = a.email || ''; bVal = b.email || ''; break;
243
- case 'Status': aVal = a.locked_until ? 1 : 0; bVal = b.locked_until ? 1 : 0; break;
244
- default: return 0;
245
- }
246
- if (aVal < bVal) return sortUserDir === 'asc' ? -1 : 1;
247
- if (aVal > bVal) return sortUserDir === 'asc' ? 1 : -1;
248
- return 0;
249
- });
250
- };
251
-
252
- const getSortedOrgs = () => {
253
- return [...organizations].sort((a, b) => {
254
- let aVal, bVal;
255
- switch (sortOrgCol) {
256
- case 'Tenant Name': aVal = a.name || ''; bVal = b.name || ''; break;
257
- case 'Tier': aVal = a.subscription_tier || ''; bVal = b.subscription_tier || ''; break;
258
- case 'Created': aVal = new Date(a.created_at || 0).getTime(); bVal = new Date(b.created_at || 0).getTime(); break;
259
- default: return 0;
260
- }
261
- if (aVal < bVal) return sortOrgDir === 'asc' ? -1 : 1;
262
- if (aVal > bVal) return sortOrgDir === 'asc' ? 1 : -1;
263
- return 0;
264
- });
265
- };
266
-
267
- return (
268
- <div className="flex flex-col gap-gutter">
269
- <div className="border-b border-outline-variant bg-surface-container-lowest p-lg rounded-xl shadow-sm">
270
- <h1 className="font-display-lg text-display-lg text-on-surface mb-sm font-bold tracking-tight">Admin Panel</h1>
271
- <p className="font-body-lg text-body-lg text-on-surface-variant">Manage users, roles, and account access.</p>
272
- </div>
273
-
274
- {message && (
275
- <div className="flex gap-sm bg-green-500/10 border border-green-500/30 rounded-lg p-md text-green-600 font-body-sm text-body-sm items-center">
276
- <span className="material-symbols-outlined shrink-0 text-green-500">check_circle</span>
277
- <div>{message}</div>
278
- </div>
279
- )}
280
-
281
- {error && (
282
- <div className="flex gap-sm bg-error-container/20 border border-error/30 rounded-lg p-md text-error font-body-sm text-body-sm items-center">
283
- <span className="material-symbols-outlined shrink-0">error</span>
284
- <div>{error}</div>
285
- </div>
286
- )}
287
-
288
- <div className="bg-surface-container-lowest border border-outline-variant rounded-lg shadow-sm overflow-hidden">
289
- <table className="w-full">
290
- <thead>
291
- <tr className="border-b border-outline-variant bg-surface-container-high select-none">
292
- {['Email', 'Role', 'Status', 'Actions'].map((h, i) => (
293
- <th
294
- key={h}
295
- onClick={() => handleUserSort(h)}
296
- className={`text-left px-lg py-md font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider ${i === 3 ? 'text-right' : ''} ${(h !== 'Actions' && h !== 'Role') ? 'cursor-pointer hover:bg-surface-container-highest transition-colors group' : ''}`}
297
- >
298
- <div className={`flex items-center gap-xs ${i === 3 ? 'justify-end' : ''}`}>
299
- {h}
300
- {(h !== 'Actions' && h !== 'Role') && (
301
- <span className={`material-symbols-outlined text-[16px] opacity-0 group-hover:opacity-50 transition-opacity ${sortUserCol === h ? 'opacity-100 group-hover:opacity-100 text-primary' : ''}`}>
302
- {sortUserCol === h && sortUserDir === 'desc' ? 'arrow_downward' : 'arrow_upward'}
303
- </span>
304
- )}
305
- </div>
306
- </th>
307
- ))}
308
- </tr>
309
- </thead>
310
- <tbody>
311
- {getSortedUsers().map((u) => (
312
- <tr key={u.id} className="border-b border-outline-variant/60 last:border-0 hover:bg-surface-container-high/50 transition-colors">
313
- <td className="px-lg py-md font-body-md text-on-surface">{u.email}</td>
314
- <td className="px-lg py-md">
315
- <select
316
- value={u.role || 'read_only'}
317
- onChange={(e) => handleRoleChange(u.id, e.target.value)}
318
- className="bg-surface-container border border-outline-variant rounded px-sm py-xs font-body-sm text-on-surface cursor-pointer"
319
- >
320
- <option value="super_admin">Super Admin</option>
321
- <option value="admin">Admin</option>
322
- <option value="support_engineer">Support Engineer</option>
323
- <option value="org_admin">Organization</option>
324
- <option value="soc_analyst">SOC Analyst</option>
325
- <option value="executive">Executive</option>
326
- <option value="read_only">Read Only</option>
327
- </select>
328
- </td>
329
- <td className="px-lg py-md">
330
- {u.locked_until ? (
331
- <span className="inline-flex items-center gap-xs bg-error-container/20 text-error px-sm py-xs rounded font-label-sm text-label-sm">
332
- <span className="material-symbols-outlined text-[16px]">lock</span>
333
- Locked
334
- </span>
335
- ) : (
336
- <span className="inline-flex items-center gap-xs bg-green-500/10 text-green-600 px-sm py-xs rounded font-label-sm text-label-sm">
337
- <span className="material-symbols-outlined text-[16px]">check_circle</span>
338
- Active
339
- </span>
340
- )}
341
- </td>
342
- <td className="px-lg py-md text-right">
343
- {u.locked_until && (
344
- <button
345
- onClick={() => handleUnlock(u.id)}
346
- className="bg-primary text-on-primary px-md py-xs rounded font-label-sm text-label-sm hover:opacity-90 transition-opacity border-0 cursor-pointer"
347
- >
348
- Unlock
349
- </button>
350
- )}
351
- </td>
352
- </tr>
353
- ))}
354
- </tbody>
355
- </table>
356
- </div>
357
- <div className="mt-xl border-b border-outline-variant bg-surface-container-lowest p-lg rounded-xl shadow-sm">
358
- <h2 className="font-headline-md text-headline-md text-on-surface mb-sm font-bold tracking-tight">Organizations</h2>
359
- <p className="font-body-md text-body-md text-on-surface-variant mb-lg">
360
- View all tenants and use Impersonation to see their Dashboard, Analytics, and Vulnerabilities.
361
- </p>
362
-
363
- <div className="bg-surface-container-lowest border border-outline-variant rounded-lg shadow-sm overflow-hidden">
364
- <table className="w-full">
365
- <thead>
366
- <tr className="border-b border-outline-variant bg-surface-container-high select-none">
367
- {['Tenant Name', 'Tier', 'Created', 'Actions'].map((h, i) => (
368
- <th
369
- key={h}
370
- onClick={() => handleOrgSort(h)}
371
- className={`text-left px-lg py-md font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider ${i === 3 ? 'text-right' : ''} ${h !== 'Actions' ? 'cursor-pointer hover:bg-surface-container-highest transition-colors group' : ''}`}
372
- >
373
- <div className={`flex items-center gap-xs ${i === 3 ? 'justify-end' : ''}`}>
374
- {h}
375
- {h !== 'Actions' && (
376
- <span className={`material-symbols-outlined text-[16px] opacity-0 group-hover:opacity-50 transition-opacity ${sortOrgCol === h ? 'opacity-100 group-hover:opacity-100 text-primary' : ''}`}>
377
- {sortOrgCol === h && sortOrgDir === 'desc' ? 'arrow_downward' : 'arrow_upward'}
378
- </span>
379
- )}
380
- </div>
381
- </th>
382
- ))}
383
- </tr>
384
- </thead>
385
- <tbody>
386
- {getSortedOrgs().map((org) => (
387
- <tr key={org.id} className="border-b border-outline-variant/60 last:border-0 hover:bg-surface-container-high/50 transition-colors">
388
- <td className="px-lg py-md font-label-md font-bold text-on-surface">{org.name}</td>
389
- <td className="px-lg py-md font-body-sm capitalize">{org.subscription_tier || 'Free'}</td>
390
- <td className="px-lg py-md font-body-sm text-on-surface-variant">
391
- {org.created_at ? new Date(org.created_at).toLocaleDateString() : 'N/A'}
392
- </td>
393
- <td className="px-lg py-md text-right">
394
- <button
395
- onClick={() => handleAssignScans(org)}
396
- className="text-on-surface-variant hover:text-primary transition-colors bg-transparent border-0 cursor-pointer p-1 inline-flex items-center gap-xs mr-2"
397
- title="Assign Custom Scans"
398
- >
399
- <span className="material-symbols-outlined text-[18px]">add_box</span>
400
- <span className="font-label-sm">Assign Scans</span>
401
- </button>
402
- <button
403
- onClick={() => handleImpersonate(org.id, org.name)}
404
- className="text-on-surface-variant hover:text-primary transition-colors bg-transparent border-0 cursor-pointer p-1 inline-flex items-center gap-xs"
405
- title="View Dashboard Data"
406
- >
407
- <span className="material-symbols-outlined text-[18px]">vpn_key</span>
408
- <span className="font-label-sm">Impersonate</span>
409
- </button>
410
- </td>
411
- </tr>
412
- ))}
413
- {organizations.length === 0 && (
414
- <tr>
415
- <td colSpan="4" className="px-lg py-xl text-center text-on-surface-variant font-body-md">
416
- No organizations found.
417
- </td>
418
- </tr>
419
- )}
420
- </tbody>
421
- </table>
422
- </div>
423
- </div>
424
-
425
- <div className="mt-xl border-b border-outline-variant bg-surface-container-lowest p-lg rounded-xl shadow-sm">
426
- <h2 className="font-headline-md text-headline-md text-on-surface mb-sm font-bold tracking-tight">Scanner Modes & Access</h2>
427
- <p className="font-body-md text-body-md text-on-surface-variant mb-lg">
428
- Configure which subscription plans grant access to specific scan modes. You can also completely enable or disable scan modes globally.
429
- </p>
430
-
431
- <div className="flex flex-col gap-md">
432
- {(scanAccess || []).map((mode) => (
433
- <div key={mode.scan_type} className="bg-surface-container-low border border-outline-variant rounded-lg p-md flex items-center justify-between">
434
- <div className="flex flex-col">
435
- <span className="font-label-md text-label-md text-on-surface font-bold">{mode.scan_type} Scan</span>
436
- <span className="font-body-sm text-body-sm text-on-surface-variant">Global Access: {mode.is_enabled ? 'Enabled' : 'Disabled'}</span>
437
- </div>
438
-
439
- <div className="flex items-center gap-lg">
440
- <div className="flex flex-col gap-xs">
441
- <label className="font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider font-semibold">
442
- Minimum Plan Required
443
- </label>
444
- <select
445
- value={mode.required_tier}
446
- onChange={(e) => updateScanAccess(mode.scan_type, e.target.value, mode.is_enabled)}
447
- className="bg-surface-container border border-outline-variant rounded px-sm py-xs font-body-sm text-on-surface cursor-pointer focus:outline-none focus:border-primary"
448
- >
449
- <option value="free">Free</option>
450
- <option value="pro">Pro</option>
451
- <option value="enterprise">Enterprise</option>
452
- </select>
453
- </div>
454
-
455
- <div className="flex flex-col gap-xs">
456
- <label className="font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider font-semibold">
457
- Enable Scan Mode
458
- </label>
459
- <label className="flex items-center cursor-pointer">
460
- <div className="relative">
461
- <input
462
- type="checkbox"
463
- className="sr-only"
464
- checked={mode.is_enabled}
465
- onChange={(e) => updateScanAccess(mode.scan_type, mode.required_tier, e.target.checked)}
466
- />
467
- <div className={`block w-10 h-6 rounded-full transition-colors ${mode.is_enabled ? 'bg-primary' : 'bg-surface-container-highest'}`}></div>
468
- <div className={`dot absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${mode.is_enabled ? 'transform translate-x-4' : ''}`}></div>
469
- </div>
470
- </label>
471
- </div>
472
- </div>
473
- </div>
474
- ))}
475
- </div>
476
- </div>
477
-
478
- <CustomModal
479
- isOpen={promptModal.isOpen}
480
- onClose={closePrompt}
481
- title={promptModal.title}
482
- description={promptModal.desc}
483
- footer={
484
- <>
485
- <button onClick={closePrompt} className="px-4 py-2 text-on-surface-variant hover:bg-surface-container rounded-lg font-bold border-0 bg-transparent cursor-pointer">Cancel</button>
486
- <button onClick={() => promptModal.onConfirm(promptValues)} className="px-4 py-2 bg-primary text-on-primary rounded-lg font-bold border-0 cursor-pointer">Confirm</button>
487
- </>
488
- }
489
- >
490
- <div className="flex flex-col gap-4">
491
- {promptModal.inputs.map(input => (
492
- <div key={input.key} className="flex flex-col">
493
- <label className="text-[12px] font-bold text-on-surface-variant mb-1">{input.label}</label>
494
- {input.type === 'select' ? (
495
- <select
496
- value={promptValues[input.key] || ''}
497
- onChange={(e) => handlePromptChange(input.key, e.target.value)}
498
- className="bg-surface-container border border-outline-variant rounded-lg px-3 py-2 focus:border-primary outline-none text-on-surface"
499
- >
500
- {input.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
501
- </select>
502
- ) : (
503
- <input
504
- type="text"
505
- value={promptValues[input.key] || ''}
506
- onChange={(e) => handlePromptChange(input.key, e.target.value)}
507
- placeholder={input.placeholder}
508
- className="bg-surface-container border border-outline-variant rounded-lg px-3 py-2 focus:border-primary outline-none text-on-surface"
509
- />
510
- )}
511
- </div>
512
- ))}
513
- </div>
514
- </CustomModal>
515
- </div>
516
- );
517
- };
518
-
519
- import { ErrorBoundary } from '../components/ErrorBoundary';
520
-
521
- export const AdminPage = () => (
522
- <ErrorBoundary>
523
- <AdminPageContent />
524
- </ErrorBoundary>
525
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { useAuth } from '../components/AuthContext';
3
+ import { CustomModal } from '../components/CustomModal';
4
+
5
+ const AdminPageContent = () => {
6
+ const { token } = useAuth();
7
+ const [users, setUsers] = useState([]);
8
+ const [organizations, setOrganizations] = useState([]);
9
+ const [scanAccess, setScanAccess] = useState([]);
10
+ const [loading, setLoading] = useState(true);
11
+ const [error, setError] = useState('');
12
+ const [message, setMessage] = useState('');
13
+
14
+ const [sortUserCol, setSortUserCol] = useState('Email');
15
+ const [sortUserDir, setSortUserDir] = useState('asc');
16
+
17
+ const [sortOrgCol, setSortOrgCol] = useState('Created');
18
+ const [sortOrgDir, setSortOrgDir] = useState('desc');
19
+
20
+ const [promptModal, setPromptModal] = useState({ isOpen: false, title: '', desc: '', inputs: [], onConfirm: null });
21
+ const [promptValues, setPromptValues] = useState({});
22
+
23
+ const closePrompt = () => {
24
+ setPromptModal({ ...promptModal, isOpen: false });
25
+ setPromptValues({});
26
+ };
27
+
28
+ const handlePromptChange = (key, val) => {
29
+ setPromptValues(prev => ({ ...prev, [key]: val }));
30
+ };
31
+
32
+ const fetchUsers = useCallback(async () => {
33
+ try {
34
+ const res = await fetch('/api/auth/users', {
35
+ headers: { 'Authorization': `Bearer ${token}` },
36
+ });
37
+ if (res.ok) {
38
+ const data = await res.json();
39
+ setUsers(data.users);
40
+ } else {
41
+ setError('Failed to load users. Admin privileges required.');
42
+ }
43
+ } catch {
44
+ setError('Could not connect to API.');
45
+ }
46
+ }, [token]);
47
+
48
+ const fetchOrganizations = useCallback(async () => {
49
+ try {
50
+ const res = await fetch('/api/auth/organizations', {
51
+ headers: { 'Authorization': `Bearer ${token}` },
52
+ });
53
+ if (res.ok) {
54
+ const data = await res.json();
55
+ setOrganizations(data.organizations || []);
56
+ }
57
+ } catch {
58
+ console.error('Could not load organizations');
59
+ }
60
+ }, [token]);
61
+
62
+ const fetchScanAccess = useCallback(async () => {
63
+ try {
64
+ const res = await fetch('/api/admin/scan-access', {
65
+ headers: { 'Authorization': `Bearer ${token}` },
66
+ });
67
+ if (res.ok) {
68
+ const data = await res.json();
69
+ setScanAccess(data.controls || []);
70
+ }
71
+ } catch {
72
+ console.error('Could not load scan access config');
73
+ }
74
+ }, [token]);
75
+
76
+ const updateScanAccess = async (scanType, requiredTier, isEnabled) => {
77
+ setMessage('');
78
+ setError('');
79
+ try {
80
+ const res = await fetch(`/api/admin/scan-access/${scanType}`, {
81
+ method: 'PUT',
82
+ headers: {
83
+ 'Content-Type': 'application/json',
84
+ 'Authorization': `Bearer ${token}`,
85
+ },
86
+ body: JSON.stringify({ required_tier: requiredTier, is_enabled: isEnabled }),
87
+ });
88
+ if (res.ok) {
89
+ setMessage(`${scanType} access updated successfully.`);
90
+ fetchScanAccess();
91
+ } else {
92
+ const data = await res.json();
93
+ setError(data.message || 'Failed to update access control.');
94
+ }
95
+ } catch {
96
+ setError('Could not connect to API.');
97
+ }
98
+ };
99
+
100
+ useEffect(() => {
101
+ const fetchAll = () => {
102
+ Promise.all([fetchUsers(), fetchScanAccess(), fetchOrganizations()]).finally(() => setLoading(false));
103
+ };
104
+ fetchAll();
105
+ const interval = setInterval(fetchAll, 5000);
106
+ return () => clearInterval(interval);
107
+ }, [fetchUsers, fetchScanAccess, fetchOrganizations]);
108
+
109
+ const handleRoleChange = async (userId, newRole) => {
110
+ setMessage('');
111
+ setError('');
112
+ try {
113
+ const res = await fetch(`/api/auth/users/${userId}/role`, {
114
+ method: 'PUT',
115
+ headers: {
116
+ 'Content-Type': 'application/json',
117
+ 'Authorization': `Bearer ${token}`,
118
+ },
119
+ body: JSON.stringify({ role: newRole }),
120
+ });
121
+ if (res.ok) {
122
+ setMessage(`User role updated to ${newRole}.`);
123
+ fetchUsers();
124
+ } else {
125
+ const data = await res.json();
126
+ setError(data.message || 'Failed to update role.');
127
+ }
128
+ } catch {
129
+ setError('Could not connect to API.');
130
+ }
131
+ };
132
+
133
+ const handleUnlock = async (userId) => {
134
+ setMessage('');
135
+ setError('');
136
+ try {
137
+ const res = await fetch(`/api/auth/users/${userId}/unlock`, {
138
+ method: 'POST',
139
+ headers: { 'Authorization': `Bearer ${token}` },
140
+ });
141
+ if (res.ok) {
142
+ setMessage('User account unlocked.');
143
+ fetchUsers();
144
+ } else {
145
+ const data = await res.json();
146
+ setError(data.message || 'Failed to unlock user.');
147
+ }
148
+ } catch {
149
+ setError('Could not connect to API.');
150
+ }
151
+ };
152
+
153
+ const handleAssignScans = (org) => {
154
+ setPromptValues({ scan_type: 'Deep', count: '1' });
155
+ setPromptModal({
156
+ isOpen: true,
157
+ title: 'Assign Custom Scans',
158
+ desc: `Grant specific scan limits for ${org.name}`,
159
+ inputs: [
160
+ {
161
+ key: 'scan_type',
162
+ label: 'Scan Type',
163
+ type: 'select',
164
+ options: ['Quick', 'Advanced', 'Deep']
165
+ },
166
+ { key: 'count', label: 'Number of Scans', placeholder: 'e.g., 5' }
167
+ ],
168
+ onConfirm: async (values) => {
169
+ const addedCount = parseInt(values.count);
170
+ if (isNaN(addedCount) || addedCount <= 0) {
171
+ setError('Please enter a valid scan count.');
172
+ closePrompt();
173
+ return;
174
+ }
175
+ try {
176
+ const res = await fetch(`/api/auth/organizations/${org.id}/quotas`, {
177
+ method: 'POST',
178
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
179
+ body: JSON.stringify({ scan_type: values.scan_type, count: addedCount })
180
+ });
181
+ if (res.ok) {
182
+ setMessage(`${addedCount} ${values.scan_type} scan(s) assigned to ${org.name}.`);
183
+ // Optimistic instant UI state update
184
+ setOrganizations(prevOrgs => prevOrgs.map(o => {
185
+ if (o.id === org.id) {
186
+ const existingQuotas = o.quotas || [];
187
+ let found = false;
188
+ const updatedQuotas = existingQuotas.map(q => {
189
+ if (q.scan_type?.toLowerCase() === values.scan_type?.toLowerCase()) {
190
+ found = true;
191
+ return {
192
+ ...q,
193
+ allocated_count: q.allocated_count === -1 ? -1 : (q.allocated_count || 0) + addedCount
194
+ };
195
+ }
196
+ return q;
197
+ });
198
+ if (!found) {
199
+ updatedQuotas.push({ scan_type: values.scan_type, allocated_count: addedCount, used_count: 0 });
200
+ }
201
+ return { ...o, quotas: updatedQuotas };
202
+ }
203
+ return o;
204
+ }));
205
+ fetchOrganizations();
206
+ } else {
207
+ const data = await res.json();
208
+ setError(data.message || 'Failed to assign scans.');
209
+ }
210
+ } catch {
211
+ setError('Could not connect to API for assigning scans.');
212
+ }
213
+ closePrompt();
214
+ }
215
+ });
216
+ };
217
+
218
+ const handleImpersonate = async (orgId, orgName) => {
219
+ try {
220
+ const res = await fetch(`/api/auth/impersonate/${orgId}`, {
221
+ method: 'POST',
222
+ headers: { 'Authorization': `Bearer ${token}` }
223
+ });
224
+ if (res.ok) {
225
+ const data = await res.json();
226
+ localStorage.setItem('original_admin_token', token);
227
+ localStorage.setItem('wss_token', data.access_token);
228
+ window.location.href = '/dashboard';
229
+ } else {
230
+ const data = await res.json();
231
+ setError(data.message || 'Failed to impersonate organization.');
232
+ }
233
+ } catch {
234
+ setError('Could not connect to API for impersonation.');
235
+ }
236
+ };
237
+
238
+ if (loading) {
239
+ return (
240
+ <div className="flex items-center justify-center py-2xl font-label-md text-label-md text-on-surface-variant">
241
+ <span className="material-symbols-outlined animate-spin mr-sm">sync</span>
242
+ Loading users...
243
+ </div>
244
+ );
245
+ }
246
+
247
+ const handleUserSort = (column) => {
248
+ if (column === 'Actions' || column === 'Role') return;
249
+ if (sortUserCol === column) {
250
+ setSortUserDir(sortUserDir === 'asc' ? 'desc' : 'asc');
251
+ } else {
252
+ setSortUserCol(column);
253
+ setSortUserDir('asc');
254
+ }
255
+ };
256
+
257
+ const handleOrgSort = (column) => {
258
+ if (column === 'Actions') return;
259
+ if (sortOrgCol === column) {
260
+ setSortOrgDir(sortOrgDir === 'asc' ? 'desc' : 'asc');
261
+ } else {
262
+ setSortOrgCol(column);
263
+ setSortOrgDir('asc');
264
+ }
265
+ };
266
+
267
+ const getSortedUsers = () => {
268
+ return [...users].sort((a, b) => {
269
+ let aVal, bVal;
270
+ switch (sortUserCol) {
271
+ case 'Email': aVal = a.email || ''; bVal = b.email || ''; break;
272
+ case 'Status': aVal = a.locked_until ? 1 : 0; bVal = b.locked_until ? 1 : 0; break;
273
+ default: return 0;
274
+ }
275
+ if (aVal < bVal) return sortUserDir === 'asc' ? -1 : 1;
276
+ if (aVal > bVal) return sortUserDir === 'asc' ? 1 : -1;
277
+ return 0;
278
+ });
279
+ };
280
+
281
+ const getSortedOrgs = () => {
282
+ return [...organizations].sort((a, b) => {
283
+ let aVal, bVal;
284
+ switch (sortOrgCol) {
285
+ case 'Tenant Name': aVal = a.name || ''; bVal = b.name || ''; break;
286
+ case 'Tier': aVal = a.subscription_tier || ''; bVal = b.subscription_tier || ''; break;
287
+ case 'Created': aVal = new Date(a.created_at || 0).getTime(); bVal = new Date(b.created_at || 0).getTime(); break;
288
+ default: return 0;
289
+ }
290
+ if (aVal < bVal) return sortOrgDir === 'asc' ? -1 : 1;
291
+ if (aVal > bVal) return sortOrgDir === 'asc' ? 1 : -1;
292
+ return 0;
293
+ });
294
+ };
295
+
296
+ return (
297
+ <div className="flex flex-col gap-gutter">
298
+ <div className="border-b border-outline-variant bg-surface-container-lowest p-lg rounded-xl shadow-sm">
299
+ <h1 className="font-display-lg text-display-lg text-on-surface mb-sm font-bold tracking-tight">Admin Panel</h1>
300
+ <p className="font-body-lg text-body-lg text-on-surface-variant">Manage users, roles, and account access.</p>
301
+ </div>
302
+
303
+ {message && (
304
+ <div className="flex gap-sm bg-green-500/10 border border-green-500/30 rounded-lg p-md text-green-600 font-body-sm text-body-sm items-center">
305
+ <span className="material-symbols-outlined shrink-0 text-green-500">check_circle</span>
306
+ <div>{message}</div>
307
+ </div>
308
+ )}
309
+
310
+ {error && (
311
+ <div className="flex gap-sm bg-error-container/20 border border-error/30 rounded-lg p-md text-error font-body-sm text-body-sm items-center">
312
+ <span className="material-symbols-outlined shrink-0">error</span>
313
+ <div>{error}</div>
314
+ </div>
315
+ )}
316
+
317
+ <div className="bg-surface-container-lowest border border-outline-variant rounded-lg shadow-sm overflow-hidden">
318
+ <table className="w-full">
319
+ <thead>
320
+ <tr className="border-b border-outline-variant bg-surface-container-high select-none">
321
+ {['Email', 'Role', 'Status', 'Actions'].map((h, i) => (
322
+ <th
323
+ key={h}
324
+ onClick={() => handleUserSort(h)}
325
+ className={`text-left px-lg py-md font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider ${i === 3 ? 'text-right' : ''} ${(h !== 'Actions' && h !== 'Role') ? 'cursor-pointer hover:bg-surface-container-highest transition-colors group' : ''}`}
326
+ >
327
+ <div className={`flex items-center gap-xs ${i === 3 ? 'justify-end' : ''}`}>
328
+ {h}
329
+ {(h !== 'Actions' && h !== 'Role') && (
330
+ <span className={`material-symbols-outlined text-[16px] opacity-0 group-hover:opacity-50 transition-opacity ${sortUserCol === h ? 'opacity-100 group-hover:opacity-100 text-primary' : ''}`}>
331
+ {sortUserCol === h && sortUserDir === 'desc' ? 'arrow_downward' : 'arrow_upward'}
332
+ </span>
333
+ )}
334
+ </div>
335
+ </th>
336
+ ))}
337
+ </tr>
338
+ </thead>
339
+ <tbody>
340
+ {getSortedUsers().map((u) => (
341
+ <tr key={u.id} className="border-b border-outline-variant/60 last:border-0 hover:bg-surface-container-high/50 transition-colors">
342
+ <td className="px-lg py-md font-body-md text-on-surface">{u.email}</td>
343
+ <td className="px-lg py-md">
344
+ <select
345
+ value={u.role || 'read_only'}
346
+ onChange={(e) => handleRoleChange(u.id, e.target.value)}
347
+ className="bg-surface-container border border-outline-variant rounded px-sm py-xs font-body-sm text-on-surface cursor-pointer"
348
+ >
349
+ <option value="super_admin">Super Admin</option>
350
+ <option value="admin">Admin</option>
351
+ <option value="support_engineer">Support Engineer</option>
352
+ <option value="org_admin">Organization</option>
353
+ <option value="soc_analyst">SOC Analyst</option>
354
+ <option value="executive">Executive</option>
355
+ <option value="read_only">Read Only</option>
356
+ </select>
357
+ </td>
358
+ <td className="px-lg py-md">
359
+ {u.locked_until ? (
360
+ <span className="inline-flex items-center gap-xs bg-error-container/20 text-error px-sm py-xs rounded font-label-sm text-label-sm">
361
+ <span className="material-symbols-outlined text-[16px]">lock</span>
362
+ Locked
363
+ </span>
364
+ ) : (
365
+ <span className="inline-flex items-center gap-xs bg-green-500/10 text-green-600 px-sm py-xs rounded font-label-sm text-label-sm">
366
+ <span className="material-symbols-outlined text-[16px]">check_circle</span>
367
+ Active
368
+ </span>
369
+ )}
370
+ </td>
371
+ <td className="px-lg py-md text-right">
372
+ {u.locked_until && (
373
+ <button
374
+ onClick={() => handleUnlock(u.id)}
375
+ className="bg-primary text-on-primary px-md py-xs rounded font-label-sm text-label-sm hover:opacity-90 transition-opacity border-0 cursor-pointer"
376
+ >
377
+ Unlock
378
+ </button>
379
+ )}
380
+ </td>
381
+ </tr>
382
+ ))}
383
+ </tbody>
384
+ </table>
385
+ </div>
386
+ <div className="mt-xl border-b border-outline-variant bg-surface-container-lowest p-lg rounded-xl shadow-sm">
387
+ <h2 className="font-headline-md text-headline-md text-on-surface mb-sm font-bold tracking-tight">Organizations</h2>
388
+ <p className="font-body-md text-body-md text-on-surface-variant mb-lg">
389
+ View all tenants and use Impersonation to see their Dashboard, Analytics, and Vulnerabilities.
390
+ </p>
391
+
392
+ <div className="bg-surface-container-lowest border border-outline-variant rounded-lg shadow-sm overflow-hidden">
393
+ <table className="w-full">
394
+ <thead>
395
+ <tr className="border-b border-outline-variant bg-surface-container-high select-none">
396
+ {['Tenant Name', 'Tier', 'Quotas', 'Created', 'Actions'].map((h, i) => (
397
+ <th
398
+ key={h}
399
+ onClick={() => handleOrgSort(h)}
400
+ className={`text-left px-lg py-md font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider ${i === 4 ? 'text-right' : ''} ${(h !== 'Actions' && h !== 'Quotas') ? 'cursor-pointer hover:bg-surface-container-highest transition-colors group' : ''}`}
401
+ >
402
+ <div className={`flex items-center gap-xs ${i === 4 ? 'justify-end' : ''}`}>
403
+ {h}
404
+ {(h !== 'Actions' && h !== 'Quotas') && (
405
+ <span className={`material-symbols-outlined text-[16px] opacity-0 group-hover:opacity-50 transition-opacity ${sortOrgCol === h ? 'opacity-100 group-hover:opacity-100 text-primary' : ''}`}>
406
+ {sortOrgCol === h && sortOrgDir === 'desc' ? 'arrow_downward' : 'arrow_upward'}
407
+ </span>
408
+ )}
409
+ </div>
410
+ </th>
411
+ ))}
412
+ </tr>
413
+ </thead>
414
+ <tbody>
415
+ {getSortedOrgs().map((org) => (
416
+ <tr key={org.id} className="border-b border-outline-variant/60 last:border-0 hover:bg-surface-container-high/50 transition-colors">
417
+ <td className="px-lg py-md font-label-md font-bold text-on-surface">{org.name}</td>
418
+ <td className="px-lg py-md font-body-sm capitalize">{org.subscription_tier || 'Free'}</td>
419
+ <td className="px-lg py-md font-body-sm">
420
+ <div className="flex flex-wrap gap-1.5 items-center">
421
+ {org.quotas?.map((q, idx) => {
422
+ const remaining = q.allocated_count === -1 ? '∞' : Math.max(0, q.allocated_count - (q.used_count || 0));
423
+ const style = q.scan_type === 'Deep' ? 'bg-orange-500/10 text-orange-600 border-orange-500/30' :
424
+ q.scan_type === 'Advanced' ? 'bg-purple-500/10 text-purple-600 border-purple-500/30' :
425
+ 'bg-blue-500/10 text-blue-600 border-blue-500/30';
426
+ return (
427
+ <div key={idx} className={`text-[10.5px] font-bold px-2 py-0.5 rounded border flex items-center gap-1 shadow-sm ${style}`}>
428
+ <span className="uppercase opacity-90 tracking-wider">{q.scan_type}:</span>
429
+ <span className="text-[12px]">{remaining}</span>
430
+ </div>
431
+ );
432
+ })}
433
+ </div>
434
+ </td>
435
+ <td className="px-lg py-md font-body-sm text-on-surface-variant">
436
+ {org.created_at ? new Date(org.created_at).toLocaleDateString() : 'N/A'}
437
+ </td>
438
+ <td className="px-lg py-md text-right">
439
+ <button
440
+ onClick={() => handleAssignScans(org)}
441
+ className="text-on-surface-variant hover:text-primary transition-colors bg-transparent border-0 cursor-pointer p-1 inline-flex items-center gap-xs mr-2"
442
+ title="Assign Custom Scans"
443
+ >
444
+ <span className="material-symbols-outlined text-[18px]">add_box</span>
445
+ <span className="font-label-sm">Assign Scans</span>
446
+ </button>
447
+ <button
448
+ onClick={() => handleImpersonate(org.id, org.name)}
449
+ className="text-on-surface-variant hover:text-primary transition-colors bg-transparent border-0 cursor-pointer p-1 inline-flex items-center gap-xs"
450
+ title="View Dashboard Data"
451
+ >
452
+ <span className="material-symbols-outlined text-[18px]">vpn_key</span>
453
+ <span className="font-label-sm">Impersonate</span>
454
+ </button>
455
+ </td>
456
+ </tr>
457
+ ))}
458
+ {organizations.length === 0 && (
459
+ <tr>
460
+ <td colSpan="5" className="px-lg py-xl text-center text-on-surface-variant font-body-md">
461
+ No organizations found.
462
+ </td>
463
+ </tr>
464
+ )}
465
+ </tbody>
466
+ </table>
467
+ </div>
468
+ </div>
469
+
470
+ <div className="mt-xl border-b border-outline-variant bg-surface-container-lowest p-lg rounded-xl shadow-sm">
471
+ <h2 className="font-headline-md text-headline-md text-on-surface mb-sm font-bold tracking-tight">Scanner Modes & Access</h2>
472
+ <p className="font-body-md text-body-md text-on-surface-variant mb-lg">
473
+ Configure which subscription plans grant access to specific scan modes. You can also completely enable or disable scan modes globally.
474
+ </p>
475
+
476
+ <div className="flex flex-col gap-md">
477
+ {(scanAccess || []).map((mode) => (
478
+ <div key={mode.scan_type} className="bg-surface-container-low border border-outline-variant rounded-lg p-md flex items-center justify-between">
479
+ <div className="flex flex-col">
480
+ <span className="font-label-md text-label-md text-on-surface font-bold">{mode.scan_type} Scan</span>
481
+ <span className="font-body-sm text-body-sm text-on-surface-variant">Global Access: {mode.is_enabled ? 'Enabled' : 'Disabled'}</span>
482
+ </div>
483
+
484
+ <div className="flex items-center gap-lg">
485
+ <div className="flex flex-col gap-xs">
486
+ <label className="font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider font-semibold">
487
+ Minimum Plan Required
488
+ </label>
489
+ <select
490
+ value={mode.required_tier}
491
+ onChange={(e) => updateScanAccess(mode.scan_type, e.target.value, mode.is_enabled)}
492
+ className="bg-surface-container border border-outline-variant rounded px-sm py-xs font-body-sm text-on-surface cursor-pointer focus:outline-none focus:border-primary"
493
+ >
494
+ <option value="free">Free</option>
495
+ <option value="pro">Pro</option>
496
+ <option value="enterprise">Enterprise</option>
497
+ </select>
498
+ </div>
499
+
500
+ <div className="flex flex-col gap-xs">
501
+ <label className="font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider font-semibold">
502
+ Enable Scan Mode
503
+ </label>
504
+ <label className="flex items-center cursor-pointer">
505
+ <div className="relative">
506
+ <input
507
+ type="checkbox"
508
+ className="sr-only"
509
+ checked={mode.is_enabled}
510
+ onChange={(e) => updateScanAccess(mode.scan_type, mode.required_tier, e.target.checked)}
511
+ />
512
+ <div className={`block w-10 h-6 rounded-full transition-colors ${mode.is_enabled ? 'bg-primary' : 'bg-surface-container-highest'}`}></div>
513
+ <div className={`dot absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${mode.is_enabled ? 'transform translate-x-4' : ''}`}></div>
514
+ </div>
515
+ </label>
516
+ </div>
517
+ </div>
518
+ </div>
519
+ ))}
520
+ </div>
521
+ </div>
522
+
523
+ <CustomModal
524
+ isOpen={promptModal.isOpen}
525
+ onClose={closePrompt}
526
+ title={promptModal.title}
527
+ description={promptModal.desc}
528
+ footer={
529
+ <>
530
+ <button onClick={closePrompt} className="px-4 py-2 text-on-surface-variant hover:bg-surface-container rounded-lg font-bold border-0 bg-transparent cursor-pointer">Cancel</button>
531
+ <button onClick={() => promptModal.onConfirm(promptValues)} className="px-4 py-2 bg-primary text-on-primary rounded-lg font-bold border-0 cursor-pointer">Confirm</button>
532
+ </>
533
+ }
534
+ >
535
+ <div className="flex flex-col gap-4">
536
+ {promptModal.inputs.map(input => (
537
+ <div key={input.key} className="flex flex-col">
538
+ <label className="text-[12px] font-bold text-on-surface-variant mb-1">{input.label}</label>
539
+ {input.type === 'select' ? (
540
+ <select
541
+ value={promptValues[input.key] || ''}
542
+ onChange={(e) => handlePromptChange(input.key, e.target.value)}
543
+ className="bg-surface-container border border-outline-variant rounded-lg px-3 py-2 focus:border-primary outline-none text-on-surface"
544
+ >
545
+ {input.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
546
+ </select>
547
+ ) : (
548
+ <input
549
+ type="text"
550
+ value={promptValues[input.key] || ''}
551
+ onChange={(e) => handlePromptChange(input.key, e.target.value)}
552
+ placeholder={input.placeholder}
553
+ className="bg-surface-container border border-outline-variant rounded-lg px-3 py-2 focus:border-primary outline-none text-on-surface"
554
+ />
555
+ )}
556
+ </div>
557
+ ))}
558
+ </div>
559
+ </CustomModal>
560
+ </div>
561
+ );
562
+ };
563
+
564
+ import { ErrorBoundary } from '../components/ErrorBoundary';
565
+
566
+ export const AdminPage = () => (
567
+ <ErrorBoundary>
568
+ <AdminPageContent />
569
+ </ErrorBoundary>
570
+ );