From b028d833c2268a0666a5d917bfb08ffb9ff4721a Mon Sep 17 00:00:00 2001 From: b-saikrishnakanth Date: Tue, 23 Dec 2025 15:13:33 +0530 Subject: [PATCH] feat: add API tokens management to workspace settings --- .../settings/(workspace)/api-tokens/page.tsx | 117 ++++++++++++++++++ .../settings/(workspace)/sidebar.tsx | 3 +- .../settings/account/api-tokens/page.tsx | 5 +- apps/web/app/routes/core.ts | 4 + .../api-token/delete-token-modal.tsx | 28 +++-- .../api-token/modal/create-token-modal.tsx | 24 ++-- .../components/api-token/token-list-item.tsx | 18 ++- .../ui/loader/settings/api-token.tsx | 12 +- apps/web/core/constants/fetch-keys.ts | 2 + packages/constants/src/event-tracker/core.ts | 7 ++ packages/constants/src/settings.ts | 2 +- packages/constants/src/workspace.ts | 8 ++ packages/i18n/src/locales/en/translations.ts | 6 +- packages/services/src/developer/index.ts | 1 + .../developer/workspace-api-token.service.ts | 73 +++++++++++ 15 files changed, 276 insertions(+), 34 deletions(-) create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/api-tokens/page.tsx create mode 100644 packages/services/src/developer/workspace-api-token.service.ts diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/api-tokens/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/api-tokens/page.tsx new file mode 100644 index 0000000000..8d84fa2dea --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/api-tokens/page.tsx @@ -0,0 +1,117 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import { WorkspaceAPITokenService } from "@plane/services"; +// component +import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal"; +import { ApiTokenListItem } from "@/components/api-token/token-list-item"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token"; +// constants +import { WORKSPACE_API_TOKENS_LIST } from "@/constants/fetch-keys"; +// helpers +import { captureClick } from "@/helpers/event-tracker.helper"; +// store hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +import type { Route } from "./+types/page"; + +const workspaceApiTokenService = new WorkspaceAPITokenService(); + +function ApiTokensPage({ params }: Route.ComponentProps) { + // states + const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); + // router + const { workspaceSlug } = params; + // plane hooks + const { t } = useTranslation(); + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentWorkspace } = useWorkspace(); + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + const { data: tokens } = useSWR( + canPerformWorkspaceAdminActions ? WORKSPACE_API_TOKENS_LIST(workspaceSlug) : null, + canPerformWorkspaceAdminActions ? () => workspaceApiTokenService.list(workspaceSlug) : null + ); + + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}` + : undefined; + + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } + + return ( + + + {!tokens ? ( + + ) : ( +
+ setIsCreateTokenModalOpen(false)} + workspaceSlug={workspaceSlug} + /> + { + captureClick({ + elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON, + }); + setIsCreateTokenModalOpen(true); + }, + }} + /> + {tokens.length > 0 ? ( +
+
+ {tokens.map((token) => ( + + ))} +
+
+ ) : ( +
+
+ { + captureClick({ + elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_PAT_BUTTON, + }); + setIsCreateTokenModalOpen(true); + }, + }, + ]} + align="start" + rootClassName="py-20" + /> +
+
+ )} +
+ )} +
+ ); +} + +export default observer(ApiTokensPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx index d4f6aed1a6..4f7c161512 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -1,5 +1,5 @@ import { useParams, usePathname } from "next/navigation"; -import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react"; +import { ArrowUpToLine, Building, CreditCard, KeyRound, Users, Webhook } from "lucide-react"; import type { LucideIcon } from "lucide-react"; // plane imports import { @@ -25,6 +25,7 @@ export const WORKSPACE_SETTINGS_ICONS: Record apiTokenService.list()); const pageTitle = currentWorkspace?.name - ? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}` + ? `${currentWorkspace.name} - ${t("account_settings.api_tokens.title")}` : undefined; if (!tokens) { - return ; + return ; } return ( diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts index ccb9d78d37..7ba083fc66 100644 --- a/apps/web/app/routes/core.ts +++ b/apps/web/app/routes/core.ts @@ -276,6 +276,10 @@ export const coreRoutes: RouteConfigEntry[] = [ ":workspaceSlug/settings/webhooks/:webhookId", "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx" ), + route( + ":workspaceSlug/settings/api-tokens", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/api-tokens/page.tsx" + ), ]), // -------------------------------------------------------------------- diff --git a/apps/web/core/components/api-token/delete-token-modal.tsx b/apps/web/core/components/api-token/delete-token-modal.tsx index 8989150ca6..18ea79b356 100644 --- a/apps/web/core/components/api-token/delete-token-modal.tsx +++ b/apps/web/core/components/api-token/delete-token-modal.tsx @@ -1,28 +1,29 @@ -import type { FC } from "react"; import { useState } from "react"; import { mutate } from "swr"; // types -import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; +import { PROFILE_SETTINGS_TRACKER_EVENTS, WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { APITokenService } from "@plane/services"; +import { APITokenService, WorkspaceAPITokenService } from "@plane/services"; import type { IApiToken } from "@plane/types"; // ui import { AlertModalCore } from "@plane/ui"; // fetch-keys -import { API_TOKENS_LIST } from "@/constants/fetch-keys"; +import { API_TOKENS_LIST, WORKSPACE_API_TOKENS_LIST } from "@/constants/fetch-keys"; import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; type Props = { isOpen: boolean; onClose: () => void; tokenId: string; + workspaceSlug?: string; }; const apiTokenService = new APITokenService(); +const workspaceApiTokenService = new WorkspaceAPITokenService(); export function DeleteApiTokenModal(props: Props) { - const { isOpen, onClose, tokenId } = props; + const { isOpen, onClose, tokenId, workspaceSlug } = props; // states const [deleteLoading, setDeleteLoading] = useState(false); // router params @@ -36,8 +37,11 @@ export function DeleteApiTokenModal(props: Props) { const handleDeletion = async () => { setDeleteLoading(true); - await apiTokenService - .destroy(tokenId) + const apiCall = workspaceSlug + ? workspaceApiTokenService.destroy(workspaceSlug, tokenId) + : apiTokenService.destroy(tokenId); + + await apiCall .then(() => { setToast({ type: TOAST_TYPE.SUCCESS, @@ -46,12 +50,14 @@ export function DeleteApiTokenModal(props: Props) { }); mutate( - API_TOKENS_LIST, + workspaceSlug ? WORKSPACE_API_TOKENS_LIST(workspaceSlug) : API_TOKENS_LIST, (prevData) => (prevData ?? []).filter((token) => token.id !== tokenId), false ); captureSuccess({ - eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted, + eventName: workspaceSlug + ? WORKSPACE_SETTINGS_TRACKER_EVENTS.pat_deleted + : PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted, payload: { token: tokenId, }, @@ -68,7 +74,9 @@ export function DeleteApiTokenModal(props: Props) { ) .catch((err) => { captureError({ - eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted, + eventName: workspaceSlug + ? WORKSPACE_SETTINGS_TRACKER_EVENTS.pat_deleted + : PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted, payload: { token: tokenId, }, diff --git a/apps/web/core/components/api-token/modal/create-token-modal.tsx b/apps/web/core/components/api-token/modal/create-token-modal.tsx index a87b18d71e..469baefa35 100644 --- a/apps/web/core/components/api-token/modal/create-token-modal.tsx +++ b/apps/web/core/components/api-token/modal/create-token-modal.tsx @@ -1,14 +1,14 @@ import React, { useState } from "react"; import { mutate } from "swr"; // plane imports -import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; +import { PROFILE_SETTINGS_TRACKER_EVENTS, WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { APITokenService } from "@plane/services"; +import { APITokenService, WorkspaceAPITokenService } from "@plane/services"; import type { IApiToken } from "@plane/types"; import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; import { renderFormattedDate, csvDownload } from "@plane/utils"; // constants -import { API_TOKENS_LIST } from "@/constants/fetch-keys"; +import { API_TOKENS_LIST, WORKSPACE_API_TOKENS_LIST } from "@/constants/fetch-keys"; // helpers import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; // local imports @@ -18,13 +18,15 @@ import { GeneratedTokenDetails } from "./generated-token-details"; type Props = { isOpen: boolean; onClose: () => void; + workspaceSlug?: string; }; // services const apiTokenService = new APITokenService(); +const workspaceApiTokenService = new WorkspaceAPITokenService(); export function CreateApiTokenModal(props: Props) { - const { isOpen, onClose } = props; + const { isOpen, onClose, workspaceSlug } = props; // states const [neverExpires, setNeverExpires] = useState(false); const [generatedToken, setGeneratedToken] = useState(null); @@ -51,14 +53,14 @@ export function CreateApiTokenModal(props: Props) { const handleCreateToken = async (data: Partial) => { // make the request to generate the token - await apiTokenService - .create(data) + const apiCall = workspaceSlug ? workspaceApiTokenService.create(workspaceSlug, data) : apiTokenService.create(data); + await apiCall .then((res) => { setGeneratedToken(res); downloadSecretKey(res); mutate( - API_TOKENS_LIST, + workspaceSlug ? WORKSPACE_API_TOKENS_LIST(workspaceSlug) : API_TOKENS_LIST, (prevData) => { if (!prevData) return; @@ -67,7 +69,9 @@ export function CreateApiTokenModal(props: Props) { false ); captureSuccess({ - eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created, + eventName: workspaceSlug + ? WORKSPACE_SETTINGS_TRACKER_EVENTS.pat_created + : PROFILE_SETTINGS_TRACKER_EVENTS.pat_created, payload: { token: res.id, }, @@ -81,7 +85,9 @@ export function CreateApiTokenModal(props: Props) { }); captureError({ - eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created, + eventName: workspaceSlug + ? WORKSPACE_SETTINGS_TRACKER_EVENTS.pat_created + : PROFILE_SETTINGS_TRACKER_EVENTS.pat_created, }); throw err; diff --git a/apps/web/core/components/api-token/token-list-item.tsx b/apps/web/core/components/api-token/token-list-item.tsx index 4e0253bd8b..d2d9b3fb93 100644 --- a/apps/web/core/components/api-token/token-list-item.tsx +++ b/apps/web/core/components/api-token/token-list-item.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { XCircle } from "lucide-react"; // plane imports -import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; +import { PROFILE_SETTINGS_TRACKER_ELEMENTS, WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; import { Tooltip } from "@plane/propel/tooltip"; import type { IApiToken } from "@plane/types"; import { renderFormattedDate, calculateTimeAgo, renderFormattedTime } from "@plane/utils"; @@ -12,24 +12,34 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; type Props = { token: IApiToken; + workspaceSlug?: string; }; export function ApiTokenListItem(props: Props) { - const { token } = props; + const { token, workspaceSlug } = props; // states const [deleteModalOpen, setDeleteModalOpen] = useState(false); // hooks const { isMobile } = usePlatformOS(); + const trackerElement = workspaceSlug + ? WORKSPACE_SETTINGS_TRACKER_ELEMENTS.LIST_ITEM_DELETE_ICON + : PROFILE_SETTINGS_TRACKER_ELEMENTS.LIST_ITEM_DELETE_ICON; + return ( <> - setDeleteModalOpen(false)} tokenId={token.id} /> + setDeleteModalOpen(false)} + tokenId={token.id} + workspaceSlug={workspaceSlug} + />
diff --git a/apps/web/core/components/ui/loader/settings/api-token.tsx b/apps/web/core/components/ui/loader/settings/api-token.tsx index 8d4fe11e8f..72d2aeda50 100644 --- a/apps/web/core/components/ui/loader/settings/api-token.tsx +++ b/apps/web/core/components/ui/loader/settings/api-token.tsx @@ -1,11 +1,15 @@ import { range } from "lodash-es"; -import { useTranslation } from "@plane/i18n"; -export function APITokenSettingsLoader() { - const { t } = useTranslation(); + +type Props = { + title: string; +}; + +export function APITokenSettingsLoader(props: Props) { + const { title } = props; return (
-

{t("workspace_settings.settings.api_tokens.title")}

+

{title}

diff --git a/apps/web/core/constants/fetch-keys.ts b/apps/web/core/constants/fetch-keys.ts index 0a54ccc196..161df2621b 100644 --- a/apps/web/core/constants/fetch-keys.ts +++ b/apps/web/core/constants/fetch-keys.ts @@ -143,6 +143,8 @@ export const USER_PROFILE_PROJECT_SEGREGATION = (workspaceSlug: string, userId: // api-tokens export const API_TOKENS_LIST = `API_TOKENS_LIST`; +export const WORKSPACE_API_TOKENS_LIST = (workspaceSlug: string) => + `WORKSPACE_API_TOKENS_LIST_${workspaceSlug.toUpperCase()}`; // marketplace export const APPLICATIONS_LIST = (workspaceSlug: string) => `APPLICATIONS_LIST_${workspaceSlug.toUpperCase()}`; diff --git a/packages/constants/src/event-tracker/core.ts b/packages/constants/src/event-tracker/core.ts index e2d305052e..c339dd9550 100644 --- a/packages/constants/src/event-tracker/core.ts +++ b/packages/constants/src/event-tracker/core.ts @@ -483,6 +483,9 @@ export const WORKSPACE_SETTINGS_TRACKER_EVENTS = { webhook_toggled: "webhook_toggled", webhook_details_page_toggled: "webhook_details_page_toggled", webhook_updated: "webhook_updated", + // PAT + pat_created: "workspace_pat_created", + pat_deleted: "workspace_pat_deleted", }; export const WORKSPACE_SETTINGS_TRACKER_ELEMENTS = { @@ -499,4 +502,8 @@ export const WORKSPACE_SETTINGS_TRACKER_ELEMENTS = { WEBHOOK_DETAILS_PAGE_TOGGLE_SWITCH: "webhook_details_page_toggle_switch", WEBHOOK_DELETE_BUTTON: "webhook_delete_button", WEBHOOK_UPDATE_BUTTON: "webhook_update_button", + // PAT + HEADER_ADD_PAT_BUTTON: "workspace_header_add_pat_button", + EMPTY_STATE_ADD_PAT_BUTTON: "workspace_empty_state_add_pat_button", + LIST_ITEM_DELETE_ICON: "workspace_list_item_delete_icon", }; diff --git a/packages/constants/src/settings.ts b/packages/constants/src/settings.ts index 2c55a6a2dd..ca2fd3f17c 100644 --- a/packages/constants/src/settings.ts +++ b/packages/constants/src/settings.ts @@ -37,7 +37,7 @@ export const GROUPED_WORKSPACE_SETTINGS = { WORKSPACE_SETTINGS["export"], ], [WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [], - [WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]], + [WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"], WORKSPACE_SETTINGS["api-tokens"]], }; export const GROUPED_PROFILE_SETTINGS = { diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index 9610333c0e..7497e80889 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -107,6 +107,13 @@ export const WORKSPACE_SETTINGS = { access: [EUserWorkspaceRoles.ADMIN], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`, }, + "api-tokens": { + key: "api-tokens", + i18n_label: "workspace_settings.settings.api_tokens.title", + href: `/settings/api-tokens`, + access: [EUserWorkspaceRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`, + }, }; export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries( @@ -125,6 +132,7 @@ export const WORKSPACE_SETTINGS_LINKS: { WORKSPACE_SETTINGS["billing-and-plans"], WORKSPACE_SETTINGS["export"], WORKSPACE_SETTINGS["webhooks"], + WORKSPACE_SETTINGS["api-tokens"], ]; export const ROLE = { diff --git a/packages/i18n/src/locales/en/translations.ts b/packages/i18n/src/locales/en/translations.ts index d41e7ecb06..f6557bc64a 100644 --- a/packages/i18n/src/locales/en/translations.ts +++ b/packages/i18n/src/locales/en/translations.ts @@ -1408,7 +1408,7 @@ export default { heading: "Security", }, api_tokens: { - heading: "Personal Access Tokens", + title: "Personal Access Tokens", description: "Generate secure API tokens to integrate your data with external systems and applications.", }, activity: { @@ -1571,7 +1571,9 @@ export default { }, }, api_tokens: { - title: "Personal Access Tokens", + heading: "Workspace Access Tokens", + description: "Generate secure API tokens to integrate your data with external systems and applications.", + title: "Workspace Access Tokens", add_token: "Add personal access token", create_token: "Create token", never_expires: "Never expires", diff --git a/packages/services/src/developer/index.ts b/packages/services/src/developer/index.ts index a78a7b0929..ccc29f68c2 100644 --- a/packages/services/src/developer/index.ts +++ b/packages/services/src/developer/index.ts @@ -1,2 +1,3 @@ export * from "./api-token.service"; export * from "./webhook.service"; +export * from "./workspace-api-token.service"; diff --git a/packages/services/src/developer/workspace-api-token.service.ts b/packages/services/src/developer/workspace-api-token.service.ts new file mode 100644 index 0000000000..a60b05a068 --- /dev/null +++ b/packages/services/src/developer/workspace-api-token.service.ts @@ -0,0 +1,73 @@ +import { API_BASE_URL } from "@plane/constants"; +import type { IApiToken } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing API tokens for a workspace + * Handles CRUD operations for API tokens + * @extends {APIService} + */ +export class WorkspaceAPITokenService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves all API tokens for a workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @returns {Promise} Promise resolving to array of API tokens + * @throws {Error} If the API request fails + */ + async list(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves details of a specific API token + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} tokenId - The unique identifier of the API token + * @returns {Promise} Promise resolving to API token details + * @throws {Error} If the API request fails + */ + async retrieve(workspaceSlug: string, tokenId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Creates a new API token for a workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {Partial} data - API token configuration data + * @returns {Promise} Promise resolving to the created API token + * @throws {Error} If the API request fails + */ + async create(workspaceSlug: string, data: Partial): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/api-tokens/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Deletes a specific API token from the workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} tokenId - The unique identifier of the API token + * @returns {Promise} Promise resolving when API token is deleted + * @throws {Error} If the API request fails + */ + async destroy(workspaceSlug: string, tokenId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +}