From d6c0b5c843ddff2c14328be9455bb68d56f2d725 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 15 Apr 2026 12:43:32 -0400 Subject: [PATCH 01/11] Enhance API key management UI and functionality - Added a new `ApiKeysPageContent` component for displaying API keys with search functionality. - Introduced `ApiKeysTable` and `ApiKeysTableRow` components for better organization of API key data. - Updated `CreateApiKeyDialog` to utilize TRPC for creating API keys and improved user feedback on key creation. - Removed deprecated key management functions and actions, streamlining the codebase. - Updated routing and data fetching to align with the new component structure. This refactor improves the user experience for managing API keys within the dashboard. --- .gitignore | 2 + src/app/dashboard/[teamSlug]/keys/page.tsx | 63 ++--- src/core/server/actions/key-actions.ts | 100 -------- src/core/server/api/routers/teams.ts | 93 ++++++- .../server/functions/keys/get-api-keys.ts | 49 ---- src/core/server/functions/keys/types.ts | 7 - .../settings/keys/api-keys-page-content.tsx | 102 ++++++++ .../settings/keys/api-keys-table-row.tsx | 211 ++++++++++++++++ .../settings/keys/api-keys-table.tsx | 78 ++++++ .../dashboard/settings/keys/api-keys-utils.ts | 54 +++++ .../settings/keys/create-api-key-dialog.tsx | 228 +++++++++++------- src/features/dashboard/settings/keys/index.ts | 4 + .../dashboard/settings/keys/table-body.tsx | 69 ------ .../dashboard/settings/keys/table-row.tsx | 160 ------------ .../dashboard/settings/keys/table.tsx | 39 --- 15 files changed, 704 insertions(+), 555 deletions(-) delete mode 100644 src/core/server/actions/key-actions.ts delete mode 100644 src/core/server/functions/keys/get-api-keys.ts delete mode 100644 src/core/server/functions/keys/types.ts create mode 100644 src/features/dashboard/settings/keys/api-keys-page-content.tsx create mode 100644 src/features/dashboard/settings/keys/api-keys-table-row.tsx create mode 100644 src/features/dashboard/settings/keys/api-keys-table.tsx create mode 100644 src/features/dashboard/settings/keys/api-keys-utils.ts create mode 100644 src/features/dashboard/settings/keys/index.ts delete mode 100644 src/features/dashboard/settings/keys/table-body.tsx delete mode 100644 src/features/dashboard/settings/keys/table-row.tsx delete mode 100644 src/features/dashboard/settings/keys/table.tsx 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/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..d72ded4f7 100644 --- a/src/core/server/api/routers/teams.ts +++ b/src/core/server/api/routers/teams.ts @@ -1,8 +1,19 @@ +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 { 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 +21,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 +37,76 @@ 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, teamSlug } = 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) + } + + updateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId)) + revalidatePath(`/dashboard/${teamSlug}/keys`, 'page') + + return { createdApiKey: result.data } + }), + + deleteApiKey: keysRepositoryProcedure + .input( + z.object({ + apiKeyId: z.uuid(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { apiKeyId, teamSlug } = 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) + } + + updateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId)) + revalidatePath(`/dashboard/${teamSlug}/keys`, 'page') + }), }) 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/settings/keys/api-keys-page-content.tsx b/src/features/dashboard/settings/keys/api-keys-page-content.tsx new file mode 100644 index 000000000..4990e83a4 --- /dev/null +++ b/src/features/dashboard/settings/keys/api-keys-page-content.tsx @@ -0,0 +1,102 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { Search } from 'lucide-react' +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 { 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 +} + +export const ApiKeysPageContent = ({ + teamSlug, + className, +}: ApiKeysPageContentProps) => { + const trpc = useTRPC() + const [query, setQuery] = useState('') + + 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]) + + const totalLabel = `${apiKeys.length} ${pluralize(apiKeys.length, 'key')} in total` + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isError) { + return ( + + ) + } + + return ( +
+
+
+ + setQuery(e.target.value)} + placeholder="Search by title or ID" + type="search" + value={query} + /> +
+ +
+ +
+

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

+

{totalLabel}

+
+ +
+ +
+
+ ) +} 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..1d66a636c --- /dev/null +++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx @@ -0,0 +1,211 @@ +'use client' + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Key, Sparkles, Trash2 } from 'lucide-react' +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 { + defaultErrorToast, + defaultSuccessToast, + useToast, +} from '@/lib/hooks/use-toast' +import { formatDate } from '@/lib/utils/formatting' +import { useTRPC } from '@/trpc/client' +import { AlertDialog } from '@/ui/alert-dialog' +import CopyButton from '@/ui/copy-button' +import { Avatar, AvatarFallback } from '@/ui/primitives/avatar' +import { Button } from '@/ui/primitives/button' +import { TableCell, TableRow } from '@/ui/primitives/table' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/ui/primitives/tooltip' +import { + formatShortRelativeAgo, + getLastUsedLabel, + toIsoUtcString, +} from './api-keys-utils' + +interface ApiKeysTableRowProps { + apiKey: TeamAPIKey + teamSlug: string +} + +const initialsFromEmail = (email: string) => { + const local = email.split('@')[0] ?? '?' + if (local.length <= 2) return local.toUpperCase() + return local.slice(0, 2).toUpperCase() +} + +export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => { + const { toast } = useToast() + const trpc = useTRPC() + const queryClient = useQueryClient() + const posthog = usePostHog() + const [deleteOpen, setDeleteOpen] = useState(false) + + const listQueryKey = trpc.teams.listApiKeys.queryOptions({ + teamSlug, + }).queryKey + + const deleteMutation = useMutation( + trpc.teams.deleteApiKey.mutationOptions({ + onSuccess: () => { + toast(defaultSuccessToast('API key has been deleted.')) + setDeleteOpen(false) + void queryClient.invalidateQueries({ queryKey: listQueryKey }) + }, + onError: (err) => { + toast(defaultErrorToast(err.message || 'Failed to delete API key.')) + setDeleteOpen(false) + }, + }) + ) + + const maskDisplay = `${apiKey.mask.prefix}${apiKey.mask.maskedValuePrefix}...${apiKey.mask.maskedValueSuffix}` + const copyValue = maskDisplay + + const addedDate = apiKey.createdAt + ? formatDate(new Date(apiKey.createdAt), 'MMM d, yyyy') + : '—' + + const lastUsedAt = apiKey.lastUsed + const lastUsedLabel = getLastUsedLabel(apiKey) + const hasLastUsedTimestamp = Boolean(lastUsedAt) + const isCliKey = apiKey.name === CLI_GENERATED_KEY_NAME + const isNeverUsed = !apiKey.lastUsed + + const deleteTitle = `DELETE '${apiKey.name}' KEY?` + + const deleteDescription = isNeverUsed ? ( + It was never used + ) : ( +
+

