diff --git a/.gitignore b/.gitignore index b6b85df6d..0999dc3ef 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,8 @@ next-env.d.ts CLAUDE.md .agent +# Cursor IDE +.cursor/ # tooling /template diff --git a/src/app/dashboard/[teamSlug]/keys/page.tsx b/src/app/dashboard/[teamSlug]/keys/page.tsx index 7acc106ef..9e646d134 100644 --- a/src/app/dashboard/[teamSlug]/keys/page.tsx +++ b/src/app/dashboard/[teamSlug]/keys/page.tsx @@ -1,55 +1,28 @@ -import { Plus } from 'lucide-react' -import CreateApiKeyDialog from '@/features/dashboard/settings/keys/create-api-key-dialog' -import ApiKeysTable from '@/features/dashboard/settings/keys/table' -import Frame from '@/ui/frame' -import { Button } from '@/ui/primitives/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/ui/primitives/card' +import { Page } from '@/features/dashboard/layouts/page' +import { ApiKeysPageContent } from '@/features/dashboard/settings/keys' +import { HydrateClient, prefetch, trpc } from '@/trpc/server' +import { Card, CardContent } from '@/ui/primitives/card' -interface KeysPageClientProps { +interface KeysPageProps { params: Promise<{ teamSlug: string }> } -export default async function KeysPage({ params }: KeysPageClientProps) { - return ( - - - -
-
- Manage API Keys - - API Keys are used to authenticate API requests from your teams - applications. - -
+export default async function KeysPage({ params }: KeysPageProps) { + const { teamSlug } = await params - - - -
-
+ prefetch(trpc.teams.listApiKeys.queryOptions({ teamSlug })) - -
- -
-
-
- + return ( + + + + + + + + + ) } diff --git a/src/configs/cache.ts b/src/configs/cache.ts index a37acf7e8..bf2ea0e46 100644 --- a/src/configs/cache.ts +++ b/src/configs/cache.ts @@ -1,7 +1,6 @@ export const CACHE_TAGS = { TEAM_ID_FROM_SLUG: (segment: string) => `team-id-from-slug-${segment}`, TEAM_USAGE: (teamId: string) => `team-usage-${teamId}`, - TEAM_API_KEYS: (teamId: string) => `team-api-keys-${teamId}`, DEFAULT_TEMPLATES: 'default-templates', } as const diff --git a/src/core/server/actions/key-actions.ts b/src/core/server/actions/key-actions.ts deleted file mode 100644 index 5c5c1bb73..000000000 --- a/src/core/server/actions/key-actions.ts +++ /dev/null @@ -1,100 +0,0 @@ -'use server' - -import { revalidatePath, updateTag } from 'next/cache' -import { z } from 'zod' -import { CACHE_TAGS } from '@/configs/cache' -import { createKeysRepository } from '@/core/modules/keys/repository.server' -import { - authActionClient, - withTeamAuthedRequestRepository, - withTeamSlugResolution, -} from '@/core/server/actions/client' -import { returnServerError } from '@/core/server/actions/utils' -import { l } from '@/core/shared/clients/logger/logger' -import { TeamSlugSchema } from '@/core/shared/schemas/team' - -const withKeysRepository = withTeamAuthedRequestRepository( - createKeysRepository, - (keysRepository) => ({ - keysRepository, - }) -) - -// Create API Key - -const CreateApiKeySchema = z.object({ - teamSlug: TeamSlugSchema, - name: z - .string({ error: 'Name is required' }) - .min(1, 'Name cannot be empty') - .max(50, 'Name cannot be longer than 50 characters') - .trim(), -}) - -export const createApiKeyAction = authActionClient - .schema(CreateApiKeySchema) - .metadata({ actionName: 'createApiKey' }) - .use(withTeamSlugResolution) - .use(withKeysRepository) - .action(async ({ parsedInput, ctx }) => { - const { name } = parsedInput - - const result = await ctx.keysRepository.createApiKey(name) - - if (!result.ok) { - l.error({ - key: 'create_api_key:error', - message: result.error.message, - error: result.error, - team_id: ctx.teamId, - user_id: ctx.session.user.id, - context: { - name, - }, - }) - - return returnServerError('Failed to create API Key') - } - - updateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId)) - revalidatePath(`/dashboard/${parsedInput.teamSlug}/keys`, 'page') - - return { - createdApiKey: result.data, - } - }) - -// Delete API Key - -const DeleteApiKeySchema = z.object({ - teamSlug: TeamSlugSchema, - apiKeyId: z.uuid(), -}) - -export const deleteApiKeyAction = authActionClient - .schema(DeleteApiKeySchema) - .metadata({ actionName: 'deleteApiKey' }) - .use(withTeamSlugResolution) - .use(withKeysRepository) - .action(async ({ parsedInput, ctx }) => { - const { apiKeyId } = parsedInput - const result = await ctx.keysRepository.deleteApiKey(apiKeyId) - - if (!result.ok) { - l.error({ - key: 'delete_api_key_action:error', - message: result.error.message, - error: result.error, - team_id: ctx.teamId, - user_id: ctx.session.user.id, - context: { - apiKeyId, - }, - }) - - return returnServerError('Failed to delete API Key') - } - - updateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId)) - revalidatePath(`/dashboard/${parsedInput.teamSlug}/keys`, 'page') - }) diff --git a/src/core/server/api/routers/teams.ts b/src/core/server/api/routers/teams.ts index 03a2d6a88..438d24600 100644 --- a/src/core/server/api/routers/teams.ts +++ b/src/core/server/api/routers/teams.ts @@ -1,8 +1,17 @@ +import { z } from 'zod' +import { createKeysRepository } from '@/core/modules/keys/repository.server' import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' -import { withAuthedRequestRepository } from '@/core/server/api/middlewares/repository' +import { + withAuthedRequestRepository, + withTeamAuthedRequestRepository, +} from '@/core/server/api/middlewares/repository' import { createTRPCRouter } from '@/core/server/trpc/init' -import { protectedProcedure } from '@/core/server/trpc/procedures' +import { + protectedProcedure, + protectedTeamProcedure, +} from '@/core/server/trpc/procedures' +import { l } from '@/core/shared/clients/logger/logger' const teamsRepositoryProcedure = protectedProcedure.use( withAuthedRequestRepository(createUserTeamsRepository, (teamsRepository) => ({ @@ -10,6 +19,12 @@ const teamsRepositoryProcedure = protectedProcedure.use( })) ) +const keysRepositoryProcedure = protectedTeamProcedure.use( + withTeamAuthedRequestRepository(createKeysRepository, (keysRepository) => ({ + keysRepository, + })) +) + export const teamsRouter = createTRPCRouter({ list: teamsRepositoryProcedure.query(async ({ ctx }) => { const teamsResult = await ctx.teamsRepository.listUserTeams() @@ -20,4 +35,70 @@ export const teamsRouter = createTRPCRouter({ return teamsResult.data }), + + listApiKeys: keysRepositoryProcedure.query(async ({ ctx }) => { + const result = await ctx.keysRepository.listTeamApiKeys() + + if (!result.ok) { + throwTRPCErrorFromRepoError(result.error) + } + + return { apiKeys: result.data } + }), + + createApiKey: keysRepositoryProcedure + .input( + z.object({ + name: z + .string({ error: 'Name is required' }) + .min(1, 'Name cannot be empty') + .max(50, 'Name cannot be longer than 50 characters') + .trim(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { name } = input + + const result = await ctx.keysRepository.createApiKey(name) + + if (!result.ok) { + l.error({ + key: 'create_api_key_trpc:error', + message: result.error.message, + error: result.error, + team_id: ctx.teamId, + user_id: ctx.session.user.id, + context: { name }, + }) + + throwTRPCErrorFromRepoError(result.error) + } + + return { createdApiKey: result.data } + }), + + deleteApiKey: keysRepositoryProcedure + .input( + z.object({ + apiKeyId: z.uuid(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { apiKeyId } = input + + const result = await ctx.keysRepository.deleteApiKey(apiKeyId) + + if (!result.ok) { + l.error({ + key: 'delete_api_key_trpc:error', + message: result.error.message, + error: result.error, + team_id: ctx.teamId, + user_id: ctx.session.user.id, + context: { apiKeyId }, + }) + + throwTRPCErrorFromRepoError(result.error) + } + }), }) diff --git a/src/core/server/functions/keys/get-api-keys.ts b/src/core/server/functions/keys/get-api-keys.ts deleted file mode 100644 index 8ec35cf7a..000000000 --- a/src/core/server/functions/keys/get-api-keys.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { cacheLife, cacheTag } from 'next/cache' -import { z } from 'zod' -import { CACHE_TAGS } from '@/configs/cache' -import { createKeysRepository } from '@/core/modules/keys/repository.server' -import { - authActionClient, - withTeamSlugResolution, -} from '@/core/server/actions/client' -import { handleDefaultInfraError } from '@/core/server/actions/utils' -import { l } from '@/core/shared/clients/logger/logger' -import { TeamSlugSchema } from '@/core/shared/schemas/team' - -const GetApiKeysSchema = z.object({ - teamSlug: TeamSlugSchema, -}) - -export const getTeamApiKeys = authActionClient - .schema(GetApiKeysSchema) - .metadata({ serverFunctionName: 'getTeamApiKeys' }) - .use(withTeamSlugResolution) - .action(async ({ ctx }) => { - 'use cache' - cacheLife('default') - const { session, teamId } = ctx - cacheTag(CACHE_TAGS.TEAM_API_KEYS(teamId)) - - const result = await createKeysRepository({ - accessToken: session.access_token, - teamId, - }).listTeamApiKeys() - - if (!result.ok) { - const status = result.error.status - - l.error({ - key: 'get_team_api_keys:error', - error: result.error, - team_id: teamId, - user_id: session.user.id, - context: { - status, - }, - }) - - return handleDefaultInfraError(status, result.error) - } - - return { apiKeys: result.data } - }) diff --git a/src/core/server/functions/keys/types.ts b/src/core/server/functions/keys/types.ts deleted file mode 100644 index 6e842c2e2..000000000 --- a/src/core/server/functions/keys/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface ObscuredApiKey { - id: string - name: string - maskedKey: string - createdBy: string | null - createdAt: string | null -} diff --git a/src/features/dashboard/billing/invoices.tsx b/src/features/dashboard/billing/invoices.tsx index f561445ce..e199c54a5 100644 --- a/src/features/dashboard/billing/invoices.tsx +++ b/src/features/dashboard/billing/invoices.tsx @@ -50,16 +50,14 @@ function InvoicesEmpty({ error }: InvoicesEmptyProps) { return ( -

