@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.
- package/package.json +34 -91
- package/src/adapter/collections.ts +0 -621
- package/src/adapter/index.ts +0 -712
- package/src/components/BeforeLogin.tsx +0 -39
- package/src/components/LoginView.tsx +0 -1516
- package/src/components/LoginViewWrapper.tsx +0 -35
- package/src/components/LogoutButton.tsx +0 -58
- package/src/components/PasskeyRegisterButton.tsx +0 -105
- package/src/components/PasskeySignInButton.tsx +0 -96
- package/src/components/auth/ForgotPasswordView.tsx +0 -274
- package/src/components/auth/ResetPasswordView.tsx +0 -331
- package/src/components/auth/index.ts +0 -8
- package/src/components/management/ApiKeysManagementClient.tsx +0 -988
- package/src/components/management/PasskeysManagementClient.tsx +0 -409
- package/src/components/management/SecurityNavLinks.tsx +0 -117
- package/src/components/management/TwoFactorManagementClient.tsx +0 -560
- package/src/components/management/index.ts +0 -20
- package/src/components/management/views/ApiKeysView.tsx +0 -57
- package/src/components/management/views/PasskeysView.tsx +0 -42
- package/src/components/management/views/TwoFactorView.tsx +0 -42
- package/src/components/management/views/index.ts +0 -10
- package/src/components/twoFactor/TwoFactorSetupView.tsx +0 -515
- package/src/components/twoFactor/TwoFactorVerifyView.tsx +0 -238
- package/src/components/twoFactor/index.ts +0 -8
- package/src/exports/client.ts +0 -77
- package/src/exports/components.ts +0 -30
- package/src/exports/management.ts +0 -25
- package/src/exports/rsc.ts +0 -11
- package/src/generated-types.ts +0 -269
- package/src/index.ts +0 -135
- package/src/plugin/index.ts +0 -834
- package/src/scripts/generate-types.ts +0 -269
- package/src/types/apiKey.ts +0 -63
- package/src/types/betterAuth.ts +0 -253
- package/src/utils/access.ts +0 -410
- package/src/utils/apiKeyAccess.ts +0 -443
- package/src/utils/betterAuthDefaults.ts +0 -102
- package/src/utils/detectAuthConfig.ts +0 -47
- package/src/utils/detectEnabledPlugins.ts +0 -69
- package/src/utils/firstUserAdmin.ts +0 -164
- package/src/utils/generateScopes.ts +0 -150
- 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
|