+ Deleting this key will immediately disable all associated applications +

+ {lastUsedAt ? ( +

+ Last used: {formatShortRelativeAgo(new Date(lastUsedAt))} +

+ ) : null} +
+ ) + + return ( + <> + + + Delete + + } + confirmProps={{ + disabled: deleteMutation.isPending, + loading: deleteMutation.isPending, + }} + onConfirm={() => { + deleteMutation.mutate({ teamSlug, apiKeyId: apiKey.id }) + }} + /> + + + +
+ + + {apiKey.name} + +
+
+ +
+ {maskDisplay} + { + posthog.capture('copied API key id') + }} + /> +
+
+ + {hasLastUsedTimestamp ? ( + + + + {lastUsedLabel} + + + + {toIsoUtcString(new Date(lastUsedAt))} + + + ) : ( + lastUsedLabel + )} + + +
+ {addedDate} + {isCliKey ? ( + + + + + + + + Added through E2B CLI + + + ) : apiKey.createdBy ? ( + + + + + {initialsFromEmail(apiKey.createdBy.email)} + + + + + {apiKey.createdBy.email} + + + ) : null} +
+
+ + + +
+ + ) +} 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..d902048ec --- /dev/null +++ b/src/features/dashboard/settings/keys/api-keys-table.tsx @@ -0,0 +1,78 @@ +'use client' + +import { Key } from 'lucide-react' +import type { FC } from 'react' +import type { TeamAPIKey } from '@/core/modules/keys/models' +import { cn } from '@/lib/utils' +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 + + + Actions + + + + + {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..5ae6f0281 --- /dev/null +++ b/src/features/dashboard/settings/keys/api-keys-utils.ts @@ -0,0 +1,54 @@ +import { API_KEYS_LAST_USED_FIRST_COLLECTION_DATE } from '@/configs/versioning' +import type { TeamAPIKey } from '@/core/modules/keys/models' + +/** 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) +} + +/** Compact relative label for "last used" column; e.g. `new Date()` → `"0s ago"` */ +export const formatShortRelativeAgo = (date: Date): string => { + const now = Date.now() + const t = date.getTime() + const sec = Math.floor((now - t) / 1000) + if (sec < 60) return `${sec}s ago` + const min = Math.floor(sec / 60) + if (min < 60) return `${min} min ago` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}h ago` + const day = Math.floor(hr / 24) + if (day < 7) return `${day}d ago` + const wk = Math.floor(day / 7) + if (wk < 5) return `${wk}w ago` + const mo = Math.floor(day / 30) + if (mo < 12) return `${mo}mo ago` + const yr = Math.floor(day / 365) + return `${yr}y ago` +} + +/** Human line for last-used cell, matching legacy semantics for pre-collection keys. */ +export const getLastUsedLabel = (apiKey: TeamAPIKey): string => { + if (apiKey.lastUsed) return formatShortRelativeAgo(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' +} + +/** ISO string for tooltips on last-used; e.g. `new Date()` → `"2025-09-29T14:18:49.000Z"` */ +export const toIsoUtcString = (date: Date): string => date.toISOString() 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..dee439a34 100644 --- a/src/features/dashboard/settings/keys/create-api-key-dialog.tsx +++ b/src/features/dashboard/settings/keys/create-api-key-dialog.tsx @@ -1,22 +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 { CheckIcon, Plus } from 'lucide-react' 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 { useTRPC } from '@/trpc/client' import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert' import { Button } from '@/ui/primitives/button' import { Dialog, DialogClose, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -29,6 +28,7 @@ import { FormItem, FormMessage, } from '@/ui/primitives/form' +import { CopyIcon } from '@/ui/primitives/icons' import { Input } from '@/ui/primitives/input' import { Label } from '@/ui/primitives/label' @@ -43,18 +43,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,98 +74,149 @@ 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 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 + + +
+ { + createMutation.mutate({ teamSlug, name: values.name }) + })} + className="flex flex-col gap-4 px-5 py-4" + > + ( + + +
+ + + + +
+ +
+ )} + /> + + + ) : ( <> -
- -
- - { + + + {successTitle} + + +
+
+ +
- - Important - - Make sure to copy your API Key now. -
You won't be able to see it again! + + + Important + + + Copy the key now. You won't be able to view it again.
- - + - + @@ -163,5 +225,3 @@ const CreateApiKeyDialog: FC = ({ children }) => {
) } - -export default CreateApiKeyDialog diff --git a/src/features/dashboard/settings/keys/index.ts b/src/features/dashboard/settings/keys/index.ts new file mode 100644 index 000000000..48d5c6921 --- /dev/null +++ b/src/features/dashboard/settings/keys/index.ts @@ -0,0 +1,4 @@ +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' 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 From 50b6c6de055643ae9f79cb6c823f1a740e7100c5 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 15 Apr 2026 12:45:20 -0400 Subject: [PATCH 02/11] Refactor cache tag handling in teams API router - Replaced `updateTag` with `revalidateTag` for cache invalidation in the teams router. - Updated the cache revalidation logic to ensure proper tag handling for API keys. This change enhances cache management and aligns with the latest API standards. --- src/core/server/api/routers/teams.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/server/api/routers/teams.ts b/src/core/server/api/routers/teams.ts index d72ded4f7..96c7399a9 100644 --- a/src/core/server/api/routers/teams.ts +++ b/src/core/server/api/routers/teams.ts @@ -1,4 +1,4 @@ -import { revalidatePath, updateTag } from 'next/cache' +import { revalidatePath, revalidateTag } from 'next/cache' import { z } from 'zod' import { CACHE_TAGS } from '@/configs/cache' import { createKeysRepository } from '@/core/modules/keys/repository.server' @@ -76,7 +76,7 @@ export const teamsRouter = createTRPCRouter({ throwTRPCErrorFromRepoError(result.error) } - updateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId)) + revalidateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId), 'default') revalidatePath(`/dashboard/${teamSlug}/keys`, 'page') return { createdApiKey: result.data } @@ -106,7 +106,7 @@ export const teamsRouter = createTRPCRouter({ throwTRPCErrorFromRepoError(result.error) } - updateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId)) + revalidateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId), 'default') revalidatePath(`/dashboard/${teamSlug}/keys`, 'page') }), }) From 99831d1ae793ec0c4cff860d104c106ad54bbf4b Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 15 Apr 2026 12:48:50 -0400 Subject: [PATCH 03/11] Refactor teams API router to remove unused cache tag handling - Removed the `TEAM_API_KEYS` cache tag from the cache configuration. - Simplified API key creation and deletion logic by eliminating unnecessary cache revalidation calls. This change streamlines the code and improves maintainability by focusing on relevant cache management. --- src/configs/cache.ts | 1 - src/core/server/api/routers/teams.ts | 12 ++---------- 2 files changed, 2 insertions(+), 11 deletions(-) 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/api/routers/teams.ts b/src/core/server/api/routers/teams.ts index 96c7399a9..438d24600 100644 --- a/src/core/server/api/routers/teams.ts +++ b/src/core/server/api/routers/teams.ts @@ -1,6 +1,4 @@ -import { revalidatePath, revalidateTag } from 'next/cache' import { z } from 'zod' -import { CACHE_TAGS } from '@/configs/cache' 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' @@ -59,7 +57,7 @@ export const teamsRouter = createTRPCRouter({ }) ) .mutation(async ({ ctx, input }) => { - const { name, teamSlug } = input + const { name } = input const result = await ctx.keysRepository.createApiKey(name) @@ -76,9 +74,6 @@ export const teamsRouter = createTRPCRouter({ throwTRPCErrorFromRepoError(result.error) } - revalidateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId), 'default') - revalidatePath(`/dashboard/${teamSlug}/keys`, 'page') - return { createdApiKey: result.data } }), @@ -89,7 +84,7 @@ export const teamsRouter = createTRPCRouter({ }) ) .mutation(async ({ ctx, input }) => { - const { apiKeyId, teamSlug } = input + const { apiKeyId } = input const result = await ctx.keysRepository.deleteApiKey(apiKeyId) @@ -105,8 +100,5 @@ export const teamsRouter = createTRPCRouter({ throwTRPCErrorFromRepoError(result.error) } - - revalidateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId), 'default') - revalidatePath(`/dashboard/${teamSlug}/keys`, 'page') }), }) From edba037a66771a605e51907592576a872330be9b Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 15 Apr 2026 13:27:13 -0400 Subject: [PATCH 04/11] Enhance API key management UI with new components and improved functionality - Introduced `DeleteApiKeyDialog` for confirming API key deletions. - Refactored `CreateApiKeyDialog` to improve user experience and input handling. - Updated `ApiKeysPageContent` to include a search field and total key count display. - Enhanced `ApiKeysTable` and `ApiKeysTableRow` for better organization and visual clarity. - Improved empty state handling in various components for clearer user feedback. These changes streamline the API key management process and enhance the overall user interface. --- src/features/dashboard/billing/invoices.tsx | 10 +- .../dashboard/members/member-table.tsx | 8 +- .../settings/keys/api-keys-page-content.tsx | 67 +++++++--- .../settings/keys/api-keys-table-row.tsx | 98 +++----------- .../settings/keys/api-keys-table.tsx | 15 ++- .../settings/keys/create-api-key-dialog.tsx | 113 ++++++++++------- .../settings/keys/delete-api-key-dialog.tsx | 120 ++++++++++++++++++ src/features/dashboard/settings/keys/index.ts | 1 + src/ui/primitives/table.tsx | 6 +- 9 files changed, 276 insertions(+), 162 deletions(-) create mode 100644 src/features/dashboard/settings/keys/delete-api-key-dialog.tsx 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.tsx b/src/features/dashboard/members/member-table.tsx index 06d9bc32f..8ac703ef9 100644 --- a/src/features/dashboard/members/member-table.tsx +++ b/src/features/dashboard/members/member-table.tsx @@ -49,11 +49,9 @@ const MemberTable: FC = ({ {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 index 4990e83a4..ee5946cf1 100644 --- a/src/features/dashboard/settings/keys/api-keys-page-content.tsx +++ b/src/features/dashboard/settings/keys/api-keys-page-content.tsx @@ -1,13 +1,13 @@ 'use client' import { useQuery } from '@tanstack/react-query' -import { Search } from 'lucide-react' 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' @@ -19,6 +19,48 @@ interface ApiKeysPageContentProps { 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} + /> +
+ ) +} + +const ApiKeysTotalLabel = ({ count }: { count: number }) => { + if (count === 0) return null + + return ( +

+ {count} {pluralize(count, 'key')} in total +

+ ) +} + export const ApiKeysPageContent = ({ teamSlug, className, @@ -42,8 +84,6 @@ export const ApiKeysPageContent = ({ return sortedKeys.filter((k) => matchesApiKeySearch(k, query)) }, [sortedKeys, query]) - const totalLabel = `${apiKeys.length} ${pluralize(apiKeys.length, 'key')} in total` - if (isLoading) { return (
@@ -65,20 +105,11 @@ export const ApiKeysPageContent = ({ return (
-
- - setQuery(e.target.value)} - placeholder="Search by title or ID" - type="search" - value={query} - /> -
+
@@ -87,7 +118,7 @@ export const ApiKeysPageContent = ({ These keys authenticate API requests from your team's applications.

-

{totalLabel}

+
diff --git a/src/features/dashboard/settings/keys/api-keys-table-row.tsx b/src/features/dashboard/settings/keys/api-keys-table-row.tsx index 1d66a636c..84b6f00d6 100644 --- a/src/features/dashboard/settings/keys/api-keys-table-row.tsx +++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx @@ -1,33 +1,23 @@ 'use client' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { Key, Sparkles, Trash2 } from 'lucide-react' 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 { - defaultErrorToast, - defaultSuccessToast, - useToast, -} from '@/lib/hooks/use-toast' import { formatDate } from '@/lib/utils/formatting' -import { useTRPC } from '@/trpc/client' -import { AlertDialog } from '@/ui/alert-dialog' +import { E2BLogo } from '@/ui/brand' import CopyButton from '@/ui/copy-button' import { Avatar, AvatarFallback } from '@/ui/primitives/avatar' import { Button } from '@/ui/primitives/button' +import { KeyIcon, TrashIcon } from '@/ui/primitives/icons' import { TableCell, TableRow } from '@/ui/primitives/table' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/ui/primitives/tooltip' -import { - formatShortRelativeAgo, - getLastUsedLabel, - toIsoUtcString, -} from './api-keys-utils' +import { getLastUsedLabel, toIsoUtcString } from './api-keys-utils' +import { DeleteApiKeyDialog } from './delete-api-key-dialog' interface ApiKeysTableRowProps { apiKey: TeamAPIKey @@ -41,30 +31,9 @@ const initialsFromEmail = (email: string) => { } export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => { - const { toast } = useToast() - const trpc = useTRPC() - const queryClient = useQueryClient() const posthog = usePostHog() const [deleteOpen, setDeleteOpen] = useState(false) - const listQueryKey = trpc.teams.listApiKeys.queryOptions({ - teamSlug, - }).queryKey - - const deleteMutation = useMutation( - trpc.teams.deleteApiKey.mutationOptions({ - onSuccess: () => { - toast(defaultSuccessToast('API key has been deleted.')) - setDeleteOpen(false) - void queryClient.invalidateQueries({ queryKey: listQueryKey }) - }, - onError: (err) => { - toast(defaultErrorToast(err.message || 'Failed to delete API key.')) - setDeleteOpen(false) - }, - }) - ) - const maskDisplay = `${apiKey.mask.prefix}${apiKey.mask.maskedValuePrefix}...${apiKey.mask.maskedValueSuffix}` const copyValue = maskDisplay @@ -74,53 +43,21 @@ export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => { const lastUsedAt = apiKey.lastUsed const lastUsedLabel = getLastUsedLabel(apiKey) - const hasLastUsedTimestamp = Boolean(lastUsedAt) const isCliKey = apiKey.name === CLI_GENERATED_KEY_NAME - const isNeverUsed = !apiKey.lastUsed - - const deleteTitle = `DELETE '${apiKey.name}' KEY?` - - const deleteDescription = isNeverUsed ? ( - It was never used - ) : ( -
-

- Deleting this key will immediately disable all associated applications -

- {lastUsedAt ? ( -

- Last used: {formatShortRelativeAgo(new Date(lastUsedAt))} -

- ) : null} -
- ) return ( <> - - - Delete - - } - confirmProps={{ - disabled: deleteMutation.isPending, - loading: deleteMutation.isPending, - }} - onConfirm={() => { - deleteMutation.mutate({ teamSlug, apiKeyId: apiKey.id }) - }} + teamSlug={teamSlug} + apiKey={apiKey} />
- @@ -144,7 +81,7 @@ export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => {
- {hasLastUsedTimestamp ? ( + {lastUsedAt ? ( @@ -165,12 +102,14 @@ export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => { {isCliKey ? ( - - - + Added through E2B CLI @@ -200,9 +139,8 @@ export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => { className="text-fg-tertiary opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100" aria-label={`Delete ${apiKey.name}`} onClick={() => setDeleteOpen(true)} - disabled={deleteMutation.isPending} > - +
diff --git a/src/features/dashboard/settings/keys/api-keys-table.tsx b/src/features/dashboard/settings/keys/api-keys-table.tsx index d902048ec..a72105ce1 100644 --- a/src/features/dashboard/settings/keys/api-keys-table.tsx +++ b/src/features/dashboard/settings/keys/api-keys-table.tsx @@ -1,9 +1,9 @@ 'use client' -import { Key } from 'lucide-react' 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, @@ -57,12 +57,13 @@ export const ApiKeysTable: FC = ({ {apiKeys.length === 0 ? ( -

- - {totalKeyCount === 0 - ? 'No keys added yet' - : 'No keys match your search.'} -

+ + {totalKeyCount === 0 + ? 'No keys added yet' + : 'No keys match your search.'}
) : ( apiKeys.map((apiKey) => ( 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 dee439a34..5d645d45f 100644 --- a/src/features/dashboard/settings/keys/create-api-key-dialog.tsx +++ b/src/features/dashboard/settings/keys/create-api-key-dialog.tsx @@ -2,13 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { CheckIcon, Plus } from 'lucide-react' 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 { useClipboard } from '@/lib/hooks/use-clipboard' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { cn } from '@/lib/utils' import { useTRPC } from '@/trpc/client' import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert' import { Button } from '@/ui/primitives/button' @@ -28,10 +28,25 @@ import { FormItem, FormMessage, } from '@/ui/primitives/form' -import { CopyIcon } from '@/ui/primitives/icons' +import { + AddIcon, + CheckIcon, + CopyIcon, + WarningIcon, +} from '@/ui/primitives/icons' import { Input } from '@/ui/primitives/input' import { Label } from '@/ui/primitives/label' +/** Neutral focus inside compound fields (Input defaults to accent bottom border). */ +const compoundInputClass = cn( + 'h-10 min-h-10 min-w-0 flex-1 rounded-none border-0 bg-transparent px-3 font-sans text-sm normal-case shadow-none', + 'placeholder:text-fg-tertiary', + 'hover:bg-bg-hover focus:bg-bg-hover', + 'focus-visible:outline-none focus-visible:ring-0', + 'focus:border-0 focus:outline-none', + 'focus:[border-bottom:1px_solid_var(--stroke)] focus:[border-bottom-style:solid]' +) + const formSchema = z.object({ name: z .string() @@ -112,42 +127,38 @@ export const CreateApiKeyDialog: FC = ({ type="button" className="h-9 w-full shrink-0 gap-2 font-sans normal-case lg:w-auto lg:self-start" > - + Create a key )} - + {!createdKey ? ( - <> - - - Create new key - - -
- { - createMutation.mutate({ teamSlug, name: values.name }) - })} - className="flex flex-col gap-4 px-5 py-4" - > + + { + createMutation.mutate({ teamSlug, name: values.name }) + })} + > +
+

+ Create new key +

+
+
( - -
From 255669e676884fc7058a5010494bb692a04049ab Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 15 Apr 2026 13:36:09 -0400 Subject: [PATCH 05/11] Refactor CreateApiKeyDialog for improved UI and functionality - Updated the layout of the CreateApiKeyDialog to enhance user experience with clearer component structure. - Replaced deprecated components and improved accessibility with appropriate dialog descriptions. - Introduced form validation to ensure the API key name is not empty before submission. - Streamlined button states based on input validity to prevent unnecessary submissions. These changes contribute to a more intuitive and user-friendly API key creation process. --- .../settings/keys/create-api-key-dialog.tsx | 144 +++++++++--------- 1 file changed, 73 insertions(+), 71 deletions(-) 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 5d645d45f..b24ba5bdf 100644 --- a/src/features/dashboard/settings/keys/create-api-key-dialog.tsx +++ b/src/features/dashboard/settings/keys/create-api-key-dialog.tsx @@ -10,13 +10,12 @@ import { useClipboard } from '@/lib/hooks/use-clipboard' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' import { useTRPC } from '@/trpc/client' -import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert' import { Button } from '@/ui/primitives/button' import { Dialog, DialogClose, DialogContent, - DialogFooter, + DialogDescription, DialogHeader, DialogTitle, DialogTrigger, @@ -37,16 +36,6 @@ import { import { Input } from '@/ui/primitives/input' import { Label } from '@/ui/primitives/label' -/** Neutral focus inside compound fields (Input defaults to accent bottom border). */ -const compoundInputClass = cn( - 'h-10 min-h-10 min-w-0 flex-1 rounded-none border-0 bg-transparent px-3 font-sans text-sm normal-case shadow-none', - 'placeholder:text-fg-tertiary', - 'hover:bg-bg-hover focus:bg-bg-hover', - 'focus-visible:outline-none focus-visible:ring-0', - 'focus:border-0 focus:outline-none', - 'focus:[border-bottom:1px_solid_var(--stroke)] focus:[border-bottom-style:solid]' -) - const formSchema = z.object({ name: z .string() @@ -89,6 +78,9 @@ export const CreateApiKeyDialog: FC = ({ }, }) + const nameDraft = form.watch('name') + const canSubmit = nameDraft.trim().length > 0 + const createMutation = useMutation( trpc.teams.createApiKey.mutationOptions({ onSuccess: (data) => { @@ -132,33 +124,37 @@ export const CreateApiKeyDialog: FC = ({ )} - + {!createdKey ? ( -
- { - createMutation.mutate({ teamSlug, name: values.name }) - })} - > -
-

- Create new key -

-
-
+ <> + + Create new key + + Enter a name and create a new API key for this team. + + + + { + createMutation.mutate({ teamSlug, name: values.name }) + })} + > ( - + -
+
= ({
- + )} /> -
- - + + + ) : ( <> - - {successTitle} + + {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. - +

+ + +
- +
- - - - - )} From 1adab93994562bab83a461ebee19a87b8bb85601 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 15 Apr 2026 18:05:26 +0000 Subject: [PATCH 06/11] Show filtered API key counts while searching Co-authored-by: Sarim Malik --- .../settings/keys/api-keys-page-content.tsx | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/features/dashboard/settings/keys/api-keys-page-content.tsx b/src/features/dashboard/settings/keys/api-keys-page-content.tsx index ee5946cf1..554070d1c 100644 --- a/src/features/dashboard/settings/keys/api-keys-page-content.tsx +++ b/src/features/dashboard/settings/keys/api-keys-page-content.tsx @@ -51,13 +51,25 @@ const ApiKeysSearchField = ({ ) } -const ApiKeysTotalLabel = ({ count }: { count: number }) => { - if (count === 0) return null +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 ( -

- {count} {pluralize(count, 'key')} in total -

+

{label}

) } @@ -67,6 +79,7 @@ export const ApiKeysPageContent = ({ }: 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 }) @@ -118,7 +131,11 @@ export const ApiKeysPageContent = ({ These keys authenticate API requests from your team's applications.

- +
From 30fc9c9efe3a20716e5cf0cd2597ef6dac35d0fa Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 15 Apr 2026 15:17:52 -0400 Subject: [PATCH 07/11] Refactor member and API key components to enhance UI and functionality - Introduced a new `UserAvatar` component for consistent avatar rendering across member and API key tables. - Updated `ApiKeysPageContent` layout for improved responsiveness and user experience. - Simplified `ApiKeysTableRow` to utilize the new `UserAvatar` component, enhancing code maintainability. - Adjusted styles in `CreateApiKeyDialog` for better alignment and responsiveness. These changes streamline the user interface and improve the overall experience in managing team members and API keys. --- .../dashboard/members/member-table-row.tsx | 14 ++--- .../settings/keys/api-keys-page-content.tsx | 62 +++++++++---------- .../settings/keys/api-keys-table-row.tsx | 14 +---- .../settings/keys/create-api-key-dialog.tsx | 2 +- src/features/dashboard/shared/index.ts | 1 + src/features/dashboard/shared/user-avatar.tsx | 23 +++++++ 6 files changed, 63 insertions(+), 53 deletions(-) create mode 100644 src/features/dashboard/shared/index.ts create mode 100644 src/features/dashboard/shared/user-avatar.tsx 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 ? ( +
{label}

- ) + return

{label}

} export const ApiKeysPageContent = ({ @@ -117,33 +115,35 @@ export const ApiKeysPageContent = ({ return (
-
- - -
- -
-

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

- -
- -
- +
+
+ + +
+ +
+

+ 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 index 84b6f00d6..5ba0ddf23 100644 --- a/src/features/dashboard/settings/keys/api-keys-table-row.tsx +++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx @@ -4,10 +4,10 @@ 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 { formatDate } from '@/lib/utils/formatting' import { E2BLogo } from '@/ui/brand' import CopyButton from '@/ui/copy-button' -import { Avatar, AvatarFallback } from '@/ui/primitives/avatar' import { Button } from '@/ui/primitives/button' import { KeyIcon, TrashIcon } from '@/ui/primitives/icons' import { TableCell, TableRow } from '@/ui/primitives/table' @@ -24,12 +24,6 @@ interface ApiKeysTableRowProps { teamSlug: string } -const initialsFromEmail = (email: string) => { - const local = email.split('@')[0] ?? '?' - if (local.length <= 2) return local.toUpperCase() - return local.slice(0, 2).toUpperCase() -} - export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => { const posthog = usePostHog() const [deleteOpen, setDeleteOpen] = useState(false) @@ -118,11 +112,7 @@ export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => { ) : apiKey.createdBy ? ( - - - {initialsFromEmail(apiKey.createdBy.email)} - - + {apiKey.createdBy.email} 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 b24ba5bdf..4d1de1e7c 100644 --- a/src/features/dashboard/settings/keys/create-api-key-dialog.tsx +++ b/src/features/dashboard/settings/keys/create-api-key-dialog.tsx @@ -117,7 +117,7 @@ export const CreateApiKeyDialog: FC = ({ {children ?? ( + + ) +} + +const ApiKeyLastUsedCell = ({ + lastUsedAt, + lastUsedLabel, +}: ApiKeyLastUsedCellProps) => ( + + {lastUsedAt ? ( + + + + {lastUsedLabel} + + + + {toIsoUtcString(new Date(lastUsedAt))} + + + ) : ( + lastUsedLabel + )} + +) + +const ApiKeyAddedCell = ({ + addedDate, + createdBy, + isCliKey, + keyName, + onDelete, +}: ApiKeyAddedCellProps) => ( + +
+ {addedDate} + {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 copyValue = maskDisplay - const addedDate = apiKey.createdAt - ? formatDate(new Date(apiKey.createdAt), 'MMM d, yyyy') + ? (formatDate(new Date(apiKey.createdAt), 'MMM d, yyyy') ?? '—') : '—' const lastUsedAt = apiKey.lastUsed @@ -48,91 +186,27 @@ export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => { apiKey={apiKey} /> - - -
- - - {apiKey.name} - -
-
- -
- {maskDisplay} - { - posthog.capture('copied API key id') - }} - /> -
-
- - {lastUsedAt ? ( - - - - {lastUsedLabel} - - - - {toIsoUtcString(new Date(lastUsedAt))} - - - ) : ( - lastUsedLabel - )} - - -
- {addedDate} - {isCliKey ? ( - - - - - - Added through E2B CLI - - - ) : apiKey.createdBy ? ( - - - - - - {apiKey.createdBy.email} - - - ) : null} -
-
- - + + + + { + 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 index a72105ce1..79f4bb8f3 100644 --- a/src/features/dashboard/settings/keys/api-keys-table.tsx +++ b/src/features/dashboard/settings/keys/api-keys-table.tsx @@ -29,11 +29,10 @@ export const ApiKeysTable: FC = ({ }) => ( - - - - - + + + + @@ -49,14 +48,11 @@ export const ApiKeysTable: FC = ({ ADDED - - Actions - {apiKeys.length === 0 ? ( - + Date: Wed, 15 Apr 2026 15:44:33 -0400 Subject: [PATCH 09/11] Refactor ApiKeysTableRow and ApiKeysTable for improved layout and functionality - Updated the API keys table row components to enhance UI consistency and readability. - Replaced the RemoveIcon with TrashIcon for better visual representation of delete actions. - Adjusted padding and column widths in the ApiKeysTable for a more responsive design. - Enhanced the empty state handling to accommodate the new column structure. These changes contribute to a more intuitive and user-friendly API key management experience. --- .../settings/keys/api-keys-table-row.tsx | 42 +++++++++++-------- .../settings/keys/api-keys-table.tsx | 10 +++-- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/features/dashboard/settings/keys/api-keys-table-row.tsx b/src/features/dashboard/settings/keys/api-keys-table-row.tsx index 188e8aa88..2a15e0186 100644 --- a/src/features/dashboard/settings/keys/api-keys-table-row.tsx +++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx @@ -10,7 +10,7 @@ import { formatDate } 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, RemoveIcon } from '@/ui/primitives/icons' +import { CheckIcon, CopyIcon, KeyIcon, TrashIcon } from '@/ui/primitives/icons' import { TableCell, TableRow } from '@/ui/primitives/table' import { Tooltip, @@ -41,6 +41,9 @@ interface ApiKeyLastUsedCellProps { interface ApiKeyAddedCellProps { addedDate: string +} + +interface ApiKeyOptionsProps { createdBy?: TeamAPIKey['createdBy'] | null isCliKey: boolean keyName: string | null @@ -48,7 +51,7 @@ interface ApiKeyAddedCellProps { } const ApiKeyNameCell = ({ name }: ApiKeyNameCellProps) => ( - +
@@ -101,7 +104,7 @@ const ApiKeyLastUsedCell = ({ lastUsedAt, lastUsedLabel, }: ApiKeyLastUsedCellProps) => ( - + {lastUsedAt ? ( @@ -119,16 +122,22 @@ const ApiKeyLastUsedCell = ({ ) -const ApiKeyAddedCell = ({ - addedDate, +const ApiKeyAddedCell = ({ addedDate }: ApiKeyAddedCellProps) => ( + + + {addedDate} + + +) + +const ApiKeyOptions = ({ createdBy, isCliKey, keyName, onDelete, -}: ApiKeyAddedCellProps) => ( - -
- {addedDate} +}: ApiKeyOptionsProps) => ( + +
{isCliKey ? ( @@ -141,7 +150,7 @@ const ApiKeyAddedCell = ({ ) : createdBy ? ( - + @@ -153,12 +162,12 @@ const ApiKeyAddedCell = ({
@@ -185,10 +194,9 @@ export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => { teamSlug={teamSlug} apiKey={apiKey} /> - - + - + { @@ -200,8 +208,8 @@ export const ApiKeysTableRow = ({ apiKey, teamSlug }: ApiKeysTableRowProps) => { lastUsedAt={lastUsedAt} lastUsedLabel={lastUsedLabel} /> - + = ({ }) => (
- + - + + @@ -48,11 +49,14 @@ export const ApiKeysTable: FC = ({ ADDED + + API key options + {apiKeys.length === 0 ? ( - + Date: Wed, 15 Apr 2026 16:03:53 -0400 Subject: [PATCH 10/11] Refactor API key date formatting for improved consistency and readability - Replaced the deprecated `formatShortRelativeAgo` function with `formatRelativeAgo` for displaying last used timestamps in the `DeleteApiKeyDialog` and `getLastUsedLabel` function. - Updated the `ApiKeysTableRow` to utilize `formatUTCTimestamp` for formatting last used dates in tooltips, enhancing clarity and consistency across the application. - Removed unused `toIsoUtcString` function from `api-keys-utils.ts` to streamline the codebase. These changes enhance the user experience by providing clearer and more consistent date representations for API keys. --- .../settings/keys/api-keys-table-row.tsx | 6 ++-- .../dashboard/settings/keys/api-keys-utils.ts | 26 ++-------------- .../settings/keys/delete-api-key-dialog.tsx | 4 +-- src/lib/utils/formatting.ts | 30 +++++++++++++++++++ 4 files changed, 37 insertions(+), 29 deletions(-) diff --git a/src/features/dashboard/settings/keys/api-keys-table-row.tsx b/src/features/dashboard/settings/keys/api-keys-table-row.tsx index 2a15e0186..3c42b91a3 100644 --- a/src/features/dashboard/settings/keys/api-keys-table-row.tsx +++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx @@ -6,7 +6,7 @@ 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 } from '@/lib/utils/formatting' +import { formatDate, formatUTCTimestamp } from '@/lib/utils/formatting' import { E2BLogo } from '@/ui/brand' import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' @@ -17,7 +17,7 @@ import { TooltipContent, TooltipTrigger, } from '@/ui/primitives/tooltip' -import { getLastUsedLabel, toIsoUtcString } from './api-keys-utils' +import { getLastUsedLabel } from './api-keys-utils' import { DeleteApiKeyDialog } from './delete-api-key-dialog' interface ApiKeysTableRowProps { @@ -113,7 +113,7 @@ const ApiKeyLastUsedCell = ({ - {toIsoUtcString(new Date(lastUsedAt))} + {formatUTCTimestamp(new Date(lastUsedAt))} ) : ( diff --git a/src/features/dashboard/settings/keys/api-keys-utils.ts b/src/features/dashboard/settings/keys/api-keys-utils.ts index 5ae6f0281..56a2522d9 100644 --- a/src/features/dashboard/settings/keys/api-keys-utils.ts +++ b/src/features/dashboard/settings/keys/api-keys-utils.ts @@ -1,5 +1,6 @@ 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 => { @@ -18,29 +19,9 @@ export const matchesApiKeySearch = ( return getMaskedIdSearchString(apiKey).includes(q) } -/** Compact relative label for "last used" column; e.g. `new Date()` → `"0s ago"` */ -export const formatShortRelativeAgo = (date: Date): string => { - const now = Date.now() - const t = date.getTime() - const sec = Math.floor((now - t) / 1000) - if (sec < 60) return `${sec}s ago` - const min = Math.floor(sec / 60) - if (min < 60) return `${min} min ago` - const hr = Math.floor(min / 60) - if (hr < 24) return `${hr}h ago` - const day = Math.floor(hr / 24) - if (day < 7) return `${day}d ago` - const wk = Math.floor(day / 7) - if (wk < 5) return `${wk}w ago` - const mo = Math.floor(day / 30) - if (mo < 12) return `${mo}mo ago` - const yr = Math.floor(day / 365) - return `${yr}y ago` -} - /** Human line for last-used cell, matching legacy semantics for pre-collection keys. */ export const getLastUsedLabel = (apiKey: TeamAPIKey): string => { - if (apiKey.lastUsed) return formatShortRelativeAgo(new Date(apiKey.lastUsed)) + if (apiKey.lastUsed) return formatRelativeAgo(new Date(apiKey.lastUsed)) const createdBefore = new Date(apiKey.createdAt).getTime() < @@ -49,6 +30,3 @@ export const getLastUsedLabel = (apiKey: TeamAPIKey): string => { if (createdBefore) return 'N/A' return 'Never' } - -/** ISO string for tooltips on last-used; e.g. `new Date()` → `"2025-09-29T14:18:49.000Z"` */ -export const toIsoUtcString = (date: Date): string => date.toISOString() diff --git a/src/features/dashboard/settings/keys/delete-api-key-dialog.tsx b/src/features/dashboard/settings/keys/delete-api-key-dialog.tsx index 080b5db40..3a8cf21a5 100644 --- a/src/features/dashboard/settings/keys/delete-api-key-dialog.tsx +++ b/src/features/dashboard/settings/keys/delete-api-key-dialog.tsx @@ -8,6 +8,7 @@ import { 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 { @@ -20,7 +21,6 @@ import { DialogTitle, } from '@/ui/primitives/dialog' import { TrashIcon } from '@/ui/primitives/icons' -import { formatShortRelativeAgo } from './api-keys-utils' interface DeleteApiKeyDialogProps { open: boolean @@ -72,7 +72,7 @@ export const DeleteApiKeyDialog: FC = ({

{lastUsedAt ? (

- Last used: {formatShortRelativeAgo(new Date(lastUsedAt))} + Last used: {formatRelativeAgo(new Date(lastUsedAt))}

) : null} 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 From ee79239403ffab0b8406334e632546e0679c8609 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 15 Apr 2026 16:13:22 -0400 Subject: [PATCH 11/11] Refactor DeleteApiKeyDialog for improved layout and user experience - Simplified the dialog structure by removing unnecessary components and enhancing the layout for better readability. - Updated the key deletion confirmation message to dynamically display the API key name and last used timestamp. - Improved button states and accessibility features for a more intuitive user interaction. These changes enhance the overall usability of the API key deletion process. --- .../settings/keys/delete-api-key-dialog.tsx | 101 +++++++++--------- 1 file changed, 51 insertions(+), 50 deletions(-) diff --git a/src/features/dashboard/settings/keys/delete-api-key-dialog.tsx b/src/features/dashboard/settings/keys/delete-api-key-dialog.tsx index 3a8cf21a5..c6fc6dd23 100644 --- a/src/features/dashboard/settings/keys/delete-api-key-dialog.tsx +++ b/src/features/dashboard/settings/keys/delete-api-key-dialog.tsx @@ -1,7 +1,7 @@ 'use client' import { useMutation, useQueryClient } from '@tanstack/react-query' -import type { FC, ReactNode } from 'react' +import type { FC } from 'react' import type { TeamAPIKey } from '@/core/modules/keys/models' import { defaultErrorToast, @@ -16,7 +16,6 @@ import { DialogClose, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from '@/ui/primitives/dialog' @@ -57,63 +56,65 @@ export const DeleteApiKeyDialog: FC = ({ }) ) - const title = `DELETE '${apiKey.name}' KEY?` + const keyLabel = apiKey.name.trim() ? apiKey.name : 'Untitled' const lastUsedAt = apiKey.lastUsed - const isNeverUsed = !lastUsedAt - - const body: ReactNode = isNeverUsed ? ( - - It was never used - - ) : ( -
-

- Deleting this key will immediately disable all associated applications -

- {lastUsedAt ? ( -

- Last used: {formatRelativeAgo(new Date(lastUsedAt))} -

- ) : null} -
- ) + const lastUsedLabel = lastUsedAt + ? `Last used: ${formatRelativeAgo(new Date(lastUsedAt))}` + : null return ( - - - {title} - - Confirm deletion of this API key. This cannot be undone. - - -
{body}
- - + +
+ + {`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 +

+ )} +
+
+ + + - - - +
+
)