+ /> + {error ? error : 'No invoices yet'} -

+
) } diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index 341d68708..8caa97573 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -10,6 +10,7 @@ import { PROTECTED_URLS } from '@/configs/urls' import { getTeamDisplayName } from '@/core/modules/teams/utils' import { removeTeamMemberAction } from '@/core/server/actions/team-actions' import type { TeamMember } from '@/core/server/functions/team/types' +import { UserAvatar } from '@/features/dashboard/shared' import { defaultErrorToast, defaultSuccessToast, @@ -250,15 +251,10 @@ const AddedCell = ({ ) : ( - - - - {addedByMember?.info.email?.charAt(0).toUpperCase() ?? '?'} - - + )} {showRemove ? ( = ({ {members.length === 0 ? ( -

- {totalMemberCount === 0 - ? 'No team members found.' - : 'No members match your search.'} -

+ {totalMemberCount === 0 + ? 'No team members found.' + : 'No members match your search.'}
) : ( members.map((member) => ( diff --git a/src/features/dashboard/settings/keys/api-keys-page-content.tsx b/src/features/dashboard/settings/keys/api-keys-page-content.tsx new file mode 100644 index 000000000..af4b8dc10 --- /dev/null +++ b/src/features/dashboard/settings/keys/api-keys-page-content.tsx @@ -0,0 +1,150 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { CLI_GENERATED_KEY_NAME } from '@/configs/api' +import { cn } from '@/lib/utils' +import { pluralize } from '@/lib/utils/formatting' +import { useTRPC } from '@/trpc/client' +import { ErrorIndicator } from '@/ui/error-indicator' +import { SearchIcon } from '@/ui/primitives/icons' +import { Input } from '@/ui/primitives/input' +import { Loader } from '@/ui/primitives/loader_d' +import { ApiKeysTable } from './api-keys-table' +import { matchesApiKeySearch } from './api-keys-utils' +import { CreateApiKeyDialog } from './create-api-key-dialog' + +interface ApiKeysPageContentProps { + teamSlug: string + className?: string +} + +interface ApiKeysSearchFieldProps { + value: string + onChange: (next: string) => void + count: number +} + +const ApiKeysSearchField = ({ + value, + onChange, + count, +}: ApiKeysSearchFieldProps) => { + const placeholder = + count === 0 ? 'Add an API key to start searching' : 'Search by title or ID' + + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + type="search" + value={value} + /> +
+ ) +} + +interface ApiKeysTotalLabelProps { + totalCount: number + filteredCount: number + hasActiveSearch: boolean +} + +const ApiKeysTotalLabel = ({ + totalCount, + filteredCount, + hasActiveSearch, +}: ApiKeysTotalLabelProps) => { + if (totalCount === 0) return null + + const label = hasActiveSearch + ? `Showing ${filteredCount} of ${totalCount} ${pluralize(totalCount, 'key')}` + : `${totalCount} ${pluralize(totalCount, 'key')} in total` + + return

{label}

+} + +export const ApiKeysPageContent = ({ + teamSlug, + className, +}: ApiKeysPageContentProps) => { + const trpc = useTRPC() + const [query, setQuery] = useState('') + const hasActiveSearch = query.trim().length > 0 + + const { data, isLoading, isError, error } = useQuery( + trpc.teams.listApiKeys.queryOptions({ teamSlug }) + ) + + const apiKeys = data?.apiKeys ?? [] + + const sortedKeys = useMemo(() => { + const normal = apiKeys.filter((k) => k.name !== CLI_GENERATED_KEY_NAME) + const cli = apiKeys.filter((k) => k.name === CLI_GENERATED_KEY_NAME) + return [...normal, ...cli] + }, [apiKeys]) + + const filtered = useMemo(() => { + return sortedKeys.filter((k) => matchesApiKeySearch(k, query)) + }, [sortedKeys, query]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isError) { + return ( + + ) + } + + return ( +
+
+
+ + +
+ +
+

+ These keys authenticate API requests from your team's + applications. +

+ +
+ +
+ +
+
+
+ ) +} diff --git a/src/features/dashboard/settings/keys/api-keys-table-row.tsx b/src/features/dashboard/settings/keys/api-keys-table-row.tsx new file mode 100644 index 000000000..3c42b91a3 --- /dev/null +++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx @@ -0,0 +1,221 @@ +'use client' + +import { usePostHog } from 'posthog-js/react' +import { useState } from 'react' +import { CLI_GENERATED_KEY_NAME } from '@/configs/api' +import type { TeamAPIKey } from '@/core/modules/keys/models' +import { UserAvatar } from '@/features/dashboard/shared' +import { useClipboard } from '@/lib/hooks/use-clipboard' +import { formatDate, formatUTCTimestamp } from '@/lib/utils/formatting' +import { E2BLogo } from '@/ui/brand' +import { Badge } from '@/ui/primitives/badge' +import { Button } from '@/ui/primitives/button' +import { CheckIcon, CopyIcon, KeyIcon, TrashIcon } from '@/ui/primitives/icons' +import { TableCell, TableRow } from '@/ui/primitives/table' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/ui/primitives/tooltip' +import { getLastUsedLabel } from './api-keys-utils' +import { DeleteApiKeyDialog } from './delete-api-key-dialog' + +interface ApiKeysTableRowProps { + apiKey: TeamAPIKey + teamSlug: string +} + +interface ApiKeyNameCellProps { + name: string | null +} + +interface ApiKeyIdBadgeProps { + value: string + onCopy: () => void +} + +interface ApiKeyLastUsedCellProps { + lastUsedAt?: string | null + lastUsedLabel: string +} + +interface ApiKeyAddedCellProps { + addedDate: string +} + +interface ApiKeyOptionsProps { + createdBy?: TeamAPIKey['createdBy'] | null + isCliKey: boolean + keyName: string | null + onDelete: () => void +} + +const ApiKeyNameCell = ({ name }: ApiKeyNameCellProps) => ( + +
+
+ +
+ + {name ?? 'Untitled key'} + +
+
+) + +const ApiKeyIdBadge = ({ value, onCopy }: ApiKeyIdBadgeProps) => { + const [wasCopied, copy] = useClipboard() + + const handleCopy = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + void copy(value) + onCopy() + } + + return ( + + {value} + + + ) +} + +const ApiKeyLastUsedCell = ({ + lastUsedAt, + lastUsedLabel, +}: ApiKeyLastUsedCellProps) => ( + + {lastUsedAt ? ( + + + + {lastUsedLabel} + + + + {formatUTCTimestamp(new Date(lastUsedAt))} + + + ) : ( + lastUsedLabel + )} + +) + +const ApiKeyAddedCell = ({ addedDate }: ApiKeyAddedCellProps) => ( + + + {addedDate} + + +) + +const ApiKeyOptions = ({ + createdBy, + isCliKey, + keyName, + onDelete, +}: ApiKeyOptionsProps) => ( + +
+ {isCliKey ? ( + + + + + + + Added through E2B CLI + + ) : createdBy ? ( + + + + + + + {createdBy.email} + + ) : ( + + )} + +
+
+) + +export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => { + const posthog = usePostHog() + const [deleteOpen, setDeleteOpen] = useState(false) + + const maskDisplay = `${apiKey.mask.prefix}${apiKey.mask.maskedValuePrefix}...${apiKey.mask.maskedValueSuffix}` + const addedDate = apiKey.createdAt + ? (formatDate(new Date(apiKey.createdAt), 'MMM d, yyyy') ?? '—') + : '—' + + const lastUsedAt = apiKey.lastUsed + const lastUsedLabel = getLastUsedLabel(apiKey) + const isCliKey = apiKey.name === CLI_GENERATED_KEY_NAME + + return ( + <> + + + + + { + posthog.capture('copied API key id') + }} + /> + + + + setDeleteOpen(true)} + /> + + + ) +} diff --git a/src/features/dashboard/settings/keys/api-keys-table.tsx b/src/features/dashboard/settings/keys/api-keys-table.tsx new file mode 100644 index 000000000..c1ad2a6c9 --- /dev/null +++ b/src/features/dashboard/settings/keys/api-keys-table.tsx @@ -0,0 +1,79 @@ +'use client' + +import type { FC } from 'react' +import type { TeamAPIKey } from '@/core/modules/keys/models' +import { cn } from '@/lib/utils' +import { KeyIcon } from '@/ui/primitives/icons' +import { + Table, + TableBody, + TableEmptyState, + TableHead, + TableHeader, + TableRow, +} from '@/ui/primitives/table' +import { ApiKeysTableRow } from './api-keys-table-row' + +interface ApiKeysTableProps { + apiKeys: TeamAPIKey[] + teamSlug: string + totalKeyCount: number + className?: string +} + +export const ApiKeysTable: FC = ({ + apiKeys, + teamSlug, + totalKeyCount, + className, +}) => ( + + + + + + + + + + + + LABEL + + + ID + + + LAST USED + + + ADDED + + + API key options + + + + + {apiKeys.length === 0 ? ( + + + {totalKeyCount === 0 + ? 'No keys added yet' + : 'No keys match your search.'} + + ) : ( + apiKeys.map((apiKey) => ( + + )) + )} + +
+) diff --git a/src/features/dashboard/settings/keys/api-keys-utils.ts b/src/features/dashboard/settings/keys/api-keys-utils.ts new file mode 100644 index 000000000..56a2522d9 --- /dev/null +++ b/src/features/dashboard/settings/keys/api-keys-utils.ts @@ -0,0 +1,32 @@ +import { API_KEYS_LAST_USED_FIRST_COLLECTION_DATE } from '@/configs/versioning' +import type { TeamAPIKey } from '@/core/modules/keys/models' +import { formatRelativeAgo } from '@/lib/utils/formatting' + +/** Builds a short masked id string for search and display; e.g. input mask fields → `"e2b_â€Ĥa1b2"` */ +export const getMaskedIdSearchString = (apiKey: TeamAPIKey): string => { + const { prefix, maskedValuePrefix, maskedValueSuffix } = apiKey.mask + return `${prefix}${maskedValuePrefix}...${maskedValueSuffix}`.toLowerCase() +} + +/** Returns true when the key name or masked id contains the trimmed query (case-insensitive). */ +export const matchesApiKeySearch = ( + apiKey: TeamAPIKey, + query: string +): boolean => { + const q = query.trim().toLowerCase() + if (!q) return true + if (apiKey.name.toLowerCase().includes(q)) return true + return getMaskedIdSearchString(apiKey).includes(q) +} + +/** Human line for last-used cell, matching legacy semantics for pre-collection keys. */ +export const getLastUsedLabel = (apiKey: TeamAPIKey): string => { + if (apiKey.lastUsed) return formatRelativeAgo(new Date(apiKey.lastUsed)) + + const createdBefore = + new Date(apiKey.createdAt).getTime() < + API_KEYS_LAST_USED_FIRST_COLLECTION_DATE.getTime() + + if (createdBefore) return 'N/A' + return 'Never' +} diff --git a/src/features/dashboard/settings/keys/create-api-key-dialog.tsx b/src/features/dashboard/settings/keys/create-api-key-dialog.tsx index d8f465449..4d1de1e7c 100644 --- a/src/features/dashboard/settings/keys/create-api-key-dialog.tsx +++ b/src/features/dashboard/settings/keys/create-api-key-dialog.tsx @@ -1,23 +1,21 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { useParams } from 'next/navigation' -import { useAction } from 'next-safe-action/hooks' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { usePostHog } from 'posthog-js/react' import { type FC, type ReactNode, useState } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' -import { createApiKeyAction } from '@/core/server/actions/key-actions' +import { useClipboard } from '@/lib/hooks/use-clipboard' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' -import CopyButton from '@/ui/copy-button' -import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert' +import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { Dialog, DialogClose, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, @@ -29,6 +27,12 @@ import { FormItem, FormMessage, } from '@/ui/primitives/form' +import { + AddIcon, + CheckIcon, + CopyIcon, + WarningIcon, +} from '@/ui/primitives/icons' import { Input } from '@/ui/primitives/input' import { Label } from '@/ui/primitives/label' @@ -43,18 +47,29 @@ const formSchema = z.object({ type FormValues = z.infer interface CreateApiKeyDialogProps { + teamSlug: string children?: ReactNode } -const CreateApiKeyDialog: FC = ({ children }) => { +export const CreateApiKeyDialog: FC = ({ + teamSlug, + children, +}) => { 'use no memo' - const { teamSlug } = useParams() as { teamSlug: string } - const [open, setOpen] = useState(false) - const [createdApiKey, setCreatedApiKey] = useState(null) + const [createdKey, setCreatedKey] = useState(null) + const [createdName, setCreatedName] = useState(null) + const posthog = usePostHog() const { toast } = useToast() + const trpc = useTRPC() + const queryClient = useQueryClient() + const [copiedReveal, copyReveal] = useClipboard() + + const listQueryKey = trpc.teams.listApiKeys.queryOptions({ + teamSlug, + }).queryKey const form = useForm({ resolver: zodResolver(formSchema), @@ -63,105 +78,175 @@ const CreateApiKeyDialog: FC = ({ children }) => { }, }) - const { execute: createApiKey, isPending } = useAction(createApiKeyAction, { - onSuccess: ({ data }) => { - if (data?.createdApiKey) { - setCreatedApiKey(data.createdApiKey.key) - form.reset() - } - }, - onError: ({ error }) => { - toast(defaultErrorToast(error.serverError || 'Failed to create API key.')) - }, - }) + const nameDraft = form.watch('name') + const canSubmit = nameDraft.trim().length > 0 + + const createMutation = useMutation( + trpc.teams.createApiKey.mutationOptions({ + onSuccess: (data) => { + if (data.createdApiKey?.key) { + setCreatedKey(data.createdApiKey.key) + setCreatedName(data.createdApiKey.name ?? '') + form.reset() + } + void queryClient.invalidateQueries({ queryKey: listQueryKey }) + }, + onError: (err) => { + toast(defaultErrorToast(err.message || 'Failed to create API key.')) + }, + }) + ) const handleOpenChange = (value: boolean) => { setOpen(value) if (!value) { form.reset() - setCreatedApiKey(null) + setCreatedKey(null) + setCreatedName(null) } } + const successTitle = + createdName != null && createdName.length > 0 + ? `${createdName.toUpperCase()} KEY CREATED` + : 'API KEY CREATED' + return ( - {children} - - - New API Key - - Create a new API key for your team. - - - - {!createdApiKey ? ( -
- - createApiKey({ teamSlug, name: values.name }) - )} - className="flex flex-col gap-6" - > - ( - - - - - - - - )} - /> - - - - - - + + {children ?? ( + + )} + + + {!createdKey ? ( + <> + + Create new key + + Enter a name and create a new API key for this team. + + +
+ { + createMutation.mutate({ teamSlug, name: values.name }) + })} + > + ( + + +
+ + + + +
+ +
+ )} + /> + + + ) : ( <> -
- -
- - { + + {successTitle} + + Your new API key is shown once. Copy it before closing. + + +
+
+ + +
+
+
+ + + Important + +
+
+

+ Copy the key now. You won't be able to view it again. +

+ + + +
- - Important - - Make sure to copy your API Key now. -
You won't be able to see it again! -
-
- - - - - - )}
) } - -export default CreateApiKeyDialog diff --git a/src/features/dashboard/settings/keys/delete-api-key-dialog.tsx b/src/features/dashboard/settings/keys/delete-api-key-dialog.tsx new file mode 100644 index 000000000..c6fc6dd23 --- /dev/null +++ b/src/features/dashboard/settings/keys/delete-api-key-dialog.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { FC } from 'react' +import type { TeamAPIKey } from '@/core/modules/keys/models' +import { + defaultErrorToast, + defaultSuccessToast, + useToast, +} from '@/lib/hooks/use-toast' +import { formatRelativeAgo } from '@/lib/utils/formatting' +import { useTRPC } from '@/trpc/client' +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { TrashIcon } from '@/ui/primitives/icons' + +interface DeleteApiKeyDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + teamSlug: string + apiKey: TeamAPIKey +} + +export const DeleteApiKeyDialog: FC = ({ + open, + onOpenChange, + teamSlug, + apiKey, +}) => { + const { toast } = useToast() + const trpc = useTRPC() + const queryClient = useQueryClient() + + const listQueryKey = trpc.teams.listApiKeys.queryOptions({ + teamSlug, + }).queryKey + + const deleteMutation = useMutation( + trpc.teams.deleteApiKey.mutationOptions({ + onSuccess: () => { + toast(defaultSuccessToast('API key has been deleted.')) + onOpenChange(false) + void queryClient.invalidateQueries({ queryKey: listQueryKey }) + }, + onError: (err) => { + toast(defaultErrorToast(err.message || 'Failed to delete API key.')) + onOpenChange(false) + }, + }) + ) + + const keyLabel = apiKey.name.trim() ? apiKey.name : 'Untitled' + const lastUsedAt = apiKey.lastUsed + const lastUsedLabel = lastUsedAt + ? `Last used: ${formatRelativeAgo(new Date(lastUsedAt))}` + : null + + return ( + + +
+ + {`Delete '${keyLabel}' key?`} + + Confirm deletion of this API key. This cannot be undone. + + {lastUsedLabel ? ( +
+

+ Deleting this key will immediately disable all associated + applications +

+

+ {lastUsedLabel} +

+
+ ) : ( +

+ It was never used +

+ )} +
+
+ + + + +
+
+
+
+ ) +} diff --git a/src/features/dashboard/settings/keys/index.ts b/src/features/dashboard/settings/keys/index.ts new file mode 100644 index 000000000..92e48eafd --- /dev/null +++ b/src/features/dashboard/settings/keys/index.ts @@ -0,0 +1,5 @@ +export { ApiKeysPageContent } from './api-keys-page-content' +export { ApiKeysTable } from './api-keys-table' +export { ApiKeysTableRow } from './api-keys-table-row' +export { CreateApiKeyDialog } from './create-api-key-dialog' +export { DeleteApiKeyDialog } from './delete-api-key-dialog' diff --git a/src/features/dashboard/settings/keys/table-body.tsx b/src/features/dashboard/settings/keys/table-body.tsx deleted file mode 100644 index 8843f55fb..000000000 --- a/src/features/dashboard/settings/keys/table-body.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { CLI_GENERATED_KEY_NAME } from '@/configs/api' -import { getTeamApiKeys } from '@/core/server/functions/keys/get-api-keys' -import { ErrorIndicator } from '@/ui/error-indicator' -import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert' -import { TableCell, TableRow } from '@/ui/primitives/table' -import ApiKeyTableRow from './table-row' - -interface TableBodyContentProps { - params: Promise<{ teamSlug: string }> -} - -export default async function TableBodyContent({ - params, -}: TableBodyContentProps) { - const { teamSlug } = await params - - const result = await getTeamApiKeys({ teamSlug }) - - if (!result?.data || result.serverError || result.validationErrors) { - return ( - - - - - - ) - } - - const { apiKeys } = result.data - - if (apiKeys.length === 0) { - return ( - - - - No API Keys - - No API keys found for this team. - - - - - ) - } - - const normalKeys = apiKeys.filter( - (key) => key.name !== CLI_GENERATED_KEY_NAME - ) - const cliKeys = apiKeys.filter((key) => key.name === CLI_GENERATED_KEY_NAME) - - return ( - <> - {normalKeys.map((key, index) => ( - - ))} - {cliKeys.map((key, index) => ( - - ))} - - ) -} diff --git a/src/features/dashboard/settings/keys/table-row.tsx b/src/features/dashboard/settings/keys/table-row.tsx deleted file mode 100644 index 86f2467c4..000000000 --- a/src/features/dashboard/settings/keys/table-row.tsx +++ /dev/null @@ -1,160 +0,0 @@ -'use client' - -import { MoreHorizontal } from 'lucide-react' -import { motion } from 'motion/react' -import { useAction } from 'next-safe-action/hooks' -import { useState } from 'react' -import { API_KEYS_LAST_USED_FIRST_COLLECTION_DATE } from '@/configs/versioning' -import type { TeamAPIKey } from '@/core/modules/keys/models' -import { deleteApiKeyAction } from '@/core/server/actions/key-actions' -import { useDashboard } from '@/features/dashboard/context' -import { - defaultErrorToast, - defaultSuccessToast, - useToast, -} from '@/lib/hooks/use-toast' -import { exponentialSmoothing } from '@/lib/utils' -import { AlertDialog } from '@/ui/alert-dialog' -import { Button } from '@/ui/primitives/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, -} from '@/ui/primitives/dropdown-menu' -import { TableCell, TableRow } from '@/ui/primitives/table' - -interface TableRowProps { - apiKey: TeamAPIKey - index: number - className?: string -} - -export default function ApiKeyTableRow({ - apiKey, - index, - className, -}: TableRowProps) { - const { toast } = useToast() - const { team } = useDashboard() - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [hoveredRowIndex, setHoveredRowIndex] = useState(-1) - const [dropDownOpen, setDropDownOpen] = useState(false) - - const { execute: executeDeleteKey, isExecuting: isDeleting } = useAction( - deleteApiKeyAction, - { - onSuccess: () => { - toast(defaultSuccessToast('API Key has been deleted.')) - setIsDeleteDialogOpen(false) - }, - onError: (error) => { - toast( - defaultErrorToast( - error.error.serverError || 'Failed to delete API Key.' - ) - ) - setIsDeleteDialogOpen(false) - }, - } - ) - - const deleteKey = () => { - executeDeleteKey({ - teamSlug: team.slug, - apiKeyId: apiKey.id, - }) - } - - const concatedKeyMask = `${apiKey.mask.prefix}${apiKey.mask.maskedValuePrefix}......${apiKey.mask.maskedValueSuffix}` - - const createdBeforeLastUsedCollection = - new Date(apiKey.createdAt).getTime() < - API_KEYS_LAST_USED_FIRST_COLLECTION_DATE.getTime() - - const lastUsed = apiKey.lastUsed - ? new Date(apiKey.lastUsed).toLocaleDateString() - : createdBeforeLastUsedCollection - ? 'N/A' - : 'No Usage' - - const createdBy = apiKey.createdBy?.email || 'N/A' - - return ( - <> - - - setHoveredRowIndex(index)} - onMouseLeave={() => setHoveredRowIndex(-1)} - className={className} - > - - {apiKey.name} - - {concatedKeyMask} - - - {lastUsed} - - - {createdBy} - - - - {apiKey.createdAt - ? new Date(apiKey.createdAt).toLocaleDateString() - : '-'} - - - - - - - - - Danger Zone - setIsDeleteDialogOpen(true)} - disabled={isDeleting} - > - X Delete - - - - - - - - ) -} diff --git a/src/features/dashboard/settings/keys/table.tsx b/src/features/dashboard/settings/keys/table.tsx deleted file mode 100644 index 28fa4645d..000000000 --- a/src/features/dashboard/settings/keys/table.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { type FC, Suspense } from 'react' -import { cn } from '@/lib/utils' -import { - Table, - TableBody, - TableHead, - TableHeader, - TableRow, -} from '@/ui/primitives/table' -import { TableLoader } from '@/ui/table-loader' -import TableBodyContent from './table-body' - -interface ApiKeysTableProps { - params: Promise<{ teamSlug: string }> - className?: string -} - -const ApiKeysTable: FC = ({ params, className }) => { - return ( - - - - Key - Last Used - Created By - Created At - - - - - }> - - - -
- ) -} - -export default ApiKeysTable diff --git a/src/features/dashboard/shared/index.ts b/src/features/dashboard/shared/index.ts new file mode 100644 index 000000000..acbd7102a --- /dev/null +++ b/src/features/dashboard/shared/index.ts @@ -0,0 +1 @@ +export { UserAvatar } from './user-avatar' diff --git a/src/features/dashboard/shared/user-avatar.tsx b/src/features/dashboard/shared/user-avatar.tsx new file mode 100644 index 000000000..0ea11e944 --- /dev/null +++ b/src/features/dashboard/shared/user-avatar.tsx @@ -0,0 +1,23 @@ +'use client' + +import { cn } from '@/lib/utils' +import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' + +interface UserAvatarProps { + email?: string | null + url?: string | null + className?: string +} + +export const UserAvatar = ({ + email, + url, + className, +}: UserAvatarProps) => ( + + + + {email?.charAt(0).toUpperCase() ?? '?'} + + +) diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts index 20f0b04d3..af62806c7 100644 --- a/src/lib/utils/formatting.ts +++ b/src/lib/utils/formatting.ts @@ -127,6 +127,36 @@ export function formatChartTimestampUTC( return formatInTimeZone(date, 'UTC', 'h:mm:ss a') } +/** Formats elapsed time as a compact relative label; e.g. `new Date(Date.now() - 7200000)` -> `"2h ago"` */ +export const formatRelativeAgo = (date: Date): string => { + const now = Date.now() + const timestamp = date.getTime() + const seconds = Math.floor((now - timestamp) / 1000) + if (seconds < 60) return `${seconds}s ago` + + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes} min ago` + + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + + const days = Math.floor(hours / 24) + if (days < 7) return `${days}d ago` + + const weeks = Math.floor(days / 7) + if (weeks < 5) return `${weeks}w ago` + + const months = Math.floor(days / 30) + if (months < 12) return `${months}mo ago` + + const years = Math.floor(days / 365) + return `${years}y ago` +} + +/** Formats a UTC timestamp for tooltips; e.g. `new Date('2025-09-29T14:18:49.000Z')` -> `"2025-09-29T14:18:49+00:00"` */ +export const formatUTCTimestamp = (date: Date): string => + formatInTimeZone(date, 'UTC', "yyyy-MM-dd'T'HH:mm:ssxxx") + /** * Format a date for compact display (used in chart range labels) * @param timestamp - Unix timestamp in milliseconds diff --git a/src/ui/primitives/table.tsx b/src/ui/primitives/table.tsx index 67bdc1eb9..81c3f87ee 100644 --- a/src/ui/primitives/table.tsx +++ b/src/ui/primitives/table.tsx @@ -157,7 +157,11 @@ const TableEmptyState = ({ > - {index === 1 && children} + {index === 1 ? ( +
+ {children} +
+ ) : null} ))}