@delmaredigital/payload-better-auth 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +34 -91
  2. package/src/adapter/collections.ts +0 -621
  3. package/src/adapter/index.ts +0 -712
  4. package/src/components/BeforeLogin.tsx +0 -39
  5. package/src/components/LoginView.tsx +0 -1516
  6. package/src/components/LoginViewWrapper.tsx +0 -35
  7. package/src/components/LogoutButton.tsx +0 -58
  8. package/src/components/PasskeyRegisterButton.tsx +0 -105
  9. package/src/components/PasskeySignInButton.tsx +0 -96
  10. package/src/components/auth/ForgotPasswordView.tsx +0 -274
  11. package/src/components/auth/ResetPasswordView.tsx +0 -331
  12. package/src/components/auth/index.ts +0 -8
  13. package/src/components/management/ApiKeysManagementClient.tsx +0 -988
  14. package/src/components/management/PasskeysManagementClient.tsx +0 -409
  15. package/src/components/management/SecurityNavLinks.tsx +0 -117
  16. package/src/components/management/TwoFactorManagementClient.tsx +0 -560
  17. package/src/components/management/index.ts +0 -20
  18. package/src/components/management/views/ApiKeysView.tsx +0 -57
  19. package/src/components/management/views/PasskeysView.tsx +0 -42
  20. package/src/components/management/views/TwoFactorView.tsx +0 -42
  21. package/src/components/management/views/index.ts +0 -10
  22. package/src/components/twoFactor/TwoFactorSetupView.tsx +0 -515
  23. package/src/components/twoFactor/TwoFactorVerifyView.tsx +0 -238
  24. package/src/components/twoFactor/index.ts +0 -8
  25. package/src/exports/client.ts +0 -77
  26. package/src/exports/components.ts +0 -30
  27. package/src/exports/management.ts +0 -25
  28. package/src/exports/rsc.ts +0 -11
  29. package/src/generated-types.ts +0 -269
  30. package/src/index.ts +0 -135
  31. package/src/plugin/index.ts +0 -834
  32. package/src/scripts/generate-types.ts +0 -269
  33. package/src/types/apiKey.ts +0 -63
  34. package/src/types/betterAuth.ts +0 -253
  35. package/src/utils/access.ts +0 -410
  36. package/src/utils/apiKeyAccess.ts +0 -443
  37. package/src/utils/betterAuthDefaults.ts +0 -102
  38. package/src/utils/detectAuthConfig.ts +0 -47
  39. package/src/utils/detectEnabledPlugins.ts +0 -69
  40. package/src/utils/firstUserAdmin.ts +0 -164
  41. package/src/utils/generateScopes.ts +0 -150
  42. package/src/utils/session.ts +0 -91
