diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx index fa3d755c5..a24e1a30a 100644 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -250,7 +250,7 @@ function MainContent({ case 'personal': return case 'keys': - return + return case 'sandboxes': return case 'templates': diff --git a/apps/web/src/components/Dashboard/Keys.tsx b/apps/web/src/components/Dashboard/Keys.tsx index de8ed38c8..95fecbcbd 100644 --- a/apps/web/src/components/Dashboard/Keys.tsx +++ b/apps/web/src/components/Dashboard/Keys.tsx @@ -1,8 +1,7 @@ import { Button } from '../Button' - +import { Button as AlertDialogAction } from '../ui/button' import { AlertDialog, - AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, @@ -11,32 +10,82 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' -import { Copy } from 'lucide-react' -import { useEffect, useState } from 'react' +import { Edit, MoreVertical, Trash } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' import { useToast } from '../ui/use-toast' -import { createPagesBrowserClient } from '@supabase/auth-helpers-nextjs' -import { Team } from '@/utils/useUser' +import { E2BUser, Team } from '@/utils/useUser' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '../ui/table' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu' -export const KeysContent = ({ currentTeam }: { currentTeam: Team }) => { - const supabase = createPagesBrowserClient() +type TeamApiKey = { + id: string + value: string | null + maskedValue: string + name: string + createdBy: { + email: string + id: string + } | null + createdAt: string + lastUsed: string | null + updatedAt: string | null +} +export const KeysContent = ({ + currentTeam, + user, +}: { + currentTeam: Team + user: E2BUser +}) => { const { toast } = useToast() - const [isDialogOpen, setIsDialogOpen] = useState(false) - const [currentKey, setCurrentKey] = useState(null) - const [hoveredKey, setHoveredKey] = useState(null) - const [apiKeys, setApiKeys] = useState([]) + const [isKeyDialogOpen, setIsKeyDialogOpen] = useState(false) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [isKeyPreviewDialogOpen, setIsKeyPreviewDialogOpen] = useState(false) + const [currentKey, setCurrentKey] = useState(null) + const [newApiKeyInput, setNewApiKeyInput] = useState('') + const [apiKeys, setApiKeys] = useState([]) useEffect(() => { - setApiKeys(currentTeam.apiKeys) - }, [currentTeam]) + async function fetchApiKeys() { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BILLING_API_URL}/teams/${currentTeam.id}/api-keys`, + { + headers: { + 'X-USER-ACCESS-TOKEN': user.accessToken, + }, + } + ) - const closeDialog = () => setIsDialogOpen(false) - const openDialog = (key: string) => { - setCurrentKey(key) - setIsDialogOpen(true) - } + if (!res.ok) { + toast({ + title: 'An error occurred', + description: 'We were unable to fetch the team API keys', + }) + console.log(res.statusText) + return + } + + const keys = await res.json() + setApiKeys(keys) + } + + fetchApiKeys() + }, [currentTeam]) - const deleteApiKey = async () => { + async function deleteApiKey() { if (apiKeys.length === 1) { toast({ title: 'Cannot delete the last API key', @@ -45,130 +94,328 @@ export const KeysContent = ({ currentTeam }: { currentTeam: Team }) => { return } - const { error } = await supabase - .from('team_api_keys') - .delete() - .eq('api_key', currentKey) - - if (error) { - // TODO: Add sentry event here - toast( - { - title: 'An error occurred', - description: 'We were unable to delete the API key', - + const res = await fetch( + `${process.env.NEXT_PUBLIC_BILLING_API_URL}/teams/${currentTeam.id}/api-keys/${currentKey?.id}`, + { + method: 'DELETE', + headers: { + 'X-USER-ACCESS-TOKEN': user.accessToken, }, - ) - console.log(error) - return - } + } + ) - setApiKeys(apiKeys.filter(apiKey => apiKey !== currentKey)) - closeDialog() - } - - const addApiKey = async () => { - if (currentTeam === null) { + if (!res.ok) { toast({ title: 'An error occurred', - description: 'We were unable to create the API key', + description: 'We were unable to delete the API key', }) } - const res = await fetch(`${process.env.NEXT_PUBLIC_BILLING_API_URL}/teams/${currentTeam.id}/api-keys`, { - method: 'POST', - headers: { - 'X-Team-API-Key': currentTeam.apiKeys[0], - }, - }) + setApiKeys(apiKeys.filter((apiKey) => apiKey.id !== currentKey?.id)) + setCurrentKey(null) + setIsDeleteDialogOpen(false) + } + + async function createApiKey() { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BILLING_API_URL}/teams/${currentTeam.id}/api-keys`, + { + method: 'POST', + headers: { + 'X-USER-ACCESS-TOKEN': user.accessToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: newApiKeyInput, + }), + } + ) if (!res.ok) { toast({ title: 'An error occurred', description: 'We were unable to create the API key', }) - console.log(res.status, res.statusText) - // TODO: Add sentry event here + return } const newKey = await res.json() + setApiKeys([...apiKeys, newKey]) + setNewApiKeyInput('') + setCurrentKey(newKey) + setIsKeyPreviewDialogOpen(true) + } + + async function updateApiKey() { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BILLING_API_URL}/teams/${currentTeam.id}/api-keys/${currentKey?.id}`, + { + method: 'PATCH', + headers: { + 'X-USER-ACCESS-TOKEN': user.accessToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: newApiKeyInput, + }), + } + ) + + if (!res.ok) { + toast({ + title: 'An error occurred', + description: 'We were unable to update the API key', + }) + } + toast({ - title: 'API key created', + title: 'API key updated', }) - setApiKeys([...apiKeys, newKey.apiKey]) + setApiKeys( + apiKeys.map((apiKey) => + apiKey.id === currentKey?.id + ? { + ...apiKey, + name: newApiKeyInput, + } + : apiKey + ) + ) + + setNewApiKeyInput('') + setCurrentKey(null) + setIsKeyDialogOpen(false) } - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text) - toast({ - title: 'API Key copied to clipboard', + const sortedApiKeys = useMemo(() => { + return apiKeys.sort((a, b) => { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() }) - } + }, [apiKeys]) + + function copyApiKey() { + navigator.clipboard.writeText( + currentKey?.value || currentKey?.maskedValue || '' + ) - const maskApiKey = (key: string) => { - const firstFour = key.slice(0, 4) - const lastFour = key.slice(-4) - const stars = '*'.repeat(key.length - 8) // use fixed-width character - return `${firstFour}${stars}${lastFour}` + toast({ + title: 'Copied API key to clipboard', + }) } return (
-
- -
- -
+
+ +
-

Active keys

+ + + + Name + Key + Created by + Created at + + + + + {sortedApiKeys.length === 0 ? ( + + + Click on "Add API Key" button above to create your + first API key. + + + ) : ( + sortedApiKeys.map((apiKey, index) => ( + + {apiKey.name} + + {apiKey.maskedValue} + + {apiKey.createdBy?.email} + + {new Date(apiKey.createdAt).toLocaleString()} + + + + + + + + { + setNewApiKeyInput(apiKey.name) + setCurrentKey(apiKey) + setIsKeyDialogOpen(true) + }} + > + + Edit + + { + setCurrentKey(apiKey) + setIsDeleteDialogOpen(true) + }} + > + + Delete + + + + + + )) + )} + +
- {apiKeys.map((apiKey, index) => ( -
+ + + + You are about to {currentKey ? 'edit' : 'create'} an API key + + +
{ + e.preventDefault() + currentKey ? updateApiKey() : createApiKey() + setIsKeyDialogOpen(false) + }} > -
setHoveredKey(apiKey)} - onMouseLeave={() => setHoveredKey(null)} - > - {hoveredKey === apiKey ? apiKey : maskApiKey(apiKey)} -
+ { + setNewApiKeyInput(e.target.value) + }} + autoFocus + required + /> -
- copyToClipboard(apiKey)} /> - -
-
- ))} + + { + setIsKeyDialogOpen(false) + setCurrentKey(null) + setNewApiKeyInput('') + }} + > + Cancel + + + {currentKey ? 'Update' : 'Create'} + + + + + + + + + Your API key + + You will only see the API key once. Make sure to copy it now. + + +
+ -
+ + { + setIsKeyPreviewDialogOpen(false) + setCurrentKey(null) + }} + > + Cancel + + { + copyApiKey() + setIsKeyPreviewDialogOpen(false) + }} + > + Copy + + + + + - + - + - You are about to delete an API key - - This action cannot be undone. This will permanently delete the - API key with immediate effect. + + You are about to delete an API key + + + This action cannot be undone. This will permanently delete the API + key with immediate effect. - Cancel - Continue + { + setIsDeleteDialogOpen(false) + setCurrentKey(null) + }} + > + Cancel + + + Delete + -
) } diff --git a/apps/web/src/components/Dashboard/Sandboxes.tsx b/apps/web/src/components/Dashboard/Sandboxes.tsx index 4ba410f9a..b57f5a23b 100644 --- a/apps/web/src/components/Dashboard/Sandboxes.tsx +++ b/apps/web/src/components/Dashboard/Sandboxes.tsx @@ -78,14 +78,10 @@ export function SandboxesContent({ team }: { team: Team }) { {sandbox.templateID} {sandbox.alias} - {new Date(sandbox.startedAt).toLocaleString('en-UK', { - timeZoneName: 'short', - })} + {new Date(sandbox.startedAt).toLocaleString()} - {new Date(sandbox.endAt).toLocaleString('en-UK', { - timeZoneName: 'short', - })} + {new Date(sandbox.endAt).toLocaleString()} {sandbox.cpuCount} {sandbox.memoryMB}