diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx index 186e5ee422..01421d53d7 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx @@ -20,13 +20,11 @@ import { z } from "zod"; const formSchema = z.object({ keyId: z.string(), - workspaceId: z.string(), enabled: z.boolean(), }); type Props = { apiKey: { id: string; - workspaceId: string; enabled: boolean; }; }; @@ -40,7 +38,6 @@ export const UpdateKeyEnabled: React.FC = ({ apiKey }) => { delayError: 100, defaultValues: { keyId: apiKey.id, - workspaceId: apiKey.workspaceId, enabled: apiKey.enabled, }, }); @@ -56,7 +53,10 @@ export const UpdateKeyEnabled: React.FC = ({ apiKey }) => { }); async function onSubmit(values: z.infer) { - await updateEnabled.mutateAsync(values); + await updateEnabled.mutateAsync({ + enabled: values.enabled, + keyIds: values.keyId, + }); } return ( diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-owner-id.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-owner-id.tsx index c78e46776a..2ae3def2b6 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-owner-id.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-owner-id.tsx @@ -63,7 +63,11 @@ export const UpdateKeyOwnerId: React.FC = ({ apiKey }) => { }); async function onSubmit(values: z.infer) { - await updateOwnerId.mutateAsync({ ...values, ownerType: "v1" }); + await updateOwnerId.mutateAsync({ + ownerId: values.ownerId, + ownerType: "v1", + keyIds: values.keyId, + }); } return (
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/disable-key.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/disable-key.tsx index 45eabfc873..642d458c3d 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/disable-key.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/disable-key.tsx @@ -75,7 +75,7 @@ export const UpdateKeyStatus = ({ keyDetails, isOpen, onClose }: UpdateKeyStatus try { setIsLoading(true); await updateKeyStatus.mutateAsync({ - keyId: keyDetails.id, + keyIds: [keyDetails.id], enabled: isEnabling, }); } catch { diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-external-id/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-external-id/index.tsx index e76faecbfa..991f9995ed 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-external-id/index.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-external-id/index.tsx @@ -33,7 +33,7 @@ export const EditExternalId = ({ const handleSubmit = () => { updateKeyOwner.mutate({ - keyId: keyDetails.id, + keyIds: keyDetails.id, ownerType: "v2", identity: { id: selectedIdentityId, @@ -59,7 +59,7 @@ export const EditExternalId = ({ const clearSelection = async () => { setSelectedIdentityId(null); await updateKeyOwner.mutateAsync({ - keyId: keyDetails.id, + keyIds: keyDetails.id, ownerType: "v2", identity: { id: null, @@ -71,7 +71,7 @@ export const EditExternalId = ({ <> ) => { + if (err.data?.code === "NOT_FOUND") { + toast.error("Key Update Failed", { + description: "Unable to find the key(s). Please refresh and try again.", + }); + } else if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid External ID Information", { + description: + err.message || "Please ensure your External ID information is valid and try again.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We are unable to update External ID information on this key. Please try again or contact support@unkey.dev", + }); + } else { + toast.error("Failed to Update Key External ID", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.dev", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } +}; export const useEditExternalId = (onSuccess?: () => void) => { const trpcUtils = trpc.useUtils(); - const updateKeyOwner = trpc.key.update.ownerId.useMutation({ onSuccess(_, variables) { let description = ""; + const keyId = Array.isArray(variables.keyIds) ? variables.keyIds[0] : variables.keyIds; if (variables.ownerType === "v2") { if (variables.identity?.id) { - description = `Identity for key ${variables.keyId} has been updated`; - } else { - description = `Identity has been removed from key ${variables.keyId}`; - } - } else { - if (variables.ownerId) { - description = `Owner for key ${variables.keyId} has been updated`; + description = `Identity for key ${keyId} has been updated`; } else { - description = `Owner has been removed from key ${variables.keyId}`; + description = `Identity has been removed from key ${keyId}`; } } - toast.success("Key External ID Updated", { description, duration: 5000, }); trpcUtils.api.keys.list.invalidate(); - if (onSuccess) { onSuccess(); } }, - onError(err) { - if (err.data?.code === "NOT_FOUND") { - toast.error("Key Update Failed", { - description: - "We are unable to find the correct key. Please try again or contact support@unkey.dev.", - }); - } else if (err.data?.code === "BAD_REQUEST") { - toast.error("Invalid External ID Information", { - description: - err.message || "Please ensure your external ID information is valid and try again.", - }); - } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { - toast.error("Server Error", { - description: - "We are unable to update external ID information on this key. Please try again or contact support@unkey.dev", - }); - } else { - toast.error("Failed to Update Key External ID", { - description: - err.message || - "An unexpected error occurred. Please try again or contact support@unkey.dev", - action: { - label: "Contact Support", - onClick: () => window.open("mailto:support@unkey.dev", "_blank"), - }, + handleKeyOwnerUpdateError(err); + }, + }); + + return updateKeyOwner; +}; + +export const useBatchEditExternalId = (onSuccess?: () => void) => { + const trpcUtils = trpc.useUtils(); + const batchUpdateKeyOwner = trpc.key.update.ownerId.useMutation({ + onSuccess(data, variables) { + const updatedCount = data.updatedCount; + let description = ""; + + if (variables.ownerType === "v2") { + if (variables.identity?.id) { + description = `Identity has been updated for ${updatedCount} ${ + updatedCount === 1 ? "key" : "keys" + }`; + } else { + description = `Identity has been removed from ${updatedCount} ${ + updatedCount === 1 ? "key" : "keys" + }`; + } + } + toast.success("Key External ID Updated", { + description, + duration: 5000, + }); + + // Show warning if some keys were not found (if that info is available in the response) + const missingCount = Array.isArray(variables.keyIds) + ? variables.keyIds.length - updatedCount + : 0; + + if (missingCount > 0) { + toast.warning("Some Keys Not Found", { + description: `${missingCount} ${ + missingCount === 1 ? "key was" : "keys were" + } not found and could not be updated.`, + duration: 7000, }); } + + trpcUtils.api.keys.list.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(err) { + handleKeyOwnerUpdateError(err); }, }); - return updateKeyOwner; + return batchUpdateKeyOwner; }; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-update-key-status.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-update-key-status.tsx index 025c8f2f65..c15dc26d7a 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-update-key-status.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-update-key-status.tsx @@ -1,5 +1,29 @@ import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; +import type { TRPCClientErrorLike } from "@trpc/client"; +import type { TRPCErrorShape } from "@trpc/server/rpc"; + +const handleKeyUpdateError = (err: TRPCClientErrorLike) => { + const errorMessage = err.message || ""; + if (err.data?.code === "NOT_FOUND") { + toast.error("Key Update Failed", { + description: "Unable to find the key(s). Please refresh and try again.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while updating your key(s). Please try again later or contact support at support.unkey.dev", + }); + } else { + toast.error("Failed to Update Key Status", { + description: errorMessage || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("https://support.unkey.dev", "_blank"), + }, + }); + } +}; export const useUpdateKeyStatus = (onSuccess?: () => void) => { const trpcUtils = trpc.useUtils(); @@ -7,41 +31,56 @@ export const useUpdateKeyStatus = (onSuccess?: () => void) => { const updateKeyEnabled = trpc.key.update.enabled.useMutation({ onSuccess(data) { toast.success(`Key ${data.enabled ? "Enabled" : "Disabled"}`, { - description: `Your key ${data.keyId} has been ${ + description: `Your key ${data.updatedKeyIds[0]} has been ${ data.enabled ? "enabled" : "disabled" } successfully`, duration: 5000, }); - trpcUtils.api.keys.list.invalidate(); - if (onSuccess) { onSuccess(); } }, onError(err) { - const errorMessage = err.message || ""; + handleKeyUpdateError(err); + }, + }); - if (err.data?.code === "NOT_FOUND") { - toast.error("Key Update Failed", { - description: "Unable to find the key. Please refresh and try again.", - }); - } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { - toast.error("Server Error", { - description: - "We encountered an issue while updating your key. Please try again later or contact support at support.unkey.dev", - }); - } else { - toast.error("Failed to Update Key Status", { - description: errorMessage || "An unexpected error occurred. Please try again later.", - action: { - label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), - }, + return updateKeyEnabled; +}; + +export const useBatchUpdateKeyStatus = (onSuccess?: () => void) => { + const trpcUtils = trpc.useUtils(); + + const updateMultipleKeysEnabled = trpc.key.update.enabled.useMutation({ + onSuccess(data) { + const updatedCount = data.updatedKeyIds.length; + toast.success(`Keys ${data.enabled ? "Enabled" : "Disabled"}`, { + description: `${updatedCount} ${ + updatedCount === 1 ? "key has" : "keys have" + } been ${data.enabled ? "enabled" : "disabled"} successfully`, + duration: 5000, + }); + + // Show warning if some keys were not found + if (data.missingKeyIds && data.missingKeyIds.length > 0) { + toast.warning("Some Keys Not Found", { + description: `${data.missingKeyIds.length} ${ + data.missingKeyIds.length === 1 ? "key was" : "keys were" + } not found and could not be updated.`, + duration: 7000, }); } + + trpcUtils.api.keys.list.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(err) { + handleKeyUpdateError(err); }, }); - return updateKeyEnabled; + return updateMultipleKeysEnabled; }; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.tsx index 92d2093b2f..bbfb0dc89a 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.tsx @@ -51,7 +51,7 @@ export const KeysTableActionPopover = ({ items, align = "end" }: BaseTableAction type="button" className={cn( "group-data-[state=open]:bg-gray-6 group-hover:bg-gray-6 group size-5 p-0 rounded m-0 items-center flex justify-center", - "border border-gray-6 hover:border-gray-8 ring-2 ring-transparent focus-visible:ring-gray-7 focus-visible:border-gray-7", + "border border-gray-6 group-hover:border-gray-8 ring-2 ring-transparent focus-visible:ring-gray-7 focus-visible:border-gray-7", )} > diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls/components/batch-edit-external-id.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls/components/batch-edit-external-id.tsx new file mode 100644 index 0000000000..181f1b534e --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls/components/batch-edit-external-id.tsx @@ -0,0 +1,169 @@ +import { ExternalIdField } from "@/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field"; +import { ConfirmPopover } from "@/components/confirmation-popover"; +import { DialogContainer } from "@/components/dialog-container"; +import { TriangleWarning2 } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useRef, useState } from "react"; +import { useBatchEditExternalId } from "../../actions/components/hooks/use-edit-external-id"; + +type BatchEditExternalIdProps = { + selectedKeyIds: string[]; + keysWithExternalIds: number; // Count of keys that already have external IDs + isOpen: boolean; + onClose: () => void; +}; + +export const BatchEditExternalId = ({ + selectedKeyIds, + keysWithExternalIds, + isOpen, + onClose, +}: BatchEditExternalIdProps): JSX.Element => { + const [selectedIdentityId, setSelectedIdentityId] = useState(null); + const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); + const clearButtonRef = useRef(null); + + const updateKeyOwner = useBatchEditExternalId(() => { + onClose(); + }); + + const handleSubmit = () => { + updateKeyOwner.mutate({ + keyIds: selectedKeyIds, + ownerType: "v2", + identity: { + id: selectedIdentityId, + }, + }); + }; + + const handleClearButtonClick = () => { + setIsConfirmPopoverOpen(true); + }; + + const handleDialogOpenChange = (open: boolean) => { + if (isConfirmPopoverOpen && !isOpen) { + // If confirm popover is active don't let this trigger outer popover + return; + } + + if (!isConfirmPopoverOpen && !open) { + onClose(); + } + }; + + const clearSelection = async () => { + await updateKeyOwner.mutateAsync({ + keyIds: selectedKeyIds, + ownerType: "v2", + identity: { + id: null, + }, + }); + }; + + const totalKeys = selectedKeyIds.length; + const hasKeysWithExternalIds = keysWithExternalIds > 0; + + // Determine what button to show based on whether a new external ID is selected + const showUpdateButton = selectedIdentityId !== null; + + return ( + <> + +
+ {showUpdateButton ? ( + + ) : ( + + )} +
+ {hasKeysWithExternalIds && ( +
+ Note: {keysWithExternalIds} out of {totalKeys} selected{" "} + {totalKeys === 1 ? "key" : "keys"} already{" "} + {keysWithExternalIds === 1 ? "has" : "have"} an External ID +
+ )} +
Changes will be applied immediately
+ + } + > + {hasKeysWithExternalIds && ( +
+
+ +
+
+ Warning:{" "} + {keysWithExternalIds === totalKeys ? ( + <> + All selected keys already have External IDs. Setting a new ID will override the + existing ones. + + ) : ( + <> + Some selected keys already have External IDs. Setting a new ID will override the + existing ones. + + )} +
+
+ )} +
+ +
+
+ 1 ? "IDs" : "ID"}`} + description={`This will remove the External ID association from ${keysWithExternalIds} ${ + keysWithExternalIds === 1 ? "key" : "keys" + }. Any tracking or analytics related to ${ + keysWithExternalIds === 1 ? "this ID" : "these IDs" + } will no longer be associated with ${ + keysWithExternalIds === 1 ? "this key" : "these keys" + }.`} + confirmButtonText={`Remove External ${keysWithExternalIds > 1 ? "IDs" : "ID"}`} + cancelButtonText="Cancel" + variant="danger" + /> + + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls/index.tsx new file mode 100644 index 0000000000..732ae2e1fd --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls/index.tsx @@ -0,0 +1,235 @@ +import { ConfirmPopover } from "@/components/confirmation-popover"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { ArrowOppositeDirectionY, Ban, CircleCheck, Trash, XMark } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { AnimatePresence, motion } from "framer-motion"; +import { useRef, useState } from "react"; +import { useDeleteKey } from "../actions/components/hooks/use-delete-key"; +import { useBatchUpdateKeyStatus } from "../actions/components/hooks/use-update-key-status"; +import { BatchEditExternalId } from "./components/batch-edit-external-id"; + +type SelectionControlsProps = { + selectedKeys: Set; + setSelectedKeys: (keys: Set) => void; + keys: KeyDetails[]; + getSelectedKeysState: () => "all-enabled" | "all-disabled" | "mixed" | null; +}; + +export const SelectionControls = ({ + selectedKeys, + keys, + setSelectedKeys, + getSelectedKeysState, +}: SelectionControlsProps) => { + const [isBatchEditExternalIdOpen, setIsBatchEditExternalIdOpen] = useState(false); + const [isDisableConfirmOpen, setIsDisableConfirmOpen] = useState(false); + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const disableButtonRef = useRef(null); + const deleteButtonRef = useRef(null); + + const updateKeyStatus = useBatchUpdateKeyStatus(); + const deleteKey = useDeleteKey(() => { + setSelectedKeys(new Set()); + }); + + const handleDisableButtonClick = () => { + setIsDisableConfirmOpen(true); + }; + + const performDisableKeys = () => { + updateKeyStatus.mutate({ + enabled: false, + keyIds: Array.from(selectedKeys), + }); + }; + + const handleDeleteButtonClick = () => { + setIsDeleteConfirmOpen(true); + }; + + const performKeyDeletion = () => { + deleteKey.mutate({ + keyIds: Array.from(selectedKeys), + }); + }; + + const keysWithExternalIds = keys.filter( + (key) => selectedKeys.has(key.id) && key.identity_id, + ).length; + + return ( + <> + + {selectedKeys.size > 0 && ( + +
+
+ +
selected
+
+
+ + + + + +
+
+
+ )} +
+ + 1 ? "s" : "" + } and prevent any verification requests from being processed.`} + confirmButtonText="Disable keys" + cancelButtonText="Cancel" + variant="danger" + /> + + 1 ? "these keys" : "this key" + } will be permanently deleted.`} + confirmButtonText={`Delete key${selectedKeys.size > 1 ? "s" : ""}`} + cancelButtonText="Cancel" + variant="danger" + /> + + {isBatchEditExternalIdOpen && ( + setIsBatchEditExternalIdOpen(false)} + /> + )} + + ); +}; + +const AnimatedDigit = ({ digit, index }: { digit: string; index: number }) => { + return ( + + {digit} + + ); +}; + +const AnimatedCounter = ({ value }: { value: number }) => { + const digits = value.toString().split(""); + + return ( +
+ +
+ {digits.map((digit, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + ))} +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx index 46846f730f..1e7632088e 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx @@ -6,6 +6,7 @@ import { BookBookmark, Focus, Key } from "@unkey/icons"; import { AnimatedLoadingSpinner, Button, + Checkbox, Empty, Tooltip, TooltipContent, @@ -20,6 +21,7 @@ import { getKeysTableActionItems } from "./components/actions/keys-table-action. import { VerificationBarChart } from "./components/bar-chart"; import { HiddenValueCell } from "./components/hidden-value"; import { LastUsedCell } from "./components/last-used"; +import { SelectionControls } from "./components/selection-controls"; import { ActionColumnSkeleton, KeyColumnSkeleton, @@ -44,12 +46,28 @@ export const KeysList = ({ }); const [selectedKey, setSelectedKey] = useState(null); const [navigatingKeyId, setNavigatingKeyId] = useState(null); + // Add state for selected keys + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + // Track which row is being hovered + const [hoveredKeyId, setHoveredKeyId] = useState(null); const handleLinkClick = useCallback((keyId: string) => { setNavigatingKeyId(keyId); setSelectedKey(null); }, []); + const toggleSelection = useCallback((keyId: string) => { + setSelectedKeys((prevSelected) => { + const newSelected = new Set(prevSelected); + if (newSelected.has(keyId)) { + newSelected.delete(keyId); + } else { + newSelected.add(keyId); + } + return newSelected; + }); + }, []); + const columns: Column[] = useMemo( () => [ { @@ -60,22 +78,44 @@ export const KeysList = ({ render: (key) => { const identity = key.identity?.external_id ?? key.owner_id; const isNavigating = key.id === navigatingKeyId; + const isSelected = selectedKeys.has(key.id); + const isHovered = hoveredKeyId === key.id; - const iconContainer = isNavigating ? ( -
- -
- ) : ( + const iconContainer = (
setHoveredKeyId(key.id)} + onMouseLeave={() => setHoveredKeyId(null)} > - {identity ? ( - + {isNavigating ? ( + ) : ( - + <> + {/* Show icon when not selected and not hovered */} + {!isSelected && !isHovered && ( + // biome-ignore lint/complexity/noUselessFragments: + <> + {identity ? ( + + ) : ( + + )} + + )} + + {/* Show checkbox when selected or hovered */} + {(isSelected || isHovered) && ( + toggleSelection(key.id)} + /> + )} + )}
); @@ -204,86 +244,140 @@ export const KeysList = ({ }, }, ], - [keyspaceId, selectedKey?.id, apiId, navigatingKeyId, handleLinkClick], + [ + keyspaceId, + selectedKey?.id, + apiId, + navigatingKeyId, + handleLinkClick, + selectedKeys, + toggleSelection, + hoveredKeyId, + ], ); - return ( - log.id} - rowClassName={(log) => getRowClassName(log, selectedKey as KeyDetails)} - loadMoreFooterProps={{ - hide: isLoading, - buttonText: "Load more keys", - hasMore, - countInfoText: ( -
- Showing {keys.length} - of - {totalCount} - keys -
- ), - }} - emptyState={ -
- - - No API Keys Found - - There are no API keys associated with this service yet. Create your first API key to - get started. - - - - - - - -
+ const getSelectedKeysState = useCallback(() => { + if (selectedKeys.size === 0) { + return null; + } + + let allEnabled = true; + let allDisabled = true; + + for (const keyId of selectedKeys) { + const key = keys.find((k) => k.id === keyId); + if (!key) { + continue; } - config={{ - rowHeight: 52, - layoutMode: "grid", - rowBorders: true, - containerPadding: "px-0", - }} - renderSkeletonRow={({ columns, rowHeight }) => - columns.map((column, idx) => ( - - {column.key === "key" && } - {column.key === "value" && } - {column.key === "usage" && } - {column.key === "last_used" && } - {column.key === "status" && } - {column.key === "action" && } - {!["key", "value", "usage", "last_used", "status", "action"].includes(column.key) && ( -
- )} - - )) + + if (key.enabled) { + allDisabled = false; + } else { + allEnabled = false; + } + + // Early exit if we already found a mix + if (!allEnabled && !allDisabled) { + break; } - /> + } + + if (allEnabled) { + return "all-enabled"; + } + if (allDisabled) { + return "all-disabled"; + } + return "mixed"; + }, [selectedKeys, keys]); + + return ( + <> + log.id} + rowClassName={(log) => getRowClassName(log, selectedKey as KeyDetails)} + loadMoreFooterProps={{ + hide: isLoading, + buttonText: "Load more keys", + hasMore, + headerContent: ( + + ), + countInfoText: ( +
+ Showing {keys.length} + of + {totalCount} + keys +
+ ), + }} + emptyState={ +
+ + + No API Keys Found + + There are no API keys associated with this service yet. Create your first API key to + get started. + + + + + + + +
+ } + config={{ + rowHeight: 52, + layoutMode: "grid", + rowBorders: true, + containerPadding: "px-0", + }} + renderSkeletonRow={({ columns, rowHeight }) => + columns.map((column, idx) => ( + + {column.key === "key" && } + {column.key === "value" && } + {column.key === "usage" && } + {column.key === "last_used" && } + {column.key === "status" && } + {column.key === "action" && } + {!["select", "key", "value", "usage", "last_used", "status", "action"].includes( + column.key, + ) &&
} + + )) + } + /> + ); }; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/table-action-button.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/table-action-button.tsx index 77e6d5651e..b2cda558b4 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/table-action-button.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/table-action-button.tsx @@ -6,8 +6,8 @@ export const TableActionButton = () => { + +
diff --git a/apps/dashboard/components/virtual-table/types.ts b/apps/dashboard/components/virtual-table/types.ts index aaedffddd4..41c97567bb 100644 --- a/apps/dashboard/components/virtual-table/types.ts +++ b/apps/dashboard/components/virtual-table/types.ts @@ -57,6 +57,7 @@ export type VirtualTableProps = { itemLabel?: string; buttonText?: string; countInfoText?: React.ReactNode; + headerContent?: React.ReactNode; hasMore?: boolean; hide?: boolean; }; diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 5734138950..0e95bd1348 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -27,7 +27,7 @@ import { createKey } from "./key/create"; import { createRootKey } from "./key/createRootKey"; import { deleteKeys } from "./key/delete"; import { deleteRootKeys } from "./key/deleteRootKey"; -import { updateKeyEnabled } from "./key/updateEnabled"; +import { updateKeysEnabled } from "./key/updateEnabled"; import { updateKeyExpiration } from "./key/updateExpiration"; import { updateKeyMetadata } from "./key/updateMetadata"; import { updateKeyName } from "./key/updateName"; @@ -88,7 +88,7 @@ export const router = t.router({ create: createKey, delete: deleteKeys, update: t.router({ - enabled: updateKeyEnabled, + enabled: updateKeysEnabled, expiration: updateKeyExpiration, metadata: updateKeyMetadata, name: updateKeyName, diff --git a/apps/dashboard/lib/trpc/routers/key/updateEnabled.ts b/apps/dashboard/lib/trpc/routers/key/updateEnabled.ts index 932301b901..b1ed193c3b 100644 --- a/apps/dashboard/lib/trpc/routers/key/updateEnabled.ts +++ b/apps/dashboard/lib/trpc/routers/key/updateEnabled.ts @@ -1,25 +1,35 @@ -import { insertAuditLogs } from "@/lib/audit"; -import { db, eq, schema } from "@/lib/db"; +import { type UnkeyAuditLog, insertAuditLogs } from "@/lib/audit"; +import { and, db, eq, inArray, schema } from "@/lib/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { requireUser, requireWorkspace, t } from "../../trpc"; -export const updateKeyEnabled = t.procedure +export const updateKeysEnabled = t.procedure .use(requireUser) .use(requireWorkspace) .input( z.object({ - keyId: z.string(), + keyIds: z + .union([z.string(), z.array(z.string())]) + .transform((ids) => (Array.isArray(ids) ? ids : [ids])), enabled: z.boolean(), }), ) .mutation(async ({ input, ctx }) => { - const key = await db.query.keys - .findFirst({ - where: (table, { eq, and, isNull }) => + // Ensure we have at least one keyId to update + if (input.keyIds.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "At least one keyId must be provided", + }); + } + + const keys = await db.query.keys + .findMany({ + where: (table, { eq, and, isNull, inArray }) => and( eq(table.workspaceId, ctx.workspace.id), - eq(table.id, input.keyId), + inArray(table.id, input.keyIds), isNull(table.deletedAtM), ), }) @@ -27,59 +37,89 @@ export const updateKeyEnabled = t.procedure throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: - "We were unable to update enabled on this key. Please try again or contact support@unkey.dev", + "We were unable to retrieve the keys. Please try again or contact support@unkey.dev", }); }); - if (!key) { + + if (keys.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: - "We are unable to find the the correct key. Please try again or contact support@unkey.dev.", + "We are unable to find any of the specified keys. Please try again or contact support@unkey.dev.", }); } - await db - .transaction(async (tx) => { + // Check if any keys were not found + const foundKeyIds = keys.map((key) => key.id); + const missingKeyIds = input.keyIds.filter((id) => !foundKeyIds.includes(id)); + + if (missingKeyIds.length > 0) { + console.warn(`Some keys were not found: ${missingKeyIds.join(", ")}`); + } + + try { + await db.transaction(async (tx) => { await tx .update(schema.keys) .set({ enabled: input.enabled, }) - .where(eq(schema.keys.id, key.id)) + .where( + and( + inArray(schema.keys.id, foundKeyIds), + eq(schema.keys.workspaceId, ctx.workspace.id), + ), + ) .catch((_err) => { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: - "We were unable to update enabled on this key. Please try again or contact support@unkey.dev", + "We were unable to update enabled status on these keys. Please try again or contact support@unkey.dev", }); }); + + const keyIds = keys.map((key) => key.id).join(", "); + const description = `Updated enabled status of keys [${keyIds}] to ${ + input.enabled ? "enabled" : "disabled" + }`; + + const resources: UnkeyAuditLog["resources"] = keys.map((key) => ({ + type: "key", + id: key.id, + name: key.name || undefined, + meta: { + enabled: input.enabled, + "previous.enabled": key.enabled, + }, + })); + await insertAuditLogs(tx, { - workspaceId: key.workspaceId, + workspaceId: ctx.workspace.id, actor: { type: "user", id: ctx.user.id, }, event: "key.update", - description: `${input.enabled ? "Enabled" : "Disabled"} ${key.id}`, - resources: [ - { - type: "key", - id: key.id, - }, - ], + description, + resources, context: { location: ctx.audit.location, userAgent: ctx.audit.userAgent, }, }); - }) - .catch((_err) => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "We were unable to update enabled on this key. Please try again or contact support@unkey.dev", - }); }); + } catch (err) { + console.error(err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We were unable to update enabled status on these keys. Please try again or contact support@unkey.dev", + }); + } - return { enabled: input.enabled, keyId: input.keyId }; + return { + enabled: input.enabled, + updatedKeyIds: foundKeyIds, + missingKeyIds: missingKeyIds.length > 0 ? missingKeyIds : undefined, + }; }); diff --git a/apps/dashboard/lib/trpc/routers/key/updateOwnerId.ts b/apps/dashboard/lib/trpc/routers/key/updateOwnerId.ts index 48bc563865..cd7177a9c6 100644 --- a/apps/dashboard/lib/trpc/routers/key/updateOwnerId.ts +++ b/apps/dashboard/lib/trpc/routers/key/updateOwnerId.ts @@ -1,11 +1,13 @@ import { type UnkeyAuditLog, insertAuditLogs } from "@/lib/audit"; -import { type Key, db, eq, schema } from "@/lib/db"; +import { type Key, and, db, eq, inArray, schema } from "@/lib/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { requireUser, requireWorkspace, t } from "../../trpc"; const baseOwnerInputSchema = z.object({ - keyId: z.string(), + keyIds: z + .union([z.string(), z.array(z.string())]) + .transform((ids) => (Array.isArray(ids) ? ids : [ids])), }); const ownerValidationV1 = z.object({ @@ -31,12 +33,21 @@ export const updateKeyOwner = t.procedure .use(requireWorkspace) .input(ownerInputSchema) .mutation(async ({ input, ctx }) => { - const key = await db.query.keys - .findFirst({ - where: (table, { eq, and, isNull }) => + // Ensure we have at least one keyId to update + if (input.keyIds.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "At least one keyId must be provided", + }); + } + + // Find all keys that match the criteria + const keys = await db.query.keys + .findMany({ + where: (table, { eq, and, isNull, inArray }) => and( eq(table.workspaceId, ctx.workspace.id), - eq(table.id, input.keyId), + inArray(table.id, input.keyIds), isNull(table.deletedAtM), ), }) @@ -44,25 +55,35 @@ export const updateKeyOwner = t.procedure throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: - "We were unable to update owner information on this key. Please try again or contact support@unkey.dev", + "We were unable to retrieve keys to update owner information. Please try again or contact support@unkey.dev", }); }); - if (!key) { + + // Check if we found all the requested keys + if (keys.length === 0) { throw new TRPCError({ message: - "We are unable to find the correct key. Please try again or contact support@unkey.dev.", + "We were unable to find any of the specified keys. Please try again or contact support@unkey.dev.", code: "NOT_FOUND", }); } + // Warn if some keys weren't found + const foundKeyIds = new Set(keys.map((key) => key.id)); + const missingKeyIds = input.keyIds.filter((id) => !foundKeyIds.has(id)); + + if (missingKeyIds.length > 0) { + console.warn(`Some keys were not found: ${missingKeyIds.join(", ")}`); + } + if (input.ownerType === "v1") { - return updateOwnerV1(input, key, { + return updateOwnerV1(input, keys, { audit: ctx.audit, userId: ctx.user.id, workspaceId: ctx.workspace.id, }); } - return updateOwnerV2(input, key, { + return updateOwnerV2(input, keys, { audit: ctx.audit, userId: ctx.user.id, workspaceId: ctx.workspace.id, @@ -71,7 +92,7 @@ export const updateKeyOwner = t.procedure const updateOwnerV1 = async ( input: OwnerInputSchema, - key: Key, + keys: Key[], ctx: { workspaceId: string; userId: string; @@ -87,27 +108,36 @@ const updateOwnerV1 = async ( code: "BAD_REQUEST", }); } + try { await db.transaction(async (tx) => { + // Update all keys in a single query await tx .update(schema.keys) .set({ ownerId: input.ownerId ?? null, }) - .where(eq(schema.keys.id, key.id)); - - const description = `Changed ownerId of ${key.id} to ${input.ownerId ?? "null"}`; - - const resources: UnkeyAuditLog["resources"] = [ - { - type: "key", - id: key.id, - name: key.name || undefined, - meta: { - "owner.id": input.ownerId ?? null, - }, + .where( + and( + eq(schema.keys.workspaceId, ctx.workspaceId), + inArray( + schema.keys.id, + keys.map((key) => key.id), + ), + ), + ); + + const keyIds = keys.map((key) => key.id).join(", "); + const description = `Changed ownerId of keys [${keyIds}] to ${input.ownerId ?? "null"}`; + + const resources: UnkeyAuditLog["resources"] = keys.map((key) => ({ + type: "key", + id: key.id, + name: key.name || undefined, + meta: { + "owner.id": input.ownerId ?? null, }, - ]; + })); await insertAuditLogs(tx, { workspaceId: ctx.workspaceId, @@ -129,16 +159,19 @@ const updateOwnerV1 = async ( throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: - "We were unable to update owner information on this key. Please try again or contact support@unkey.dev", + "We were unable to update owner information on these keys. Please try again or contact support@unkey.dev", }); } - return { keyId: key.id }; + return { + keyIds: keys.map((key) => key.id), + updatedCount: keys.length, + }; }; const updateOwnerV2 = async ( input: OwnerInputSchema, - key: Key, + keys: Key[], ctx: { workspaceId: string; userId: string; @@ -154,6 +187,7 @@ const updateOwnerV2 = async ( code: "BAD_REQUEST", }); } + try { await db.transaction(async (tx) => { await tx @@ -163,20 +197,27 @@ const updateOwnerV2 = async ( // Set ownerId to null to maintain consistency ownerId: null, }) - .where(eq(schema.keys.id, key.id)); - - const description = `Updated identity of ${key.id} to ${input.identity.id ?? "null"}`; - - const resources: UnkeyAuditLog["resources"] = [ - { - type: "key", - id: key.id, - name: key.name || undefined, - meta: { - "identity.id": input.identity.id ?? null, - }, + .where( + and( + eq(schema.keys.workspaceId, ctx.workspaceId), + inArray( + schema.keys.id, + keys.map((key) => key.id), + ), + ), + ); + + const keyIds = keys.map((key) => key.id).join(", "); + const description = `Updated identity of keys [${keyIds}] to ${input.identity.id ?? "null"}`; + + const resources: UnkeyAuditLog["resources"] = keys.map((key) => ({ + type: "key", + id: key.id, + name: key.name || undefined, + meta: { + "identity.id": input.identity.id ?? null, }, - ]; + })); await insertAuditLogs(tx, { workspaceId: ctx.workspaceId, @@ -198,9 +239,12 @@ const updateOwnerV2 = async ( throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: - "We were unable to update identity information on this key. Please try again or contact support@unkey.dev", + "We were unable to update identity information on these keys. Please try again or contact support@unkey.dev", }); } - return { keyId: key.id }; + return { + keyIds: keys.map((key) => key.id), + updatedCount: keys.length, + }; };