@slyxup/ui 0.2.13 → 0.2.14

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.
@@ -0,0 +1,663 @@
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';
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
+ createdAt: string;
32
+ }
33
+
34
+ interface Session {
35
+ id: string;
36
+ userId: string;
37
+ ipAddress: string | null;
38
+ userAgent: string | null;
39
+ expiresAt: string;
40
+ isExpired: boolean;
41
+ createdAt: string;
42
+ }
43
+
44
+ interface ApiKey {
45
+ id: string;
46
+ name: string;
47
+ prefix: string;
48
+ environment: string;
49
+ type: string;
50
+ lastUsedAt: string | null;
51
+ createdAt: string;
52
+ }
53
+
54
+ function displayName(u: {
55
+ firstName: string | null;
56
+ lastName: string | null;
57
+ email: string;
58
+ }): string {
59
+ const parts = [u.firstName?.trim(), u.lastName?.trim()].filter(Boolean);
60
+ return parts.join(' ') || u.email;
61
+ }
62
+
63
+ function initials(u: {
64
+ firstName: string | null;
65
+ lastName: string | null;
66
+ email: string;
67
+ }): string {
68
+ const f = u.firstName?.trim();
69
+ const l = u.lastName?.trim();
70
+ if (f && l) return (f[0] + l[0]).toUpperCase();
71
+ if (f) return f.slice(0, 1).toUpperCase();
72
+ if (l) return l.slice(0, 1).toUpperCase();
73
+ return u.email.slice(0, 1).toUpperCase();
74
+ }
75
+
76
+ function timeAgo(dateStr: string): string {
77
+ const now = Date.now();
78
+ const then = new Date(dateStr).getTime();
79
+ const diff = Math.floor((now - then) / 1000);
80
+ if (diff < 60) return 'just now';
81
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
82
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
83
+ if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
84
+ return new Date(dateStr).toLocaleDateString();
85
+ }
86
+
87
+ const TABS: { key: Tab; label: string; icon: string }[] = [
88
+ { key: 'overview', label: 'Overview', icon: '📊' },
89
+ { key: 'users', label: 'Users', icon: '👥' },
90
+ { key: 'sessions', label: 'Sessions', icon: '🔐' },
91
+ { key: 'keys', label: 'API Keys', icon: '🔑' },
92
+ ];
93
+
94
+ export function AdminPanel({
95
+ secretKey,
96
+ apiUrl,
97
+ fullPage = true,
98
+ }: AdminPanelProps) {
99
+ const [tab, setTab] = useState<Tab>('overview');
100
+ const [loading, setLoading] = useState(true);
101
+ const [error, setError] = useState<string | null>(null);
102
+ const [project, setProject] = useState<Project | null>(null);
103
+ const [users, setUsers] = useState<User[]>([]);
104
+ const [sessions, setSessions] = useState<Session[]>([]);
105
+ const [keys, setKeys] = useState<ApiKey[]>([]);
106
+ const [creatingKey, setCreatingKey] = useState(false);
107
+ const [newKeyName, setNewKeyName] = useState('');
108
+ const [newKeyType, setNewKeyType] = useState<'publishable' | 'secret'>(
109
+ 'secret'
110
+ );
111
+ const [newKeyEnv, setNewKeyEnv] = useState<'test' | 'live'>('test');
112
+ const [createdKeyValue, setCreatedKeyValue] = useState<string | null>(null);
113
+
114
+ const client = useMemo(
115
+ () => new SlyxupClient({ secretKey, apiUrl }),
116
+ [secretKey, apiUrl]
117
+ );
118
+
119
+ const loadAll = useCallback(async () => {
120
+ setLoading(true);
121
+ setError(null);
122
+ try {
123
+ const [p, u, s, k] = await Promise.all([
124
+ client.admin.getProject(),
125
+ client.admin.listUsers({ limit: 100 }),
126
+ client.admin.listSessions(),
127
+ client.admin.listKeys(),
128
+ ]);
129
+ setProject(p.project);
130
+ setUsers(u.users);
131
+ setSessions(s.sessions);
132
+ setKeys(k.keys);
133
+ } catch (err: unknown) {
134
+ const msg =
135
+ err instanceof Error ? err.message : 'Failed to load admin data';
136
+ setError(msg);
137
+ } finally {
138
+ setLoading(false);
139
+ }
140
+ }, [client]);
141
+
142
+ useEffect(() => {
143
+ loadAll();
144
+ }, [loadAll]);
145
+
146
+ const handleCreateKey = useCallback(async () => {
147
+ if (!newKeyName.trim()) return;
148
+ setCreatingKey(true);
149
+ try {
150
+ const res = await client.admin.createKey({
151
+ name: newKeyName.trim(),
152
+ type: newKeyType,
153
+ environment: newKeyEnv,
154
+ });
155
+ setCreatedKeyValue(res.key);
156
+ setNewKeyName('');
157
+ const k = await client.admin.listKeys();
158
+ setKeys(k.keys);
159
+ } catch (err: unknown) {
160
+ const msg = err instanceof Error ? err.message : 'Failed to create key';
161
+ setError(msg);
162
+ } finally {
163
+ setCreatingKey(false);
164
+ }
165
+ }, [client, newKeyName, newKeyType, newKeyEnv]);
166
+
167
+ const handleRevokeKey = useCallback(
168
+ async (keyId: string) => {
169
+ try {
170
+ await client.admin.revokeKey(keyId);
171
+ const k = await client.admin.listKeys();
172
+ setKeys(k.keys);
173
+ } catch (err: unknown) {
174
+ const msg = err instanceof Error ? err.message : 'Failed to revoke key';
175
+ setError(msg);
176
+ }
177
+ },
178
+ [client]
179
+ );
180
+
181
+ const activeSessions = sessions.filter((s) => !s.isExpired);
182
+ const totalKeys = keys.length;
183
+ const secretKeys = keys.filter((k) => k.type === 'secret');
184
+
185
+ injectStyles();
186
+
187
+ const scopeStyle: React.CSSProperties = fullPage
188
+ ? {
189
+ minHeight: '100vh',
190
+ background: 'var(--slx-bg-page, #f4f4f5)',
191
+ padding: '32px 24px',
192
+ }
193
+ : {
194
+ background: 'var(--slx-bg, #fff)',
195
+ borderRadius: 'var(--slx-radius-lg, 14px)',
196
+ border: '1px solid var(--slx-border, #e4e4e7)',
197
+ padding: 24,
198
+ };
199
+
200
+ return (
201
+ <div className="slyxup-root" style={scopeStyle}>
202
+ <style>{`
203
+ .slx-admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
204
+ .slx-admin-stat { background: var(--slx-bg, #fff); border: 1px solid var(--slx-border, #e4e4e7); border-radius: var(--slx-radius, 10px); padding: 20px; }
205
+ .slx-admin-stat-value { font-size: 28px; font-weight: 700; color: var(--slx-ink, #16161d); font-family: var(--slx-display, inherit); }
206
+ .slx-admin-stat-label { font-size: 13px; color: var(--slx-muted, #71717a); margin-top: 4px; font-weight: 500; }
207
+ .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; }
208
+ .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); }
209
+ .slx-admin-tab:hover { color: var(--slx-ink, #16161d); }
210
+ .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); }
211
+ .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; }
212
+ .slx-admin-card-title { font-size: 16px; font-weight: 600; color: var(--slx-ink, #16161d); margin-bottom: 16px; }
213
+ .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; }
214
+ .slx-admin-row:hover { border-color: var(--slx-border-strong, #d1d5db); }
215
+ .slx-admin-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; }
216
+ .slx-admin-badge--green { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
217
+ .slx-admin-badge--yellow { background: rgba(234, 179, 8, 0.12); color: #ca8a04; }
218
+ .slx-admin-badge--red { background: rgba(239, 68, 68, 0.12); color: #dc2626; }
219
+ .slx-admin-badge--blue { background: rgba(59, 130, 246, 0.12); color: #2563eb; }
220
+ .slx-admin-badge--gray { background: rgba(113, 113, 122, 0.12); color: #71717a; }
221
+ .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; }
222
+ .slx-admin-empty { text-align: center; padding: 48px 24px; color: var(--slx-muted, #71717a); font-size: 14px; }
223
+ .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; }
224
+ .slx-admin-input:focus { border-color: var(--slx-accent, #5b5bd6); }
225
+ .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; }
226
+ .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; }
227
+ .slx-admin-btn--primary { background: var(--slx-accent, #5b5bd6); color: #fff; }
228
+ .slx-admin-btn--primary:hover { background: var(--slx-accent-hover, #4c4cc4); }
229
+ .slx-admin-btn--primary:disabled { opacity: 0.6; cursor: not-allowed; }
230
+ .slx-admin-btn--danger { background: transparent; color: var(--slx-danger, #d64550); border: 1px solid var(--slx-danger, #d64550); }
231
+ .slx-admin-btn--danger:hover { background: rgba(214, 69, 80, 0.08); }
232
+ .slx-admin-btn--ghost { background: transparent; color: var(--slx-muted, #71717a); border: 1px solid var(--slx-border, #e4e4e7); }
233
+ .slx-admin-btn--ghost:hover { background: var(--slx-bg-subtle, #f4f4f5); }
234
+ .slx-admin-create-form { display: flex; gap: 8px; align-items: end; flex-wrap: wrap; margin-bottom: 16px; }
235
+ .slx-admin-create-field { display: flex; flex-direction: column; gap: 4px; }
236
+ .slx-admin-create-label { font-size: 12px; font-weight: 600; color: var(--slx-muted, #71717a); text-transform: uppercase; letter-spacing: 0.5px; }
237
+ .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); }
238
+ .slx-admin-loading { display: flex; justify-content: center; align-items: center; padding: 64px; }
239
+ .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; }
240
+ @keyframes slx-spin { to { transform: rotate(360deg); } }
241
+ .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; }
242
+ @media (max-width: 640px) {
243
+ .slx-admin-grid { grid-template-columns: 1fr 1fr; }
244
+ .slx-admin-tabs { width: 100%; overflow-x: auto; }
245
+ .slx-admin-create-form { flex-direction: column; align-items: stretch; }
246
+ .slx-admin-create-field { width: 100%; }
247
+ .slx-admin-row { flex-direction: column; align-items: flex-start; gap: 8px; }
248
+ }
249
+ `}</style>
250
+
251
+ {error && (
252
+ <div
253
+ className="slx-admin-error"
254
+ style={{
255
+ display: 'flex',
256
+ justifyContent: 'space-between',
257
+ alignItems: 'center',
258
+ }}
259
+ >
260
+ <span>{error}</span>
261
+ <button
262
+ type="button"
263
+ className="slx-admin-btn slx-admin-btn--ghost"
264
+ style={{ padding: '4px 12px', fontSize: 12 }}
265
+ onClick={() => setError(null)}
266
+ >
267
+ Dismiss
268
+ </button>
269
+ </div>
270
+ )}
271
+
272
+ {loading ? (
273
+ <div className="slx-admin-loading">
274
+ <div className="slx-admin-spinner" />
275
+ </div>
276
+ ) : (
277
+ <>
278
+ {/* Header */}
279
+ <div style={{ marginBottom: 24 }}>
280
+ <h1
281
+ style={{
282
+ fontSize: 24,
283
+ fontWeight: 700,
284
+ color: 'var(--slx-ink, #16161d)',
285
+ fontFamily: 'var(--slx-display, inherit)',
286
+ margin: 0,
287
+ }}
288
+ >
289
+ Admin Dashboard
290
+ </h1>
291
+ {project && (
292
+ <p
293
+ style={{
294
+ fontSize: 14,
295
+ color: 'var(--slx-muted, #71717a)',
296
+ marginTop: 4,
297
+ }}
298
+ >
299
+ {project.name}
300
+ </p>
301
+ )}
302
+ </div>
303
+
304
+ {/* Tabs */}
305
+ <div className="slx-admin-tabs">
306
+ {TABS.map((t) => (
307
+ <button
308
+ type="button"
309
+ key={t.key}
310
+ className={`slx-admin-tab ${tab === t.key ? 'slx-admin-tab--active' : ''}`}
311
+ onClick={() => setTab(t.key)}
312
+ >
313
+ {t.icon} {t.label}
314
+ </button>
315
+ ))}
316
+ </div>
317
+
318
+ {/* Content */}
319
+ {tab === 'overview' && (
320
+ <div className="slx-admin-grid">
321
+ <div className="slx-admin-stat">
322
+ <div className="slx-admin-stat-value">{users.length}</div>
323
+ <div className="slx-admin-stat-label">Total Users</div>
324
+ </div>
325
+ <div className="slx-admin-stat">
326
+ <div className="slx-admin-stat-value">
327
+ {activeSessions.length}
328
+ </div>
329
+ <div className="slx-admin-stat-label">Active Sessions</div>
330
+ </div>
331
+ <div className="slx-admin-stat">
332
+ <div className="slx-admin-stat-value">{totalKeys}</div>
333
+ <div className="slx-admin-stat-label">API Keys</div>
334
+ </div>
335
+ <div className="slx-admin-stat">
336
+ <div className="slx-admin-stat-value">
337
+ {users.filter((u) => u.emailVerified).length}
338
+ </div>
339
+ <div className="slx-admin-stat-label">Verified Users</div>
340
+ </div>
341
+ <div className="slx-admin-stat">
342
+ <div className="slx-admin-stat-value">
343
+ {users.filter((u) => u.blocked).length}
344
+ </div>
345
+ <div className="slx-admin-stat-label">Blocked Users</div>
346
+ </div>
347
+ <div className="slx-admin-stat">
348
+ <div className="slx-admin-stat-value">{secretKeys.length}</div>
349
+ <div className="slx-admin-stat-label">Secret Keys</div>
350
+ </div>
351
+ </div>
352
+ )}
353
+
354
+ {tab === 'users' && (
355
+ <div className="slx-admin-card">
356
+ <div className="slx-admin-card-title">Users ({users.length})</div>
357
+ {users.length === 0 ? (
358
+ <div className="slx-admin-empty">No users found.</div>
359
+ ) : (
360
+ users.map((u) => (
361
+ <div key={u.id} className="slx-admin-row">
362
+ <div
363
+ style={{ display: 'flex', alignItems: 'center', gap: 12 }}
364
+ >
365
+ <div
366
+ className="slx-admin-avatar"
367
+ style={{
368
+ background: `hsl(${(u.email.charCodeAt(0) * 37) % 360}, 55%, 50%)`,
369
+ }}
370
+ >
371
+ {initials(u)}
372
+ </div>
373
+ <div>
374
+ <div
375
+ style={{
376
+ fontSize: 14,
377
+ fontWeight: 500,
378
+ color: 'var(--slx-ink, #16161d)',
379
+ }}
380
+ >
381
+ {displayName(u)}
382
+ </div>
383
+ <div
384
+ style={{
385
+ fontSize: 12,
386
+ color: 'var(--slx-muted, #71717a)',
387
+ }}
388
+ >
389
+ {u.email}
390
+ </div>
391
+ </div>
392
+ </div>
393
+ <div
394
+ style={{ display: 'flex', gap: 6, alignItems: 'center' }}
395
+ >
396
+ {u.emailVerified ? (
397
+ <span className="slx-admin-badge slx-admin-badge--green">
398
+ Verified
399
+ </span>
400
+ ) : (
401
+ <span className="slx-admin-badge slx-admin-badge--yellow">
402
+ Unverified
403
+ </span>
404
+ )}
405
+ {u.blocked && (
406
+ <span className="slx-admin-badge slx-admin-badge--red">
407
+ Blocked
408
+ </span>
409
+ )}
410
+ <span
411
+ style={{
412
+ fontSize: 11,
413
+ color: 'var(--slx-muted, #71717a)',
414
+ marginLeft: 4,
415
+ }}
416
+ >
417
+ {timeAgo(u.createdAt)}
418
+ </span>
419
+ </div>
420
+ </div>
421
+ ))
422
+ )}
423
+ </div>
424
+ )}
425
+
426
+ {tab === 'sessions' && (
427
+ <div className="slx-admin-card">
428
+ <div className="slx-admin-card-title">
429
+ Sessions ({activeSessions.length} active)
430
+ </div>
431
+ {activeSessions.length === 0 ? (
432
+ <div className="slx-admin-empty">No active sessions.</div>
433
+ ) : (
434
+ activeSessions.map((s) => {
435
+ const u = users.find((u) => u.id === s.userId);
436
+ return (
437
+ <div key={s.id} className="slx-admin-row">
438
+ <div>
439
+ <div
440
+ style={{
441
+ fontSize: 14,
442
+ fontWeight: 500,
443
+ color: 'var(--slx-ink, #16161d)',
444
+ }}
445
+ >
446
+ {u ? displayName(u) : `${s.userId.slice(0, 8)}...`}
447
+ </div>
448
+ <div
449
+ style={{
450
+ fontSize: 12,
451
+ color: 'var(--slx-muted, #71717a)',
452
+ }}
453
+ >
454
+ {s.userAgent
455
+ ? s.userAgent.slice(0, 60) +
456
+ (s.userAgent.length > 60 ? '...' : '')
457
+ : 'No user agent'}
458
+ </div>
459
+ </div>
460
+ <div style={{ textAlign: 'right' }}>
461
+ <div
462
+ style={{
463
+ fontSize: 12,
464
+ color: 'var(--slx-muted, #71717a)',
465
+ }}
466
+ >
467
+ Expires {timeAgo(s.expiresAt)}
468
+ </div>
469
+ {s.ipAddress && (
470
+ <div
471
+ style={{
472
+ fontSize: 11,
473
+ color: 'var(--slx-muted, #a1a1aa)',
474
+ }}
475
+ >
476
+ {s.ipAddress}
477
+ </div>
478
+ )}
479
+ </div>
480
+ </div>
481
+ );
482
+ })
483
+ )}
484
+ </div>
485
+ )}
486
+
487
+ {tab === 'keys' && (
488
+ <div className="slx-admin-card">
489
+ <div className="slx-admin-card-title">
490
+ API Keys ({keys.length})
491
+ </div>
492
+
493
+ {/* Create form */}
494
+ <div className="slx-admin-create-form">
495
+ <div
496
+ className="slx-admin-create-field"
497
+ style={{ flex: 2, minWidth: 160 }}
498
+ >
499
+ <label
500
+ className="slx-admin-create-label"
501
+ htmlFor="slx-admin-key-name"
502
+ >
503
+ Name
504
+ </label>
505
+ <input
506
+ id="slx-admin-key-name"
507
+ className="slx-admin-input"
508
+ placeholder="e.g. Production Key"
509
+ value={newKeyName}
510
+ onChange={(e) => setNewKeyName(e.target.value)}
511
+ onKeyDown={(e) => e.key === 'Enter' && handleCreateKey()}
512
+ />
513
+ </div>
514
+ <div
515
+ className="slx-admin-create-field"
516
+ style={{ minWidth: 120 }}
517
+ >
518
+ <label
519
+ className="slx-admin-create-label"
520
+ htmlFor="slx-admin-key-type"
521
+ >
522
+ Type
523
+ </label>
524
+ <select
525
+ id="slx-admin-key-type"
526
+ className="slx-admin-select"
527
+ value={newKeyType}
528
+ onChange={(e) =>
529
+ setNewKeyType(e.target.value as 'publishable' | 'secret')
530
+ }
531
+ >
532
+ <option value="secret">Secret (sk)</option>
533
+ <option value="publishable">Publishable (pk)</option>
534
+ </select>
535
+ </div>
536
+ <div
537
+ className="slx-admin-create-field"
538
+ style={{ minWidth: 100 }}
539
+ >
540
+ <label
541
+ className="slx-admin-create-label"
542
+ htmlFor="slx-admin-key-env"
543
+ >
544
+ Env
545
+ </label>
546
+ <select
547
+ id="slx-admin-key-env"
548
+ className="slx-admin-select"
549
+ value={newKeyEnv}
550
+ onChange={(e) =>
551
+ setNewKeyEnv(e.target.value as 'test' | 'live')
552
+ }
553
+ >
554
+ <option value="test">Test</option>
555
+ <option value="live">Live</option>
556
+ </select>
557
+ </div>
558
+ <button
559
+ type="button"
560
+ className="slx-admin-btn slx-admin-btn--primary"
561
+ onClick={handleCreateKey}
562
+ disabled={creatingKey || !newKeyName.trim()}
563
+ style={{ alignSelf: 'end' }}
564
+ >
565
+ {creatingKey ? 'Creating...' : 'Create Key'}
566
+ </button>
567
+ </div>
568
+
569
+ {createdKeyValue && (
570
+ <div
571
+ className="slx-admin-key-display"
572
+ style={{
573
+ display: 'flex',
574
+ justifyContent: 'space-between',
575
+ alignItems: 'center',
576
+ }}
577
+ >
578
+ <span>{createdKeyValue}</span>
579
+ <button
580
+ type="button"
581
+ className="slx-admin-btn slx-admin-btn--ghost"
582
+ style={{
583
+ padding: '4px 12px',
584
+ fontSize: 12,
585
+ flexShrink: 0,
586
+ marginLeft: 12,
587
+ }}
588
+ onClick={() => {
589
+ navigator.clipboard.writeText(createdKeyValue);
590
+ }}
591
+ >
592
+ Copy
593
+ </button>
594
+ </div>
595
+ )}
596
+
597
+ {keys.length === 0 ? (
598
+ <div className="slx-admin-empty">
599
+ No API keys. Create one above.
600
+ </div>
601
+ ) : (
602
+ keys.map((k) => (
603
+ <div key={k.id} className="slx-admin-row">
604
+ <div>
605
+ <div
606
+ style={{
607
+ fontSize: 14,
608
+ fontWeight: 500,
609
+ color: 'var(--slx-ink, #16161d)',
610
+ }}
611
+ >
612
+ {k.name}
613
+ </div>
614
+ <div
615
+ style={{
616
+ fontSize: 12,
617
+ color: 'var(--slx-muted, #71717a)',
618
+ fontFamily: 'var(--slx-mono, monospace)',
619
+ }}
620
+ >
621
+ {k.prefix}...
622
+ </div>
623
+ </div>
624
+ <div
625
+ style={{ display: 'flex', gap: 6, alignItems: 'center' }}
626
+ >
627
+ <span
628
+ className={`slx-admin-badge ${k.type === 'secret' ? 'slx-admin-badge--red' : 'slx-admin-badge--blue'}`}
629
+ >
630
+ {k.type}
631
+ </span>
632
+ <span className="slx-admin-badge slx-admin-badge--gray">
633
+ {k.environment}
634
+ </span>
635
+ {k.lastUsedAt && (
636
+ <span
637
+ style={{
638
+ fontSize: 11,
639
+ color: 'var(--slx-muted, #71717a)',
640
+ }}
641
+ >
642
+ Used {timeAgo(k.lastUsedAt)}
643
+ </span>
644
+ )}
645
+ <button
646
+ type="button"
647
+ className="slx-admin-btn slx-admin-btn--danger"
648
+ style={{ padding: '4px 10px', fontSize: 12 }}
649
+ onClick={() => handleRevokeKey(k.id)}
650
+ >
651
+ Revoke
652
+ </button>
653
+ </div>
654
+ </div>
655
+ ))
656
+ )}
657
+ </div>
658
+ )}
659
+ </>
660
+ )}
661
+ </div>
662
+ );
663
+ }
@@ -93,7 +93,7 @@ export function PricingTable({ plans, onSelect, loading }: PricingTableProps) {
93
93
  fontSize: 38,
94
94
  fontWeight: 750,
95
95
  letterSpacing: '-0.03em',
96
- fontFamily: '"Space Grotesk",sans-serif',
96
+ fontFamily: 'var(--slx-display)',
97
97
  }}
98
98
  >
99
99
  ${(plan.amount / 100).toFixed(0)}
package/src/index.ts CHANGED
@@ -35,6 +35,10 @@ export {
35
35
  PricingTable,
36
36
  type PricingTableProps,
37
37
  } from './components/PricingTable/PricingTable';
38
+ export {
39
+ AdminPanel,
40
+ type AdminPanelProps,
41
+ } from './components/AdminPanel/AdminPanel';
38
42
  export { initPaddle, openPaddleCheckout } from './lib/paddle';
39
43
 
40
44
  import { useEffect } from 'react';