Compare commits

...
Author SHA1 Message Date
vamsikrishnamathala 1741278fec chore: added licence 2026-03-05 17:02:04 +05:30
vamsikrishnamathala e5ecb275fb fix: format 2026-03-05 16:59:09 +05:30
vamsikrishnamathala f8c9384c92 fix: format 2026-03-03 19:33:00 +05:30
vamsikrishnamathala f88062328f Merge branch 'preview' of github.com:makeplane/plane into feat-workspace-api-tokens 2026-03-03 19:31:31 +05:30
vamsikrishnamathala fcbe9d23b9 chore: added access tokens to workspace sidebar list item 2026-03-03 19:29:55 +05:30
sangeethailango d42371ac2b merge preview 2026-02-17 13:31:35 +05:30
sangeethailango f1ae07e5ea chore: Resolver404 error handling 2026-01-02 16:32:10 +05:30
sangeethailango 03e2c5a8d8 fix: error handling 2026-01-02 15:43:54 +05:30
vamsikrishnamathala d3911dc59f chore: removed event capture 2026-01-02 13:03:10 +05:30
vamsikrishnamathala fea647dec8 Merge branch 'preview' of github.com:makeplane/plane into feat-workspace-api-tokens 2026-01-02 12:08:43 +05:30
vamsikrishnamathala a32412ec18 Merge branch 'feat-workspace-api-tokens' of github.com:makeplane/plane into feat-workspace-api-tokens 2025-12-30 16:51:47 +05:30
vamsikrishnamathala 3a5d01c80a chore: updated routes and headings 2025-12-30 16:51:16 +05:30
sangeethailango 50040733d8 merge preview 2025-12-30 16:26:35 +05:30
sangeethailango 42f060b899 Merge branch 'migration-webhook' into feat-workspace-api-tokens 2025-12-30 16:04:22 +05:30
sangeethailango a2fcc06d96 chore: added is_subscribed_to_changelog field 2025-12-29 17:52:38 +05:30
sangeethailango 0fd1f66e3e fix: error handling for APIToken not exist 2025-12-29 15:27:37 +05:30
sangeethailango 26eba89d1b fix: error handling for APIToken not exist 2025-12-29 15:22:35 +05:30
sangeethailango ae0550e3d3 merge preview 2025-12-29 15:07:45 +05:30
sangeethailango 1705037d38 Merge branch 'feat-workspace-api-tokens' of github.com:makeplane/plane into feat-workspace-api-tokens 2025-12-29 14:38:29 +05:30
vamsikrishnamathalaandsangeethailango 466bccc9a8 chore: added translations 2025-12-29 14:34:29 +05:30
sangeethailango b031dea5a9 fix: expired_at set as a read only field 2025-12-29 14:32:58 +05:30
sangeethailango 97e18f5d54 fix: workspace tokens are returned in user tokens 2025-12-29 14:32:51 +05:30
sangeethailango bc52fa1cc4 chore: workspace api token permission check 2025-12-29 14:32:44 +05:30
b-saikrishnakanthandsangeethailango 0a20a47b9d feat: add API tokens management to workspace settings 2025-12-29 14:32:38 +05:30
sangeethailango d21f106113 chore: set header 2025-12-29 14:31:54 +05:30
sangeethailango 42d5407fdd chore: separate rate limit class for workspace api token 2025-12-29 14:31:42 +05:30
pablohashescobarandsangeethailango 0bf6e1c8b2 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.
2025-12-29 14:30:57 +05:30
NarayanBavisetti 07cf029c1e Merge branch 'migration-webhook' of github.com:makeplane/plane into migration-webhook 2025-12-24 17:45:45 +05:30
NarayanBavisetti 9cc3f811ff chore: changed key feature tour to product tour 2025-12-24 17:45:02 +05:30
NarayanBavisetti 10f4418b0c Merge branch 'preview' of github.com:makeplane/plane into migration-webhook 2025-12-24 17:32:38 +05:30
sangeethailango 56a41a8d67 chore: added allowed_rate_limit for api_tokens 2025-12-23 19:20:34 +05:30
NarayanBavisetti e76d3688da chore: removed the duplicated migration file 2025-12-23 18:53:01 +05:30
NarayanBavisetti 1d9390d4ca chore: updated the migration file 2025-12-23 18:52:05 +05:30
NarayanBavisetti ab47d4cd63 chore: added product tour fields 2025-12-23 18:50:24 +05:30
b-saikrishnakanth b028d833c2 feat: add API tokens management to workspace settings 2025-12-23 15:13:33 +05:30
sangeethailango 04ec99f711 chore: add max_length 2025-12-15 15:59:51 +05:30
sangeethailango 64328f6f53 migration: added version field in webhook 2025-12-15 15:44:13 +05:30
43 changed files with 541 additions and 88 deletions
@@ -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 @@ class APIKeyAuthentication(authentication.BaseAuthentication):
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
+39
View File
@@ -7,6 +7,7 @@ import os
# Third party imports
from rest_framework.throttling import SimpleRateThrottle
from plane.db.models import APIToken
class ApiKeyRateThrottle(SimpleRateThrottle):
@@ -89,3 +90,41 @@ class ServiceTokenRateThrottle(SimpleRateThrottle):
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
+10 -2
View File
@@ -24,7 +24,7 @@ from rest_framework.generics import GenericAPIView
# 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 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
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
+7 -2
View File
@@ -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):
+11 -1
View File
@@ -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 @@ urlpatterns = [
ServiceApiTokenEndpoint.as_view(),
name="service-api-tokens",
),
path(
"workspaces/<str:slug>/api-tokens/",
WorkspaceAPITokenEndpoint.as_view(),
name="workspace-api-tokens",
),
path(
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
WorkspaceAPITokenEndpoint.as_view(),
name="workspace-api-tokens-details",
),
## End API Tokens
]
+1 -1
View File
@@ -165,7 +165,7 @@ from .module.issue import ModuleIssueViewSet
from .module.archive import ModuleArchiveUnarchiveEndpoint
from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint, WorkspaceAPITokenEndpoint
from .page.base import (
PageViewSet,
+7
View File
@@ -0,0 +1,7 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from .base import ApiTokenEndpoint
from .service import ServiceApiTokenEndpoint
from .workspace import WorkspaceAPITokenEndpoint
@@ -12,10 +12,9 @@ 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.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 @@ class ApiTokenEndpoint(BaseAPIView):
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 @@ class ApiTokenEndpoint(BaseAPIView):
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)
+41
View File
@@ -0,0 +1,41 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# 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)
+72
View File
@@ -0,0 +1,72 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# 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)
@@ -0,0 +1,115 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
import { Button } from "@plane/propel/button";
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 <NotAuthorizedView section="settings" className="h-auto" />;
}
return (
<SettingsContentWrapper>
<PageHead title={pageTitle} />
{!tokens ? (
<APITokenSettingsLoader title={t("workspace_settings.settings.api_tokens.title")} />
) : (
<div className="w-full">
<CreateApiTokenModal
isOpen={isCreateTokenModalOpen}
onClose={() => setIsCreateTokenModalOpen(false)}
workspaceSlug={workspaceSlug}
/>
<SettingsHeading
title={t("workspace_settings.settings.api_tokens.heading")}
description={t("workspace_settings.settings.api_tokens.description")}
control={
<Button variant="primary" size="lg" onClick={() => setIsCreateTokenModalOpen(true)}>
{t("workspace_settings.settings.api_tokens.add_token")}
</Button>
}
/>
{tokens.length > 0 ? (
<div className="flex h-full w-full flex-col">
<div className="h-full w-full overflow-y-auto">
{tokens.map((token) => (
<ApiTokenListItem key={token.id} token={token} workspaceSlug={workspaceSlug} />
))}
</div>
</div>
) : (
<div className="flex h-full w-full flex-col">
<div className="flex h-full w-full items-center justify-center">
<EmptyStateCompact
assetKey="token"
title={t("settings_empty_state.tokens.title")}
description={t("settings_empty_state.tokens.description")}
actions={[
{
label: t("settings_empty_state.tokens.cta_primary"),
onClick: () => {
setIsCreateTokenModalOpen(true);
},
},
]}
align="start"
rootClassName="py-20"
/>
</div>
</div>
)}
</div>
)}
</SettingsContentWrapper>
);
}
export default observer(ApiTokensPage);
+4
View File
@@ -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"
),
]),
// --------------------------------------------------------------------
@@ -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<boolean>(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<IApiToken[]>(
API_TOKENS_LIST,
workspaceSlug ? WORKSPACE_API_TOKENS_LIST(workspaceSlug) : API_TOKENS_LIST,
(prevData) => (prevData ?? []).filter((token) => token.id !== tokenId),
false
);
handleClose();
setDeleteLoading(false);
})
@@ -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<boolean>(false);
const [generatedToken, setGeneratedToken] = useState<IApiToken | null | undefined>(null);
@@ -54,14 +56,14 @@ export function CreateApiTokenModal(props: Props) {
const handleCreateToken = async (data: Partial<IApiToken>) => {
// 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<IApiToken[]>(
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;
});
};
@@ -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 (
<>
<DeleteApiTokenModal isOpen={deleteModalOpen} onClose={() => setDeleteModalOpen(false)} tokenId={token.id} />
<DeleteApiTokenModal
isOpen={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
tokenId={token.id}
workspaceSlug={workspaceSlug}
/>
<div className="group relative flex flex-col justify-center border-b border-subtle py-3">
<Tooltip tooltipContent="Delete token" isMobile={isMobile}>
<button
onClick={() => setDeleteModalOpen(true)}
className="absolute right-4 hidden place-items-center group-hover:grid"
data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.LIST_ITEM_DELETE_ICON}
data-ph-element={trackerElement}
>
<XCircle className="h-4 w-4 text-danger-primary" />
</button>
@@ -31,7 +31,7 @@ export const APITokensProfileSettings = observer(function APITokensProfileSettin
const { t } = useTranslation();
if (!tokens) {
return <APITokenSettingsLoader />;
return <APITokenSettingsLoader title={t("workspace_settings.settings.api_tokens.title")} />;
}
return (
@@ -5,7 +5,7 @@
*/
import type { LucideIcon } from "lucide-react";
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
import { ArrowUpToLine, Building, CreditCard, Users, Webhook, KeyRound } from "lucide-react";
// plane imports
import type { ISvgIcons } from "@plane/propel/icons";
import type { TWorkspaceSettingsTabs } from "@plane/types";
@@ -16,4 +16,5 @@ export const WORKSPACE_SETTINGS_ICONS: Record<TWorkspaceSettingsTabs, LucideIcon
export: ArrowUpToLine,
"billing-and-plans": CreditCard,
webhooks: Webhook,
"access-tokens": KeyRound,
};
@@ -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 (
<section className="w-full overflow-y-auto">
<div className="mb-2 flex items-center justify-between border-b border-subtle pb-3.5">
<h3 className="text-18 font-medium">{t("workspace_settings.settings.api_tokens.title")}</h3>
<h3 className="text-xl font-medium">{title}</h3>
<span className="h-8 w-28 rounded-sm bg-layer-1" />
</div>
<div className="divide-y-[0.5px] divide-subtle-1">
+2
View File
@@ -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()}`;
@@ -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",
};
+8 -1
View File
@@ -56,6 +56,13 @@ export const WORKSPACE_SETTINGS: Record<TWorkspaceSettingsTabs, TWorkspaceSettin
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
},
"access-tokens": {
key: "access-tokens",
i18n_label: "workspace_settings.settings.api_tokens.title",
href: `/settings/access-tokens`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/access-tokens/`,
},
};
export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries(
@@ -70,5 +77,5 @@ export const GROUPED_WORKSPACE_SETTINGS: Record<WORKSPACE_SETTINGS_CATEGORY, TWo
WORKSPACE_SETTINGS["export"],
],
[WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [],
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]],
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"], WORKSPACE_SETTINGS["access-tokens"]],
};
+3 -1
View File
@@ -1715,8 +1715,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",
+4 -1
View File
@@ -1735,8 +1735,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",
+7 -5
View File
@@ -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.",
},
+3 -1
View File
@@ -1740,8 +1740,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",
+4 -1
View File
@@ -1738,8 +1738,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: "Nexpire jamais",
generate_token: "Générer un jeton",
+3 -1
View File
@@ -1726,8 +1726,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",
+3 -1
View File
@@ -1730,8 +1730,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",
+3 -1
View File
@@ -1716,8 +1716,10 @@ export default {
},
},
api_tokens: {
heading: "APIトークン",
description: "セキュアなAPIトークンを生成して、データを外部システムやアプリケーションと統合します。",
title: "APIトークン",
add_token: "APIトークンを追加",
add_token: "アクセストークンを追加",
create_token: "トークンを作成",
never_expires: "無期限",
generate_token: "トークンを生成",
+3 -1
View File
@@ -1709,8 +1709,10 @@ export default {
},
},
api_tokens: {
heading: "API 토큰",
description: "보안 API 토큰을 생성하여 데이터를 외부 시스템 및 애플리케이션과 통합합니다.",
title: "API 토큰",
add_token: "API 토큰 추가",
add_token: "액세스 토큰 추가",
create_token: "토큰 생성",
never_expires: "만료되지 않음",
generate_token: "토큰 생성",
+3 -1
View File
@@ -1718,8 +1718,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",
@@ -1738,8 +1738,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",
+3 -1
View File
@@ -1730,8 +1730,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ă",
+4 -1
View File
@@ -1758,8 +1758,11 @@ export default {
},
},
api_tokens: {
heading: "API-токены",
description:
"Создавайте безопасные API-токены для интеграции ваших данных с внешними системами и приложениями.",
title: "API-токены",
add_token: "Добавить токен",
add_token: "Добавить токен доступа",
create_token: "Создать токен",
never_expires: "Бессрочный",
generate_token: "Сгенерировать токен",
+3 -1
View File
@@ -1717,8 +1717,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",
@@ -1726,8 +1726,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",
+3 -1
View File
@@ -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: "Згенерувати токен",
@@ -1726,8 +1726,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",
@@ -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: "生成令牌",
@@ -1698,8 +1698,10 @@ export default {
},
},
api_tokens: {
heading: "API 權杖",
description: "產生安全的 API 權杖,將您的資料與外部系統和應用程式整合。",
title: "API 權杖",
add_token: "新增 API 權杖",
add_token: "新增存取權杖",
create_token: "建立權杖",
never_expires: "永不過期",
generate_token: "產生權杖",
+1
View File
@@ -6,3 +6,4 @@
export * from "./api-token.service";
export * from "./webhook.service";
export * from "./workspace-api-token.service";
@@ -0,0 +1,79 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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<IApiToken[]>} Promise resolving to array of API tokens
* @throws {Error} If the API request fails
*/
async list(workspaceSlug: string): Promise<IApiToken[]> {
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<IApiToken>} Promise resolving to API token details
* @throws {Error} If the API request fails
*/
async retrieve(workspaceSlug: string, tokenId: string): Promise<IApiToken> {
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<IApiToken>} data - API token configuration data
* @returns {Promise<IApiToken>} Promise resolving to the created API token
* @throws {Error} If the API request fails
*/
async create(workspaceSlug: string, data: Partial<IApiToken>): Promise<IApiToken> {
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<void>} Promise resolving when API token is deleted
* @throws {Error} If the API request fails
*/
async destroy(workspaceSlug: string, tokenId: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+7 -1
View File
@@ -10,7 +10,13 @@ import type { EUserWorkspaceRoles } from "./workspace";
export type TProfileSettingsTabs = "general" | "preferences" | "activity" | "notifications" | "security" | "api-tokens";
export type TWorkspaceSettingsTabs = "general" | "members" | "billing-and-plans" | "export" | "webhooks";
export type TWorkspaceSettingsTabs =
| "general"
| "members"
| "billing-and-plans"
| "export"
| "webhooks"
| "access-tokens";
export type TWorkspaceSettingsItem = {
key: TWorkspaceSettingsTabs;
i18n_label: string;