From 3164b6981cb9a084e6f035270e97154ab661d155 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 7 Oct 2025 21:05:13 +0100 Subject: [PATCH] feat: api keys management (EE) (#1665) * feat: api keys (EE) * improvements * fix table * fix route * remove token suffix * api settings * Fix * fix * fix * fix --- apps/client/package.json | 1 + .../public/locales/en-US/translation.json | 24 ++- apps/client/src/App.tsx | 4 + .../components/common/no-table-results.tsx | 5 +- .../components/settings/settings-queries.tsx | 15 ++ .../components/settings/settings-sidebar.tsx | 25 +++ .../components/api-key-created-modal.tsx | 72 +++++++++ .../ee/api-key/components/api-key-table.tsx | 143 ++++++++++++++++ .../components/create-api-key-modal.tsx | 153 ++++++++++++++++++ .../components/revoke-api-key-modal.tsx | 62 +++++++ .../components/update-api-key-modal.tsx | 80 +++++++++ apps/client/src/ee/api-key/index.ts | 11 ++ .../src/ee/api-key/pages/user-api-keys.tsx | 106 ++++++++++++ .../ee/api-key/pages/workspace-api-keys.tsx | 117 ++++++++++++++ .../src/ee/api-key/queries/api-key-query.ts | 97 +++++++++++ .../ee/api-key/services/api-key-service.ts | 32 ++++ .../src/ee/api-key/types/api-key.types.ts | 23 +++ .../group/components/create-group-form.tsx | 3 +- .../group/components/edit-group-form.tsx | 3 +- .../src/features/group/queries/group-query.ts | 13 +- apps/client/src/lib/types.ts | 1 + apps/client/src/main.tsx | 2 + apps/server/src/core/auth/dto/jwt-payload.ts | 8 + .../src/core/auth/services/token.service.ts | 27 +++- .../src/core/auth/strategies/jwt.strategy.ts | 42 ++++- .../abilities/workspace-ability.factory.ts | 3 + .../casl/interfaces/workspace-ability.type.ts | 4 +- .../workspace/dto/update-workspace.dto.ts | 4 + .../workspace/services/workspace.service.ts | 9 ++ .../migrations/20250912T101500-api-keys.ts | 30 ++++ .../database/pagination/pagination-options.ts | 5 + .../repos/workspace/workspace.repo.ts | 18 +++ apps/server/src/database/types/db.d.ts | 28 +++- .../server/src/database/types/entity.types.ts | 6 + apps/server/src/ee | 2 +- pnpm-lock.yaml | 21 +++ 36 files changed, 1176 insertions(+), 23 deletions(-) create mode 100644 apps/client/src/ee/api-key/components/api-key-created-modal.tsx create mode 100644 apps/client/src/ee/api-key/components/api-key-table.tsx create mode 100644 apps/client/src/ee/api-key/components/create-api-key-modal.tsx create mode 100644 apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx create mode 100644 apps/client/src/ee/api-key/components/update-api-key-modal.tsx create mode 100644 apps/client/src/ee/api-key/index.ts create mode 100644 apps/client/src/ee/api-key/pages/user-api-keys.tsx create mode 100644 apps/client/src/ee/api-key/pages/workspace-api-keys.tsx create mode 100644 apps/client/src/ee/api-key/queries/api-key-query.ts create mode 100644 apps/client/src/ee/api-key/services/api-key-service.ts create mode 100644 apps/client/src/ee/api-key/types/api-key.types.ts create mode 100644 apps/server/src/database/migrations/20250912T101500-api-keys.ts diff --git a/apps/client/package.json b/apps/client/package.json index 0cff05cc..37ecb93d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -17,6 +17,7 @@ "@emoji-mart/react": "^1.1.1", "@excalidraw/excalidraw": "0.18.0-864353b", "@mantine/core": "^8.1.3", + "@mantine/dates": "^8.3.2", "@mantine/form": "^8.1.3", "@mantine/hooks": "^8.1.3", "@mantine/modals": "^8.1.3", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 6d9e548b..b89dcf61 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -533,5 +533,27 @@ "Remove image": "Remove image", "Failed to remove image": "Failed to remove image", "Image exceeds 10MB limit.": "Image exceeds 10MB limit.", - "Image removed successfully": "Image removed successfully" + "Image removed successfully": "Image removed successfully", + "API key": "API key", + "API key created successfully": "API key created successfully", + "API keys": "API keys", + "API management": "API management", + "Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key", + "Create API Key": "Create API Key", + "Custom expiration date": "Custom expiration date", + "Enter a descriptive token name": "Enter a descriptive token name", + "Expiration": "Expiration", + "Expired": "Expired", + "Expires": "Expires", + "I've saved my API key": "I've saved my API key", + "Last use": "Last Used", + "No API keys found": "No API keys found", + "No expiration": "No expiration", + "Revoke API key": "Revoke API key", + "Revoked successfully": "Revoked successfully", + "Select expiration date": "Select expiration date", + "This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.", + "Token name": "Token name", + "Update API key": "Update API key", + "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 3995191d..7048f08a 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -35,6 +35,8 @@ import SpacesPage from "@/pages/spaces/spaces.tsx"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; import SpaceTrash from "@/pages/space/space-trash.tsx"; +import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; +import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; export default function App() { const { t } = useTranslation(); @@ -96,8 +98,10 @@ export default function App() { path={"account/preferences"} element={} /> + } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/client/src/components/common/no-table-results.tsx b/apps/client/src/components/common/no-table-results.tsx index 124bbb9b..0f34fa2f 100644 --- a/apps/client/src/components/common/no-table-results.tsx +++ b/apps/client/src/components/common/no-table-results.tsx @@ -4,14 +4,15 @@ import { useTranslation } from "react-i18next"; interface NoTableResultsProps { colSpan: number; + text?: string; } -export default function NoTableResults({ colSpan }: NoTableResultsProps) { +export default function NoTableResults({ colSpan, text }: NoTableResultsProps) { const { t } = useTranslation(); return ( - {t("No results found...")} + {text || t("No results found...")} diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx index 2f3b46bd..bc528466 100644 --- a/apps/client/src/components/settings/settings-queries.tsx +++ b/apps/client/src/components/settings/settings-queries.tsx @@ -10,6 +10,7 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser import { getLicenseInfo } from "@/ee/licence/services/license-service.ts"; import { getSsoProviders } from "@/ee/security/services/security-service.ts"; import { getShares } from "@/features/share/services/share-service.ts"; +import { getApiKeys } from "@/ee/api-key"; export const prefetchWorkspaceMembers = () => { const params = { limit: 100, page: 1, query: "" } as QueryParams; @@ -65,3 +66,17 @@ export const prefetchShares = () => { queryFn: () => getShares({ page: 1, limit: 100 }), }); }; + +export const prefetchApiKeys = () => { + queryClient.prefetchQuery({ + queryKey: ["api-key-list", { page: 1 }], + queryFn: () => getApiKeys({ page: 1 }), + }); +}; + +export const prefetchApiKeyManagement = () => { + queryClient.prefetchQuery({ + queryKey: ["api-key-list", { page: 1 }], + queryFn: () => getApiKeys({ page: 1, adminView: true }), + }); +}; diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index fe0c7e88..abd3f962 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -21,6 +21,8 @@ import useUserRole from "@/hooks/use-user-role.tsx"; import { useAtom } from "jotai/index"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { + prefetchApiKeyManagement, + prefetchApiKeys, prefetchBilling, prefetchGroups, prefetchLicense, @@ -60,6 +62,14 @@ const groupedData: DataGroup[] = [ icon: IconBrush, path: "/settings/account/preferences", }, + { + label: "API keys", + icon: IconKey, + path: "/settings/account/api-keys", + isCloud: true, + isEnterprise: true, + showDisabledInNonEE: true, + }, ], }, { @@ -90,6 +100,15 @@ const groupedData: DataGroup[] = [ { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, + { + label: "API management", + icon: IconKey, + path: "/settings/api-keys", + isCloud: true, + isEnterprise: true, + isAdmin: true, + showDisabledInNonEE: true, + }, ], }, { @@ -195,6 +214,12 @@ export default function SettingsSidebar() { case "Public sharing": prefetchHandler = prefetchShares; break; + case "API keys": + prefetchHandler = prefetchApiKeys; + break; + case "API management": + prefetchHandler = prefetchApiKeyManagement; + break; default: break; } diff --git a/apps/client/src/ee/api-key/components/api-key-created-modal.tsx b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx new file mode 100644 index 00000000..6a01ee3c --- /dev/null +++ b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx @@ -0,0 +1,72 @@ +import { + Modal, + Text, + Stack, + Alert, + Group, + Button, + TextInput, +} from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { IApiKey } from "@/ee/api-key"; +import CopyTextButton from "@/components/common/copy.tsx"; + +interface ApiKeyCreatedModalProps { + opened: boolean; + onClose: () => void; + apiKey: IApiKey; +} + +export function ApiKeyCreatedModal({ + opened, + onClose, + apiKey, +}: ApiKeyCreatedModalProps) { + const { t } = useTranslation(); + + if (!apiKey) return null; + + return ( + + + } + title={t("Important")} + color="red" + > + {t( + "Make sure to copy your API key now. You won't be able to see it again!", + )} + + +
+ + {t("API key")} + + + + + + +
+ + +
+
+ ); +} diff --git a/apps/client/src/ee/api-key/components/api-key-table.tsx b/apps/client/src/ee/api-key/components/api-key-table.tsx new file mode 100644 index 00000000..48757acc --- /dev/null +++ b/apps/client/src/ee/api-key/components/api-key-table.tsx @@ -0,0 +1,143 @@ +import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; +import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; +import { format } from "date-fns"; +import { useTranslation } from "react-i18next"; +import { IApiKey } from "@/ee/api-key"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import React from "react"; +import NoTableResults from "@/components/common/no-table-results"; + +interface ApiKeyTableProps { + apiKeys: IApiKey[]; + isLoading?: boolean; + showUserColumn?: boolean; + onUpdate?: (apiKey: IApiKey) => void; + onRevoke?: (apiKey: IApiKey) => void; +} + +export function ApiKeyTable({ + apiKeys, + isLoading, + showUserColumn = false, + onUpdate, + onRevoke, +}: ApiKeyTableProps) { + const { t } = useTranslation(); + + const formatDate = (date: Date | string | null) => { + if (!date) return t("Never"); + return format(new Date(date), "MMM dd, yyyy"); + }; + + const isExpired = (expiresAt: string | null) => { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); + }; + + return ( + + + + + {t("Name")} + {showUserColumn && {t("User")}} + {t("Last used")} + {t("Expires")} + {t("Created")} + + + + + + {apiKeys && apiKeys.length > 0 ? ( + apiKeys.map((apiKey: IApiKey, index: number) => ( + + + + {apiKey.name} + + + + {showUserColumn && apiKey.creator && ( + + + + + {apiKey.creator.name} + + + + )} + + + + {formatDate(apiKey.lastUsedAt)} + + + + + {apiKey.expiresAt ? ( + isExpired(apiKey.expiresAt) ? ( + + {t("Expired")} + + ) : ( + + {formatDate(apiKey.expiresAt)} + + ) + ) : ( + + {t("Never")} + + )} + + + + + {formatDate(apiKey.createdAt)} + + + + + + + + + + + + {onUpdate && ( + } + onClick={() => onUpdate(apiKey)} + > + {t("Rename")} + + )} + {onRevoke && ( + } + color="red" + onClick={() => onRevoke(apiKey)} + > + {t("Revoke")} + + )} + + + + + )) + ) : ( + + )} + +
+
+ ); +} diff --git a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx new file mode 100644 index 00000000..cade36e8 --- /dev/null +++ b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx @@ -0,0 +1,153 @@ +import { lazy, Suspense, useState } from "react"; +import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { z } from "zod"; +import { useTranslation } from "react-i18next"; +import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query"; +import { IconCalendar } from "@tabler/icons-react"; +import { IApiKey } from "@/ee/api-key"; + +const DateInput = lazy(() => + import("@mantine/dates").then((module) => ({ + default: module.DateInput, + })), +); + +interface CreateApiKeyModalProps { + opened: boolean; + onClose: () => void; + onSuccess: (response: IApiKey) => void; +} + +const formSchema = z.object({ + name: z.string().min(1, "Name is required"), + expiresAt: z.string().optional(), +}); +type FormValues = z.infer; + +export function CreateApiKeyModal({ + opened, + onClose, + onSuccess, +}: CreateApiKeyModalProps) { + const { t } = useTranslation(); + const [expirationOption, setExpirationOption] = useState("30"); + const createApiKeyMutation = useCreateApiKeyMutation(); + + const form = useForm({ + validate: zodResolver(formSchema), + initialValues: { + name: "", + expiresAt: "", + }, + }); + + const getExpirationDate = (): string | undefined => { + if (expirationOption === "never") { + return undefined; + } + if (expirationOption === "custom") { + return form.values.expiresAt; + } + const days = parseInt(expirationOption); + const date = new Date(); + date.setDate(date.getDate() + days); + return date.toISOString(); + }; + + const getExpirationLabel = (days: number) => { + const date = new Date(); + date.setDate(date.getDate() + days); + const formatted = date.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); + return `${days} days (${formatted})`; + }; + + const expirationOptions = [ + { value: "30", label: getExpirationLabel(30) }, + { value: "60", label: getExpirationLabel(60) }, + { value: "90", label: getExpirationLabel(90) }, + { value: "365", label: getExpirationLabel(365) }, + { value: "custom", label: t("Custom") }, + { value: "never", label: t("No expiration") }, + ]; + + const handleSubmit = async (data: { + name?: string; + expiresAt?: string | Date; + }) => { + const groupData = { + name: data.name, + expiresAt: getExpirationDate(), + }; + + try { + const createdKey = await createApiKeyMutation.mutateAsync(groupData); + onSuccess(createdKey); + form.reset(); + onClose(); + } catch (err) { + // + } + }; + + const handleClose = () => { + form.reset(); + setExpirationOption("30"); + onClose(); + }; + + return ( + +
handleSubmit(values))}> + + + +