nobalmako 1.0.0

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 (123) hide show
  1. package/README.md +112 -0
  2. package/components.json +22 -0
  3. package/dist/nobalmako.js +272 -0
  4. package/drizzle/0000_pink_spiral.sql +126 -0
  5. package/drizzle/meta/0000_snapshot.json +1027 -0
  6. package/drizzle/meta/_journal.json +13 -0
  7. package/drizzle.config.ts +10 -0
  8. package/eslint.config.mjs +18 -0
  9. package/next.config.ts +7 -0
  10. package/package.json +80 -0
  11. package/postcss.config.mjs +7 -0
  12. package/public/file.svg +1 -0
  13. package/public/globe.svg +1 -0
  14. package/public/next.svg +1 -0
  15. package/public/vercel.svg +1 -0
  16. package/public/window.svg +1 -0
  17. package/server/index.ts +118 -0
  18. package/src/app/api/api-keys/[id]/route.ts +147 -0
  19. package/src/app/api/api-keys/route.ts +151 -0
  20. package/src/app/api/audit-logs/route.ts +84 -0
  21. package/src/app/api/auth/forgot-password/route.ts +47 -0
  22. package/src/app/api/auth/login/route.ts +99 -0
  23. package/src/app/api/auth/logout/route.ts +15 -0
  24. package/src/app/api/auth/me/route.ts +23 -0
  25. package/src/app/api/auth/mfa/setup/route.ts +33 -0
  26. package/src/app/api/auth/mfa/verify/route.ts +45 -0
  27. package/src/app/api/auth/register/route.ts +140 -0
  28. package/src/app/api/auth/reset-password/route.ts +52 -0
  29. package/src/app/api/auth/update/route.ts +71 -0
  30. package/src/app/api/auth/verify/route.ts +39 -0
  31. package/src/app/api/environments/route.ts +227 -0
  32. package/src/app/api/team-members/route.ts +385 -0
  33. package/src/app/api/teams/route.ts +217 -0
  34. package/src/app/api/variable-history/route.ts +218 -0
  35. package/src/app/api/variables/route.ts +476 -0
  36. package/src/app/api/webhooks/route.ts +77 -0
  37. package/src/app/api-keys/APIKeysClient.tsx +316 -0
  38. package/src/app/api-keys/page.tsx +10 -0
  39. package/src/app/api-reference/page.tsx +324 -0
  40. package/src/app/audit-log/AuditLogClient.tsx +229 -0
  41. package/src/app/audit-log/page.tsx +10 -0
  42. package/src/app/auth/forgot-password/page.tsx +121 -0
  43. package/src/app/auth/login/LoginForm.tsx +145 -0
  44. package/src/app/auth/login/page.tsx +11 -0
  45. package/src/app/auth/register/RegisterForm.tsx +156 -0
  46. package/src/app/auth/register/page.tsx +16 -0
  47. package/src/app/auth/reset-password/page.tsx +160 -0
  48. package/src/app/dashboard/DashboardClient.tsx +219 -0
  49. package/src/app/dashboard/page.tsx +11 -0
  50. package/src/app/docs/page.tsx +251 -0
  51. package/src/app/favicon.ico +0 -0
  52. package/src/app/globals.css +123 -0
  53. package/src/app/layout.tsx +35 -0
  54. package/src/app/page.tsx +231 -0
  55. package/src/app/profile/ProfileClient.tsx +230 -0
  56. package/src/app/profile/page.tsx +10 -0
  57. package/src/app/project/[id]/ProjectDetailsClient.tsx +512 -0
  58. package/src/app/project/[id]/page.tsx +17 -0
  59. package/src/bin/nobalmako.ts +341 -0
  60. package/src/components/ApiKeysManager.tsx +529 -0
  61. package/src/components/AppLayout.tsx +193 -0
  62. package/src/components/BulkActions.tsx +138 -0
  63. package/src/components/CreateEnvironmentDialog.tsx +207 -0
  64. package/src/components/CreateTeamDialog.tsx +174 -0
  65. package/src/components/CreateVariableDialog.tsx +311 -0
  66. package/src/components/DeleteEnvironmentDialog.tsx +104 -0
  67. package/src/components/DeleteTeamDialog.tsx +112 -0
  68. package/src/components/DeleteVariableDialog.tsx +103 -0
  69. package/src/components/EditEnvironmentDialog.tsx +202 -0
  70. package/src/components/EditMemberDialog.tsx +143 -0
  71. package/src/components/EditTeamDialog.tsx +178 -0
  72. package/src/components/EditVariableDialog.tsx +231 -0
  73. package/src/components/ImportVariablesDialog.tsx +347 -0
  74. package/src/components/InviteMemberDialog.tsx +191 -0
  75. package/src/components/LeaveProjectDialog.tsx +111 -0
  76. package/src/components/MFASettings.tsx +136 -0
  77. package/src/components/ProjectDiff.tsx +123 -0
  78. package/src/components/Providers.tsx +24 -0
  79. package/src/components/RemoveMemberDialog.tsx +112 -0
  80. package/src/components/SearchDialog.tsx +276 -0
  81. package/src/components/SecurityOverview.tsx +92 -0
  82. package/src/components/TeamMembersManager.tsx +103 -0
  83. package/src/components/VariableHistoryDialog.tsx +265 -0
  84. package/src/components/WebhooksManager.tsx +169 -0
  85. package/src/components/ui/alert-dialog.tsx +160 -0
  86. package/src/components/ui/alert.tsx +59 -0
  87. package/src/components/ui/avatar.tsx +53 -0
  88. package/src/components/ui/badge.tsx +46 -0
  89. package/src/components/ui/button.tsx +62 -0
  90. package/src/components/ui/card.tsx +92 -0
  91. package/src/components/ui/checkbox.tsx +32 -0
  92. package/src/components/ui/dialog.tsx +143 -0
  93. package/src/components/ui/dropdown-menu.tsx +257 -0
  94. package/src/components/ui/input.tsx +21 -0
  95. package/src/components/ui/label.tsx +24 -0
  96. package/src/components/ui/select.tsx +190 -0
  97. package/src/components/ui/separator.tsx +28 -0
  98. package/src/components/ui/sonner.tsx +37 -0
  99. package/src/components/ui/switch.tsx +31 -0
  100. package/src/components/ui/table.tsx +117 -0
  101. package/src/components/ui/tabs.tsx +66 -0
  102. package/src/components/ui/textarea.tsx +18 -0
  103. package/src/hooks/use-api-keys.ts +95 -0
  104. package/src/hooks/use-audit-logs.ts +58 -0
  105. package/src/hooks/use-auth.tsx +121 -0
  106. package/src/hooks/use-environments.ts +33 -0
  107. package/src/hooks/use-project-permissions.ts +49 -0
  108. package/src/hooks/use-team-members.ts +30 -0
  109. package/src/hooks/use-teams.ts +33 -0
  110. package/src/hooks/use-variables.ts +38 -0
  111. package/src/lib/audit.ts +36 -0
  112. package/src/lib/auth.ts +108 -0
  113. package/src/lib/crypto.ts +39 -0
  114. package/src/lib/db.ts +15 -0
  115. package/src/lib/dynamic-providers.ts +19 -0
  116. package/src/lib/email.ts +110 -0
  117. package/src/lib/mail.ts +51 -0
  118. package/src/lib/permissions.ts +51 -0
  119. package/src/lib/schema.ts +240 -0
  120. package/src/lib/seed.ts +107 -0
  121. package/src/lib/utils.ts +6 -0
  122. package/src/lib/webhooks.ts +42 -0
  123. package/tsconfig.json +34 -0
@@ -0,0 +1,529 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
5
+ import { Button } from '@/components/ui/button'
6
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
7
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
8
+ import { Input } from '@/components/ui/input'
9
+ import { Label } from '@/components/ui/label'
10
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
11
+ import { Badge } from '@/components/ui/badge'
12
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
13
+ import { Checkbox } from '@/components/ui/checkbox'
14
+ import { toast } from 'sonner'
15
+ import { Key, Plus, Trash2, Edit, Copy } from 'lucide-react'
16
+ import { useTeams } from '@/hooks/use-teams'
17
+
18
+ interface ApiKey {
19
+ id: string
20
+ name: string
21
+ permissions: string[]
22
+ lastUsed: string | null
23
+ createdAt: string
24
+ expiresAt: string | null
25
+ teamId: string | null
26
+ teamName: string | null
27
+ allowedIps?: string[] | null
28
+ }
29
+
30
+ interface CreateApiKeyData {
31
+ name: string
32
+ teamId?: string
33
+ permissions: string[]
34
+ expiresAt?: string
35
+ allowedIps?: string[]
36
+ }
37
+
38
+ interface UpdateApiKeyData {
39
+ name?: string
40
+ permissions?: string[]
41
+ expiresAt?: string
42
+ allowedIps?: string[]
43
+ }
44
+
45
+ const PERMISSION_OPTIONS = [
46
+ { value: 'read', label: 'Read' },
47
+ { value: 'write', label: 'Write' },
48
+ { value: 'delete', label: 'Delete' },
49
+ { value: 'admin', label: 'Admin' },
50
+ ]
51
+
52
+ export default function ApiKeysManager() {
53
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
54
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
55
+ const [selectedApiKey, setSelectedApiKey] = useState<ApiKey | null>(null)
56
+ const [createForm, setCreateForm] = useState<CreateApiKeyData>({
57
+ name: '',
58
+ permissions: ['read'],
59
+ expiresAt: '',
60
+ allowedIps: [],
61
+ })
62
+ const [editForm, setEditForm] = useState<UpdateApiKeyData>({
63
+ name: '',
64
+ permissions: ['read'],
65
+ expiresAt: '',
66
+ allowedIps: [],
67
+ })
68
+
69
+ const queryClient = useQueryClient()
70
+ const { teams } = useTeams()
71
+
72
+ // Fetch API keys
73
+ const { data: apiKeysData, isLoading } = useQuery<{ apiKeys: ApiKey[] }>({
74
+ queryKey: ['api-keys'],
75
+ queryFn: async () => {
76
+ const response = await fetch('/api/api-keys')
77
+ if (!response.ok) throw new Error('Failed to fetch API keys')
78
+ return response.json()
79
+ },
80
+ })
81
+
82
+ const apiKeys = apiKeysData?.apiKeys || []
83
+
84
+ // Create API key mutation
85
+ const createApiKeyMutation = useMutation({
86
+ mutationFn: async (data: CreateApiKeyData) => {
87
+ const response = await fetch('/api/api-keys', {
88
+ method: 'POST',
89
+ headers: { 'Content-Type': 'application/json' },
90
+ body: JSON.stringify(data),
91
+ })
92
+ if (!response.ok) throw new Error('Failed to create API key')
93
+ return response.json()
94
+ },
95
+ onSuccess: (data) => {
96
+ queryClient.invalidateQueries({ queryKey: ['api-keys'] })
97
+ setIsCreateDialogOpen(false)
98
+ setCreateForm({ name: '', permissions: ['read'], expiresAt: '' })
99
+
100
+ // Show the generated key to the user
101
+ toast.success('API Key Created', {
102
+ description: `Your new API key: ${data.apiKey.key}`,
103
+ duration: 10000,
104
+ })
105
+ },
106
+ onError: (error) => {
107
+ toast.error('Failed to create API key', {
108
+ description: error.message,
109
+ })
110
+ },
111
+ })
112
+
113
+ // Update API key mutation
114
+ const updateApiKeyMutation = useMutation({
115
+ mutationFn: async ({ id, data }: { id: string; data: UpdateApiKeyData }) => {
116
+ const response = await fetch(`/api/api-keys/${id}`, {
117
+ method: 'PUT',
118
+ headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify(data),
120
+ })
121
+ if (!response.ok) throw new Error('Failed to update API key')
122
+ return response.json()
123
+ },
124
+ onSuccess: () => {
125
+ queryClient.invalidateQueries({ queryKey: ['api-keys'] })
126
+ setIsEditDialogOpen(false)
127
+ setSelectedApiKey(null)
128
+ toast.success('API key updated successfully')
129
+ },
130
+ onError: (error) => {
131
+ toast.error('Failed to update API key', {
132
+ description: error.message,
133
+ })
134
+ },
135
+ })
136
+
137
+ // Delete API key mutation
138
+ const deleteApiKeyMutation = useMutation({
139
+ mutationFn: async (id: string) => {
140
+ const response = await fetch(`/api/api-keys/${id}`, {
141
+ method: 'DELETE',
142
+ })
143
+ if (!response.ok) throw new Error('Failed to delete API key')
144
+ return response.json()
145
+ },
146
+ onSuccess: () => {
147
+ queryClient.invalidateQueries({ queryKey: ['api-keys'] })
148
+ toast.success('API key deleted successfully')
149
+ },
150
+ onError: (error) => {
151
+ toast.error('Failed to delete API key', {
152
+ description: error.message,
153
+ })
154
+ },
155
+ })
156
+
157
+ const handleCreateApiKey = () => {
158
+ if (!createForm.name.trim()) {
159
+ toast.error('Please enter a name for the API key')
160
+ return
161
+ }
162
+ createApiKeyMutation.mutate(createForm)
163
+ }
164
+
165
+ const handleEditApiKey = () => {
166
+ if (!selectedApiKey || !editForm.name?.trim()) {
167
+ toast.error('Please enter a name for the API key')
168
+ return
169
+ }
170
+ updateApiKeyMutation.mutate({
171
+ id: selectedApiKey.id,
172
+ data: editForm,
173
+ })
174
+ }
175
+
176
+ const handleDeleteApiKey = (apiKey: ApiKey) => {
177
+ deleteApiKeyMutation.mutate(apiKey.id)
178
+ }
179
+
180
+ const openEditDialog = (apiKey: ApiKey) => {
181
+ setSelectedApiKey(apiKey)
182
+ setEditForm({
183
+ name: apiKey.name,
184
+ permissions: apiKey.permissions,
185
+ expiresAt: apiKey.expiresAt || '',
186
+ allowedIps: apiKey.allowedIps || [],
187
+ })
188
+ setIsEditDialogOpen(true)
189
+ }
190
+
191
+ const copyToClipboard = (text: string) => {
192
+ navigator.clipboard.writeText(text)
193
+ toast.success('Copied to clipboard')
194
+ }
195
+
196
+ const formatDate = (dateString: string) => {
197
+ return new Date(dateString).toLocaleDateString('en-US', {
198
+ year: 'numeric',
199
+ month: 'short',
200
+ day: 'numeric',
201
+ hour: '2-digit',
202
+ minute: '2-digit',
203
+ })
204
+ }
205
+
206
+ const isExpired = (expiresAt: string | null) => {
207
+ if (!expiresAt) return false
208
+ return new Date(expiresAt) < new Date()
209
+ }
210
+
211
+ if (isLoading) {
212
+ return <div className="flex items-center justify-center p-8">Loading API keys...</div>
213
+ }
214
+
215
+ return (
216
+ <div className="space-y-6">
217
+ <div className="flex items-center justify-between">
218
+ <div>
219
+ <h2 className="text-2xl font-bold tracking-tight">API Keys</h2>
220
+ <p className="text-muted-foreground">
221
+ Manage API keys for programmatic access to your environment variables.
222
+ </p>
223
+ </div>
224
+ <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
225
+ <DialogTrigger asChild>
226
+ <Button>
227
+ <Plus className="mr-2 h-4 w-4" />
228
+ Create API Key
229
+ </Button>
230
+ </DialogTrigger>
231
+ <DialogContent>
232
+ <DialogHeader>
233
+ <DialogTitle>Create API Key</DialogTitle>
234
+ <DialogDescription>
235
+ Create a new API key for programmatic access. Make sure to copy the key after creation - it will only be shown once.
236
+ </DialogDescription>
237
+ </DialogHeader>
238
+ <div className="space-y-4">
239
+ <div>
240
+ <Label htmlFor="name">Name</Label>
241
+ <Input
242
+ id="name"
243
+ value={createForm.name}
244
+ onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
245
+ placeholder="e.g., CI/CD Pipeline"
246
+ />
247
+ </div>
248
+ <div>
249
+ <Label htmlFor="team">Team (Optional)</Label>
250
+ <Select
251
+ value={createForm.teamId || ''}
252
+ onValueChange={(value) => setCreateForm({ ...createForm, teamId: value || undefined })}
253
+ >
254
+ <SelectTrigger>
255
+ <SelectValue placeholder="Select a team" />
256
+ </SelectTrigger>
257
+ <SelectContent>
258
+ <SelectItem value="">Personal</SelectItem>
259
+ {teams?.map((team) => (
260
+ <SelectItem key={team.id} value={team.id}>
261
+ {team.name}
262
+ </SelectItem>
263
+ ))}
264
+ </SelectContent>
265
+ </Select>
266
+ </div>
267
+ <div>
268
+ <Label>Permissions</Label>
269
+ <div className="space-y-2 mt-2">
270
+ {PERMISSION_OPTIONS.map((permission) => (
271
+ <div key={permission.value} className="flex items-center space-x-2">
272
+ <Checkbox
273
+ id={`create-${permission.value}`}
274
+ checked={createForm.permissions.includes(permission.value)}
275
+ onCheckedChange={(checked) => {
276
+ if (checked) {
277
+ setCreateForm({
278
+ ...createForm,
279
+ permissions: [...createForm.permissions, permission.value],
280
+ })
281
+ } else {
282
+ setCreateForm({
283
+ ...createForm,
284
+ permissions: createForm.permissions.filter((p) => p !== permission.value),
285
+ })
286
+ }
287
+ }}
288
+ />
289
+ <Label htmlFor={`create-${permission.value}`}>{permission.label}</Label>
290
+ </div>
291
+ ))}
292
+ </div>
293
+ </div>
294
+ <div>
295
+ <Label htmlFor="expiresAt">Expires At (Optional)</Label>
296
+ <Input
297
+ id="expiresAt"
298
+ type="datetime-local"
299
+ value={createForm.expiresAt}
300
+ onChange={(e) => setCreateForm({ ...createForm, expiresAt: e.target.value })}
301
+ />
302
+ </div>
303
+ <div>
304
+ <Label htmlFor="allowedIps">Whitelisted IPs (Optional)</Label>
305
+ <Input
306
+ id="allowedIps"
307
+ placeholder="e.g. 192.168.1.1, 10.0.0.1"
308
+ onChange={(e) => setCreateForm({ ...createForm, allowedIps: e.target.value.split(',').map(ip => ip.trim()).filter(ip => ip) })}
309
+ />
310
+ <p className="text-[10px] text-muted-foreground mt-1">Limit this key to specific IP addresses. Multiple IPs should be comma-separated.</p>
311
+ </div>
312
+ </div>
313
+ <DialogFooter>
314
+ <Button
315
+ onClick={handleCreateApiKey}
316
+ disabled={createApiKeyMutation.isPending}
317
+ >
318
+ {createApiKeyMutation.isPending ? 'Creating...' : 'Create API Key'}
319
+ </Button>
320
+ </DialogFooter>
321
+ </DialogContent>
322
+ </Dialog>
323
+ </div>
324
+
325
+ <div className="grid gap-4">
326
+ {apiKeys.length === 0 ? (
327
+ <Card>
328
+ <CardContent className="flex flex-col items-center justify-center py-12">
329
+ <Key className="h-12 w-12 text-muted-foreground mb-4" />
330
+ <h3 className="text-lg font-semibold mb-2">No API keys yet</h3>
331
+ <p className="text-muted-foreground text-center mb-4">
332
+ Create your first API key to enable programmatic access to your environment variables.
333
+ </p>
334
+ <Button onClick={() => setIsCreateDialogOpen(true)}>
335
+ <Plus className="mr-2 h-4 w-4" />
336
+ Create API Key
337
+ </Button>
338
+ </CardContent>
339
+ </Card>
340
+ ) : (
341
+ apiKeys.map((apiKey) => (
342
+ <Card key={apiKey.id}>
343
+ <CardHeader className="pb-3">
344
+ <div className="flex items-center justify-between">
345
+ <div className="flex items-center space-x-2">
346
+ <Key className="h-4 w-4" />
347
+ <CardTitle className="text-base">{apiKey.name}</CardTitle>
348
+ {apiKey.teamName && (
349
+ <Badge variant="secondary">{apiKey.teamName}</Badge>
350
+ )}
351
+ </div>
352
+ <div className="flex items-center space-x-2">
353
+ <Button
354
+ variant="ghost"
355
+ size="sm"
356
+ onClick={() => openEditDialog(apiKey)}
357
+ >
358
+ <Edit className="h-4 w-4" />
359
+ </Button>
360
+ <AlertDialog>
361
+ <AlertDialogTrigger asChild>
362
+ <Button variant="ghost" size="sm">
363
+ <Trash2 className="h-4 w-4" />
364
+ </Button>
365
+ </AlertDialogTrigger>
366
+ <AlertDialogContent>
367
+ <AlertDialogHeader>
368
+ <AlertDialogTitle>Delete API Key</AlertDialogTitle>
369
+ <AlertDialogDescription>
370
+ Are you sure you want to delete the API key &quot;{apiKey.name}&quot;? This action cannot be undone.
371
+ </AlertDialogDescription>
372
+ </AlertDialogHeader>
373
+ <AlertDialogFooter>
374
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
375
+ <AlertDialogAction
376
+ onClick={() => handleDeleteApiKey(apiKey)}
377
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
378
+ >
379
+ Delete
380
+ </AlertDialogAction>
381
+ </AlertDialogFooter>
382
+ </AlertDialogContent>
383
+ </AlertDialog>
384
+ </div>
385
+ </div>
386
+ </CardHeader>
387
+ <CardContent>
388
+ <div className="space-y-2">
389
+ <div className="flex items-center justify-between text-sm">
390
+ <span className="text-muted-foreground">Permissions:</span>
391
+ <div className="flex flex-wrap gap-1">
392
+ {apiKey.permissions.map((permission) => (
393
+ <Badge key={permission} variant="outline">
394
+ {permission}
395
+ </Badge>
396
+ ))}
397
+ </div>
398
+ </div>
399
+ <div className="flex items-center justify-between text-sm">
400
+ <span className="text-muted-foreground">Created:</span>
401
+ <span>{formatDate(apiKey.createdAt)}</span>
402
+ </div>
403
+ {apiKey.lastUsed && (
404
+ <div className="flex items-center justify-between text-sm">
405
+ <span className="text-muted-foreground">Last Used:</span>
406
+ <span>{formatDate(apiKey.lastUsed)}</span>
407
+ </div>
408
+ )}
409
+ {apiKey.expiresAt && (
410
+ <div className="flex items-center justify-between text-sm">
411
+ <span className="text-muted-foreground">Expires:</span>
412
+ <span className={isExpired(apiKey.expiresAt) ? 'text-destructive' : ''}>
413
+ {formatDate(apiKey.expiresAt)}
414
+ {isExpired(apiKey.expiresAt) && ' (Expired)'}
415
+ </span>
416
+ </div>
417
+ )}
418
+ {apiKey.allowedIps && apiKey.allowedIps.length > 0 && (
419
+ <div className="flex items-center justify-between text-sm">
420
+ <span className="text-muted-foreground">IP White-list:</span>
421
+ <div className="flex flex-wrap gap-1">
422
+ {apiKey.allowedIps.map((ip) => (
423
+ <Badge key={ip} variant="secondary" className="text-[10px] px-1 h-4">
424
+ {ip}
425
+ </Badge>
426
+ ))}
427
+ </div>
428
+ </div>
429
+ )}
430
+ <div className="flex items-center justify-between text-sm">
431
+ <span className="text-muted-foreground">Key ID:</span>
432
+ <div className="flex items-center space-x-2">
433
+ <code className="text-xs bg-muted px-2 py-1 rounded">
434
+ {apiKey.id}
435
+ </code>
436
+ <Button
437
+ variant="ghost"
438
+ size="sm"
439
+ onClick={() => copyToClipboard(apiKey.id)}
440
+ >
441
+ <Copy className="h-3 w-3" />
442
+ </Button>
443
+ </div>
444
+ </div>
445
+ </div>
446
+ </CardContent>
447
+ </Card>
448
+ ))
449
+ )}
450
+ </div>
451
+
452
+ {/* Edit API Key Dialog */}
453
+ <Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
454
+ <DialogContent>
455
+ <DialogHeader>
456
+ <DialogTitle>Edit API Key</DialogTitle>
457
+ <DialogDescription>
458
+ Update the settings for this API key.
459
+ </DialogDescription>
460
+ </DialogHeader>
461
+ <div className="space-y-4">
462
+ <div>
463
+ <Label htmlFor="edit-name">Name</Label>
464
+ <Input
465
+ id="edit-name"
466
+ value={editForm.name}
467
+ onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
468
+ />
469
+ </div>
470
+ <div>
471
+ <Label>Permissions</Label>
472
+ <div className="space-y-2 mt-2">
473
+ {PERMISSION_OPTIONS.map((permission) => (
474
+ <div key={permission.value} className="flex items-center space-x-2">
475
+ <Checkbox
476
+ id={`edit-${permission.value}`}
477
+ checked={editForm.permissions?.includes(permission.value)}
478
+ onCheckedChange={(checked) => {
479
+ if (checked) {
480
+ setEditForm({
481
+ ...editForm,
482
+ permissions: [...(editForm.permissions || []), permission.value],
483
+ })
484
+ } else {
485
+ setEditForm({
486
+ ...editForm,
487
+ permissions: (editForm.permissions || []).filter((p) => p !== permission.value),
488
+ })
489
+ }
490
+ }}
491
+ />
492
+ <Label htmlFor={`edit-${permission.value}`}>{permission.label}</Label>
493
+ </div>
494
+ ))}
495
+ </div>
496
+ </div>
497
+ <div>
498
+ <Label htmlFor="edit-expiresAt">Expires At (Optional)</Label>
499
+ <Input
500
+ id="edit-expiresAt"
501
+ type="datetime-local"
502
+ value={editForm.expiresAt}
503
+ onChange={(e) => setEditForm({ ...editForm, expiresAt: e.target.value })}
504
+ />
505
+ </div>
506
+ <div>
507
+ <Label htmlFor="edit-allowedIps">Whitelisted IPs (Optional)</Label>
508
+ <Input
509
+ id="edit-allowedIps"
510
+ value={editForm.allowedIps?.join(', ')}
511
+ placeholder="e.g. 192.168.1.1, 10.0.0.1"
512
+ onChange={(e) => setEditForm({ ...editForm, allowedIps: e.target.value.split(',').map(ip => ip.trim()).filter(ip => ip) })}
513
+ />
514
+ <p className="text-[10px] text-muted-foreground mt-1">Limit this key to specific IP addresses. Multiple IPs should be comma-separated.</p>
515
+ </div>
516
+ </div>
517
+ <DialogFooter>
518
+ <Button
519
+ onClick={handleEditApiKey}
520
+ disabled={updateApiKeyMutation.isPending}
521
+ >
522
+ {updateApiKeyMutation.isPending ? 'Updating...' : 'Update API Key'}
523
+ </Button>
524
+ </DialogFooter>
525
+ </DialogContent>
526
+ </Dialog>
527
+ </div>
528
+ )
529
+ }