@slyxup/ui 0.2.15 → 0.3.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.
@@ -1,1081 +0,0 @@
1
- 'use client';
2
-
3
- import { SlyxupClient } from '@slyxup/core';
4
- import { useCallback, useEffect, useMemo, useState } from 'react';
5
- import { injectStyles } from '../../styles';
6
-
7
- export interface AdminPanelProps {
8
- /** Secret key — sk_test_xxx / sk_live_xxx */
9
- secretKey: string;
10
- /** API base URL (default: https://auth.slyxup.online) */
11
- apiUrl?: string;
12
- /** Render as full-page (default) or inline */
13
- fullPage?: boolean;
14
- }
15
-
16
- type Tab = 'overview' | 'users' | 'sessions' | 'keys' | 'audit';
17
-
18
- interface Project {
19
- id: string;
20
- name: string;
21
- createdAt: string;
22
- }
23
-
24
- interface User {
25
- id: string;
26
- email: string;
27
- firstName: string | null;
28
- lastName: string | null;
29
- emailVerified: boolean;
30
- blocked: boolean;
31
- blockedReason: string | null;
32
- role: string;
33
- createdAt: string;
34
- }
35
-
36
- interface Session {
37
- id: string;
38
- userId: string;
39
- ipAddress: string | null;
40
- userAgent: string | null;
41
- expiresAt: string;
42
- isExpired: boolean;
43
- createdAt: string;
44
- }
45
-
46
- interface ApiKey {
47
- id: string;
48
- name: string;
49
- prefix: string;
50
- environment: string;
51
- type: string;
52
- lastUsedAt: string | null;
53
- createdAt: string;
54
- }
55
-
56
- interface AuditLog {
57
- id: string;
58
- action: string;
59
- userId: string | null;
60
- metadata: Record<string, unknown> | null;
61
- ipAddress: string | null;
62
- userAgent: string | null;
63
- createdAt: string;
64
- }
65
-
66
- interface Stats {
67
- totalUsers: number;
68
- totalSessions: number;
69
- blockedUsers: number;
70
- verifiedUsers: number;
71
- totalKeys: number;
72
- }
73
-
74
- function displayName(u: {
75
- firstName: string | null;
76
- lastName: string | null;
77
- email: string;
78
- }): string {
79
- const parts = [u.firstName?.trim(), u.lastName?.trim()].filter(Boolean);
80
- return parts.join(' ') || u.email;
81
- }
82
-
83
- function initials(u: {
84
- firstName: string | null;
85
- lastName: string | null;
86
- email: string;
87
- }): string {
88
- const f = u.firstName?.trim();
89
- const l = u.lastName?.trim();
90
- if (f && l) return (f[0] + l[0]).toUpperCase();
91
- if (f) return f.slice(0, 1).toUpperCase();
92
- if (l) return l.slice(0, 1).toUpperCase();
93
- return u.email.slice(0, 1).toUpperCase();
94
- }
95
-
96
- function timeAgo(dateStr: string): string {
97
- const now = Date.now();
98
- const then = new Date(dateStr).getTime();
99
- const diff = Math.floor((now - then) / 1000);
100
- if (diff < 60) return 'just now';
101
- if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
102
- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
103
- if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
104
- return new Date(dateStr).toLocaleDateString();
105
- }
106
-
107
- const TABS: { key: Tab; label: string; icon: string }[] = [
108
- { key: 'overview', label: 'Overview', icon: '📊' },
109
- { key: 'users', label: 'Users', icon: '👥' },
110
- { key: 'sessions', label: 'Sessions', icon: '🔐' },
111
- { key: 'keys', label: 'API Keys', icon: '🔑' },
112
- { key: 'audit', label: 'Audit Log', icon: '📋' },
113
- ];
114
-
115
- const AUDIT_ACTIONS = [
116
- 'user.created',
117
- 'user.signed_in',
118
- 'user.signed_out',
119
- 'user.blocked',
120
- 'user.unblocked',
121
- 'email.verified',
122
- 'password.reset',
123
- 'password.changed',
124
- 'key.created',
125
- 'key.revoked',
126
- ];
127
-
128
- export function AdminPanel({
129
- secretKey,
130
- apiUrl,
131
- fullPage = true,
132
- }: AdminPanelProps) {
133
- const [tab, setTab] = useState<Tab>('overview');
134
- const [loading, setLoading] = useState(true);
135
- const [error, setError] = useState<string | null>(null);
136
- const [project, setProject] = useState<Project | null>(null);
137
- const [stats, setStats] = useState<Stats | null>(null);
138
- const [users, setUsers] = useState<User[]>([]);
139
- const [sessions, setSessions] = useState<Session[]>([]);
140
- const [keys, setKeys] = useState<ApiKey[]>([]);
141
- const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
142
- const [auditTotal, setAuditTotal] = useState(0);
143
- const [auditFilter, setAuditFilter] = useState('');
144
- const [creatingKey, setCreatingKey] = useState(false);
145
- const [newKeyName, setNewKeyName] = useState('');
146
- const [newKeyType, setNewKeyType] = useState<'publishable' | 'secret'>(
147
- 'secret'
148
- );
149
- const [newKeyEnv, setNewKeyEnv] = useState<'test' | 'live'>('test');
150
- const [createdKeyValue, setCreatedKeyValue] = useState<string | null>(null);
151
- const [confirmAction, setConfirmAction] = useState<{
152
- type: string;
153
- id: string;
154
- label: string;
155
- } | null>(null);
156
-
157
- const client = useMemo(
158
- () => new SlyxupClient({ secretKey, apiUrl }),
159
- [secretKey, apiUrl]
160
- );
161
-
162
- const loadAll = useCallback(async () => {
163
- setLoading(true);
164
- setError(null);
165
- try {
166
- const [p, s, u, se, k] = await Promise.all([
167
- client.admin.getProject(),
168
- client.admin.getStats(),
169
- client.admin.listUsers({ limit: 100 }),
170
- client.admin.listSessions({ limit: 100 }),
171
- client.admin.listKeys(),
172
- ]);
173
- setProject(p.project);
174
- setStats(s.stats);
175
- setUsers(u.users);
176
- setSessions(se.sessions);
177
- setKeys(k.keys);
178
- } catch (err: unknown) {
179
- const msg =
180
- err instanceof Error ? err.message : 'Failed to load admin data';
181
- setError(msg);
182
- } finally {
183
- setLoading(false);
184
- }
185
- }, [client]);
186
-
187
- const loadAuditLogs = useCallback(async () => {
188
- try {
189
- const res = await client.admin.listAuditLogs({
190
- action: auditFilter || undefined,
191
- limit: 50,
192
- });
193
- setAuditLogs(res.logs);
194
- setAuditTotal(res.total);
195
- } catch (err: unknown) {
196
- const msg =
197
- err instanceof Error ? err.message : 'Failed to load audit logs';
198
- setError(msg);
199
- }
200
- }, [client, auditFilter]);
201
-
202
- useEffect(() => {
203
- loadAll();
204
- }, [loadAll]);
205
-
206
- useEffect(() => {
207
- if (tab === 'audit') loadAuditLogs();
208
- }, [tab, loadAuditLogs]);
209
-
210
- const handleCreateKey = useCallback(async () => {
211
- if (!newKeyName.trim()) return;
212
- setCreatingKey(true);
213
- try {
214
- const res = await client.admin.createKey({
215
- name: newKeyName.trim(),
216
- type: newKeyType,
217
- environment: newKeyEnv,
218
- });
219
- setCreatedKeyValue(res.key);
220
- setNewKeyName('');
221
- const k = await client.admin.listKeys();
222
- setKeys(k.keys);
223
- } catch (err: unknown) {
224
- const msg = err instanceof Error ? err.message : 'Failed to create key';
225
- setError(msg);
226
- } finally {
227
- setCreatingKey(false);
228
- }
229
- }, [client, newKeyName, newKeyType, newKeyEnv]);
230
-
231
- const handleRevokeKey = useCallback(
232
- async (keyId: string) => {
233
- try {
234
- await client.admin.revokeKey(keyId);
235
- const k = await client.admin.listKeys();
236
- setKeys(k.keys);
237
- } catch (err: unknown) {
238
- const msg = err instanceof Error ? err.message : 'Failed to revoke key';
239
- setError(msg);
240
- }
241
- },
242
- [client]
243
- );
244
-
245
- const handleBlockUser = useCallback(
246
- async (userId: string) => {
247
- try {
248
- await client.admin.blockUser(userId, 'Blocked by admin');
249
- setUsers((prev) =>
250
- prev.map((u) => (u.id === userId ? { ...u, blocked: true } : u))
251
- );
252
- setStats((prev) =>
253
- prev ? { ...prev, blockedUsers: prev.blockedUsers + 1 } : prev
254
- );
255
- } catch (err: unknown) {
256
- const msg = err instanceof Error ? err.message : 'Failed to block user';
257
- setError(msg);
258
- }
259
- setConfirmAction(null);
260
- },
261
- [client]
262
- );
263
-
264
- const handleUnblockUser = useCallback(
265
- async (userId: string) => {
266
- try {
267
- await client.admin.unblockUser(userId);
268
- setUsers((prev) =>
269
- prev.map((u) => (u.id === userId ? { ...u, blocked: false } : u))
270
- );
271
- setStats((prev) =>
272
- prev
273
- ? { ...prev, blockedUsers: Math.max(0, prev.blockedUsers - 1) }
274
- : prev
275
- );
276
- } catch (err: unknown) {
277
- const msg =
278
- err instanceof Error ? err.message : 'Failed to unblock user';
279
- setError(msg);
280
- }
281
- setConfirmAction(null);
282
- },
283
- [client]
284
- );
285
-
286
- const handleDeleteUser = useCallback(
287
- async (userId: string) => {
288
- try {
289
- await client.admin.deleteUser(userId);
290
- setUsers((prev) => prev.filter((u) => u.id !== userId));
291
- setStats((prev) =>
292
- prev ? { ...prev, totalUsers: prev.totalUsers - 1 } : prev
293
- );
294
- } catch (err: unknown) {
295
- const msg =
296
- err instanceof Error ? err.message : 'Failed to delete user';
297
- setError(msg);
298
- }
299
- setConfirmAction(null);
300
- },
301
- [client]
302
- );
303
-
304
- const handleRevokeSession = useCallback(
305
- async (sessionId: string) => {
306
- try {
307
- await client.admin.revokeSession(sessionId);
308
- setSessions((prev) => prev.filter((s) => s.id !== sessionId));
309
- } catch (err: unknown) {
310
- const msg =
311
- err instanceof Error ? err.message : 'Failed to revoke session';
312
- setError(msg);
313
- }
314
- },
315
- [client]
316
- );
317
-
318
- const handleRevokeAllSessions = useCallback(
319
- async (userId: string) => {
320
- try {
321
- await client.admin.revokeAllSessions(userId);
322
- setSessions((prev) => prev.filter((s) => s.userId !== userId));
323
- } catch (err: unknown) {
324
- const msg =
325
- err instanceof Error ? err.message : 'Failed to revoke sessions';
326
- setError(msg);
327
- }
328
- setConfirmAction(null);
329
- },
330
- [client]
331
- );
332
-
333
- const activeSessions = sessions.filter((s) => !s.isExpired);
334
- const secretKeys = keys.filter((k) => k.type === 'secret');
335
-
336
- injectStyles();
337
-
338
- const scopeStyle: React.CSSProperties = fullPage
339
- ? {
340
- minHeight: '100vh',
341
- background: 'var(--slx-bg-page, #f4f4f5)',
342
- padding: '32px 24px',
343
- }
344
- : {
345
- background: 'var(--slx-bg, #fff)',
346
- borderRadius: 'var(--slx-radius-lg, 14px)',
347
- border: '1px solid var(--slx-border, #e4e4e7)',
348
- padding: 24,
349
- };
350
-
351
- return (
352
- <div className="slyxup-root" style={scopeStyle}>
353
- <style>{`
354
- .slx-admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
355
- .slx-admin-stat { background: var(--slx-bg, #fff); border: 1px solid var(--slx-border, #e4e4e7); border-radius: var(--slx-radius, 10px); padding: 20px; }
356
- .slx-admin-stat-value { font-size: 28px; font-weight: 700; color: var(--slx-ink, #16161d); font-family: var(--slx-display, inherit); }
357
- .slx-admin-stat-label { font-size: 13px; color: var(--slx-muted, #71717a); margin-top: 4px; font-weight: 500; }
358
- .slx-admin-tabs { display: flex; gap: 4px; background: var(--slx-bg-subtle, #f4f4f5); border-radius: var(--slx-radius, 10px); padding: 4px; margin-bottom: 24px; width: fit-content; flex-wrap: wrap; }
359
- .slx-admin-tab { padding: 8px 16px; border-radius: 8px; border: none; background: transparent; color: var(--slx-muted, #71717a); font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s; font-family: var(--slx-font, inherit); }
360
- .slx-admin-tab:hover { color: var(--slx-ink, #16161d); }
361
- .slx-admin-tab--active { background: var(--slx-bg, #fff); color: var(--slx-ink, #16161d); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
362
- .slx-admin-card { background: var(--slx-bg, #fff); border: 1px solid var(--slx-border, #e4e4e7); border-radius: var(--slx-radius-lg, 14px); padding: 24px; margin-bottom: 16px; }
363
- .slx-admin-card-title { font-size: 16px; font-weight: 600; color: var(--slx-ink, #16161d); margin-bottom: 16px; }
364
- .slx-admin-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-radius: var(--slx-radius-sm, 8px); border: 1px solid var(--slx-border, #e4e4e7); margin-bottom: 8px; transition: border-color 0.15s; }
365
- .slx-admin-row:hover { border-color: var(--slx-border-strong, #d1d5db); }
366
- .slx-admin-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; }
367
- .slx-admin-badge--green { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
368
- .slx-admin-badge--yellow { background: rgba(234, 179, 8, 0.12); color: #ca8a04; }
369
- .slx-admin-badge--red { background: rgba(239, 68, 68, 0.12); color: #dc2626; }
370
- .slx-admin-badge--blue { background: rgba(59, 130, 246, 0.12); color: #2563eb; }
371
- .slx-admin-badge--gray { background: rgba(113, 113, 122, 0.12); color: #71717a; }
372
- .slx-admin-badge--purple { background: rgba(139, 92, 246, 0.12); color: #7c3aed; }
373
- .slx-admin-avatar { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; font-weight: 700; font-size: 13px; color: #fff; flex-shrink: 0; }
374
- .slx-admin-empty { text-align: center; padding: 48px 24px; color: var(--slx-muted, #71717a); font-size: 14px; }
375
- .slx-admin-input { padding: 10px 14px; border-radius: var(--slx-radius-sm, 8px); border: 1px solid var(--slx-border, #e4e4e7); background: var(--slx-bg, #fff); color: var(--slx-ink, #16161d); font-size: 14px; font-family: var(--slx-font, inherit); outline: none; width: 100%; transition: border-color 0.15s; box-sizing: border-box; }
376
- .slx-admin-input:focus { border-color: var(--slx-accent, #5b5bd6); }
377
- .slx-admin-select { padding: 10px 14px; border-radius: var(--slx-radius-sm, 8px); border: 1px solid var(--slx-border, #e4e4e7); background: var(--slx-bg, #fff); color: var(--slx-ink, #16161d); font-size: 14px; font-family: var(--slx-font, inherit); outline: none; cursor: pointer; }
378
- .slx-admin-btn { padding: 10px 20px; border-radius: var(--slx-radius-sm, 8px); border: none; font-size: 14px; font-weight: 600; cursor: pointer; font-family: var(--slx-font, inherit); transition: all 0.15s; }
379
- .slx-admin-btn--primary { background: var(--slx-accent, #5b5bd6); color: #fff; }
380
- .slx-admin-btn--primary:hover { background: var(--slx-accent-hover, #4c4cc4); }
381
- .slx-admin-btn--primary:disabled { opacity: 0.6; cursor: not-allowed; }
382
- .slx-admin-btn--danger { background: transparent; color: var(--slx-danger, #d64550); border: 1px solid var(--slx-danger, #d64550); }
383
- .slx-admin-btn--danger:hover { background: rgba(214, 69, 80, 0.08); }
384
- .slx-admin-btn--warning { background: transparent; color: #ca8a04; border: 1px solid #ca8a04; }
385
- .slx-admin-btn--warning:hover { background: rgba(202, 138, 4, 0.08); }
386
- .slx-admin-btn--ghost { background: transparent; color: var(--slx-muted, #71717a); border: 1px solid var(--slx-border, #e4e4e7); }
387
- .slx-admin-btn--ghost:hover { background: var(--slx-bg-subtle, #f4f4f5); }
388
- .slx-admin-btn--sm { padding: 4px 10px; font-size: 12px; }
389
- .slx-admin-create-form { display: flex; gap: 8px; align-items: end; flex-wrap: wrap; margin-bottom: 16px; }
390
- .slx-admin-create-field { display: flex; flex-direction: column; gap: 4px; }
391
- .slx-admin-create-label { font-size: 12px; font-weight: 600; color: var(--slx-muted, #71717a); text-transform: uppercase; letter-spacing: 0.5px; }
392
- .slx-admin-key-display { background: var(--slx-bg-subtle, #f4f4f5); border: 1px solid var(--slx-border, #e4e4e7); border-radius: var(--slx-radius-sm, 8px); padding: 12px 16px; margin-bottom: 16px; font-family: var(--slx-mono, monospace); font-size: 13px; word-break: break-all; color: var(--slx-ink, #16161d); }
393
- .slx-admin-loading { display: flex; justify-content: center; align-items: center; padding: 64px; }
394
- .slx-admin-spinner { width: 32px; height: 32px; border: 3px solid var(--slx-border, #e4e4e7); border-top-color: var(--slx-accent, #5b5bd6); border-radius: 50%; animation: slx-spin 0.6s linear infinite; }
395
- @keyframes slx-spin { to { transform: rotate(360deg); } }
396
- .slx-admin-error { background: rgba(214, 69, 80, 0.08); border: 1px solid rgba(214, 69, 80, 0.2); border-radius: var(--slx-radius-sm, 8px); padding: 12px 16px; margin-bottom: 16px; color: var(--slx-danger, #d64550); font-size: 14px; }
397
- .slx-admin-confirm-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: grid; place-items: center; z-index: 1000; }
398
- .slx-admin-confirm-box { background: var(--slx-bg, #fff); border-radius: var(--slx-radius-lg, 14px); padding: 24px; max-width: 400px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
399
- .slx-admin-confirm-title { font-size: 16px; font-weight: 600; color: var(--slx-ink, #16161d); margin-bottom: 8px; }
400
- .slx-admin-confirm-msg { font-size: 14px; color: var(--slx-muted, #71717a); margin-bottom: 20px; }
401
- .slx-admin-confirm-actions { display: flex; gap: 8px; justify-content: flex-end; }
402
- .slx-admin-audit-action { font-family: var(--slx-mono, monospace); font-size: 12px; padding: 2px 6px; border-radius: 4px; }
403
- @media (max-width: 640px) {
404
- .slx-admin-grid { grid-template-columns: 1fr 1fr; }
405
- .slx-admin-tabs { width: 100%; overflow-x: auto; }
406
- .slx-admin-create-form { flex-direction: column; align-items: stretch; }
407
- .slx-admin-create-field { width: 100%; }
408
- .slx-admin-row { flex-direction: column; align-items: flex-start; gap: 8px; }
409
- }
410
- `}</style>
411
-
412
- {/* Confirm Dialog */}
413
- {confirmAction && (
414
- <div
415
- className="slx-admin-confirm-overlay"
416
- onClick={() => setConfirmAction(null)}
417
- onKeyDown={(e) => e.key === 'Escape' && setConfirmAction(null)}
418
- tabIndex={-1}
419
- >
420
- <div
421
- className="slx-admin-confirm-box"
422
- onClick={(e) => e.stopPropagation()}
423
- onKeyDown={(e) => e.stopPropagation()}
424
- >
425
- <div className="slx-admin-confirm-title">Confirm Action</div>
426
- <div className="slx-admin-confirm-msg">
427
- {confirmAction.type === 'block' &&
428
- 'Block this user? They will be signed out immediately.'}
429
- {confirmAction.type === 'unblock' &&
430
- 'Unblock this user? They will be able to sign in again.'}
431
- {confirmAction.type === 'delete' &&
432
- 'Delete this user permanently? This cannot be undone.'}
433
- {confirmAction.type === 'revokeAllSessions' &&
434
- 'Revoke all sessions for this user? They will need to sign in again.'}
435
- </div>
436
- <div className="slx-admin-confirm-actions">
437
- <button
438
- type="button"
439
- className="slx-admin-btn slx-admin-btn--ghost"
440
- onClick={() => setConfirmAction(null)}
441
- >
442
- Cancel
443
- </button>
444
- <button
445
- type="button"
446
- className={`slx-admin-btn ${confirmAction.type === 'delete' ? 'slx-admin-btn--danger' : confirmAction.type === 'block' ? 'slx-admin-btn--warning' : 'slx-admin-btn--primary'}`}
447
- onClick={() => {
448
- if (confirmAction.type === 'block')
449
- handleBlockUser(confirmAction.id);
450
- else if (confirmAction.type === 'unblock')
451
- handleUnblockUser(confirmAction.id);
452
- else if (confirmAction.type === 'delete')
453
- handleDeleteUser(confirmAction.id);
454
- else if (confirmAction.type === 'revokeAllSessions')
455
- handleRevokeAllSessions(confirmAction.id);
456
- }}
457
- >
458
- {confirmAction.type === 'block'
459
- ? 'Block'
460
- : confirmAction.type === 'delete'
461
- ? 'Delete'
462
- : confirmAction.type === 'unblock'
463
- ? 'Unblock'
464
- : 'Revoke All'}
465
- </button>
466
- </div>
467
- </div>
468
- </div>
469
- )}
470
-
471
- {error && (
472
- <div
473
- className="slx-admin-error"
474
- style={{
475
- display: 'flex',
476
- justifyContent: 'space-between',
477
- alignItems: 'center',
478
- }}
479
- >
480
- <span>{error}</span>
481
- <button
482
- type="button"
483
- className="slx-admin-btn slx-admin-btn--ghost slx-admin-btn--sm"
484
- onClick={() => setError(null)}
485
- >
486
- Dismiss
487
- </button>
488
- </div>
489
- )}
490
-
491
- {loading ? (
492
- <div className="slx-admin-loading">
493
- <div className="slx-admin-spinner" />
494
- </div>
495
- ) : (
496
- <>
497
- <div style={{ marginBottom: 24 }}>
498
- <h1
499
- style={{
500
- fontSize: 24,
501
- fontWeight: 700,
502
- color: 'var(--slx-ink, #16161d)',
503
- fontFamily: 'var(--slx-display, inherit)',
504
- margin: 0,
505
- }}
506
- >
507
- Admin Dashboard
508
- </h1>
509
- {project && (
510
- <p
511
- style={{
512
- fontSize: 14,
513
- color: 'var(--slx-muted, #71717a)',
514
- marginTop: 4,
515
- }}
516
- >
517
- {project.name}
518
- </p>
519
- )}
520
- </div>
521
-
522
- <div className="slx-admin-tabs">
523
- {TABS.map((t) => (
524
- <button
525
- type="button"
526
- key={t.key}
527
- className={`slx-admin-tab ${tab === t.key ? 'slx-admin-tab--active' : ''}`}
528
- onClick={() => setTab(t.key)}
529
- >
530
- {t.icon} {t.label}
531
- </button>
532
- ))}
533
- </div>
534
-
535
- {/* Overview */}
536
- {tab === 'overview' && stats && (
537
- <div className="slx-admin-grid">
538
- <div className="slx-admin-stat">
539
- <div className="slx-admin-stat-value">{stats.totalUsers}</div>
540
- <div className="slx-admin-stat-label">Total Users</div>
541
- </div>
542
- <div className="slx-admin-stat">
543
- <div className="slx-admin-stat-value">
544
- {activeSessions.length}
545
- </div>
546
- <div className="slx-admin-stat-label">Active Sessions</div>
547
- </div>
548
- <div className="slx-admin-stat">
549
- <div className="slx-admin-stat-value">{keys.length}</div>
550
- <div className="slx-admin-stat-label">API Keys</div>
551
- </div>
552
- <div className="slx-admin-stat">
553
- <div className="slx-admin-stat-value">
554
- {stats.verifiedUsers}
555
- </div>
556
- <div className="slx-admin-stat-label">Verified Users</div>
557
- </div>
558
- <div className="slx-admin-stat">
559
- <div className="slx-admin-stat-value">{stats.blockedUsers}</div>
560
- <div className="slx-admin-stat-label">Blocked Users</div>
561
- </div>
562
- <div className="slx-admin-stat">
563
- <div className="slx-admin-stat-value">{secretKeys.length}</div>
564
- <div className="slx-admin-stat-label">Secret Keys</div>
565
- </div>
566
- </div>
567
- )}
568
-
569
- {/* Users */}
570
- {tab === 'users' && (
571
- <div className="slx-admin-card">
572
- <div className="slx-admin-card-title">Users ({users.length})</div>
573
- {users.length === 0 ? (
574
- <div className="slx-admin-empty">No users found.</div>
575
- ) : (
576
- users.map((u) => (
577
- <div key={u.id} className="slx-admin-row">
578
- <div
579
- style={{ display: 'flex', alignItems: 'center', gap: 12 }}
580
- >
581
- <div
582
- className="slx-admin-avatar"
583
- style={{
584
- background: `hsl(${(u.email.charCodeAt(0) * 37) % 360}, 55%, 50%)`,
585
- }}
586
- >
587
- {initials(u)}
588
- </div>
589
- <div>
590
- <div
591
- style={{
592
- fontSize: 14,
593
- fontWeight: 500,
594
- color: 'var(--slx-ink, #16161d)',
595
- }}
596
- >
597
- {displayName(u)}
598
- </div>
599
- <div
600
- style={{
601
- fontSize: 12,
602
- color: 'var(--slx-muted, #71717a)',
603
- }}
604
- >
605
- {u.email}
606
- </div>
607
- </div>
608
- </div>
609
- <div
610
- style={{
611
- display: 'flex',
612
- gap: 6,
613
- alignItems: 'center',
614
- flexWrap: 'wrap',
615
- }}
616
- >
617
- {u.emailVerified ? (
618
- <span className="slx-admin-badge slx-admin-badge--green">
619
- Verified
620
- </span>
621
- ) : (
622
- <span className="slx-admin-badge slx-admin-badge--yellow">
623
- Unverified
624
- </span>
625
- )}
626
- {u.blocked && (
627
- <span className="slx-admin-badge slx-admin-badge--red">
628
- Blocked
629
- </span>
630
- )}
631
- {u.role === 'admin' && (
632
- <span className="slx-admin-badge slx-admin-badge--purple">
633
- Admin
634
- </span>
635
- )}
636
- <span
637
- style={{
638
- fontSize: 11,
639
- color: 'var(--slx-muted, #71717a)',
640
- }}
641
- >
642
- {timeAgo(u.createdAt)}
643
- </span>
644
- {u.blocked ? (
645
- <button
646
- type="button"
647
- className="slx-admin-btn slx-admin-btn--ghost slx-admin-btn--sm"
648
- onClick={() =>
649
- setConfirmAction({
650
- type: 'unblock',
651
- id: u.id,
652
- label: u.email,
653
- })
654
- }
655
- >
656
- Unblock
657
- </button>
658
- ) : (
659
- <button
660
- type="button"
661
- className="slx-admin-btn slx-admin-btn--warning slx-admin-btn--sm"
662
- onClick={() =>
663
- setConfirmAction({
664
- type: 'block',
665
- id: u.id,
666
- label: u.email,
667
- })
668
- }
669
- >
670
- Block
671
- </button>
672
- )}
673
- <button
674
- type="button"
675
- className="slx-admin-btn slx-admin-btn--ghost slx-admin-btn--sm"
676
- onClick={() =>
677
- setConfirmAction({
678
- type: 'revokeAllSessions',
679
- id: u.id,
680
- label: displayName(u),
681
- })
682
- }
683
- >
684
- Revoke Sessions
685
- </button>
686
- <button
687
- type="button"
688
- className="slx-admin-btn slx-admin-btn--danger slx-admin-btn--sm"
689
- onClick={() =>
690
- setConfirmAction({
691
- type: 'delete',
692
- id: u.id,
693
- label: u.email,
694
- })
695
- }
696
- >
697
- Delete
698
- </button>
699
- </div>
700
- </div>
701
- ))
702
- )}
703
- </div>
704
- )}
705
-
706
- {/* Sessions */}
707
- {tab === 'sessions' && (
708
- <div className="slx-admin-card">
709
- <div className="slx-admin-card-title">
710
- Sessions ({activeSessions.length} active)
711
- </div>
712
- {activeSessions.length === 0 ? (
713
- <div className="slx-admin-empty">No active sessions.</div>
714
- ) : (
715
- activeSessions.map((s) => {
716
- const u = users.find((u) => u.id === s.userId);
717
- return (
718
- <div key={s.id} className="slx-admin-row">
719
- <div>
720
- <div
721
- style={{
722
- fontSize: 14,
723
- fontWeight: 500,
724
- color: 'var(--slx-ink, #16161d)',
725
- }}
726
- >
727
- {u ? displayName(u) : `${s.userId.slice(0, 8)}...`}
728
- </div>
729
- <div
730
- style={{
731
- fontSize: 12,
732
- color: 'var(--slx-muted, #71717a)',
733
- }}
734
- >
735
- {s.userAgent
736
- ? s.userAgent.slice(0, 50) +
737
- (s.userAgent.length > 50 ? '...' : '')
738
- : 'No user agent'}
739
- </div>
740
- </div>
741
- <div
742
- style={{
743
- display: 'flex',
744
- gap: 8,
745
- alignItems: 'center',
746
- }}
747
- >
748
- <div style={{ textAlign: 'right' }}>
749
- <div
750
- style={{
751
- fontSize: 12,
752
- color: 'var(--slx-muted, #71717a)',
753
- }}
754
- >
755
- Expires {timeAgo(s.expiresAt)}
756
- </div>
757
- {s.ipAddress && (
758
- <div
759
- style={{
760
- fontSize: 11,
761
- color: 'var(--slx-muted, #a1a1aa)',
762
- }}
763
- >
764
- {s.ipAddress}
765
- </div>
766
- )}
767
- </div>
768
- <button
769
- type="button"
770
- className="slx-admin-btn slx-admin-btn--danger slx-admin-btn--sm"
771
- onClick={() => handleRevokeSession(s.id)}
772
- >
773
- Revoke
774
- </button>
775
- </div>
776
- </div>
777
- );
778
- })
779
- )}
780
- </div>
781
- )}
782
-
783
- {/* Keys */}
784
- {tab === 'keys' && (
785
- <div className="slx-admin-card">
786
- <div className="slx-admin-card-title">
787
- API Keys ({keys.length})
788
- </div>
789
- <div className="slx-admin-create-form">
790
- <div
791
- className="slx-admin-create-field"
792
- style={{ flex: 2, minWidth: 160 }}
793
- >
794
- <label
795
- className="slx-admin-create-label"
796
- htmlFor="slx-admin-key-name"
797
- >
798
- Name
799
- </label>
800
- <input
801
- id="slx-admin-key-name"
802
- className="slx-admin-input"
803
- placeholder="e.g. Production Key"
804
- value={newKeyName}
805
- onChange={(e) => setNewKeyName(e.target.value)}
806
- onKeyDown={(e) => e.key === 'Enter' && handleCreateKey()}
807
- />
808
- </div>
809
- <div
810
- className="slx-admin-create-field"
811
- style={{ minWidth: 120 }}
812
- >
813
- <label
814
- className="slx-admin-create-label"
815
- htmlFor="slx-admin-key-type"
816
- >
817
- Type
818
- </label>
819
- <select
820
- id="slx-admin-key-type"
821
- className="slx-admin-select"
822
- value={newKeyType}
823
- onChange={(e) =>
824
- setNewKeyType(e.target.value as 'publishable' | 'secret')
825
- }
826
- >
827
- <option value="secret">Secret (sk)</option>
828
- <option value="publishable">Publishable (pk)</option>
829
- </select>
830
- </div>
831
- <div
832
- className="slx-admin-create-field"
833
- style={{ minWidth: 100 }}
834
- >
835
- <label
836
- className="slx-admin-create-label"
837
- htmlFor="slx-admin-key-env"
838
- >
839
- Env
840
- </label>
841
- <select
842
- id="slx-admin-key-env"
843
- className="slx-admin-select"
844
- value={newKeyEnv}
845
- onChange={(e) =>
846
- setNewKeyEnv(e.target.value as 'test' | 'live')
847
- }
848
- >
849
- <option value="test">Test</option>
850
- <option value="live">Live</option>
851
- </select>
852
- </div>
853
- <button
854
- type="button"
855
- className="slx-admin-btn slx-admin-btn--primary"
856
- onClick={handleCreateKey}
857
- disabled={creatingKey || !newKeyName.trim()}
858
- style={{ alignSelf: 'end' }}
859
- >
860
- {creatingKey ? 'Creating...' : 'Create Key'}
861
- </button>
862
- </div>
863
-
864
- {createdKeyValue && (
865
- <div
866
- className="slx-admin-key-display"
867
- style={{
868
- display: 'flex',
869
- justifyContent: 'space-between',
870
- alignItems: 'center',
871
- }}
872
- >
873
- <span>{createdKeyValue}</span>
874
- <button
875
- type="button"
876
- className="slx-admin-btn slx-admin-btn--ghost slx-admin-btn--sm"
877
- style={{ flexShrink: 0, marginLeft: 12 }}
878
- onClick={() => {
879
- navigator.clipboard.writeText(createdKeyValue);
880
- }}
881
- >
882
- Copy
883
- </button>
884
- </div>
885
- )}
886
-
887
- {keys.length === 0 ? (
888
- <div className="slx-admin-empty">
889
- No API keys. Create one above.
890
- </div>
891
- ) : (
892
- keys.map((k) => (
893
- <div key={k.id} className="slx-admin-row">
894
- <div>
895
- <div
896
- style={{
897
- fontSize: 14,
898
- fontWeight: 500,
899
- color: 'var(--slx-ink, #16161d)',
900
- }}
901
- >
902
- {k.name}
903
- </div>
904
- <div
905
- style={{
906
- fontSize: 12,
907
- color: 'var(--slx-muted, #71717a)',
908
- fontFamily: 'var(--slx-mono, monospace)',
909
- }}
910
- >
911
- {k.prefix}...
912
- </div>
913
- </div>
914
- <div
915
- style={{ display: 'flex', gap: 6, alignItems: 'center' }}
916
- >
917
- <span
918
- className={`slx-admin-badge ${k.type === 'secret' ? 'slx-admin-badge--red' : 'slx-admin-badge--blue'}`}
919
- >
920
- {k.type}
921
- </span>
922
- <span className="slx-admin-badge slx-admin-badge--gray">
923
- {k.environment}
924
- </span>
925
- {k.lastUsedAt && (
926
- <span
927
- style={{
928
- fontSize: 11,
929
- color: 'var(--slx-muted, #71717a)',
930
- }}
931
- >
932
- Used {timeAgo(k.lastUsedAt)}
933
- </span>
934
- )}
935
- <button
936
- type="button"
937
- className="slx-admin-btn slx-admin-btn--danger slx-admin-btn--sm"
938
- onClick={() => handleRevokeKey(k.id)}
939
- >
940
- Revoke
941
- </button>
942
- </div>
943
- </div>
944
- ))
945
- )}
946
- </div>
947
- )}
948
-
949
- {/* Audit Log */}
950
- {tab === 'audit' && (
951
- <div className="slx-admin-card">
952
- <div
953
- style={{
954
- display: 'flex',
955
- justifyContent: 'space-between',
956
- alignItems: 'center',
957
- marginBottom: 16,
958
- }}
959
- >
960
- <div className="slx-admin-card-title" style={{ margin: 0 }}>
961
- Audit Log ({auditTotal} entries)
962
- </div>
963
- <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
964
- <select
965
- className="slx-admin-select"
966
- value={auditFilter}
967
- onChange={(e) => setAuditFilter(e.target.value)}
968
- style={{ fontSize: 13, padding: '6px 10px' }}
969
- >
970
- <option value="">All actions</option>
971
- {AUDIT_ACTIONS.map((a) => (
972
- <option key={a} value={a}>
973
- {a}
974
- </option>
975
- ))}
976
- </select>
977
- <button
978
- type="button"
979
- className="slx-admin-btn slx-admin-btn--ghost slx-admin-btn--sm"
980
- onClick={loadAuditLogs}
981
- >
982
- Refresh
983
- </button>
984
- </div>
985
- </div>
986
- {auditLogs.length === 0 ? (
987
- <div className="slx-admin-empty">No audit logs found.</div>
988
- ) : (
989
- auditLogs.map((log) => (
990
- <div
991
- key={log.id}
992
- className="slx-admin-row"
993
- style={{ alignItems: 'flex-start' }}
994
- >
995
- <div style={{ flex: 1 }}>
996
- <div
997
- style={{
998
- display: 'flex',
999
- gap: 8,
1000
- alignItems: 'center',
1001
- marginBottom: 4,
1002
- }}
1003
- >
1004
- <span
1005
- className="slx-admin-audit-action"
1006
- style={{
1007
- background: log.action.includes('blocked')
1008
- ? 'rgba(239,68,68,0.12)'
1009
- : log.action.includes('signed_in')
1010
- ? 'rgba(34,197,94,0.12)'
1011
- : log.action.includes('created')
1012
- ? 'rgba(59,130,246,0.12)'
1013
- : log.action.includes('revoked')
1014
- ? 'rgba(234,179,8,0.12)'
1015
- : 'rgba(113,113,122,0.12)',
1016
- color: log.action.includes('blocked')
1017
- ? '#dc2626'
1018
- : log.action.includes('signed_in')
1019
- ? '#16a34a'
1020
- : log.action.includes('created')
1021
- ? '#2563eb'
1022
- : log.action.includes('revoked')
1023
- ? '#ca8a04'
1024
- : '#71717a',
1025
- }}
1026
- >
1027
- {log.action}
1028
- </span>
1029
- {log.userId && (
1030
- <span
1031
- style={{
1032
- fontSize: 11,
1033
- color: 'var(--slx-muted, #a1a1aa)',
1034
- }}
1035
- >
1036
- by {log.userId.slice(0, 8)}...
1037
- </span>
1038
- )}
1039
- </div>
1040
- {log.metadata && (
1041
- <div
1042
- style={{
1043
- fontSize: 12,
1044
- color: 'var(--slx-muted, #71717a)',
1045
- fontFamily: 'var(--slx-mono, monospace)',
1046
- }}
1047
- >
1048
- {JSON.stringify(log.metadata)}
1049
- </div>
1050
- )}
1051
- </div>
1052
- <div style={{ textAlign: 'right', flexShrink: 0 }}>
1053
- <div
1054
- style={{
1055
- fontSize: 12,
1056
- color: 'var(--slx-muted, #71717a)',
1057
- }}
1058
- >
1059
- {timeAgo(log.createdAt)}
1060
- </div>
1061
- {log.ipAddress && (
1062
- <div
1063
- style={{
1064
- fontSize: 11,
1065
- color: 'var(--slx-muted, #a1a1aa)',
1066
- }}
1067
- >
1068
- {log.ipAddress}
1069
- </div>
1070
- )}
1071
- </div>
1072
- </div>
1073
- ))
1074
- )}
1075
- </div>
1076
- )}
1077
- </>
1078
- )}
1079
- </div>
1080
- );
1081
- }