From 77f2f38686bb319c5c8222c229eb3b0fa89074b5 Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 1 Apr 2026 07:30:33 +0300 Subject: [PATCH] chore: improve API key management - Added a query parameter to filter API keys by status (active, revoked, all) in the getApiKey endpoint. - Updated the AuthService to handle status filtering logic for API key retrieval. - Modified the frontend to support status-based API key listing and added a button to view revoked keys. --- api/src/auth/auth.controller.ts | 15 +- api/src/auth/auth.service.ts | 36 ++- .../(app)/dashboard/(components)/api-keys.tsx | 244 +++++++++++++----- web/config/api.ts | 5 +- 4 files changed, 223 insertions(+), 77 deletions(-) diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 3b8b0e2..77b4f12 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -8,6 +8,7 @@ import { Param, Patch, Post, + Query, Request, UseGuards, } from '@nestjs/common' @@ -98,10 +99,20 @@ export class AuthController { @UseGuards(AuthGuard) @ApiOperation({ summary: 'Get Api Key List (masked***)' }) + @ApiQuery({ + name: 'status', + required: false, + enum: ['active', 'revoked', 'all'], + description: + 'Filter keys: active (default), revoked only, or all (legacy full list)', + }) @ApiBearerAuth() @Get('/api-keys') - async getApiKey(@Request() req) { - const data = await this.authService.getUserApiKeys(req.user) + async getApiKey( + @Request() req, + @Query('status') status?: string, + ) { + const data = await this.authService.getUserApiKeys(req.user, status) return { data } } diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 3ac7bba..273b64a 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -363,8 +363,36 @@ export class AuthService { return { apiKey, message: 'Save this key, it wont be shown again ;)' } } - async getUserApiKeys(currentUser: User) { - return this.apiKeyModel.find({ user: currentUser._id }, null, { + async getUserApiKeys( + currentUser: User, + statusParam?: string, + ) { + const normalized = + statusParam === undefined || statusParam === '' ? 'active' : statusParam + if (!['active', 'revoked', 'all'].includes(normalized)) { + throw new HttpException( + { error: 'Invalid status. Use active, revoked, or all.' }, + HttpStatus.BAD_REQUEST, + ) + } + const status = normalized as 'active' | 'revoked' | 'all' + + const base = { user: currentUser._id } + let filter: Record = { ...base } + + if (status === 'active') { + filter = { + ...base, + $or: [{ revokedAt: { $exists: false } }, { revokedAt: null }], + } + } else if (status === 'revoked') { + filter = { + ...base, + revokedAt: { $exists: true, $ne: null }, + } + } + + return this.apiKeyModel.find(filter, null, { sort: { createdAt: -1 }, }) } @@ -387,9 +415,9 @@ export class AuthService { HttpStatus.NOT_FOUND, ) } - if (apiKey.usageCount > 0) { + if (!apiKey.revokedAt) { throw new HttpException( - { error: 'Api key cannot be deleted' }, + { error: 'Revoke this API key before you can delete it' }, HttpStatus.BAD_REQUEST, ) } diff --git a/web/app/(app)/dashboard/(components)/api-keys.tsx b/web/app/(app)/dashboard/(components)/api-keys.tsx index 071808a..ba5977b 100644 --- a/web/app/(app)/dashboard/(components)/api-keys.tsx +++ b/web/app/(app)/dashboard/(components)/api-keys.tsx @@ -4,8 +4,7 @@ import { useState, useRef } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { ScrollArea } from '@/components/ui/scroll-area' -import { Copy, Key, MoreVertical, Loader2, Plus } from 'lucide-react' +import { Key, MoreVertical, Loader2, Plus, AlertTriangle } from 'lucide-react' import { Dialog, DialogContent, @@ -22,54 +21,78 @@ import { } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { useToast } from '@/hooks/use-toast' -import { useMutation, useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import httpBrowserClient from '@/lib/httpBrowserClient' import { ApiEndpoints } from '@/config/api' import { Skeleton } from '@/components/ui/skeleton' import GenerateApiKey, { type GenerateApiKeyHandle, } from './generate-api-key' +import { Alert, AlertDescription } from '@/components/ui/alert' + +type ApiKeyRow = { + _id: string + apiKey: string + name?: string + revokedAt?: string + createdAt: string + lastUsedAt?: string + usageCount?: number +} export default function ApiKeys() { const addApiKeyRef = useRef(null) + const queryClient = useQueryClient() + + const [selectedKey, setSelectedKey] = useState(null) + const [isRevokeDialogOpen, setIsRevokeDialogOpen] = useState(false) + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false) + const [isRevokedModalOpen, setIsRevokedModalOpen] = useState(false) + const [isConfirmDeleteRevokedOpen, setIsConfirmDeleteRevokedOpen] = + useState(false) + const [revokedKeyToDelete, setRevokedKeyToDelete] = + useState(null) + const [newKeyName, setNewKeyName] = useState('') + + const { toast } = useToast() + const { isPending, error, data: apiKeys, - refetch: refetchApiKeys, } = useQuery({ - queryKey: ['apiKeys'], + queryKey: ['apiKeys', 'active'], queryFn: () => httpBrowserClient - .get(ApiEndpoints.auth.listApiKeys()) + .get(ApiEndpoints.auth.listApiKeys('active')) .then((res) => res.data), - // select: (res) => res.data, }) - const { toast } = useToast() - - const [selectedKey, setSelectedKey] = useState<(typeof apiKeys)[0] | null>( - null - ) - const [isRevokeDialogOpen, setIsRevokeDialogOpen] = useState(false) - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false) - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [newKeyName, setNewKeyName] = useState('') + const { + data: revokedKeysData, + isPending: isRevokedPending, + } = useQuery({ + queryKey: ['apiKeys', 'revoked'], + queryFn: () => + httpBrowserClient + .get(ApiEndpoints.auth.listApiKeys('revoked')) + .then((res) => res.data), + enabled: isRevokedModalOpen, + }) const { mutate: revokeApiKey, isPending: isRevokingApiKey, error: revokeApiKeyError, - isSuccess: isRevokeApiKeySuccess, } = useMutation({ mutationFn: (id: string) => httpBrowserClient.post(ApiEndpoints.auth.revokeApiKey(id)), onSuccess: () => { setIsRevokeDialogOpen(false) toast({ - title: `API key "${selectedKey.apiKey}" has been revoked`, + title: `API key "${selectedKey?.apiKey}" has been revoked`, }) - refetchApiKeys() + void queryClient.invalidateQueries({ queryKey: ['apiKeys'] }) }, onError: () => { toast({ @@ -81,19 +104,19 @@ export default function ApiKeys() { }) const { - mutate: deleteApiKey, - isPending: isDeletingApiKey, + mutate: deleteRevokedApiKey, + isPending: isDeletingRevokedApiKey, error: deleteApiKeyError, - isSuccess: isDeleteApiKeySuccess, } = useMutation({ mutationFn: (id: string) => httpBrowserClient.delete(ApiEndpoints.auth.deleteApiKey(id)), onSuccess: () => { - setIsDeleteDialogOpen(false) + setIsConfirmDeleteRevokedOpen(false) + setRevokedKeyToDelete(null) toast({ - title: `API key deleted`, + title: 'API key removed', }) - refetchApiKeys() + void queryClient.invalidateQueries({ queryKey: ['apiKeys'] }) }, onError: () => { toast({ @@ -107,7 +130,6 @@ export default function ApiKeys() { mutate: renameApiKey, isPending: isRenamingApiKey, error: renameApiKeyError, - isSuccess: isRenameApiKeySuccess, } = useMutation({ mutationFn: ({ id, name }: { id: string; name: string }) => httpBrowserClient.patch(ApiEndpoints.auth.renameApiKey(id), { name }), @@ -116,7 +138,7 @@ export default function ApiKeys() { toast({ title: `API key renamed to "${newKeyName}"`, }) - refetchApiKeys() + void queryClient.invalidateQueries({ queryKey: ['apiKeys', 'active'] }) }, onError: () => { toast({ @@ -127,20 +149,32 @@ export default function ApiKeys() { }, }) + const revokedList = revokedKeysData?.data as ApiKeyRow[] | undefined + return ( <> API Keys - +
+ + +
@@ -182,7 +216,7 @@ export default function ApiKeys() {
)} - {apiKeys?.data?.map((apiKey) => ( + {apiKeys?.data?.map((apiKey: ApiKeyRow) => ( @@ -191,11 +225,8 @@ export default function ApiKeys() {

{apiKey.name || 'API Key'}

- - {apiKey.revokedAt ? 'Revoked' : 'Active'} + + Active
@@ -212,8 +243,8 @@ export default function ApiKeys() { })}
- Last used: {/* if usage count is 0, show never */} - {apiKey?.lastUsedAt && apiKey.usageCount > 0 + Last used:{' '} + {apiKey?.lastUsedAt && apiKey.usageCount ? new Date(apiKey.lastUsedAt).toLocaleString( 'en-US', { @@ -248,19 +279,9 @@ export default function ApiKeys() { setSelectedKey(apiKey) setIsRevokeDialogOpen(true) }} - disabled={!!apiKey.revokedAt} > Revoke - { - setSelectedKey(apiKey) - setIsDeleteDialogOpen(true) - }} - > - Delete -
@@ -273,13 +294,21 @@ export default function ApiKeys() { - Revoke API Key - - Are you sure you want to revoke this API key? This action cannot - be undone, and any applications using this key will stop working - immediately. + Revoke API key? + + Revoking stops this key from working everywhere it is used. + + + + Revoking immediately stops this key from working everywhere it is + still used—apps, servers, scripts, devices, and other integrations. + Create a new API key first if you need one, then update every + place the old key is stored and reconnect or reconfigure anything + that depends on it. + + - {/* Delete Dialog */} - + {/* Revoked keys list */} + + + + Revoked API keys + + These keys no longer work. You can remove them from your account + to tidy your list. + + + {isRevokedPending && ( +
+ + +
+ )} + {!isRevokedPending && + (!revokedList || revokedList.length === 0) && ( +

+ No revoked keys. +

+ )} + {!isRevokedPending && + revokedList?.map((k) => ( +
+
+
+ {k.name || 'API Key'} +
+ + {k.apiKey} + +
+ Revoked{' '} + {k.revokedAt + ? new Date(k.revokedAt).toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }) + : ''} +
+
+ +
+ ))} +
+
+ + {/* Confirm delete revoked key */} + { + setIsConfirmDeleteRevokedOpen(open) + if (!open) setRevokedKeyToDelete(null) + }} + > - Delete API Key + Remove this key? - Are you sure you want to delete this API key? This action cannot - be undone. + This removes the key from your account permanently. It is already + revoked and cannot be used. This cannot be undone. diff --git a/web/config/api.ts b/web/config/api.ts index ce71ea6..55588b4 100644 --- a/web/config/api.ts +++ b/web/config/api.ts @@ -16,7 +16,10 @@ export const ApiEndpoints = { resetPassword: () => '/auth/reset-password', generateApiKey: () => '/auth/api-keys', - listApiKeys: () => '/auth/api-keys', + listApiKeys: (status?: 'active' | 'revoked' | 'all') => + status + ? `/auth/api-keys?status=${encodeURIComponent(status)}` + : '/auth/api-keys', revokeApiKey: (id: string) => `/auth/api-keys/${id}/revoke`, renameApiKey: (id: string) => `/auth/api-keys/${id}/rename`, deleteApiKey: (id: string) => `/auth/api-keys/${id}`,