diff --git a/apps/api/plane/api/middleware/api_authentication.py b/apps/api/plane/api/middleware/api_authentication.py index abd81398573..48b56e6d114 100644 --- a/apps/api/plane/api/middleware/api_authentication.py +++ b/apps/api/plane/api/middleware/api_authentication.py @@ -5,13 +5,14 @@ # Django imports from django.utils import timezone from django.db.models import Q +from django.urls import resolve, Resolver404 # 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): @@ -26,13 +27,21 @@ 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 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: + raise AuthenticationFailed("Given API token is not valid") + except APIToken.DoesNotExist: raise AuthenticationFailed("Given API token is not valid") @@ -42,10 +51,15 @@ def validate_api_token(self, token): return (api_token.user, api_token.token) def authenticate(self, request): + 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: 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 diff --git a/apps/api/plane/api/rate_limit.py b/apps/api/plane/api/rate_limit.py index 33df895cbfb..643b858bc76 100644 --- a/apps/api/plane/api/rate_limit.py +++ b/apps/api/plane/api/rate_limit.py @@ -7,6 +7,7 @@ # Third party imports from rest_framework.throttling import SimpleRateThrottle +from plane.db.models import APIToken class ApiKeyRateThrottle(SimpleRateThrottle): @@ -89,3 +90,41 @@ 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 + + self.num_requests, self.duration = self.parse_rate(self.rate) + + 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 diff --git a/apps/api/plane/api/views/base.py b/apps/api/plane/api/views/base.py index fc65e7abdcf..0afbd68f5ea 100644 --- a/apps/api/plane/api/views/base.py +++ b/apps/api/plane/api/views/base.py @@ -24,7 +24,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 @@ -64,12 +64,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 diff --git a/apps/api/plane/app/serializers/api.py b/apps/api/plane/app/serializers/api.py index 05c6198f594..969184f6fe1 100644 --- a/apps/api/plane/app/serializers/api.py +++ b/apps/api/plane/app/serializers/api.py @@ -2,10 +2,15 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +# 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 -from rest_framework import serializers -from django.utils import timezone class APITokenSerializer(BaseSerializer): diff --git a/apps/api/plane/app/urls/api.py b/apps/api/plane/app/urls/api.py index eedf18ccc3d..482e3601584 100644 --- a/apps/api/plane/app/urls/api.py +++ b/apps/api/plane/app/urls/api.py @@ -3,7 +3,7 @@ # See the LICENSE file for details. from django.urls import path -from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint +from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint, WorkspaceAPITokenEndpoint urlpatterns = [ # API Tokens @@ -22,5 +22,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 baa6661b9cc..31b9430bfbb 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -165,7 +165,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 f2abc1a2dec..7ebc64c360f 100644 --- a/apps/api/plane/app/views/api.py +++ b/apps/api/plane/app/views/api/base.py @@ -12,10 +12,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): @@ -41,11 +40,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) @@ -61,28 +60,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..a0a244eb85a --- /dev/null +++ b/apps/api/plane/app/views/api/workspace.py @@ -0,0 +1,68 @@ +# Python import +from typing import Optional +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 plane.app.views import BaseAPIView +from plane.db.models import APIToken, Workspace +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: + 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: + 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: + 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: + 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) 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 new file mode 100644 index 00000000000..65289b5fa6e --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/access-tokens/page.tsx @@ -0,0 +1,109 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } 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"; +// 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} + /> + { + setIsCreateTokenModalOpen(true); + }, + }} + /> + {tokens.length > 0 ? ( +
+
+ {tokens.map((token) => ( + + ))} +
+
+ ) : ( +
+
+ { + setIsCreateTokenModalOpen(true); + }, + }, + ]} + align="start" + rootClassName="py-20" + /> +
+
+ )} +
+ )} +
+ ); +} + +export default observer(ApiTokensPage); diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts index c9c82bd2475..30f4913ee82 100644 --- a/apps/web/app/routes/core.ts +++ b/apps/web/app/routes/core.ts @@ -282,6 +282,10 @@ export const coreRoutes: RouteConfigEntry[] = [ ":workspaceSlug/settings/webhooks/:webhookId", "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx" ), + route( + ":workspaceSlug/settings/access-tokens", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/access-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 d2c9d653a4b..a5d91699d31 100644 --- a/apps/web/core/components/api-token/delete-token-modal.tsx +++ b/apps/web/core/components/api-token/delete-token-modal.tsx @@ -9,23 +9,25 @@ import { mutate } from "swr"; // types 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"; 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 @@ -39,8 +41,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, @@ -49,11 +54,10 @@ 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 ); - handleClose(); setDeleteLoading(false); }) 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 9ea6055daf2..b9ca6a2648b 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 @@ -8,12 +8,12 @@ import { useState } from "react"; import { mutate } from "swr"; // plane imports 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"; // local imports import { CreateApiTokenForm } from "./form"; import { GeneratedTokenDetails } from "./generated-token-details"; @@ -21,13 +21,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); @@ -54,14 +56,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; @@ -76,7 +78,6 @@ export function CreateApiTokenModal(props: Props) { title: "Error!", message: err.message || err.detail, }); - 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 8b40b9b16cc..466dede529b 100644 --- a/apps/web/core/components/api-token/token-list-item.tsx +++ b/apps/web/core/components/api-token/token-list-item.tsx @@ -7,7 +7,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"; @@ -18,24 +18,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 d4295055598..8052eec6179 100644 --- a/apps/web/core/components/ui/loader/settings/api-token.tsx +++ b/apps/web/core/components/ui/loader/settings/api-token.tsx @@ -5,13 +5,17 @@ */ 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 abcbcf2bf48..b70a88372ce 100644 --- a/apps/web/core/constants/fetch-keys.ts +++ b/apps/web/core/constants/fetch-keys.ts @@ -152,6 +152,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 7a262088abf..1ac7fa39601 100644 --- a/packages/constants/src/event-tracker/core.ts +++ b/packages/constants/src/event-tracker/core.ts @@ -489,6 +489,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 = { @@ -505,4 +508,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/workspace.ts b/packages/constants/src/workspace.ts index 6083c77195f..1ca1051fa3b 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -172,23 +172,23 @@ export const DEFAULT_GLOBAL_VIEWS_LIST: { key: TStaticViewTypes; i18n_label: string; }[] = [ - { - key: "all-issues", - i18n_label: "default_global_view.all_issues", - }, - { - key: "assigned", - i18n_label: "default_global_view.assigned", - }, - { - key: "created", - i18n_label: "default_global_view.created", - }, - { - key: "subscribed", - i18n_label: "default_global_view.subscribed", - }, -]; + { + key: "all-issues", + i18n_label: "default_global_view.all_issues", + }, + { + key: "assigned", + i18n_label: "default_global_view.assigned", + }, + { + key: "created", + i18n_label: "default_global_view.created", + }, + { + key: "subscribed", + i18n_label: "default_global_view.subscribed", + }, + ]; export interface IWorkspaceSidebarNavigationItem { key: string; diff --git a/packages/i18n/src/locales/cs/translations.ts b/packages/i18n/src/locales/cs/translations.ts index 70ffb91f3ae..f3d28ab0dcd 100644 --- a/packages/i18n/src/locales/cs/translations.ts +++ b/packages/i18n/src/locales/cs/translations.ts @@ -1714,8 +1714,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 API token", + 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 2293008ff71..08fc00aa263 100644 --- a/packages/i18n/src/locales/de/translations.ts +++ b/packages/i18n/src/locales/de/translations.ts @@ -1734,8 +1734,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: "API-Token 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 5df71b2eaa6..7ae2b12b19c 100644 --- a/packages/i18n/src/locales/en/translations.ts +++ b/packages/i18n/src/locales/en/translations.ts @@ -1414,7 +1414,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: { @@ -1577,14 +1577,16 @@ export default { }, }, api_tokens: { - title: "Personal Access Tokens", - add_token: "Add personal access token", + heading: "Access Tokens", + description: "Generate secure API tokens to integrate your data with external systems and applications.", + 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: { @@ -1600,7 +1602,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 61d2b9ad1aa..a3b91bc113d 100644 --- a/packages/i18n/src/locales/es/translations.ts +++ b/packages/i18n/src/locales/es/translations.ts @@ -1739,8 +1739,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 API", + 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 2409aee21f3..f34576651d2 100644 --- a/packages/i18n/src/locales/fr/translations.ts +++ b/packages/i18n/src/locales/fr/translations.ts @@ -1737,8 +1737,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 API", + 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 e0e4383f475..7aaf5bc1dc1 100644 --- a/packages/i18n/src/locales/id/translations.ts +++ b/packages/i18n/src/locales/id/translations.ts @@ -1725,8 +1725,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 API", + 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 ca11ebab17f..ec97ed350df 100644 --- a/packages/i18n/src/locales/it/translations.ts +++ b/packages/i18n/src/locales/it/translations.ts @@ -1729,8 +1729,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 API", + 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 8db2fbba6d7..6e1f033b0f4 100644 --- a/packages/i18n/src/locales/ja/translations.ts +++ b/packages/i18n/src/locales/ja/translations.ts @@ -1715,8 +1715,10 @@ export default { }, }, api_tokens: { + heading: "APIトークン", + description: "セキュアなAPIトークンを生成して、データを外部システムやアプリケーションと統合します。", 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 911a17b69ad..215b413907a 100644 --- a/packages/i18n/src/locales/ko/translations.ts +++ b/packages/i18n/src/locales/ko/translations.ts @@ -1708,8 +1708,10 @@ export default { }, }, api_tokens: { + heading: "API 토큰", + description: "보안 API 토큰을 생성하여 데이터를 외부 시스템 및 애플리케이션과 통합합니다.", 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 008540a666b..772af866c6e 100644 --- a/packages/i18n/src/locales/pl/translations.ts +++ b/packages/i18n/src/locales/pl/translations.ts @@ -1717,8 +1717,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 API", + 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 10ce2a39bb9..3295cf0b916 100644 --- a/packages/i18n/src/locales/pt-BR/translations.ts +++ b/packages/i18n/src/locales/pt-BR/translations.ts @@ -1737,8 +1737,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 API", + 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 4bd68c082dc..a333aaeb58f 100644 --- a/packages/i18n/src/locales/ro/translations.ts +++ b/packages/i18n/src/locales/ro/translations.ts @@ -1729,8 +1729,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ă cheie secretă API", + 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 6335c35b768..65b253b6296 100644 --- a/packages/i18n/src/locales/ru/translations.ts +++ b/packages/i18n/src/locales/ru/translations.ts @@ -1758,8 +1758,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 0700f6a280e..ae1ca8fffe5 100644 --- a/packages/i18n/src/locales/sk/translations.ts +++ b/packages/i18n/src/locales/sk/translations.ts @@ -1716,8 +1716,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ť API token", + 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 4089d84b850..a47b304fa85 100644 --- a/packages/i18n/src/locales/tr-TR/translations.ts +++ b/packages/i18n/src/locales/tr-TR/translations.ts @@ -1725,8 +1725,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: "API 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 52a0e106b4f..4de26c0ecc6 100644 --- a/packages/i18n/src/locales/ua/translations.ts +++ b/packages/i18n/src/locales/ua/translations.ts @@ -1761,8 +1761,10 @@ export default { }, }, api_tokens: { + heading: "API токени", + description: "Створюйте безпечні API токени для інтеграції ваших даних із зовнішніми системами та додатками.", 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 fb48215d25f..52822959a9d 100644 --- a/packages/i18n/src/locales/vi-VN/translations.ts +++ b/packages/i18n/src/locales/vi-VN/translations.ts @@ -1725,8 +1725,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 API", + 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 22495420e00..d1cf6d56b75 100644 --- a/packages/i18n/src/locales/zh-CN/translations.ts +++ b/packages/i18n/src/locales/zh-CN/translations.ts @@ -1696,8 +1696,10 @@ export default { }, }, api_tokens: { + heading: "API 令牌", + description: "生成安全的 API 令牌,将您的数据与外部系统和应用程序集成。", 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 59019aa12d9..0d52fddceaf 100644 --- a/packages/i18n/src/locales/zh-TW/translations.ts +++ b/packages/i18n/src/locales/zh-TW/translations.ts @@ -1697,8 +1697,10 @@ export default { }, }, api_tokens: { + heading: "API 權杖", + description: "產生安全的 API 權杖,將您的資料與外部系統和應用程式整合。", title: "API 權杖", - add_token: "新增 API 權杖", + add_token: "新增存取權杖", create_token: "建立權杖", never_expires: "永不過期", generate_token: "產生權杖", diff --git a/packages/services/src/developer/index.ts b/packages/services/src/developer/index.ts index b27617136db..248da19864f 100644 --- a/packages/services/src/developer/index.ts +++ b/packages/services/src/developer/index.ts @@ -6,3 +6,4 @@ 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; + }); + } +}