From 64328f6f53c71abf30d49356efa51c95f9207ee1 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Mon, 15 Dec 2025 15:44:13 +0530 Subject: [PATCH 01/23] migration: added version field in webhook --- .../db/migrations/0113_webhook_version.py | 18 ++++++++++++++++++ apps/api/plane/db/models/webhook.py | 1 + 2 files changed, 19 insertions(+) create mode 100644 apps/api/plane/db/migrations/0113_webhook_version.py diff --git a/apps/api/plane/db/migrations/0113_webhook_version.py b/apps/api/plane/db/migrations/0113_webhook_version.py new file mode 100644 index 00000000000..77a0411ef1a --- /dev/null +++ b/apps/api/plane/db/migrations/0113_webhook_version.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.26 on 2025-12-15 10:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0112_auto_20251124_0603'), + ] + + operations = [ + migrations.AddField( + model_name='webhook', + name='version', + field=models.CharField(default='v1'), + ), + ] diff --git a/apps/api/plane/db/models/webhook.py b/apps/api/plane/db/models/webhook.py index 8872d0bb235..e246f2452e9 100644 --- a/apps/api/plane/db/models/webhook.py +++ b/apps/api/plane/db/models/webhook.py @@ -38,6 +38,7 @@ class Webhook(BaseModel): cycle = models.BooleanField(default=False) issue_comment = models.BooleanField(default=False) is_internal = models.BooleanField(default=False) + version = models.CharField(default="v1") def __str__(self): return f"{self.workspace.slug} {self.url}" From 04ec99f71186dfec3c49f754814995054c713e53 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Mon, 15 Dec 2025 15:59:51 +0530 Subject: [PATCH 02/23] chore: add max_length --- apps/api/plane/db/migrations/0113_webhook_version.py | 4 ++-- apps/api/plane/db/models/webhook.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/plane/db/migrations/0113_webhook_version.py b/apps/api/plane/db/migrations/0113_webhook_version.py index 77a0411ef1a..e4571b78ba7 100644 --- a/apps/api/plane/db/migrations/0113_webhook_version.py +++ b/apps/api/plane/db/migrations/0113_webhook_version.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.26 on 2025-12-15 10:12 +# Generated by Django 4.2.26 on 2025-12-15 10:29 from django.db import migrations, models @@ -13,6 +13,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='webhook', name='version', - field=models.CharField(default='v1'), + field=models.CharField(default='v1', max_length=50), ), ] diff --git a/apps/api/plane/db/models/webhook.py b/apps/api/plane/db/models/webhook.py index e246f2452e9..298b0dba3b0 100644 --- a/apps/api/plane/db/models/webhook.py +++ b/apps/api/plane/db/models/webhook.py @@ -38,7 +38,7 @@ class Webhook(BaseModel): cycle = models.BooleanField(default=False) issue_comment = models.BooleanField(default=False) is_internal = models.BooleanField(default=False) - version = models.CharField(default="v1") + version = models.CharField(default="v1", max_length=50) def __str__(self): return f"{self.workspace.slug} {self.url}" From b028d833c2268a0666a5d917bfb08ffb9ff4721a Mon Sep 17 00:00:00 2001 From: b-saikrishnakanth Date: Tue, 23 Dec 2025 15:13:33 +0530 Subject: [PATCH 03/23] 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 00000000000..8d84fa2dead --- /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 d4f6aed1a6f..4f7c1615128 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 ccb9d78d378..7ba083fc661 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 8989150ca60..18ea79b3566 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 a87b18d71e5..469baefa35a 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 4e0253bd8b7..d2d9b3fb93c 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 8d4fe11e8f2..72d2aeda503 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 0a54ccc1967..161df2621b2 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 e2d305052eb..c339dd9550f 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 2c55a6a2dd7..ca2fd3f17cc 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 9610333c0e6..7497e808896 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 d41e7ecb06c..f6557bc64a7 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 a78a7b0929e..ccc29f68c21 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 00000000000..a60b05a0689 --- /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; + }); + } +} From ab47d4cd63e84733d2424531172a3b5139a2ec2e Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Tue, 2 Dec 2025 20:33:07 +0530 Subject: [PATCH 04/23] chore: added product tour fields --- ...e_is_navigation_tour_completed_and_more.py | 42 +++++++++++++++++++ apps/api/plane/db/models/user.py | 3 ++ apps/api/plane/db/models/workspace.py | 11 +++++ 3 files changed, 56 insertions(+) create mode 100644 apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py diff --git a/apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py b/apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py new file mode 100644 index 00000000000..0c09403225b --- /dev/null +++ b/apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.25 on 2025-12-02 14:16 + +from django.db import migrations, models +import plane.db.models.workspace + + +def get_default_feature_tours(): + return { + "work_items": True, + "cycles": True, + "modules": True, + "intake": True, + "pages": True, + } + +def populate_feature_tours(apps, _schema_editor): + WorkspaceUserProperties = apps.get_model('db', 'WorkspaceUserProperties') + default_value = get_default_feature_tours() + # Use bulk update for better performance + WorkspaceUserProperties.objects.all().update(feature_tours=default_value) + + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0112_auto_20251124_0603'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='is_navigation_tour_completed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='workspaceuserproperties', + name='feature_tours', + field=models.JSONField(default=plane.db.models.workspace.get_default_feature_tours), + ), + migrations.RunPython(populate_feature_tours, reverse_code=migrations.RunPython.noop), + ] \ No newline at end of file diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py index ee70032cf42..9b5f5ac6b55 100644 --- a/apps/api/plane/db/models/user.py +++ b/apps/api/plane/db/models/user.py @@ -218,6 +218,9 @@ class Profile(TimeAuditModel): goals = models.JSONField(default=dict) background_color = models.CharField(max_length=255, default=get_random_color) + # navigation tour + is_navigation_tour_completed = models.BooleanField(default=False) + # marketing has_marketing_email_consent = models.BooleanField(default=False) diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index d3470d531ea..4f34caeb729 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -112,6 +112,16 @@ def slug_validator(value): raise ValidationError("Slug is not valid") +def get_default_feature_tours(): + return { + "work_items": False, + "cycles": False, + "modules": False, + "intake": False, + "pages": False, + } + + class Workspace(BaseModel): TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones)) @@ -325,6 +335,7 @@ class NavigationControlPreference(models.TextChoices): choices=NavigationControlPreference.choices, default=NavigationControlPreference.ACCORDION, ) + feature_tours = models.JSONField(default=get_default_feature_tours) class Meta: unique_together = ["workspace", "user", "deleted_at"] From 1d9390d4cabbfebc83a7b90d6506a0664f839a23 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Tue, 23 Dec 2025 18:52:05 +0530 Subject: [PATCH 05/23] chore: updated the migration file --- ...e_is_navigation_tour_completed_and_more.py | 28 ++---------------- .../db/migrations/0113_webhook_version.py | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py b/apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py index 0c09403225b..4c6d0fd2d51 100644 --- a/apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py +++ b/apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py @@ -1,23 +1,9 @@ # Generated by Django 4.2.25 on 2025-12-02 14:16 -from django.db import migrations, models -import plane.db.models.workspace -def get_default_feature_tours(): - return { - "work_items": True, - "cycles": True, - "modules": True, - "intake": True, - "pages": True, - } -def populate_feature_tours(apps, _schema_editor): - WorkspaceUserProperties = apps.get_model('db', 'WorkspaceUserProperties') - default_value = get_default_feature_tours() - # Use bulk update for better performance - WorkspaceUserProperties.objects.all().update(feature_tours=default_value) + @@ -28,15 +14,5 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='profile', - name='is_navigation_tour_completed', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='workspaceuserproperties', - name='feature_tours', - field=models.JSONField(default=plane.db.models.workspace.get_default_feature_tours), - ), - migrations.RunPython(populate_feature_tours, reverse_code=migrations.RunPython.noop), + ] \ No newline at end of file diff --git a/apps/api/plane/db/migrations/0113_webhook_version.py b/apps/api/plane/db/migrations/0113_webhook_version.py index e4571b78ba7..3beb9918731 100644 --- a/apps/api/plane/db/migrations/0113_webhook_version.py +++ b/apps/api/plane/db/migrations/0113_webhook_version.py @@ -1,6 +1,24 @@ # Generated by Django 4.2.26 on 2025-12-15 10:29 from django.db import migrations, models +import plane.db.models.workspace + + +def get_default_feature_tours(): + return { + "work_items": True, + "cycles": True, + "modules": True, + "intake": True, + "pages": True, + } + + +def populate_feature_tours(apps, _schema_editor): + WorkspaceUserProperties = apps.get_model('db', 'WorkspaceUserProperties') + default_value = get_default_feature_tours() + # Use bulk update for better performance + WorkspaceUserProperties.objects.all().update(feature_tours=default_value) class Migration(migrations.Migration): @@ -15,4 +33,15 @@ class Migration(migrations.Migration): name='version', field=models.CharField(default='v1', max_length=50), ), + migrations.AddField( + model_name='profile', + name='is_navigation_tour_completed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='workspaceuserproperties', + name='feature_tours', + field=models.JSONField(default=plane.db.models.workspace.get_default_feature_tours), + ), + migrations.RunPython(populate_feature_tours, reverse_code=migrations.RunPython.noop), ] From e76d3688da9286f77ce2b8d235a831d647f24ac0 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Tue, 23 Dec 2025 18:53:01 +0530 Subject: [PATCH 06/23] chore: removed the duplicated migration file --- ...le_is_navigation_tour_completed_and_more.py | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py diff --git a/apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py b/apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py deleted file mode 100644 index 4c6d0fd2d51..00000000000 --- a/apps/api/plane/db/migrations/0113_profile_is_navigation_tour_completed_and_more.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.25 on 2025-12-02 14:16 - - - - - - - - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0112_auto_20251124_0603'), - ] - - operations = [ - - ] \ No newline at end of file From 56a41a8d674ce7abf40c470477c0d6b1a859e0be Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Tue, 23 Dec 2025 19:20:34 +0530 Subject: [PATCH 07/23] chore: added allowed_rate_limit for api_tokens --- apps/api/plane/db/migrations/0113_webhook_version.py | 5 +++++ apps/api/plane/db/models/api.py | 1 + 2 files changed, 6 insertions(+) diff --git a/apps/api/plane/db/migrations/0113_webhook_version.py b/apps/api/plane/db/migrations/0113_webhook_version.py index 3beb9918731..256a5fe193b 100644 --- a/apps/api/plane/db/migrations/0113_webhook_version.py +++ b/apps/api/plane/db/migrations/0113_webhook_version.py @@ -42,6 +42,11 @@ class Migration(migrations.Migration): model_name='workspaceuserproperties', name='feature_tours', field=models.JSONField(default=plane.db.models.workspace.get_default_feature_tours), + ), + migrations.AddField( + model_name='apitoken', + name='allowed_rate_limit', + field=models.CharField(default='60/min', max_length=255), ), migrations.RunPython(populate_feature_tours, reverse_code=migrations.RunPython.noop), ] diff --git a/apps/api/plane/db/models/api.py b/apps/api/plane/db/models/api.py index 7d040ebc284..75449a74283 100644 --- a/apps/api/plane/db/models/api.py +++ b/apps/api/plane/db/models/api.py @@ -32,6 +32,7 @@ class APIToken(BaseModel): workspace = models.ForeignKey("db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True) expired_at = models.DateTimeField(blank=True, null=True) is_service = models.BooleanField(default=False) + allowed_rate_limit = models.CharField(max_length=255, default="60/min") class Meta: verbose_name = "API Token" From 9cc3f811ff00f545be53830566a2cd13d0d67405 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Wed, 24 Dec 2025 17:45:02 +0530 Subject: [PATCH 08/23] chore: changed key feature tour to product tour --- .../plane/db/migrations/0113_webhook_version.py | 14 +++++++------- apps/api/plane/db/models/workspace.py | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/api/plane/db/migrations/0113_webhook_version.py b/apps/api/plane/db/migrations/0113_webhook_version.py index 3beb9918731..f55bb1f49b6 100644 --- a/apps/api/plane/db/migrations/0113_webhook_version.py +++ b/apps/api/plane/db/migrations/0113_webhook_version.py @@ -4,7 +4,7 @@ import plane.db.models.workspace -def get_default_feature_tours(): +def get_default_product_tour(): return { "work_items": True, "cycles": True, @@ -14,11 +14,11 @@ def get_default_feature_tours(): } -def populate_feature_tours(apps, _schema_editor): +def populate_product_tour(apps, _schema_editor): WorkspaceUserProperties = apps.get_model('db', 'WorkspaceUserProperties') - default_value = get_default_feature_tours() + default_value = get_default_product_tour() # Use bulk update for better performance - WorkspaceUserProperties.objects.all().update(feature_tours=default_value) + WorkspaceUserProperties.objects.all().update(product_tour=default_value) class Migration(migrations.Migration): @@ -40,8 +40,8 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='workspaceuserproperties', - name='feature_tours', - field=models.JSONField(default=plane.db.models.workspace.get_default_feature_tours), + name='product_tour', + field=models.JSONField(default=plane.db.models.workspace.get_default_product_tour), ), - migrations.RunPython(populate_feature_tours, reverse_code=migrations.RunPython.noop), + migrations.RunPython(populate_product_tour, reverse_code=migrations.RunPython.noop), ] diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index 4f34caeb729..9690168a11a 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -112,7 +112,7 @@ def slug_validator(value): raise ValidationError("Slug is not valid") -def get_default_feature_tours(): +def get_default_product_tour(): return { "work_items": False, "cycles": False, @@ -335,7 +335,7 @@ class NavigationControlPreference(models.TextChoices): choices=NavigationControlPreference.choices, default=NavigationControlPreference.ACCORDION, ) - feature_tours = models.JSONField(default=get_default_feature_tours) + product_tour = models.JSONField(default=get_default_product_tour) class Meta: unique_together = ["workspace", "user", "deleted_at"] From 0bf6e1c8b225ec04f3899bfd8d5db828f33c0951 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 4 Dec 2025 14:04:07 +0530 Subject: [PATCH 09/23] feat: implement workspace-specific API token management - Added WorkspaceAPITokenEndpoint for managing API tokens within specific workspaces. - Enhanced APITokenSerializer to associate tokens with workspaces and users. - Updated URL routing to include endpoints for workspace API tokens. - Introduced ServiceApiTokenEndpoint for handling service tokens linked to workspaces. - Created base views for API token operations, including create, read, update, and delete functionalities. --- apps/api/plane/app/serializers/api.py | 24 +++++++++-- apps/api/plane/app/urls/api.py | 12 +++++- apps/api/plane/app/views/__init__.py | 2 +- apps/api/plane/app/views/api/__init__.py | 3 ++ .../plane/app/views/{api.py => api/base.py} | 30 +------------- apps/api/plane/app/views/api/service.py | 37 +++++++++++++++++ apps/api/plane/app/views/api/workspace.py | 41 +++++++++++++++++++ 7 files changed, 116 insertions(+), 33 deletions(-) create mode 100644 apps/api/plane/app/views/api/__init__.py rename apps/api/plane/app/views/{api.py => api/base.py} (68%) create mode 100644 apps/api/plane/app/views/api/service.py create mode 100644 apps/api/plane/app/views/api/workspace.py diff --git a/apps/api/plane/app/serializers/api.py b/apps/api/plane/app/serializers/api.py index 009f7a611f5..07a437d8041 100644 --- a/apps/api/plane/app/serializers/api.py +++ b/apps/api/plane/app/serializers/api.py @@ -1,8 +1,13 @@ -from .base import BaseSerializer -from plane.db.models import APIToken, APIActivityLog -from rest_framework import serializers +# Django import from django.utils import timezone +# Third party import +from rest_framework import serializers + +# Module import +from .base import BaseSerializer +from plane.db.models import APIToken, APIActivityLog, Workspace + class APITokenSerializer(BaseSerializer): class Meta: @@ -17,6 +22,19 @@ class Meta: "user", ] + def create(self, validated_data): + workspace_slug = self.context.get("workspace_slug") + user = self.context.get("user") + + # Set the workspace + workspace = Workspace.objects.get(slug=workspace_slug) + validated_data["workspace"] = workspace + + # Set the user and user type + validated_data["user"] = user + validated_data["user_type"] = 1 if user.is_bot else 0 + return super().create(validated_data) + class APITokenReadSerializer(BaseSerializer): is_active = serializers.SerializerMethodField() diff --git a/apps/api/plane/app/urls/api.py b/apps/api/plane/app/urls/api.py index c74aeddbf2b..fd03c5dc52c 100644 --- a/apps/api/plane/app/urls/api.py +++ b/apps/api/plane/app/urls/api.py @@ -1,5 +1,5 @@ from django.urls import path -from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint +from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint, WorkspaceAPITokenEndpoint urlpatterns = [ # API Tokens @@ -18,5 +18,15 @@ ServiceApiTokenEndpoint.as_view(), name="service-api-tokens", ), + path( + "workspaces//api-tokens/", + WorkspaceAPITokenEndpoint.as_view(), + name="workspace-api-tokens", + ), + path( + "workspaces//api-tokens//", + WorkspaceAPITokenEndpoint.as_view(), + name="workspace-api-tokens-details", + ), ## End API Tokens ] diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 7a0e5cb3a28..dcdb920425e 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -161,7 +161,7 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint -from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint +from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint, WorkspaceAPITokenEndpoint from .page.base import ( PageViewSet, diff --git a/apps/api/plane/app/views/api/__init__.py b/apps/api/plane/app/views/api/__init__.py new file mode 100644 index 00000000000..6736ced5e8d --- /dev/null +++ b/apps/api/plane/app/views/api/__init__.py @@ -0,0 +1,3 @@ +from .base import ApiTokenEndpoint +from .service import ServiceApiTokenEndpoint +from .workspace import WorkspaceAPITokenEndpoint diff --git a/apps/api/plane/app/views/api.py b/apps/api/plane/app/views/api/base.py similarity index 68% rename from apps/api/plane/app/views/api.py rename to apps/api/plane/app/views/api/base.py index 41985990239..99358b7cfd0 100644 --- a/apps/api/plane/app/views/api.py +++ b/apps/api/plane/app/views/api/base.py @@ -8,10 +8,9 @@ from rest_framework import status # Module import -from .base import BaseAPIView -from plane.db.models import APIToken, Workspace +from plane.app.views.base import BaseAPIView +from plane.db.models import APIToken from plane.app.serializers import APITokenSerializer, APITokenReadSerializer -from plane.app.permissions import WorkspaceEntityPermission class ApiTokenEndpoint(BaseAPIView): @@ -57,28 +56,3 @@ def patch(self, request: Request, pk: str) -> Response: serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class ServiceApiTokenEndpoint(BaseAPIView): - permission_classes = [WorkspaceEntityPermission] - - def post(self, request: Request, slug: str) -> Response: - workspace = Workspace.objects.get(slug=slug) - - api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first() - - if api_token: - return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK) - else: - # Check the user type - user_type = 1 if request.user.is_bot else 0 - - api_token = APIToken.objects.create( - label=str(uuid4().hex), - description="Service Token", - user=request.user, - workspace=workspace, - user_type=user_type, - is_service=True, - ) - return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED) diff --git a/apps/api/plane/app/views/api/service.py b/apps/api/plane/app/views/api/service.py new file mode 100644 index 00000000000..c22514902e0 --- /dev/null +++ b/apps/api/plane/app/views/api/service.py @@ -0,0 +1,37 @@ +# Python import +from uuid import uuid4 + +# Third party +from rest_framework.response import Response +from rest_framework.request import Request +from rest_framework import status + +# Module import +from .base import BaseAPIView +from plane.db.models import APIToken, Workspace +from plane.app.permissions import WorkspaceEntityPermission + + +class ServiceApiTokenEndpoint(BaseAPIView): + permission_classes = [WorkspaceEntityPermission] + + def post(self, request: Request, slug: str) -> Response: + workspace = Workspace.objects.get(slug=slug) + + api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first() + + if api_token: + return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK) + else: + # Check the user type + user_type = 1 if request.user.is_bot else 0 + + api_token = APIToken.objects.create( + label=str(uuid4().hex), + description="Service Token", + user=request.user, + workspace=workspace, + user_type=user_type, + is_service=True, + ) + return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED) diff --git a/apps/api/plane/app/views/api/workspace.py b/apps/api/plane/app/views/api/workspace.py new file mode 100644 index 00000000000..c9597d185db --- /dev/null +++ b/apps/api/plane/app/views/api/workspace.py @@ -0,0 +1,41 @@ +# Python import +from typing import Optional + +# Third party +from rest_framework.response import Response +from rest_framework.request import Request +from rest_framework import status + +# Module import +from plane.app.views import BaseAPIView +from plane.db.models import APIToken +from plane.app.serializers import APITokenSerializer, APITokenReadSerializer +from plane.app.permissions import WorkSpaceAdminPermission + + +class WorkspaceAPITokenEndpoint(BaseAPIView): + permission_classes = [ + WorkSpaceAdminPermission, + ] + + def post(self, request: Request, slug: str) -> Response: + serializer = APITokenSerializer(data=request.data, context={"workspace_slug": slug, "user": request.user}) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def get(self, request: Request, slug: str, pk: Optional[str] = None) -> Response: + if pk is None: + api_tokens = APIToken.objects.filter(workspace__slug=slug, is_service=False, user=request.user) + serializer = APITokenReadSerializer(api_tokens, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + api_tokens = APIToken.objects.get(workspace__slug=slug, pk=pk, user=request.user) + serializer = APITokenReadSerializer(api_tokens) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request: Request, slug: str, pk: str) -> Response: + api_token = APIToken.objects.get(workspace__slug=slug, pk=pk, is_service=False, user=request.user) + api_token.delete() + return Response(status=status.HTTP_204_NO_CONTENT) From 42d5407fddf5d0003cc4d14676db14b296b5de64 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Fri, 19 Dec 2025 19:14:43 +0530 Subject: [PATCH 10/23] chore: separate rate limit class for workspace api token --- apps/api/plane/api/rate_limit.py | 25 +++++++++++++++++++++++++ apps/api/plane/api/views/base.py | 12 ++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/api/rate_limit.py b/apps/api/plane/api/rate_limit.py index 0d266e98b50..1cc99bfd48d 100644 --- a/apps/api/plane/api/rate_limit.py +++ b/apps/api/plane/api/rate_limit.py @@ -3,6 +3,7 @@ # Third party imports from rest_framework.throttling import SimpleRateThrottle +from plane.db.models import APIToken class ApiKeyRateThrottle(SimpleRateThrottle): @@ -85,3 +86,27 @@ def allow_request(self, request, view): request.META["X-RateLimit-Reset"] = reset_time return allowed + + +class WorkspaceTokenRateThrottle(SimpleRateThrottle): + scope = "workspace_token" + rate = "60/minute" + + def get_cache_key(self, request, view): + api_key = request.headers.get("X-Api-Key") + if not api_key: + return None + + return f"{self.scope}:{api_key}" + + def allow_request(self, request, view): + api_key = request.headers.get("X-Api-Key") + + if api_key: + token = APIToken.objects.filter(token=api_key).only("allowed_rate_limit").first() + if token and token.allowed_rate_limit: + self.rate = token.allowed_rate_limit + # Must re-parse to update num_requests and duration + self.num_requests, self.duration = self.parse_rate(self.rate) + + return super().allow_request(request, view) diff --git a/apps/api/plane/api/views/base.py b/apps/api/plane/api/views/base.py index 2e658443018..3cb736df610 100644 --- a/apps/api/plane/api/views/base.py +++ b/apps/api/plane/api/views/base.py @@ -20,7 +20,7 @@ # Module imports from plane.db.models.api import APIToken from plane.api.middleware.api_authentication import APIKeyAuthentication -from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle +from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle, WorkspaceTokenRateThrottle from plane.utils.exception_logger import log_exception from plane.utils.paginator import BasePaginator from plane.utils.core.mixins import ReadReplicaControlMixin @@ -60,12 +60,20 @@ def get_throttles(self): api_key = self.request.headers.get("X-Api-Key") if api_key: - service_token = APIToken.objects.filter(token=api_key, is_service=True).first() + api_token = APIToken.objects.filter(token=api_key) + + service_token = api_token.filter(is_service=True).first() + + workspace_token = api_token.filter(workspace_id__isnull=False).first() if service_token: throttle_classes.append(ServiceTokenRateThrottle()) return throttle_classes + if workspace_token: + throttle_classes.append(WorkspaceTokenRateThrottle()) + return throttle_classes + throttle_classes.append(ApiKeyRateThrottle()) return throttle_classes From d21f1061138ee0e981dda2611502e9be2af950b3 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Mon, 22 Dec 2025 16:05:08 +0530 Subject: [PATCH 11/23] chore: set header --- apps/api/plane/api/rate_limit.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/api/rate_limit.py b/apps/api/plane/api/rate_limit.py index 1cc99bfd48d..299a13118a9 100644 --- a/apps/api/plane/api/rate_limit.py +++ b/apps/api/plane/api/rate_limit.py @@ -106,7 +106,21 @@ def allow_request(self, request, view): token = APIToken.objects.filter(token=api_key).only("allowed_rate_limit").first() if token and token.allowed_rate_limit: self.rate = token.allowed_rate_limit - # Must re-parse to update num_requests and duration + self.num_requests, self.duration = self.parse_rate(self.rate) - return super().allow_request(request, view) + allowed = super().allow_request(request, view) + + if allowed: + now = self.timer() + history = self.cache.get(self.key, []) + + while history and history[-1] <= now - self.duration: + history.pop() + + available = self.num_requests - len(history) + + request.META["X-RateLimit-Remaining"] = max(0, available) + request.META["X-RateLimit-Reset"] = int(now + self.duration) + + return allowed From 0a20a47b9d24490d3443272d47f72c309b76a70b Mon Sep 17 00:00:00 2001 From: b-saikrishnakanth Date: Tue, 23 Dec 2025 15:13:33 +0530 Subject: [PATCH 12/23] 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 00000000000..8d84fa2dead --- /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 d4f6aed1a6f..4f7c1615128 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 ccb9d78d378..7ba083fc661 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 8989150ca60..18ea79b3566 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 a87b18d71e5..469baefa35a 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 4e0253bd8b7..d2d9b3fb93c 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 8d4fe11e8f2..72d2aeda503 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 0a54ccc1967..161df2621b2 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 e2d305052eb..c339dd9550f 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 2c55a6a2dd7..ca2fd3f17cc 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 9610333c0e6..7497e808896 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 d41e7ecb06c..f6557bc64a7 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 a78a7b0929e..ccc29f68c21 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 00000000000..a60b05a0689 --- /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; + }); + } +} From bc52fa1cc427ca0868076a6c342bb8fb3ee47d99 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Tue, 23 Dec 2025 15:57:31 +0530 Subject: [PATCH 13/23] chore: workspace api token permission check --- .../plane/api/middleware/api_authentication.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/api/plane/api/middleware/api_authentication.py b/apps/api/plane/api/middleware/api_authentication.py index ddabb4132da..2792c8d55bf 100644 --- a/apps/api/plane/api/middleware/api_authentication.py +++ b/apps/api/plane/api/middleware/api_authentication.py @@ -1,13 +1,14 @@ # Django imports from django.utils import timezone from django.db.models import Q +from django.urls import resolve # Third party imports from rest_framework import authentication from rest_framework.exceptions import AuthenticationFailed # Module imports -from plane.db.models import APIToken +from plane.db.models import APIToken, Workspace class APIKeyAuthentication(authentication.BaseAuthentication): @@ -22,13 +23,20 @@ class APIKeyAuthentication(authentication.BaseAuthentication): def get_api_token(self, request): return request.headers.get(self.auth_header_name) - def validate_api_token(self, token): + def validate_api_token(self, token, workspace_slug): try: api_token = APIToken.objects.get( Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), token=token, is_active=True, ) + + if workspace_slug: + workspace = Workspace.objects.get(slug=workspace_slug) + + if api_token.workspace_id != workspace.id: + raise AuthenticationFailed("Given API token is not valid") + except APIToken.DoesNotExist: raise AuthenticationFailed("Given API token is not valid") @@ -38,10 +46,12 @@ def validate_api_token(self, token): return (api_token.user, api_token.token) def authenticate(self, request): + workspace_slug = resolve(request.path_info).kwargs.get("slug") + token = self.get_api_token(request=request) if not token: return None # Validate the API token - user, token = self.validate_api_token(token) + user, token = self.validate_api_token(token, workspace_slug) return user, token From 97e18f5d54186b3dc37a6eda9157a6983b2ccead Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Wed, 24 Dec 2025 19:15:27 +0530 Subject: [PATCH 14/23] fix: workspace tokens are returned in user tokens --- apps/api/plane/app/views/api/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/app/views/api/base.py b/apps/api/plane/app/views/api/base.py index 99358b7cfd0..b9306506bf3 100644 --- a/apps/api/plane/app/views/api/base.py +++ b/apps/api/plane/app/views/api/base.py @@ -36,11 +36,11 @@ def post(self, request: Request) -> Response: def get(self, request: Request, pk: Optional[str] = None) -> Response: if pk is None: - api_tokens = APIToken.objects.filter(user=request.user, is_service=False) + api_tokens = APIToken.objects.filter(user=request.user, is_service=False, workspace_id__isnull=True) serializer = APITokenReadSerializer(api_tokens, many=True) return Response(serializer.data, status=status.HTTP_200_OK) else: - api_tokens = APIToken.objects.get(user=request.user, pk=pk) + api_tokens = APIToken.objects.get(user=request.user, pk=pk, workspace_id__isnull=True) serializer = APITokenReadSerializer(api_tokens) return Response(serializer.data, status=status.HTTP_200_OK) From b031dea5a9ab9019f0d43be6871bd5a4a0028063 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Wed, 24 Dec 2025 19:58:41 +0530 Subject: [PATCH 15/23] fix: expired_at set as a read only field --- apps/api/plane/app/serializers/api.py | 15 +----------- apps/api/plane/app/views/api/workspace.py | 30 ++++++++++++++++++----- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/apps/api/plane/app/serializers/api.py b/apps/api/plane/app/serializers/api.py index 07a437d8041..2150b408837 100644 --- a/apps/api/plane/app/serializers/api.py +++ b/apps/api/plane/app/serializers/api.py @@ -6,7 +6,7 @@ # Module import from .base import BaseSerializer -from plane.db.models import APIToken, APIActivityLog, Workspace +from plane.db.models import APIToken, APIActivityLog class APITokenSerializer(BaseSerializer): @@ -22,19 +22,6 @@ class Meta: "user", ] - def create(self, validated_data): - workspace_slug = self.context.get("workspace_slug") - user = self.context.get("user") - - # Set the workspace - workspace = Workspace.objects.get(slug=workspace_slug) - validated_data["workspace"] = workspace - - # Set the user and user type - validated_data["user"] = user - validated_data["user_type"] = 1 if user.is_bot else 0 - return super().create(validated_data) - class APITokenReadSerializer(BaseSerializer): is_active = serializers.SerializerMethodField() diff --git a/apps/api/plane/app/views/api/workspace.py b/apps/api/plane/app/views/api/workspace.py index c9597d185db..80682377e5d 100644 --- a/apps/api/plane/app/views/api/workspace.py +++ b/apps/api/plane/app/views/api/workspace.py @@ -1,5 +1,7 @@ # Python import from typing import Optional +from uuid import uuid4 + # Third party from rest_framework.response import Response @@ -8,7 +10,7 @@ # Module import from plane.app.views import BaseAPIView -from plane.db.models import APIToken +from plane.db.models import APIToken, Workspace from plane.app.serializers import APITokenSerializer, APITokenReadSerializer from plane.app.permissions import WorkSpaceAdminPermission @@ -19,11 +21,27 @@ class WorkspaceAPITokenEndpoint(BaseAPIView): ] def post(self, request: Request, slug: str) -> Response: - serializer = APITokenSerializer(data=request.data, context={"workspace_slug": slug, "user": request.user}) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + label = request.data.get("label", str(uuid4().hex)) + description = request.data.get("description", "") + expired_at = request.data.get("expired_at", None) + + # Check the user type + user_type = 1 if request.user.is_bot else 0 + + workspace = Workspace.objects.get(slug=slug) + + api_token = APIToken.objects.create( + label=label, + description=description, + user=request.user, + user_type=user_type, + expired_at=expired_at, + workspace=workspace, + ) + + serializer = APITokenSerializer(api_token) + + return Response(serializer.data, status=status.HTTP_201_CREATED) def get(self, request: Request, slug: str, pk: Optional[str] = None) -> Response: if pk is None: From 466bccc9a85c5170b06e6c8d551a3048b5bb5c5d Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Fri, 26 Dec 2025 13:26:42 +0530 Subject: [PATCH 16/23] chore: added translations --- .../settings/account/api-tokens/page.tsx | 87 ++++++++----------- packages/i18n/src/locales/cs/translations.ts | 2 +- packages/i18n/src/locales/de/translations.ts | 2 +- packages/i18n/src/locales/en/translations.ts | 4 +- packages/i18n/src/locales/es/translations.ts | 2 +- packages/i18n/src/locales/fr/translations.ts | 2 +- packages/i18n/src/locales/id/translations.ts | 2 +- packages/i18n/src/locales/it/translations.ts | 2 +- packages/i18n/src/locales/ja/translations.ts | 2 +- packages/i18n/src/locales/ko/translations.ts | 2 +- packages/i18n/src/locales/pl/translations.ts | 2 +- .../i18n/src/locales/pt-BR/translations.ts | 2 +- packages/i18n/src/locales/ro/translations.ts | 2 +- packages/i18n/src/locales/ru/translations.ts | 2 +- packages/i18n/src/locales/sk/translations.ts | 2 +- .../i18n/src/locales/tr-TR/translations.ts | 2 +- packages/i18n/src/locales/ua/translations.ts | 2 +- .../i18n/src/locales/vi-VN/translations.ts | 2 +- .../i18n/src/locales/zh-CN/translations.ts | 2 +- .../i18n/src/locales/zh-TW/translations.ts | 2 +- 20 files changed, 55 insertions(+), 72 deletions(-) diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx index 82e5073936f..66fddcfb1d7 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx @@ -1,4 +1,5 @@ -import React, { useState } from "react"; +import { useState } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; // plane imports @@ -19,7 +20,7 @@ import { useWorkspace } from "@/hooks/store/use-workspace"; const apiTokenService = new APITokenService(); -function ApiTokensPage() { +const ApiTokensPage: FC = observer(function ApiTokensPage() { // states const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); // plane hooks @@ -42,67 +43,49 @@ function ApiTokensPage() { setIsCreateTokenModalOpen(false)} />
+ { + captureClick({ + elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON, + }); + setIsCreateTokenModalOpen(true); + }, + }} + /> {tokens.length > 0 ? ( <> - { - captureClick({ - elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON, - }); - setIsCreateTokenModalOpen(true); - }, - }} - /> -
- {tokens.map((token) => ( - - ))} -
+ {tokens.map((token) => ( + + ))} ) : ( -
- { captureClick({ - elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON, + elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_PAT_BUTTON, }); setIsCreateTokenModalOpen(true); }, - }} - /> - - { - captureClick({ - elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_PAT_BUTTON, - }); - setIsCreateTokenModalOpen(true); - }, - }, - ]} - align="start" - rootClassName="py-20" - /> -
+ }, + ]} + align="start" + rootClassName="py-20" + /> )}
); -} +}); -export default observer(ApiTokensPage); +export default ApiTokensPage; diff --git a/packages/i18n/src/locales/cs/translations.ts b/packages/i18n/src/locales/cs/translations.ts index 3b98f60c20a..86010532bff 100644 --- a/packages/i18n/src/locales/cs/translations.ts +++ b/packages/i18n/src/locales/cs/translations.ts @@ -1709,7 +1709,7 @@ export default { }, api_tokens: { title: "API Tokeny", - add_token: "Přidat API token", + add_token: "Přidat token přístupu k pracovnímu prostoru", create_token: "Vytvořit token", never_expires: "Nikdy neexpiruje", generate_token: "Generovat token", diff --git a/packages/i18n/src/locales/de/translations.ts b/packages/i18n/src/locales/de/translations.ts index d6b5a7d417a..544789b7dbb 100644 --- a/packages/i18n/src/locales/de/translations.ts +++ b/packages/i18n/src/locales/de/translations.ts @@ -1729,7 +1729,7 @@ export default { }, api_tokens: { title: "API-Tokens", - add_token: "API-Token hinzufügen", + add_token: "Workspace-Zugriffstoken hinzufügen", create_token: "Token erstellen", never_expires: "Läuft nie ab", generate_token: "Token generieren", diff --git a/packages/i18n/src/locales/en/translations.ts b/packages/i18n/src/locales/en/translations.ts index f6557bc64a7..294dabebbf1 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: { - title: "Personal Access Tokens", + heading: "Personal Access Tokens", description: "Generate secure API tokens to integrate your data with external systems and applications.", }, activity: { @@ -1574,7 +1574,7 @@ export default { 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", + add_token: "Add Workspace access token", create_token: "Create token", never_expires: "Never expires", generate_token: "Generate token", diff --git a/packages/i18n/src/locales/es/translations.ts b/packages/i18n/src/locales/es/translations.ts index 85807f743f2..b9f6376b529 100644 --- a/packages/i18n/src/locales/es/translations.ts +++ b/packages/i18n/src/locales/es/translations.ts @@ -1734,7 +1734,7 @@ export default { }, api_tokens: { title: "Tokens de API", - add_token: "Agregar token de API", + add_token: "Agregar token de acceso al espacio de trabajo", create_token: "Crear token", never_expires: "Nunca expira", generate_token: "Generar token", diff --git a/packages/i18n/src/locales/fr/translations.ts b/packages/i18n/src/locales/fr/translations.ts index 79673abf0e6..3dd5dd3dd0c 100644 --- a/packages/i18n/src/locales/fr/translations.ts +++ b/packages/i18n/src/locales/fr/translations.ts @@ -1732,7 +1732,7 @@ export default { }, api_tokens: { title: "Jetons API", - add_token: "Ajouter un jeton API", + add_token: "Ajouter un jeton d'accès à l'espace de travail", create_token: "Créer un jeton", never_expires: "N’expire jamais", generate_token: "Générer un jeton", diff --git a/packages/i18n/src/locales/id/translations.ts b/packages/i18n/src/locales/id/translations.ts index f49d4e8d25f..d7ea161ecdd 100644 --- a/packages/i18n/src/locales/id/translations.ts +++ b/packages/i18n/src/locales/id/translations.ts @@ -1720,7 +1720,7 @@ export default { }, api_tokens: { title: "Token API", - add_token: "Tambah token API", + add_token: "Tambah token akses ruang kerja", create_token: "Buat token", never_expires: "Tidak pernah kedaluwarsa", generate_token: "Hasilkan token", diff --git a/packages/i18n/src/locales/it/translations.ts b/packages/i18n/src/locales/it/translations.ts index 0506e30693e..1d60534d266 100644 --- a/packages/i18n/src/locales/it/translations.ts +++ b/packages/i18n/src/locales/it/translations.ts @@ -1724,7 +1724,7 @@ export default { }, api_tokens: { title: "Token API", - add_token: "Aggiungi token API", + add_token: "Aggiungi token di accesso allo spazio di lavoro", create_token: "Crea token", never_expires: "Non scade mai", generate_token: "Genera token", diff --git a/packages/i18n/src/locales/ja/translations.ts b/packages/i18n/src/locales/ja/translations.ts index 80ae283c79e..4d616a29773 100644 --- a/packages/i18n/src/locales/ja/translations.ts +++ b/packages/i18n/src/locales/ja/translations.ts @@ -1710,7 +1710,7 @@ export default { }, api_tokens: { title: "APIトークン", - add_token: "APIトークンを追加", + add_token: "ワークスペースアクセストークンを追加", create_token: "トークンを作成", never_expires: "無期限", generate_token: "トークンを生成", diff --git a/packages/i18n/src/locales/ko/translations.ts b/packages/i18n/src/locales/ko/translations.ts index 11b6fa1c34c..f7c5c349f9c 100644 --- a/packages/i18n/src/locales/ko/translations.ts +++ b/packages/i18n/src/locales/ko/translations.ts @@ -1703,7 +1703,7 @@ export default { }, api_tokens: { title: "API 토큰", - add_token: "API 토큰 추가", + add_token: "워크스페이스 액세스 토큰 추가", create_token: "토큰 생성", never_expires: "만료되지 않음", generate_token: "토큰 생성", diff --git a/packages/i18n/src/locales/pl/translations.ts b/packages/i18n/src/locales/pl/translations.ts index be091cbcf5b..edad1075a3a 100644 --- a/packages/i18n/src/locales/pl/translations.ts +++ b/packages/i18n/src/locales/pl/translations.ts @@ -1712,7 +1712,7 @@ export default { }, api_tokens: { title: "Tokeny API", - add_token: "Dodaj token API", + add_token: "Dodaj token dostępu do obszaru roboczego", create_token: "Utwórz token", never_expires: "Nigdy nie wygasa", generate_token: "Wygeneruj token", diff --git a/packages/i18n/src/locales/pt-BR/translations.ts b/packages/i18n/src/locales/pt-BR/translations.ts index d926cdbe186..cc6f08c7294 100644 --- a/packages/i18n/src/locales/pt-BR/translations.ts +++ b/packages/i18n/src/locales/pt-BR/translations.ts @@ -1732,7 +1732,7 @@ export default { }, api_tokens: { title: "Tokens de API", - add_token: "Adicionar token de API", + add_token: "Adicionar token de acesso ao espaço de trabalho", create_token: "Criar token", never_expires: "Nunca expira", generate_token: "Gerar token", diff --git a/packages/i18n/src/locales/ro/translations.ts b/packages/i18n/src/locales/ro/translations.ts index fc4f0430243..c60c1417838 100644 --- a/packages/i18n/src/locales/ro/translations.ts +++ b/packages/i18n/src/locales/ro/translations.ts @@ -1724,7 +1724,7 @@ export default { }, api_tokens: { title: "Chei secrete API", - add_token: "Adaugă cheie secretă API", + add_token: "Adaugă token de acces la spațiul de lucru", create_token: "Creează cheie secretă", never_expires: "Nu expiră niciodată", generate_token: "Generează cheie secretă", diff --git a/packages/i18n/src/locales/ru/translations.ts b/packages/i18n/src/locales/ru/translations.ts index ae823d17fed..49fa10b2333 100644 --- a/packages/i18n/src/locales/ru/translations.ts +++ b/packages/i18n/src/locales/ru/translations.ts @@ -1714,7 +1714,7 @@ export default { }, api_tokens: { title: "API-токены", - add_token: "Добавить токен", + add_token: "Добавить токен доступа к рабочему пространству", create_token: "Создать токен", never_expires: "Бессрочный", generate_token: "Сгенерировать токен", diff --git a/packages/i18n/src/locales/sk/translations.ts b/packages/i18n/src/locales/sk/translations.ts index fb295de3f4b..225c0680970 100644 --- a/packages/i18n/src/locales/sk/translations.ts +++ b/packages/i18n/src/locales/sk/translations.ts @@ -1711,7 +1711,7 @@ export default { }, api_tokens: { title: "API Tokeny", - add_token: "Pridať API token", + add_token: "Pridať token prístupu k pracovnému priestoru", create_token: "Vytvoriť token", never_expires: "Nikdy neexpiruje", generate_token: "Generovať token", diff --git a/packages/i18n/src/locales/tr-TR/translations.ts b/packages/i18n/src/locales/tr-TR/translations.ts index f7d987abb3b..48e7b283a86 100644 --- a/packages/i18n/src/locales/tr-TR/translations.ts +++ b/packages/i18n/src/locales/tr-TR/translations.ts @@ -1720,7 +1720,7 @@ export default { }, api_tokens: { title: "API Token'ları", - add_token: "API Token'ı ekle", + add_token: "Çalışma alanı erişim token'ı ekle", create_token: "Token oluştur", never_expires: "Süresi dolmaz", generate_token: "Token oluştur", diff --git a/packages/i18n/src/locales/ua/translations.ts b/packages/i18n/src/locales/ua/translations.ts index 04878fa054c..eef6de6e48a 100644 --- a/packages/i18n/src/locales/ua/translations.ts +++ b/packages/i18n/src/locales/ua/translations.ts @@ -1715,7 +1715,7 @@ export default { }, api_tokens: { title: "API токени", - add_token: "Додати API токен", + add_token: "Додати токен доступу до робочого простору", create_token: "Створити токен", never_expires: "Ніколи не спливає", generate_token: "Згенерувати токен", diff --git a/packages/i18n/src/locales/vi-VN/translations.ts b/packages/i18n/src/locales/vi-VN/translations.ts index c913d25b09a..c1d3aa74a08 100644 --- a/packages/i18n/src/locales/vi-VN/translations.ts +++ b/packages/i18n/src/locales/vi-VN/translations.ts @@ -1720,7 +1720,7 @@ export default { }, api_tokens: { title: "Token API", - add_token: "Thêm token API", + add_token: "Thêm token truy cập không gian làm việc", create_token: "Tạo token", never_expires: "Không bao giờ hết hạn", generate_token: "Tạo token", diff --git a/packages/i18n/src/locales/zh-CN/translations.ts b/packages/i18n/src/locales/zh-CN/translations.ts index 4d2a520c2ea..bbab7a60b53 100644 --- a/packages/i18n/src/locales/zh-CN/translations.ts +++ b/packages/i18n/src/locales/zh-CN/translations.ts @@ -1691,7 +1691,7 @@ export default { }, api_tokens: { title: "API 令牌", - add_token: "添加 API 令牌", + add_token: "添加工作区访问令牌", create_token: "创建令牌", never_expires: "永不过期", generate_token: "生成令牌", diff --git a/packages/i18n/src/locales/zh-TW/translations.ts b/packages/i18n/src/locales/zh-TW/translations.ts index d63b1d06eee..6eda8c92df5 100644 --- a/packages/i18n/src/locales/zh-TW/translations.ts +++ b/packages/i18n/src/locales/zh-TW/translations.ts @@ -1692,7 +1692,7 @@ export default { }, api_tokens: { title: "API 權杖", - add_token: "新增 API 權杖", + add_token: "新增工作區存取權杖", create_token: "建立權杖", never_expires: "永不過期", generate_token: "產生權杖", From 26eba89d1b0a7f6406f264a2609eaf9a06de8877 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Mon, 29 Dec 2025 15:22:35 +0530 Subject: [PATCH 17/23] fix: error handling for APIToken not exist --- apps/api/plane/app/views/api/workspace.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/api/plane/app/views/api/workspace.py b/apps/api/plane/app/views/api/workspace.py index 80682377e5d..8e899431724 100644 --- a/apps/api/plane/app/views/api/workspace.py +++ b/apps/api/plane/app/views/api/workspace.py @@ -45,15 +45,27 @@ def post(self, request: Request, slug: str) -> Response: def get(self, request: Request, slug: str, pk: Optional[str] = None) -> Response: if pk is None: - api_tokens = APIToken.objects.filter(workspace__slug=slug, is_service=False, user=request.user) + try: + api_tokens = APIToken.objects.filter(workspace__slug=slug, is_service=False, user=request.user) + except APIToken.DoesNotExist: + return Response({"error": "API token does not exist"}, status=status.HTTP_404_NOT_FOUND) + serializer = APITokenReadSerializer(api_tokens, many=True) return Response(serializer.data, status=status.HTTP_200_OK) else: - api_tokens = APIToken.objects.get(workspace__slug=slug, pk=pk, user=request.user) + try: + api_tokens = APIToken.objects.get(workspace__slug=slug, pk=pk, user=request.user) + except APIToken.DoesNotExist: + return Response({"error": "API token does not exist"}, status=status.HTTP_404_NOT_FOUND) + serializer = APITokenReadSerializer(api_tokens) return Response(serializer.data, status=status.HTTP_200_OK) def delete(self, request: Request, slug: str, pk: str) -> Response: - api_token = APIToken.objects.get(workspace__slug=slug, pk=pk, is_service=False, user=request.user) + try: + api_token = APIToken.objects.get(workspace__slug=slug, pk=pk, is_service=False, user=request.user) + except APIToken.DoesNotExist: + return Response({"error": "API token does not exist"}, status=status.HTTP_404_NOT_FOUND) + api_token.delete() return Response(status=status.HTTP_204_NO_CONTENT) From 0fd1f66e3ec443e2b323504ceccdd1d3dd8f87db Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Mon, 29 Dec 2025 15:27:37 +0530 Subject: [PATCH 18/23] fix: error handling for APIToken not exist --- apps/api/plane/app/views/api/workspace.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/api/plane/app/views/api/workspace.py b/apps/api/plane/app/views/api/workspace.py index 8e899431724..a0a244eb85a 100644 --- a/apps/api/plane/app/views/api/workspace.py +++ b/apps/api/plane/app/views/api/workspace.py @@ -45,10 +45,7 @@ def post(self, request: Request, slug: str) -> Response: def get(self, request: Request, slug: str, pk: Optional[str] = None) -> Response: if pk is None: - try: - api_tokens = APIToken.objects.filter(workspace__slug=slug, is_service=False, user=request.user) - except APIToken.DoesNotExist: - return Response({"error": "API token does not exist"}, status=status.HTTP_404_NOT_FOUND) + api_tokens = APIToken.objects.filter(workspace__slug=slug, is_service=False, user=request.user) serializer = APITokenReadSerializer(api_tokens, many=True) return Response(serializer.data, status=status.HTTP_200_OK) From a2fcc06d96dd4890c49a14edd74da4906184edaf Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Mon, 29 Dec 2025 17:52:38 +0530 Subject: [PATCH 19/23] chore: added is_subscribed_to_changelog field --- apps/api/plane/db/migrations/0113_webhook_version.py | 5 +++++ apps/api/plane/db/models/user.py | 1 + 2 files changed, 6 insertions(+) diff --git a/apps/api/plane/db/migrations/0113_webhook_version.py b/apps/api/plane/db/migrations/0113_webhook_version.py index 229174430fd..3be3120eb43 100644 --- a/apps/api/plane/db/migrations/0113_webhook_version.py +++ b/apps/api/plane/db/migrations/0113_webhook_version.py @@ -47,6 +47,11 @@ class Migration(migrations.Migration): model_name='apitoken', name='allowed_rate_limit', field=models.CharField(default='60/min', max_length=255), + ), + migrations.AddField( + model_name='profile', + name='is_subscribed_to_changelog', + field=models.BooleanField(default=False), ), migrations.RunPython(populate_product_tour, reverse_code=migrations.RunPython.noop), ] diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py index 2a4f42f8fcc..b0f571be9c8 100644 --- a/apps/api/plane/db/models/user.py +++ b/apps/api/plane/db/models/user.py @@ -238,6 +238,7 @@ class Profile(TimeAuditModel): # marketing has_marketing_email_consent = models.BooleanField(default=False) + is_subscribed_to_changelog = models.BooleanField(default=False) class Meta: verbose_name = "Profile" From 3a5d01c80af29c2437ab60bcf5b1b094f6f40f36 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Tue, 30 Dec 2025 16:51:16 +0530 Subject: [PATCH 20/23] chore: updated routes and headings --- .../(workspace)/{api-tokens => access-tokens}/page.tsx | 2 +- .../(settings)/settings/(workspace)/sidebar.tsx | 2 +- apps/web/app/routes/core.ts | 4 ++-- packages/constants/src/settings.ts | 2 +- packages/constants/src/workspace.ts | 10 +++++----- packages/i18n/src/locales/cs/translations.ts | 4 +++- packages/i18n/src/locales/de/translations.ts | 5 ++++- packages/i18n/src/locales/en/translations.ts | 10 +++++----- packages/i18n/src/locales/es/translations.ts | 4 +++- packages/i18n/src/locales/fr/translations.ts | 5 ++++- packages/i18n/src/locales/id/translations.ts | 4 +++- packages/i18n/src/locales/it/translations.ts | 4 +++- packages/i18n/src/locales/ja/translations.ts | 4 +++- packages/i18n/src/locales/ko/translations.ts | 4 +++- packages/i18n/src/locales/pl/translations.ts | 4 +++- packages/i18n/src/locales/pt-BR/translations.ts | 4 +++- packages/i18n/src/locales/ro/translations.ts | 4 +++- packages/i18n/src/locales/ru/translations.ts | 5 ++++- packages/i18n/src/locales/sk/translations.ts | 4 +++- packages/i18n/src/locales/tr-TR/translations.ts | 5 ++++- packages/i18n/src/locales/ua/translations.ts | 4 +++- packages/i18n/src/locales/vi-VN/translations.ts | 4 +++- packages/i18n/src/locales/zh-CN/translations.ts | 4 +++- packages/i18n/src/locales/zh-TW/translations.ts | 4 +++- 24 files changed, 73 insertions(+), 33 deletions(-) rename apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/{api-tokens => access-tokens}/page.tsx (99%) diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/api-tokens/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/access-tokens/page.tsx similarity index 99% rename from apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/api-tokens/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/access-tokens/page.tsx index 8d84fa2dead..8038bd21e51 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/api-tokens/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/access-tokens/page.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; // plane imports 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 4f7c1615128..2c4fcc9b435 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -25,7 +25,7 @@ export const WORKSPACE_SETTINGS_ICONS: Record pathname === `${baseUrl}/settings/webhooks/`, }, - "api-tokens": { - key: "api-tokens", + "access-tokens": { + key: "access-tokens", i18n_label: "workspace_settings.settings.api_tokens.title", - href: `/settings/api-tokens`, + href: `/settings/access-tokens`, access: [EUserWorkspaceRoles.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/access-tokens/`, }, }; @@ -132,7 +132,7 @@ export const WORKSPACE_SETTINGS_LINKS: { WORKSPACE_SETTINGS["billing-and-plans"], WORKSPACE_SETTINGS["export"], WORKSPACE_SETTINGS["webhooks"], - WORKSPACE_SETTINGS["api-tokens"], + WORKSPACE_SETTINGS["access-tokens"], ]; export const ROLE = { diff --git a/packages/i18n/src/locales/cs/translations.ts b/packages/i18n/src/locales/cs/translations.ts index 86010532bff..756f5c3b20a 100644 --- a/packages/i18n/src/locales/cs/translations.ts +++ b/packages/i18n/src/locales/cs/translations.ts @@ -1708,8 +1708,10 @@ export default { }, }, api_tokens: { + heading: "API Tokeny", + description: "Generujte bezpečné API tokeny pro integraci vašich dat s externími systémy a aplikacemi.", title: "API Tokeny", - add_token: "Přidat token přístupu k pracovnímu prostoru", + add_token: "Přidat token přístupu", create_token: "Vytvořit token", never_expires: "Nikdy neexpiruje", generate_token: "Generovat token", diff --git a/packages/i18n/src/locales/de/translations.ts b/packages/i18n/src/locales/de/translations.ts index 544789b7dbb..d92f63274e5 100644 --- a/packages/i18n/src/locales/de/translations.ts +++ b/packages/i18n/src/locales/de/translations.ts @@ -1728,8 +1728,11 @@ export default { }, }, api_tokens: { + heading: "API-Tokens", + description: + "Generieren Sie sichere API-Tokens, um Ihre Daten mit externen Systemen und Anwendungen zu integrieren.", title: "API-Tokens", - add_token: "Workspace-Zugriffstoken hinzufügen", + add_token: "Zugriffstoken hinzufügen", create_token: "Token erstellen", never_expires: "Läuft nie ab", generate_token: "Token generieren", diff --git a/packages/i18n/src/locales/en/translations.ts b/packages/i18n/src/locales/en/translations.ts index 4b328d7ee17..af541c7b40a 100644 --- a/packages/i18n/src/locales/en/translations.ts +++ b/packages/i18n/src/locales/en/translations.ts @@ -1571,16 +1571,16 @@ export default { }, }, api_tokens: { - heading: "Workspace Access Tokens", + heading: "Access Tokens", description: "Generate secure API tokens to integrate your data with external systems and applications.", - title: "Workspace Access Tokens", - add_token: "Add Workspace access token", + title: "Access Tokens", + add_token: "Add access token", create_token: "Create token", never_expires: "Never expires", generate_token: "Generate token", generating: "Generating", delete: { - title: "Delete personal access token", + title: "Delete access token", description: "Any application using this token will no longer have the access to Plane data. This action cannot be undone.", success: { @@ -1596,7 +1596,7 @@ export default { }, empty_state: { api_tokens: { - title: "No personal access tokens created", + title: "No access tokens created", description: "Plane APIs can be used to integrate your data in Plane with any external system. Create a token to get started.", }, diff --git a/packages/i18n/src/locales/es/translations.ts b/packages/i18n/src/locales/es/translations.ts index b9f6376b529..f291f9895af 100644 --- a/packages/i18n/src/locales/es/translations.ts +++ b/packages/i18n/src/locales/es/translations.ts @@ -1733,8 +1733,10 @@ export default { }, }, api_tokens: { + heading: "Tokens de API", + description: "Genere tokens de API seguros para integrar sus datos con sistemas y aplicaciones externos.", title: "Tokens de API", - add_token: "Agregar token de acceso al espacio de trabajo", + add_token: "Agregar token de acceso", create_token: "Crear token", never_expires: "Nunca expira", generate_token: "Generar token", diff --git a/packages/i18n/src/locales/fr/translations.ts b/packages/i18n/src/locales/fr/translations.ts index 3dd5dd3dd0c..664e4ee6635 100644 --- a/packages/i18n/src/locales/fr/translations.ts +++ b/packages/i18n/src/locales/fr/translations.ts @@ -1731,8 +1731,11 @@ export default { }, }, api_tokens: { + heading: "Jetons API", + description: + "Générez des jetons API sécurisés pour intégrer vos données avec des systèmes et applications externes.", title: "Jetons API", - add_token: "Ajouter un jeton d'accès à l'espace de travail", + add_token: "Ajouter un jeton d'accès", create_token: "Créer un jeton", never_expires: "N’expire jamais", generate_token: "Générer un jeton", diff --git a/packages/i18n/src/locales/id/translations.ts b/packages/i18n/src/locales/id/translations.ts index d7ea161ecdd..f918359cd26 100644 --- a/packages/i18n/src/locales/id/translations.ts +++ b/packages/i18n/src/locales/id/translations.ts @@ -1719,8 +1719,10 @@ export default { }, }, api_tokens: { + heading: "Token API", + description: "Buat token API yang aman untuk mengintegrasikan data Anda dengan sistem dan aplikasi eksternal.", title: "Token API", - add_token: "Tambah token akses ruang kerja", + add_token: "Tambah token akses", create_token: "Buat token", never_expires: "Tidak pernah kedaluwarsa", generate_token: "Hasilkan token", diff --git a/packages/i18n/src/locales/it/translations.ts b/packages/i18n/src/locales/it/translations.ts index 1d60534d266..6e4e1eb22af 100644 --- a/packages/i18n/src/locales/it/translations.ts +++ b/packages/i18n/src/locales/it/translations.ts @@ -1723,8 +1723,10 @@ export default { }, }, api_tokens: { + heading: "Token API", + description: "Genera token API sicuri per integrare i tuoi dati con sistemi e applicazioni esterne.", title: "Token API", - add_token: "Aggiungi token di accesso allo spazio di lavoro", + add_token: "Aggiungi token di accesso", create_token: "Crea token", never_expires: "Non scade mai", generate_token: "Genera token", diff --git a/packages/i18n/src/locales/ja/translations.ts b/packages/i18n/src/locales/ja/translations.ts index 4d616a29773..d8c8732e876 100644 --- a/packages/i18n/src/locales/ja/translations.ts +++ b/packages/i18n/src/locales/ja/translations.ts @@ -1709,8 +1709,10 @@ export default { }, }, api_tokens: { + heading: "APIトークン", + description: "セキュアなAPIトークンを生成して、データを外部システムやアプリケーションと統合します。", title: "APIトークン", - add_token: "ワークスペースアクセストークンを追加", + add_token: "アクセストークンを追加", create_token: "トークンを作成", never_expires: "無期限", generate_token: "トークンを生成", diff --git a/packages/i18n/src/locales/ko/translations.ts b/packages/i18n/src/locales/ko/translations.ts index f7c5c349f9c..c7b2a89100a 100644 --- a/packages/i18n/src/locales/ko/translations.ts +++ b/packages/i18n/src/locales/ko/translations.ts @@ -1702,8 +1702,10 @@ export default { }, }, api_tokens: { + heading: "API 토큰", + description: "보안 API 토큰을 생성하여 데이터를 외부 시스템 및 애플리케이션과 통합합니다.", title: "API 토큰", - add_token: "워크스페이스 액세스 토큰 추가", + add_token: "액세스 토큰 추가", create_token: "토큰 생성", never_expires: "만료되지 않음", generate_token: "토큰 생성", diff --git a/packages/i18n/src/locales/pl/translations.ts b/packages/i18n/src/locales/pl/translations.ts index edad1075a3a..19311219b8e 100644 --- a/packages/i18n/src/locales/pl/translations.ts +++ b/packages/i18n/src/locales/pl/translations.ts @@ -1711,8 +1711,10 @@ export default { }, }, api_tokens: { + heading: "Tokeny API", + description: "Generuj bezpieczne tokeny API, aby integrować swoje dane z zewnętrznymi systemami i aplikacjami.", title: "Tokeny API", - add_token: "Dodaj token dostępu do obszaru roboczego", + add_token: "Dodaj token dostępu", create_token: "Utwórz token", never_expires: "Nigdy nie wygasa", generate_token: "Wygeneruj token", diff --git a/packages/i18n/src/locales/pt-BR/translations.ts b/packages/i18n/src/locales/pt-BR/translations.ts index cc6f08c7294..3e22e4b1f5e 100644 --- a/packages/i18n/src/locales/pt-BR/translations.ts +++ b/packages/i18n/src/locales/pt-BR/translations.ts @@ -1731,8 +1731,10 @@ export default { }, }, api_tokens: { + heading: "Tokens de API", + description: "Gere tokens de API seguros para integrar seus dados com sistemas e aplicativos externos.", title: "Tokens de API", - add_token: "Adicionar token de acesso ao espaço de trabalho", + add_token: "Adicionar token de acesso", create_token: "Criar token", never_expires: "Nunca expira", generate_token: "Gerar token", diff --git a/packages/i18n/src/locales/ro/translations.ts b/packages/i18n/src/locales/ro/translations.ts index c60c1417838..5f39f2dd479 100644 --- a/packages/i18n/src/locales/ro/translations.ts +++ b/packages/i18n/src/locales/ro/translations.ts @@ -1723,8 +1723,10 @@ export default { }, }, api_tokens: { + heading: "Chei secrete API", + description: "Generează chei secrete API sigure pentru a integra datele tale cu sisteme și aplicații externe.", title: "Chei secrete API", - add_token: "Adaugă token de acces la spațiul de lucru", + add_token: "Adaugă token de acces", create_token: "Creează cheie secretă", never_expires: "Nu expiră niciodată", generate_token: "Generează cheie secretă", diff --git a/packages/i18n/src/locales/ru/translations.ts b/packages/i18n/src/locales/ru/translations.ts index 49fa10b2333..e06dcbed0f0 100644 --- a/packages/i18n/src/locales/ru/translations.ts +++ b/packages/i18n/src/locales/ru/translations.ts @@ -1713,8 +1713,11 @@ export default { }, }, api_tokens: { + heading: "API-токены", + description: + "Создавайте безопасные API-токены для интеграции ваших данных с внешними системами и приложениями.", title: "API-токены", - add_token: "Добавить токен доступа к рабочему пространству", + add_token: "Добавить токен доступа", create_token: "Создать токен", never_expires: "Бессрочный", generate_token: "Сгенерировать токен", diff --git a/packages/i18n/src/locales/sk/translations.ts b/packages/i18n/src/locales/sk/translations.ts index 225c0680970..15c2bc020f1 100644 --- a/packages/i18n/src/locales/sk/translations.ts +++ b/packages/i18n/src/locales/sk/translations.ts @@ -1710,8 +1710,10 @@ export default { }, }, api_tokens: { + heading: "API Tokeny", + description: "Generujte bezpečné API tokeny na integráciu vašich dát s externými systémami a aplikáciami.", title: "API Tokeny", - add_token: "Pridať token prístupu k pracovnému priestoru", + add_token: "Pridať token prístupu", create_token: "Vytvoriť token", never_expires: "Nikdy neexpiruje", generate_token: "Generovať token", diff --git a/packages/i18n/src/locales/tr-TR/translations.ts b/packages/i18n/src/locales/tr-TR/translations.ts index 48e7b283a86..bcd220e9f91 100644 --- a/packages/i18n/src/locales/tr-TR/translations.ts +++ b/packages/i18n/src/locales/tr-TR/translations.ts @@ -1719,8 +1719,11 @@ export default { }, }, api_tokens: { + heading: "API Token'ları", + description: + "Verilerinizi harici sistemler ve uygulamalarla entegre etmek için güvenli API token'ları oluşturun.", title: "API Token'ları", - add_token: "Çalışma alanı erişim token'ı ekle", + add_token: "Erişim token'ı ekle", create_token: "Token oluştur", never_expires: "Süresi dolmaz", generate_token: "Token oluştur", diff --git a/packages/i18n/src/locales/ua/translations.ts b/packages/i18n/src/locales/ua/translations.ts index eef6de6e48a..fe012ade3b3 100644 --- a/packages/i18n/src/locales/ua/translations.ts +++ b/packages/i18n/src/locales/ua/translations.ts @@ -1714,8 +1714,10 @@ export default { }, }, api_tokens: { + heading: "API токени", + description: "Створюйте безпечні API токени для інтеграції ваших даних із зовнішніми системами та додатками.", title: "API токени", - add_token: "Додати токен доступу до робочого простору", + add_token: "Додати токен доступу", create_token: "Створити токен", never_expires: "Ніколи не спливає", generate_token: "Згенерувати токен", diff --git a/packages/i18n/src/locales/vi-VN/translations.ts b/packages/i18n/src/locales/vi-VN/translations.ts index c1d3aa74a08..48a3acb3e03 100644 --- a/packages/i18n/src/locales/vi-VN/translations.ts +++ b/packages/i18n/src/locales/vi-VN/translations.ts @@ -1719,8 +1719,10 @@ export default { }, }, api_tokens: { + heading: "Token API", + description: "Tạo token API bảo mật để tích hợp dữ liệu của bạn với các hệ thống và ứng dụng bên ngoài.", title: "Token API", - add_token: "Thêm token truy cập không gian làm việc", + add_token: "Thêm token truy cập", create_token: "Tạo token", never_expires: "Không bao giờ hết hạn", generate_token: "Tạo token", diff --git a/packages/i18n/src/locales/zh-CN/translations.ts b/packages/i18n/src/locales/zh-CN/translations.ts index bbab7a60b53..ef7d65ec687 100644 --- a/packages/i18n/src/locales/zh-CN/translations.ts +++ b/packages/i18n/src/locales/zh-CN/translations.ts @@ -1690,8 +1690,10 @@ export default { }, }, api_tokens: { + heading: "API 令牌", + description: "生成安全的 API 令牌,将您的数据与外部系统和应用程序集成。", title: "API 令牌", - add_token: "添加工作区访问令牌", + add_token: "添加访问令牌", create_token: "创建令牌", never_expires: "永不过期", generate_token: "生成令牌", diff --git a/packages/i18n/src/locales/zh-TW/translations.ts b/packages/i18n/src/locales/zh-TW/translations.ts index 6eda8c92df5..dcc18b30196 100644 --- a/packages/i18n/src/locales/zh-TW/translations.ts +++ b/packages/i18n/src/locales/zh-TW/translations.ts @@ -1691,8 +1691,10 @@ export default { }, }, api_tokens: { + heading: "API 權杖", + description: "產生安全的 API 權杖,將您的資料與外部系統和應用程式整合。", title: "API 權杖", - add_token: "新增工作區存取權杖", + add_token: "新增存取權杖", create_token: "建立權杖", never_expires: "永不過期", generate_token: "產生權杖", From d3911dc59f3abfc31b09c5bce951c866f817286d Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Fri, 2 Jan 2026 13:03:10 +0530 Subject: [PATCH 21/23] chore: removed event capture --- .../settings/(workspace)/access-tokens/page.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/access-tokens/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/access-tokens/page.tsx index 8038bd21e51..65289b5fa6e 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/access-tokens/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/access-tokens/page.tsx @@ -2,7 +2,7 @@ import { 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 { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { EmptyStateCompact } from "@plane/propel/empty-state"; import { WorkspaceAPITokenService } from "@plane/services"; @@ -16,8 +16,6 @@ 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"; @@ -69,9 +67,6 @@ function ApiTokensPage({ params }: Route.ComponentProps) { button={{ label: t("workspace_settings.settings.api_tokens.add_token"), onClick: () => { - captureClick({ - elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON, - }); setIsCreateTokenModalOpen(true); }, }} @@ -95,9 +90,6 @@ function ApiTokensPage({ params }: Route.ComponentProps) { { label: t("settings_empty_state.tokens.cta_primary"), onClick: () => { - captureClick({ - elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_PAT_BUTTON, - }); setIsCreateTokenModalOpen(true); }, }, From 03e2c5a8d8cd21e618ed5bd78a7b1cf2c85a2404 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Fri, 2 Jan 2026 15:43:54 +0530 Subject: [PATCH 22/23] fix: error handling --- apps/api/plane/api/middleware/api_authentication.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/plane/api/middleware/api_authentication.py b/apps/api/plane/api/middleware/api_authentication.py index 2792c8d55bf..87fdce7657e 100644 --- a/apps/api/plane/api/middleware/api_authentication.py +++ b/apps/api/plane/api/middleware/api_authentication.py @@ -31,7 +31,8 @@ def validate_api_token(self, token, workspace_slug): is_active=True, ) - if workspace_slug: + # If the api token has workspace_id, then check if it matches the workspace_slug + if api_token.workspace_id and workspace_slug: workspace = Workspace.objects.get(slug=workspace_slug) if api_token.workspace_id != workspace.id: From f1ae07e5eaf1ff69f4fd5f121847a19e00be12b4 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Fri, 2 Jan 2026 16:32:10 +0530 Subject: [PATCH 23/23] chore: Resolver404 error handling --- apps/api/plane/api/middleware/api_authentication.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/api/middleware/api_authentication.py b/apps/api/plane/api/middleware/api_authentication.py index 87fdce7657e..9ca0903542b 100644 --- a/apps/api/plane/api/middleware/api_authentication.py +++ b/apps/api/plane/api/middleware/api_authentication.py @@ -1,7 +1,7 @@ # Django imports from django.utils import timezone from django.db.models import Q -from django.urls import resolve +from django.urls import resolve, Resolver404 # Third party imports from rest_framework import authentication @@ -47,7 +47,10 @@ def validate_api_token(self, token, workspace_slug): return (api_token.user, api_token.token) def authenticate(self, request): - workspace_slug = resolve(request.path_info).kwargs.get("slug") + try: + workspace_slug = resolve(request.path_info).kwargs.get("slug") + except Resolver404: + workspace_slug = None token = self.get_api_token(request=request) if not token: