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.
- package/README.md +112 -0
- package/components.json +22 -0
- package/dist/nobalmako.js +272 -0
- package/drizzle/0000_pink_spiral.sql +126 -0
- package/drizzle/meta/0000_snapshot.json +1027 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/eslint.config.mjs +18 -0
- package/next.config.ts +7 -0
- package/package.json +80 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/server/index.ts +118 -0
- package/src/app/api/api-keys/[id]/route.ts +147 -0
- package/src/app/api/api-keys/route.ts +151 -0
- package/src/app/api/audit-logs/route.ts +84 -0
- package/src/app/api/auth/forgot-password/route.ts +47 -0
- package/src/app/api/auth/login/route.ts +99 -0
- package/src/app/api/auth/logout/route.ts +15 -0
- package/src/app/api/auth/me/route.ts +23 -0
- package/src/app/api/auth/mfa/setup/route.ts +33 -0
- package/src/app/api/auth/mfa/verify/route.ts +45 -0
- package/src/app/api/auth/register/route.ts +140 -0
- package/src/app/api/auth/reset-password/route.ts +52 -0
- package/src/app/api/auth/update/route.ts +71 -0
- package/src/app/api/auth/verify/route.ts +39 -0
- package/src/app/api/environments/route.ts +227 -0
- package/src/app/api/team-members/route.ts +385 -0
- package/src/app/api/teams/route.ts +217 -0
- package/src/app/api/variable-history/route.ts +218 -0
- package/src/app/api/variables/route.ts +476 -0
- package/src/app/api/webhooks/route.ts +77 -0
- package/src/app/api-keys/APIKeysClient.tsx +316 -0
- package/src/app/api-keys/page.tsx +10 -0
- package/src/app/api-reference/page.tsx +324 -0
- package/src/app/audit-log/AuditLogClient.tsx +229 -0
- package/src/app/audit-log/page.tsx +10 -0
- package/src/app/auth/forgot-password/page.tsx +121 -0
- package/src/app/auth/login/LoginForm.tsx +145 -0
- package/src/app/auth/login/page.tsx +11 -0
- package/src/app/auth/register/RegisterForm.tsx +156 -0
- package/src/app/auth/register/page.tsx +16 -0
- package/src/app/auth/reset-password/page.tsx +160 -0
- package/src/app/dashboard/DashboardClient.tsx +219 -0
- package/src/app/dashboard/page.tsx +11 -0
- package/src/app/docs/page.tsx +251 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +123 -0
- package/src/app/layout.tsx +35 -0
- package/src/app/page.tsx +231 -0
- package/src/app/profile/ProfileClient.tsx +230 -0
- package/src/app/profile/page.tsx +10 -0
- package/src/app/project/[id]/ProjectDetailsClient.tsx +512 -0
- package/src/app/project/[id]/page.tsx +17 -0
- package/src/bin/nobalmako.ts +341 -0
- package/src/components/ApiKeysManager.tsx +529 -0
- package/src/components/AppLayout.tsx +193 -0
- package/src/components/BulkActions.tsx +138 -0
- package/src/components/CreateEnvironmentDialog.tsx +207 -0
- package/src/components/CreateTeamDialog.tsx +174 -0
- package/src/components/CreateVariableDialog.tsx +311 -0
- package/src/components/DeleteEnvironmentDialog.tsx +104 -0
- package/src/components/DeleteTeamDialog.tsx +112 -0
- package/src/components/DeleteVariableDialog.tsx +103 -0
- package/src/components/EditEnvironmentDialog.tsx +202 -0
- package/src/components/EditMemberDialog.tsx +143 -0
- package/src/components/EditTeamDialog.tsx +178 -0
- package/src/components/EditVariableDialog.tsx +231 -0
- package/src/components/ImportVariablesDialog.tsx +347 -0
- package/src/components/InviteMemberDialog.tsx +191 -0
- package/src/components/LeaveProjectDialog.tsx +111 -0
- package/src/components/MFASettings.tsx +136 -0
- package/src/components/ProjectDiff.tsx +123 -0
- package/src/components/Providers.tsx +24 -0
- package/src/components/RemoveMemberDialog.tsx +112 -0
- package/src/components/SearchDialog.tsx +276 -0
- package/src/components/SecurityOverview.tsx +92 -0
- package/src/components/TeamMembersManager.tsx +103 -0
- package/src/components/VariableHistoryDialog.tsx +265 -0
- package/src/components/WebhooksManager.tsx +169 -0
- package/src/components/ui/alert-dialog.tsx +160 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/avatar.tsx +53 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/checkbox.tsx +32 -0
- package/src/components/ui/dialog.tsx +143 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/switch.tsx +31 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +66 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/hooks/use-api-keys.ts +95 -0
- package/src/hooks/use-audit-logs.ts +58 -0
- package/src/hooks/use-auth.tsx +121 -0
- package/src/hooks/use-environments.ts +33 -0
- package/src/hooks/use-project-permissions.ts +49 -0
- package/src/hooks/use-team-members.ts +30 -0
- package/src/hooks/use-teams.ts +33 -0
- package/src/hooks/use-variables.ts +38 -0
- package/src/lib/audit.ts +36 -0
- package/src/lib/auth.ts +108 -0
- package/src/lib/crypto.ts +39 -0
- package/src/lib/db.ts +15 -0
- package/src/lib/dynamic-providers.ts +19 -0
- package/src/lib/email.ts +110 -0
- package/src/lib/mail.ts +51 -0
- package/src/lib/permissions.ts +51 -0
- package/src/lib/schema.ts +240 -0
- package/src/lib/seed.ts +107 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/webhooks.ts +42 -0
- 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 "{apiKey.name}"? 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
|
+
}
|