@@ -1,988 +0,0 @@
1
- 'use client'
2
-
3
- import { useState, useEffect, useMemo, useRef, type FormEvent } from 'react'
4
- import {
5
- createPayloadAuthClient,
6
- type PayloadAuthClient,
7
- } from '../../exports/client.js'
8
- import type { AvailableScope } from '../../types/apiKey.js'
9
-
10
- type ApiKey = {
11
- id: string
12
- name: string | null
13
- start?: string | null
14
- startsWith?: string
15
- createdAt: Date
16
- expiresAt?: Date | null
17
- lastUsedAt?: Date | null
18
- /** Stored scope IDs for display */
19
- metadata?: { scopes?: string[] }
20
- }
21
-
22
- /** A group of scopes for a single collection */
23
- type ScopeGroup = {
24
- collection: string
25
- label: string
26
- scopes: {
27
- type: 'read' | 'write' | 'delete' | 'other'
28
- scope: AvailableScope
29
- }[]
30
- }
31
-
32
- export type ApiKeysManagementClientProps = {
33
- /** Optional pre-configured auth client */
34
- authClient?: PayloadAuthClient
35
- /** Page title. Default: 'API Keys' */
36
- title?: string
37
- /** Available scopes for key creation. Auto-generated if not provided. */
38
- availableScopes?: AvailableScope[]
39
- /** Default scopes to pre-select when creating a key */
40
- defaultScopes?: string[]
41
- }
42
-
43
- /**
44
- * Group scopes by collection for the UI.
45
- * Scopes like "posts:read", "posts:write" get grouped under "Posts"
46
- */
47
- function groupScopesByCollection(scopes: AvailableScope[]): ScopeGroup[] {
48
- const groups = new Map<string, ScopeGroup>()
49
-
50
- for (const scope of scopes) {
51
- // Parse scope ID like "posts:read" -> collection="posts", type="read"
52
- const colonIndex = scope.id.indexOf(':')
53
- let collection: string
54
- let type: 'read' | 'write' | 'delete' | 'other'
55
-
56
- if (colonIndex > 0) {
57
- collection = scope.id.substring(0, colonIndex)
58
- const typeStr = scope.id.substring(colonIndex + 1)
59
- type = ['read', 'write', 'delete'].includes(typeStr)
60
- ? (typeStr as 'read' | 'write' | 'delete')
61
- : 'other'
62
- } else {
63
- // No colon - treat as standalone scope
64
- collection = scope.id
65
- type = 'other'
66
- }
67
-
68
- if (!groups.has(collection)) {
69
- // Create label from collection slug (posts -> Posts)
70
- const label = collection.charAt(0).toUpperCase() + collection.slice(1).replace(/-/g, ' ')
71
- groups.set(collection, {
72
- collection,
73
- label,
74
- scopes: [],
75
- })
76
- }
77
-
78
- groups.get(collection)!.scopes.push({ type, scope })
79
- }
80
-
81
- // Sort groups alphabetically, sort scopes within group by type order
82
- const typeOrder = { read: 0, write: 1, delete: 2, other: 3 }
83
- return Array.from(groups.values())
84
- .sort((a, b) => a.label.localeCompare(b.label))
85
- .map((group) => ({
86
- ...group,
87
- scopes: group.scopes.sort((a, b) => typeOrder[a.type] - typeOrder[b.type]),
88
- }))
89
- }
90
-
91
- /**
92
- * Client component for API keys management.
93
- * Lists, creates, and deletes API keys with scope selection.
94
- */
95
- export function ApiKeysManagementClient({
96
- authClient: providedClient,
97
- title = 'API Keys',
98
- availableScopes = [],
99
- defaultScopes = [],
100
- }: ApiKeysManagementClientProps = {}) {
101
- const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
102
- const [loading, setLoading] = useState(true)
103
- const [error, setError] = useState<string | null>(null)
104
- const [creating, setCreating] = useState(false)
105
- const [deleting, setDeleting] = useState<string | null>(null)
106
- const [showCreateForm, setShowCreateForm] = useState(false)
107
- const [newKeyName, setNewKeyName] = useState('')
108
- const [newKeyExpiry, setNewKeyExpiry] = useState('')
109
- const [selectedScopes, setSelectedScopes] = useState<string[]>(defaultScopes)
110
- const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null)
111
- const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
112
-
113
- const hasScopes = availableScopes.length > 0
114
-
115
- // Group scopes by collection
116
- const scopeGroups = useMemo(
117
- () => groupScopesByCollection(availableScopes),
118
- [availableScopes]
119
- )
120
-
121
- // Get all scope IDs by type for bulk actions
122
- const scopesByType = useMemo(() => {
123
- const result = { read: [] as string[], write: [] as string[], delete: [] as string[] }
124
- for (const group of scopeGroups) {
125
- for (const { type, scope } of group.scopes) {
126
- if (type === 'read' || type === 'write' || type === 'delete') {
127
- result[type].push(scope.id)
128
- }
129
- }
130
- }
131
- return result
132
- }, [scopeGroups])
133
-
134
- const getClient = () => providedClient ?? createPayloadAuthClient()
135
-
136
- // Toggle a scope selection
137
- function toggleScope(scopeId: string) {
138
- setSelectedScopes((prev) =>
139
- prev.includes(scopeId)
140
- ? prev.filter((s) => s !== scopeId)
141
- : [...prev, scopeId]
142
- )
143
- }
144
-
145
- // Toggle all scopes in a group
146
- function toggleGroup(group: ScopeGroup) {
147
- const groupScopeIds = group.scopes.map((s) => s.scope.id)
148
- const allSelected = groupScopeIds.every((id) => selectedScopes.includes(id))
149
-
150
- if (allSelected) {
151
- // Deselect all in group
152
- setSelectedScopes((prev) => prev.filter((id) => !groupScopeIds.includes(id)))
153
- } else {
154
- // Select all in group
155
- setSelectedScopes((prev) => [...new Set([...prev, ...groupScopeIds])])
156
- }
157
- }
158
-
159
- // Toggle expand/collapse for a group
160
- function toggleExpanded(collection: string) {
161
- setExpandedGroups((prev) => {
162
- const next = new Set(prev)
163
- if (next.has(collection)) {
164
- next.delete(collection)
165
- } else {
166
- next.add(collection)
167
- }
168
- return next
169
- })
170
- }
171
-
172
- // Bulk toggle all scopes of a type
173
- function toggleAllOfType(type: 'read' | 'write' | 'delete') {
174
- const typeScopes = scopesByType[type]
175
- const allSelected = typeScopes.every((id) => selectedScopes.includes(id))
176
-
177
- if (allSelected) {
178
- setSelectedScopes((prev) => prev.filter((id) => !typeScopes.includes(id)))
179
- } else {
180
- setSelectedScopes((prev) => [...new Set([...prev, ...typeScopes])])
181
- }
182
- }
183
-
184
- // Check if all scopes of a type are selected
185
- function isAllOfTypeSelected(type: 'read' | 'write' | 'delete'): boolean {
186
- return scopesByType[type].length > 0 && scopesByType[type].every((id) => selectedScopes.includes(id))
187
- }
188
-
189
- // Check if some (but not all) scopes of a type are selected
190
- function isSomeOfTypeSelected(type: 'read' | 'write' | 'delete'): boolean {
191
- const typeScopes = scopesByType[type]
192
- const selectedCount = typeScopes.filter((id) => selectedScopes.includes(id)).length
193
- return selectedCount > 0 && selectedCount < typeScopes.length
194
- }
195
-
196
- // Clear all selections
197
- function clearAll() {
198
- setSelectedScopes([])
199
- }
200
-
201
- // Select all scopes
202
- function selectAll() {
203
- setSelectedScopes(availableScopes.map((s) => s.id))
204
- }
205
-
206
- // Get group selection state
207
- function getGroupState(group: ScopeGroup): 'all' | 'some' | 'none' {
208
- const groupScopeIds = group.scopes.map((s) => s.scope.id)
209
- const selectedCount = groupScopeIds.filter((id) => selectedScopes.includes(id)).length
210
- if (selectedCount === 0) return 'none'
211
- if (selectedCount === groupScopeIds.length) return 'all'
212
- return 'some'
213
- }
214
-
215
- // Get scope label by ID
216
- function getScopeLabel(scopeId: string): string {
217
- const scope = availableScopes.find((s) => s.id === scopeId)
218
- return scope?.label ?? scopeId
219
- }
220
-
221
- // Get short label for scope type
222
- function getTypeLabel(type: 'read' | 'write' | 'delete' | 'other'): string {
223
- switch (type) {
224
- case 'read': return 'Read'
225
- case 'write': return 'Write'
226
- case 'delete': return 'Delete'
227
- default: return 'Access'
228
- }
229
- }
230
-
231
- useEffect(() => {
232
- fetchApiKeys()
233
- // eslint-disable-next-line react-hooks/exhaustive-deps
234
- }, [])
235
-
236
- async function fetchApiKeys() {
237
- setLoading(true)
238
- setError(null)
239
-
240
- try {
241
- const client = getClient()
242
- const result = await client.apiKey.list()
243
-
244
- if (result.error) {
245
- setError(result.error.message ?? 'Failed to load API keys')
246
- } else {
247
- setApiKeys((result.data as ApiKey[]) ?? [])
248
- }
249
- } catch {
250
- setError('Failed to load API keys')
251
- } finally {
252
- setLoading(false)
253
- }
254
- }
255
-
256
- async function handleCreate(e: FormEvent) {
257
- e.preventDefault()
258
- setCreating(true)
259
- setError(null)
260
- setNewlyCreatedKey(null)
261
-
262
- try {
263
- const client = getClient()
264
- // Send scopes to server - server will convert to permissions
265
- const createOptions: {
266
- name: string
267
- expiresIn?: number
268
- scopes?: string[]
269
- } = { name: newKeyName }
270
-
271
- if (newKeyExpiry) {
272
- createOptions.expiresIn = parseInt(newKeyExpiry) * 24 * 60 * 60 // Convert days to seconds
273
- }
274
-
275
- // Add scopes if any are selected - server handles conversion to permissions
276
- if (hasScopes && selectedScopes.length > 0) {
277
- createOptions.scopes = selectedScopes
278
- }
279
-
280
- const result = await client.apiKey.create(createOptions)
281
-
282
- if (result.error) {
283
- setError(result.error.message ?? 'Failed to create API key')
284
- } else if (result.data) {
285
- setNewlyCreatedKey(result.data.key)
286
- setShowCreateForm(false)
287
- setNewKeyName('')
288
- setNewKeyExpiry('')
289
- setSelectedScopes(defaultScopes) // Reset to defaults
290
- fetchApiKeys()
291
- }
292
- } catch {
293
- setError('Failed to create API key')
294
- } finally {
295
- setCreating(false)
296
- }
297
- }
298
-
299
- async function handleDelete(keyId: string) {
300
- if (!confirm('Are you sure you want to delete this API key?')) {
301
- return
302
- }
303
-
304
- setDeleting(keyId)
305
- setError(null)
306
-
307
- try {
308
- const client = getClient()
309
- const result = await client.apiKey.delete({ keyId })
310
-
311
- if (result.error) {
312
- setError(result.error.message ?? 'Failed to delete API key')
313
- } else {
314
- setApiKeys((prev) => prev.filter((k) => k.id !== keyId))
315
- }
316
- } catch {
317
- setError('Failed to delete API key')
318
- } finally {
319
- setDeleting(null)
320
- }
321
- }
322
-
323
- function formatDate(date?: Date | string | null) {
324
- if (!date) return 'Never'
325
- const d = date instanceof Date ? date : new Date(date)
326
- return d.toLocaleString()
327
- }
328
-
329
- return (
330
- <div
331
- style={{
332
- maxWidth: '900px',
333
- margin: '0 auto',
334
- padding: 'calc(var(--base) * 2)',
335
- }}
336
- >
337
-
338
- <div
339
- style={{
340
- display: 'flex',
341
- justifyContent: 'space-between',
342
- alignItems: 'center',
343
- marginBottom: 'calc(var(--base) * 2)',
344
- }}
345
- >
346
- <h1
347
- style={{
348
- color: 'var(--theme-text)',
349
- fontSize: 'var(--font-size-h2)',
350
- fontWeight: 600,
351
- margin: 0,
352
- }}
353
- >
354
- {title}
355
- </h1>
356
-
357
- <button
358
- onClick={() => setShowCreateForm(true)}
359
- style={{
360
- padding: 'calc(var(--base) * 0.5) calc(var(--base) * 1)',
361
- background: 'var(--theme-elevation-800)',
362
- border: 'none',
363
- borderRadius: 'var(--style-radius-s)',
364
- color: 'var(--theme-elevation-50)',
365
- fontSize: 'var(--font-size-small)',
366
- cursor: 'pointer',
367
- }}
368
- >
369
- Create API Key
370
- </button>
371
- </div>
372
-
373
- {error && (
374
- <div
375
- style={{
376
- color: 'var(--theme-error-500)',
377
- marginBottom: 'var(--base)',
378
- fontSize: 'var(--font-size-small)',
379
- padding: 'calc(var(--base) * 0.75)',
380
- background: 'var(--theme-error-50)',
381
- borderRadius: 'var(--style-radius-s)',
382
- border: '1px solid var(--theme-error-200)',
383
- }}
384
- >
385
- {error}
386
- </div>
387
- )}
388
-
389
- {newlyCreatedKey && (
390
- <div
391
- style={{
392
- marginBottom: 'calc(var(--base) * 1.5)',
393
- padding: 'calc(var(--base) * 1)',
394
- background: 'var(--theme-success-50)',
395
- borderRadius: 'var(--style-radius-m)',
396
- border: '1px solid var(--theme-success-200)',
397
- }}
398
- >
399
- <div
400
- style={{
401
- color: 'var(--theme-success-700)',
402
- fontWeight: 500,
403
- marginBottom: 'calc(var(--base) * 0.5)',
404
- }}
405
- >
406
- API Key Created
407
- </div>
408
- <p
409
- style={{
410
- color: 'var(--theme-text)',
411
- opacity: 0.8,
412
- fontSize: 'var(--font-size-small)',
413
- marginBottom: 'calc(var(--base) * 0.5)',
414
- }}
415
- >
416
- Copy this key now - you won't be able to see it again:
417
- </p>
418
- <div
419
- style={{
420
- display: 'flex',
421
- gap: 'calc(var(--base) * 0.5)',
422
- alignItems: 'center',
423
- }}
424
- >
425
- <code
426
- style={{
427
- flex: 1,
428
- padding: 'calc(var(--base) * 0.5)',
429
- background: 'var(--theme-elevation-100)',
430
- borderRadius: 'var(--style-radius-s)',
431
- fontFamily: 'monospace',
432
- fontSize: 'var(--font-size-small)',
433
- color: 'var(--theme-text)',
434
- wordBreak: 'break-all',
435
- }}
436
- >
437
- {newlyCreatedKey}
438
- </code>
439
- <button
440
- onClick={() => {
441
- navigator.clipboard.writeText(newlyCreatedKey)
442
- }}
443
- style={{
444
- padding: 'calc(var(--base) * 0.5)',
445
- background: 'var(--theme-elevation-200)',
446
- border: 'none',
447
- borderRadius: 'var(--style-radius-s)',
448
- cursor: 'pointer',
449
- }}
450
- >
451
- Copy
452
- </button>
453
- </div>
454
- </div>
455
- )}
456
-
457
- {showCreateForm && (
458
- <div
459
- style={{
460
- marginBottom: 'calc(var(--base) * 1.5)',
461
- padding: 'calc(var(--base) * 1.5)',
462
- background: 'var(--theme-elevation-50)',
463
- borderRadius: 'var(--style-radius-m)',
464
- border: '1px solid var(--theme-elevation-100)',
465
- }}
466
- >
467
- <h2
468
- style={{
469
- color: 'var(--theme-text)',
470
- fontSize: 'var(--font-size-h4)',
471
- fontWeight: 500,
472
- margin: '0 0 var(--base) 0',
473
- }}
474
- >
475
- Create New API Key
476
- </h2>
477
- <form onSubmit={handleCreate}>
478
- <div style={{ marginBottom: 'var(--base)' }}>
479
- <label
480
- style={{
481
- display: 'block',
482
- color: 'var(--theme-text)',
483
- fontSize: 'var(--font-size-small)',
484
- marginBottom: 'calc(var(--base) * 0.25)',
485
- }}
486
- >
487
- Name
488
- </label>
489
- <input
490
- type="text"
491
- value={newKeyName}
492
- onChange={(e) => setNewKeyName(e.target.value)}
493
- required
494
- placeholder="My API Key"
495
- style={{
496
- width: '100%',
497
- padding: 'calc(var(--base) * 0.5)',
498
- background: 'var(--theme-input-bg)',
499
- border: '1px solid var(--theme-elevation-150)',
500
- borderRadius: 'var(--style-radius-s)',
501
- color: 'var(--theme-text)',
502
- boxSizing: 'border-box',
503
- }}
504
- />
505
- </div>
506
- <div style={{ marginBottom: 'var(--base)' }}>
507
- <label
508
- style={{
509
- display: 'block',
510
- color: 'var(--theme-text)',
511
- fontSize: 'var(--font-size-small)',
512
- marginBottom: 'calc(var(--base) * 0.25)',
513
- }}
514
- >
515
- Expires in (days, optional)
516
- </label>
517
- <input
518
- type="number"
519
- value={newKeyExpiry}
520
- onChange={(e) => setNewKeyExpiry(e.target.value)}
521
- placeholder="30"
522
- min="1"
523
- style={{
524
- width: '100%',
525
- padding: 'calc(var(--base) * 0.5)',
526
- background: 'var(--theme-input-bg)',
527
- border: '1px solid var(--theme-elevation-150)',
528
- borderRadius: 'var(--style-radius-s)',
529
- color: 'var(--theme-text)',
530
- boxSizing: 'border-box',
531
- }}
532
- />
533
- </div>
534
-
535
- {/* Scope selection - grouped by collection */}
536
- {hasScopes && (
537
- <div style={{ marginBottom: 'var(--base)' }}>
538
- <label
539
- style={{
540
- display: 'block',
541
- color: 'var(--theme-text)',
542
- fontSize: 'var(--font-size-small)',
543
- marginBottom: 'calc(var(--base) * 0.5)',
544
- }}
545
- >
546
- Permissions
547
- </label>
548
-
549
- {/* Bulk action buttons */}
550
- <div
551
- style={{
552
- display: 'flex',
553
- flexWrap: 'wrap',
554
- gap: 'calc(var(--base) * 0.5)',
555
- marginBottom: 'calc(var(--base) * 0.75)',
556
- }}
557
- >
558
- <BulkButton
559
- label="All Read"
560
- active={isAllOfTypeSelected('read')}
561
- indeterminate={isSomeOfTypeSelected('read')}
562
- onClick={() => toggleAllOfType('read')}
563
- />
564
- <BulkButton
565
- label="All Write"
566
- active={isAllOfTypeSelected('write')}
567
- indeterminate={isSomeOfTypeSelected('write')}
568
- onClick={() => toggleAllOfType('write')}
569
- />
570
- <BulkButton
571
- label="All Delete"
572
- active={isAllOfTypeSelected('delete')}
573
- indeterminate={isSomeOfTypeSelected('delete')}
574
- onClick={() => toggleAllOfType('delete')}
575
- />
576
- <div style={{ flex: 1 }} />
577
- <button
578
- type="button"
579
- onClick={selectAll}
580
- style={{
581
- padding: '4px 8px',
582
- background: 'transparent',
583
- border: '1px solid var(--theme-elevation-200)',
584
- borderRadius: 'var(--style-radius-s)',
585
- color: 'var(--theme-text)',
586
- fontSize: '11px',
587
- cursor: 'pointer',
588
- opacity: 0.8,
589
- }}
590
- >
591
- Select All
592
- </button>
593
- <button
594
- type="button"
595
- onClick={clearAll}
596
- style={{
597
- padding: '4px 8px',
598
- background: 'transparent',
599
- border: '1px solid var(--theme-elevation-200)',
600
- borderRadius: 'var(--style-radius-s)',
601
- color: 'var(--theme-text)',
602
- fontSize: '11px',
603
- cursor: 'pointer',
604
- opacity: 0.8,
605
- }}
606
- >
607
- Clear
608
- </button>
609
- </div>
610
-
611
- {/* Collection groups */}
612
- <div
613
- style={{
614
- background: 'var(--theme-input-bg)',
615
- border: '1px solid var(--theme-elevation-150)',
616
- borderRadius: 'var(--style-radius-s)',
617
- maxHeight: '400px',
618
- overflowY: 'auto',
619
- }}
620
- >
621
- {scopeGroups.map((group) => {
622
- const groupState = getGroupState(group)
623
- const isExpanded = expandedGroups.has(group.collection)
624
-
625
- return (
626
- <div
627
- key={group.collection}
628
- style={{
629
- borderBottom: '1px solid var(--theme-elevation-100)',
630
- }}
631
- >
632
- {/* Group header */}
633
- <div
634
- style={{
635
- display: 'flex',
636
- alignItems: 'center',
637
- gap: 'calc(var(--base) * 0.5)',
638
- padding: 'calc(var(--base) * 0.5) calc(var(--base) * 0.75)',
639
- cursor: 'pointer',
640
- background: groupState !== 'none' ? 'var(--theme-elevation-50)' : 'transparent',
641
- }}
642
- onClick={() => toggleExpanded(group.collection)}
643
- >
644
- {/* Expand/collapse arrow */}
645
- <span
646
- style={{
647
- color: 'var(--theme-elevation-500)',
648
- fontSize: '10px',
649
- width: '12px',
650
- transition: 'transform 0.15s',
651
- transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
652
- }}
653
- >
654
-
655
- </span>
656
-
657
- {/* Group checkbox */}
658
- <IndeterminateCheckbox
659
- checked={groupState === 'all'}
660
- indeterminate={groupState === 'some'}
661
- onChange={(e) => {
662
- e.stopPropagation()
663
- toggleGroup(group)
664
- }}
665
- />
666
-
667
- {/* Group label */}
668
- <span
669
- style={{
670
- color: 'var(--theme-text)',
671
- fontSize: 'var(--font-size-small)',
672
- fontWeight: 500,
673
- flex: 1,
674
- }}
675
- >
676
- {group.label}
677
- </span>
678
-
679
- {/* Selected count badge */}
680
- {groupState !== 'none' && (
681
- <span
682
- style={{
683
- padding: '2px 6px',
684
- background: 'var(--theme-elevation-200)',
685
- borderRadius: '10px',
686
- fontSize: '10px',
687
- color: 'var(--theme-elevation-700)',
688
- }}
689
- >
690
- {group.scopes.filter((s) => selectedScopes.includes(s.scope.id)).length}/{group.scopes.length}
691
- </span>
692
- )}
693
- </div>
694
-
695
- {/* Expanded scopes */}
696
- {isExpanded && (
697
- <div
698
- style={{
699
- padding: '0 calc(var(--base) * 0.75) calc(var(--base) * 0.5)',
700
- paddingLeft: 'calc(var(--base) * 2.5)',
701
- display: 'flex',
702
- flexDirection: 'column',
703
- gap: 'calc(var(--base) * 0.25)',
704
- }}
705
- >
706
- {group.scopes.map(({ type, scope }) => (
707
- <label
708
- key={scope.id}
709
- style={{
710
- display: 'flex',
711
- alignItems: 'center',
712
- gap: 'calc(var(--base) * 0.5)',
713
- cursor: 'pointer',
714
- padding: 'calc(var(--base) * 0.25)',
715
- borderRadius: 'var(--style-radius-s)',
716
- background: selectedScopes.includes(scope.id)
717
- ? 'var(--theme-elevation-100)'
718
- : 'transparent',
719
- }}
720
- >
721
- <input
722
- type="checkbox"
723
- checked={selectedScopes.includes(scope.id)}
724
- onChange={() => toggleScope(scope.id)}
725
- />
726
- <span
727
- style={{
728
- color: 'var(--theme-text)',
729
- fontSize: 'var(--font-size-small)',
730
- }}
731
- >
732
- {getTypeLabel(type)}
733
- </span>
734
- </label>
735
- ))}
736
- </div>
737
- )}
738
- </div>
739
- )
740
- })}
741
- </div>
742
-
743
- {/* Selection summary */}
744
- <div
745
- style={{
746
- marginTop: 'calc(var(--base) * 0.5)',
747
- fontSize: '11px',
748
- color: selectedScopes.length === 0 ? 'var(--theme-warning-500)' : 'var(--theme-elevation-600)',
749
- }}
750
- >
751
- {selectedScopes.length === 0
752
- ? 'No permissions selected. Key will have no access.'
753
- : `${selectedScopes.length} permission${selectedScopes.length === 1 ? '' : 's'} selected`}
754
- </div>
755
- </div>
756
- )}
757
-
758
- <div style={{ display: 'flex', gap: 'calc(var(--base) * 0.5)' }}>
759
- <button
760
- type="submit"
761
- disabled={creating}
762
- style={{
763
- padding: 'calc(var(--base) * 0.5) calc(var(--base) * 1)',
764
- background: 'var(--theme-elevation-800)',
765
- border: 'none',
766
- borderRadius: 'var(--style-radius-s)',
767
- color: 'var(--theme-elevation-50)',
768
- fontSize: 'var(--font-size-small)',
769
- cursor: creating ? 'not-allowed' : 'pointer',
770
- opacity: creating ? 0.7 : 1,
771
- }}
772
- >
773
- {creating ? 'Creating...' : 'Create'}
774
- </button>
775
- <button
776
- type="button"
777
- onClick={() => setShowCreateForm(false)}
778
- style={{
779
- padding: 'calc(var(--base) * 0.5) calc(var(--base) * 1)',
780
- background: 'transparent',
781
- border: '1px solid var(--theme-elevation-200)',
782
- borderRadius: 'var(--style-radius-s)',
783
- color: 'var(--theme-text)',
784
- fontSize: 'var(--font-size-small)',
785
- cursor: 'pointer',
786
- }}
787
- >
788
- Cancel
789
- </button>
790
- </div>
791
- </form>
792
- </div>
793
- )}
794
-
795
- {loading ? (
796
- <div
797
- style={{
798
- color: 'var(--theme-text)',
799
- opacity: 0.7,
800
- textAlign: 'center',
801
- padding: 'calc(var(--base) * 3)',
802
- }}
803
- >
804
- Loading API keys...
805
- </div>
806
- ) : apiKeys.length === 0 ? (
807
- <div
808
- style={{
809
- color: 'var(--theme-text)',
810
- opacity: 0.7,
811
- textAlign: 'center',
812
- padding: 'calc(var(--base) * 3)',
813
- }}
814
- >
815
- No API keys found. Create one to get started.
816
- </div>
817
- ) : (
818
- <div
819
- style={{
820
- background: 'var(--theme-elevation-50)',
821
- borderRadius: 'var(--style-radius-m)',
822
- overflow: 'hidden',
823
- border: '1px solid var(--theme-elevation-100)',
824
- }}
825
- >
826
- {apiKeys.map((key, index) => (
827
- <div
828
- key={key.id}
829
- style={{
830
- display: 'flex',
831
- justifyContent: 'space-between',
832
- alignItems: 'center',
833
- padding: 'calc(var(--base) * 1)',
834
- borderBottom:
835
- index < apiKeys.length - 1
836
- ? '1px solid var(--theme-elevation-100)'
837
- : 'none',
838
- }}
839
- >
840
- <div style={{ flex: 1 }}>
841
- <div
842
- style={{
843
- color: 'var(--theme-text)',
844
- fontWeight: 500,
845
- marginBottom: 'calc(var(--base) * 0.25)',
846
- }}
847
- >
848
- {key.name}
849
- </div>
850
- <div
851
- style={{
852
- color: 'var(--theme-elevation-600)',
853
- fontSize: 'var(--font-size-small)',
854
- }}
855
- >
856
- {(key.start || key.startsWith) && <code>{key.start || key.startsWith}...</code>}
857
- <span> • Created: {formatDate(key.createdAt)}</span>
858
- {key.expiresAt && (
859
- <span> • Expires: {formatDate(key.expiresAt)}</span>
860
- )}
861
- {key.lastUsedAt && (
862
- <span> • Last used: {formatDate(key.lastUsedAt)}</span>
863
- )}
864
- </div>
865
- {/* Display scopes if available */}
866
- {key.metadata?.scopes && key.metadata.scopes.length > 0 && (
867
- <div
868
- style={{
869
- display: 'flex',
870
- flexWrap: 'wrap',
871
- gap: 'calc(var(--base) * 0.25)',
872
- marginTop: 'calc(var(--base) * 0.5)',
873
- }}
874
- >
875
- {key.metadata.scopes.map((scopeId) => (
876
- <span
877
- key={scopeId}
878
- style={{
879
- padding: '2px 6px',
880
- background: 'var(--theme-elevation-100)',
881
- borderRadius: 'var(--style-radius-s)',
882
- fontSize: '11px',
883
- color: 'var(--theme-elevation-700)',
884
- }}
885
- >
886
- {getScopeLabel(scopeId)}
887
- </span>
888
- ))}
889
- </div>
890
- )}
891
- </div>
892
-
893
- <button
894
- onClick={() => handleDelete(key.id)}
895
- disabled={deleting === key.id}
896
- style={{
897
- padding: 'calc(var(--base) * 0.5) calc(var(--base) * 0.75)',
898
- background: 'transparent',
899
- border: '1px solid var(--theme-error-300)',
900
- borderRadius: 'var(--style-radius-s)',
901
- color: 'var(--theme-error-500)',
902
- fontSize: 'var(--font-size-small)',
903
- cursor: deleting === key.id ? 'not-allowed' : 'pointer',
904
- opacity: deleting === key.id ? 0.7 : 1,
905
- }}
906
- >
907
- {deleting === key.id ? 'Deleting...' : 'Delete'}
908
- </button>
909
- </div>
910
- ))}
911
- </div>
912
- )}
913
- </div>
914
- )
915
- }
916
-
917
- /**
918
- * Bulk action button with active/indeterminate states
919
- */
920
- function BulkButton({
921
- label,
922
- active,
923
- indeterminate,
924
- onClick,
925
- }: {
926
- label: string
927
- active: boolean
928
- indeterminate: boolean
929
- onClick: () => void
930
- }) {
931
- return (
932
- <button
933
- type="button"
934
- onClick={onClick}
935
- style={{
936
- padding: '4px 10px',
937
- background: active
938
- ? 'var(--theme-elevation-700)'
939
- : indeterminate
940
- ? 'var(--theme-elevation-300)'
941
- : 'var(--theme-elevation-100)',
942
- border: 'none',
943
- borderRadius: 'var(--style-radius-s)',
944
- color: active ? 'var(--theme-elevation-50)' : 'var(--theme-text)',
945
- fontSize: '11px',
946
- fontWeight: 500,
947
- cursor: 'pointer',
948
- transition: 'background 0.15s',
949
- }}
950
- >
951
- {label}
952
- </button>
953
- )
954
- }
955
-
956
- /**
957
- * Checkbox that supports indeterminate state
958
- */
959
- function IndeterminateCheckbox({
960
- checked,
961
- indeterminate,
962
- onChange,
963
- }: {
964
- checked: boolean
965
- indeterminate: boolean
966
- onChange: (e: React.MouseEvent) => void
967
- }) {
968
- const ref = useRef<HTMLInputElement>(null)
969
-
970
- useEffect(() => {
971
- if (ref.current) {
972
- ref.current.indeterminate = indeterminate
973
- }
974
- }, [indeterminate])
975
-
976
- return (
977
- <input
978
- ref={ref}
979
- type="checkbox"
980
- checked={checked}
981
- onChange={() => {}}
982
- onClick={onChange}
983
- style={{ cursor: 'pointer' }}
984
- />
985
- )
986
- }
987
-
988
- export default ApiKeysManagementClient