[WEB-4327] Chore PAT permissions (#7224)

* chore: improved pat permissions

* fix: err message

* fix: removed permission from backend

* [WEB-4330] refactor: update API token endpoints to use user context instead of workspace slug

- Changed URL patterns for API token endpoints to use "users/api-tokens/" instead of "workspaces/<str:slug>/api-tokens/".
- Refactored ApiTokenEndpoint methods to remove workspace slug parameter and adjust database queries accordingly.
- Added new test cases for API token creation, retrieval, deletion, and updates, including support for bot users and minimal data submissions.

* fix: removed workspace slug from api-tokens

* fix: refactor

* chore: url.py code rabbit suggestion

* fix: APITokenService moved to package

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Akshita Goyal
2025-06-18 16:08:11 +05:30
committed by GitHub
parent c7d17d00b7
commit d65f0e264e
13 changed files with 469 additions and 146 deletions

View File

@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [
# API Tokens
path(
"workspaces/<str:slug>/api-tokens/",
"users/api-tokens/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
"users/api-tokens/<uuid:pk>/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
name="api-tokens-details",
),
path(
"workspaces/<str:slug>/service-api-tokens/",

View File

@@ -1,8 +1,10 @@
# Python import
from uuid import uuid4
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
@@ -13,12 +15,9 @@ from plane.app.permissions import WorkspaceEntityPermission
class ApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request, slug):
def post(self, request: Request) -> Response:
label = request.data.get("label", str(uuid4().hex))
description = request.data.get("description", "")
workspace = Workspace.objects.get(slug=slug)
expired_at = request.data.get("expired_at", None)
# Check the user type
@@ -28,7 +27,6 @@ class ApiTokenEndpoint(BaseAPIView):
label=label,
description=description,
user=request.user,
workspace=workspace,
user_type=user_type,
expired_at=expired_at,
)
@@ -37,29 +35,23 @@ class ApiTokenEndpoint(BaseAPIView):
# Token will be only visible while creating
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, pk=None):
def get(self, request: Request, pk: Optional[str] = None) -> Response:
if pk is None:
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug, is_service=False
)
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
api_tokens = APIToken.objects.get(
user=request.user, workspace__slug=slug, pk=pk
)
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
serializer = APITokenReadSerializer(api_tokens)
return Response(serializer.data, status=status.HTTP_200_OK)
def delete(self, request, slug, pk):
api_token = APIToken.objects.get(
workspace__slug=slug, user=request.user, pk=pk, is_service=False
)
def delete(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, pk):
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk)
def patch(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk)
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -70,7 +62,7 @@ class ApiTokenEndpoint(BaseAPIView):
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request, slug):
def post(self, request: Request, slug: str) -> Response:
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(

View File

@@ -27,7 +27,7 @@ def user_data():
"email": "test@plane.so",
"password": "test-password",
"first_name": "Test",
"last_name": "User"
"last_name": "User",
}
@@ -37,7 +37,7 @@ def create_user(db, user_data):
user = User.objects.create(
email=user_data["email"],
first_name=user_data["first_name"],
last_name=user_data["last_name"]
last_name=user_data["last_name"],
)
user.set_password(user_data["password"])
user.save()
@@ -69,10 +69,52 @@ def session_client(api_client, create_user):
return api_client
@pytest.fixture
def create_bot_user(db):
"""Create and return a bot user instance"""
from uuid import uuid4
unique_id = uuid4().hex[:8]
user = User.objects.create(
email=f"bot-{unique_id}@plane.so",
username=f"bot_user_{unique_id}",
first_name="Bot",
last_name="User",
is_bot=True,
)
user.set_password("bot@123")
user.save()
return user
@pytest.fixture
def api_token_data():
"""Return sample API token data for testing"""
from django.utils import timezone
from datetime import timedelta
return {
"label": "Test API Token",
"description": "Test description for API token",
"expired_at": (timezone.now() + timedelta(days=30)).isoformat(),
}
@pytest.fixture
def create_api_token_for_user(db, create_user):
"""Create and return an API token for a specific user"""
return APIToken.objects.create(
label="Test Token",
description="Test token description",
user=create_user,
user_type=0,
)
@pytest.fixture
def plane_server(live_server):
"""
Renamed version of live_server fixture to avoid name clashes.
Returns a live Django server for testing HTTP requests.
"""
return live_server
return live_server

View File

@@ -0,0 +1,372 @@
import pytest
from datetime import timedelta
from uuid import uuid4
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from plane.db.models import APIToken, User
@pytest.mark.contract
class TestApiTokenEndpoint:
"""Test cases for ApiTokenEndpoint"""
# POST /user/api-tokens/ tests
@pytest.mark.django_db
def test_create_api_token_success(
self, session_client, create_user, api_token_data
):
"""Test successful API token creation"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert response.data["label"] == api_token_data["label"]
assert response.data["description"] == api_token_data["description"]
assert response.data["user_type"] == 0 # Human user
# Verify token was created in database
token = APIToken.objects.get(pk=response.data["id"])
assert token.user == create_user
assert token.label == api_token_data["label"]
@pytest.mark.django_db
def test_create_api_token_for_bot_user(
self, session_client, create_bot_user, api_token_data
):
"""Test API token creation for bot user"""
# Arrange
session_client.force_authenticate(user=create_bot_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert response.data["user_type"] == 1 # Bot user
@pytest.mark.django_db
def test_create_api_token_minimal_data(self, session_client, create_user):
"""Test API token creation with minimal data"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, {}, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert len(response.data["label"]) == 32 # UUID hex length
assert response.data["description"] == ""
@pytest.mark.django_db
def test_create_api_token_with_expiry(self, session_client, create_user):
"""Test API token creation with expiry date"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
future_date = timezone.now() + timedelta(days=30)
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
# Act
response = session_client.post(url, data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
# Verify expiry date was set
token = APIToken.objects.get(pk=response.data["id"])
assert token.expired_at is not None
@pytest.mark.django_db
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
"""Test API token creation without authentication"""
# Arrange
url = reverse("api-tokens")
# Act
response = api_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# GET /user/api-tokens/ tests
@pytest.mark.django_db
def test_get_all_api_tokens(self, session_client, create_user):
"""Test retrieving all API tokens for user"""
# Arrange
session_client.force_authenticate(user=create_user)
# Create multiple tokens
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
# Create a service token (should be excluded)
APIToken.objects.create(
label="Service Token", user=create_user, user_type=0, is_service=True
)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 2 # Only non-service tokens
assert all(token["is_service"] is False for token in response.data)
@pytest.mark.django_db
def test_get_empty_api_tokens_list(self, session_client, create_user):
"""Test retrieving API tokens when none exist"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data == []
# GET /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_get_specific_api_token(
self, session_client, create_user, create_api_token_for_user
):
"""Test retrieving a specific API token"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
assert response.data["label"] == create_api_token_for_user.label
assert (
"token" not in response.data
) # Token should not be visible in read serializer
@pytest.mark.django_db
def test_get_nonexistent_api_token(self, session_client, create_user):
"""Test retrieving a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_get_other_users_api_token(self, session_client, create_user, db):
"""Test retrieving another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"other-{unique_id}@plane.so"
unique_username = f"other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# DELETE /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_delete_api_token_success(
self, session_client, create_user, create_api_token_for_user
):
"""Test successful API token deletion"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
@pytest.mark.django_db
def test_delete_nonexistent_api_token(self, session_client, create_user):
"""Test deleting a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_delete_other_users_api_token(self, session_client, create_user, db):
"""Test deleting another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"delete-other-{unique_id}@plane.so"
unique_username = f"delete_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=other_token.pk).exists()
@pytest.mark.django_db
def test_delete_service_api_token_forbidden(self, session_client, create_user):
"""Test deleting a service API token (should fail)"""
# Arrange
service_token = APIToken.objects.create(
label="Service Token", user=create_user, user_type=0, is_service=True
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": service_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=service_token.pk).exists()
# PATCH /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_patch_api_token_success(
self, session_client, create_user, create_api_token_for_user
):
"""Test successful API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
update_data = {
"label": "Updated Token Label",
"description": "Updated description",
}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == update_data["description"]
# Verify database was updated
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.label == update_data["label"]
assert create_api_token_for_user.description == update_data["description"]
@pytest.mark.django_db
def test_patch_api_token_partial_update(
self, session_client, create_user, create_api_token_for_user
):
"""Test partial API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
original_description = create_api_token_for_user.description
update_data = {"label": "Only Label Updated"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == original_description
@pytest.mark.django_db
def test_patch_nonexistent_api_token(self, session_client, create_user):
"""Test updating a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
update_data = {"label": "New Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_patch_other_users_api_token(self, session_client, create_user, db):
"""Test updating another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"patch-other-{unique_id}@plane.so"
unique_username = f"patch_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
update_data = {"label": "Hacked Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token was not updated
other_token.refresh_from_db()
assert other_token.label == "Other Token"
# Authentication tests
@pytest.mark.django_db
def test_all_endpoints_require_authentication(self, api_client):
"""Test that all endpoints require authentication"""
# Arrange
endpoints = [
(reverse("api-tokens"), "get"),
(reverse("api-tokens"), "post"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "get"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"),
]
# Act & Assert
for url, method in endpoints:
response = getattr(api_client, method)(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View File

@@ -9,12 +9,11 @@ export class APITokenService extends APIService {
/**
* Retrieves all API tokens for a specific workspace
* @param {string} workspaceSlug - The unique identifier for the workspace
* @returns {Promise<IApiToken[]>} Array of API tokens associated with the workspace
* @throws {Error} Throws response data if the request fails
*/
async list(workspaceSlug: string): Promise<IApiToken[]> {
return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/`)
async list(): Promise<IApiToken[]> {
return this.get(`/api/users/api-tokens/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@@ -23,13 +22,12 @@ export class APITokenService extends APIService {
/**
* Retrieves a specific API token by its ID
* @param {string} workspaceSlug - The unique identifier for the workspace
* @param {string} tokenId - The unique identifier of the API token
* @returns {Promise<IApiToken>} The requested API token's details
* @throws {Error} Throws response data if the request fails
*/
async retrieve(workspaceSlug: string, tokenId: string): Promise<IApiToken> {
return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
async retrieve(tokenId: string): Promise<IApiToken> {
return this.get(`/api/users/api-tokens/${tokenId}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@@ -38,13 +36,12 @@ export class APITokenService extends APIService {
/**
* Creates a new API token for a workspace
* @param {string} workspaceSlug - The unique identifier for the workspace
* @param {Partial<IApiToken>} data - The data for creating the new API token
* @returns {Promise<IApiToken>} The newly created API token
* @throws {Error} Throws response data if the request fails
*/
async create(workspaceSlug: string, data: Partial<IApiToken>): Promise<IApiToken> {
return this.post(`/api/workspaces/${workspaceSlug}/api-tokens/`, data)
async create(data: Partial<IApiToken>): Promise<IApiToken> {
return this.post(`/api/users/api-tokens/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@@ -53,13 +50,12 @@ export class APITokenService extends APIService {
/**
* Deletes a specific API token from the workspace
* @param {string} workspaceSlug - The unique identifier for the workspace
* @param {string} tokenId - The unique identifier of the API token to delete
* @returns {Promise<IApiToken>} The deleted API token's details
* @throws {Error} Throws response data if the request fails
*/
async destroy(workspaceSlug: string, tokenId: string): Promise<IApiToken> {
return this.delete(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
async destroy(tokenId: string): Promise<IApiToken> {
return this.delete(`/api/users/api-tokens/${tokenId}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;

View File

@@ -2,24 +2,21 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// component
import { APITokenService } from "@plane/services";
import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { DetailedEmptyState } from "@/components/empty-state";
import { SettingsHeading } from "@/components/settings";
import { APITokenSettingsLoader } from "@/components/ui";
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
// store hooks
import { useUserPermissions, useWorkspace } from "@/hooks/store";
import { useWorkspace } from "@/hooks/store";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// services
import { APITokenService } from "@/services/api_token.service";
const apiTokenService = new APITokenService();
@@ -27,30 +24,19 @@ const ApiTokensPage = observer(() => {
// states
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
// router
const { workspaceSlug } = useParams();
// plane hooks
const { t } = useTranslation();
// store hooks
const { currentWorkspace } = useWorkspace();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/api-tokens" });
const { data: tokens } = useSWR(
workspaceSlug && canPerformWorkspaceAdminActions ? API_TOKENS_LIST(workspaceSlug.toString()) : null,
() =>
workspaceSlug && canPerformWorkspaceAdminActions ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null
);
const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list());
const pageTitle = currentWorkspace?.name
? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}`
: undefined;
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
}
if (!tokens) {
return <APITokenSettingsLoader />;
}

View File

@@ -2,18 +2,12 @@ import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks, Lock } from "lucide-react";
// plane imports
import {
EUserPermissions,
EUserPermissionsLevel,
GROUPED_PROFILE_SETTINGS,
PROFILE_SETTINGS_CATEGORIES,
PROFILE_SETTINGS_CATEGORY,
} from "@plane/constants";
import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants";
import { getFileURL } from "@plane/utils";
// components
import { SettingsSidebar } from "@/components/settings";
// hooks
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useUser } from "@/hooks/store/user";
const ICONS = {
profile: CircleUser,
@@ -44,14 +38,10 @@ export const ProfileSidebar = observer((props: TProfileSidebarProps) => {
// store hooks
const { data: currentUser } = useUser();
const { allowPermissions } = useUserPermissions();
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
return (
<SettingsSidebar
isMobile={isMobile}
categories={PROFILE_SETTINGS_CATEGORIES.filter(
(category) => isAdmin || category !== PROFILE_SETTINGS_CATEGORY.DEVELOPER
)}
categories={PROFILE_SETTINGS_CATEGORIES}
groupedSettings={GROUPED_PROFILE_SETTINGS}
workspaceSlug={workspaceSlug?.toString() ?? ""}
isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`}

View File

@@ -1,17 +1,15 @@
"use client";
import { useState, FC } from "react";
import { useParams } from "next/navigation";
import { mutate } from "swr";
// types
import { useTranslation } from "@plane/i18n";
import { APITokenService } from "@plane/services";
import { IApiToken } from "@plane/types";
// ui
import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
// fetch-keys
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
// services
import { APITokenService } from "@/services/api_token.service";
type Props = {
isOpen: boolean;
@@ -26,7 +24,6 @@ export const DeleteApiTokenModal: FC<Props> = (props) => {
// states
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
// router params
const { workspaceSlug } = useParams();
const { t } = useTranslation();
const handleClose = () => {
@@ -35,12 +32,10 @@ export const DeleteApiTokenModal: FC<Props> = (props) => {
};
const handleDeletion = async () => {
if (!workspaceSlug) return;
setDeleteLoading(true);
await apiTokenService
.deleteApiToken(workspaceSlug.toString(), tokenId)
.destroy(tokenId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
@@ -49,7 +44,7 @@ export const DeleteApiTokenModal: FC<Props> = (props) => {
});
mutate<IApiToken[]>(
API_TOKENS_LIST(workspaceSlug.toString()),
API_TOKENS_LIST,
(prevData) => (prevData ?? []).filter((token) => token.id !== tokenId),
false
);

View File

@@ -1,9 +1,9 @@
"use client";
import React, { useState } from "react";
import { useParams } from "next/navigation";
import { mutate } from "swr";
// types
import { APITokenService } from "@plane/services";
import { IApiToken } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
@@ -14,7 +14,6 @@ import { CreateApiTokenForm, GeneratedTokenDetails } from "@/components/api-toke
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
// helpers
// services
import { APITokenService } from "@/services/api_token.service";
type Props = {
isOpen: boolean;
@@ -29,8 +28,6 @@ export const CreateApiTokenModal: React.FC<Props> = (props) => {
// states
const [neverExpires, setNeverExpires] = useState<boolean>(false);
const [generatedToken, setGeneratedToken] = useState<IApiToken | null | undefined>(null);
// router
const { workspaceSlug } = useParams();
const handleClose = () => {
onClose();
@@ -53,17 +50,15 @@ export const CreateApiTokenModal: React.FC<Props> = (props) => {
};
const handleCreateToken = async (data: Partial<IApiToken>) => {
if (!workspaceSlug) return;
// make the request to generate the token
await apiTokenService
.createApiToken(workspaceSlug.toString(), data)
.create(data)
.then((res) => {
setGeneratedToken(res);
downloadSecretKey(res);
mutate<IApiToken[]>(
API_TOKENS_LIST(workspaceSlug.toString()),
API_TOKENS_LIST,
(prevData) => {
if (!prevData) return;
@@ -76,7 +71,7 @@ export const CreateApiTokenModal: React.FC<Props> = (props) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err.message,
message: err.message || err.detail,
});
throw err;

View File

@@ -20,14 +20,14 @@ export const SettingsHeading = ({
customButton,
showButton = true,
}: Props) => (
<div className="flex items-center justify-between border-b border-custom-border-100 pb-3.5">
<div className="flex flex-col md:flex-row gap-2 items-start md:items-center justify-between border-b border-custom-border-100 pb-3.5">
<div className="flex flex-col items-start gap-1">
{typeof title === "string" ? <h3 className="text-xl font-medium">{title}</h3> : title}
{description && <div className="text-sm text-custom-text-300">{description}</div>}
</div>
{showButton && customButton}
{button && showButton && (
<Button variant="primary" onClick={button.onClick} size="sm">
<Button variant="primary" onClick={button.onClick} size="sm" className="w-fit">
{button.label}
</Button>
)}

View File

@@ -114,4 +114,4 @@ export const USER_PROFILE_PROJECT_SEGREGATION = (workspaceSlug: string, userId:
`USER_PROFILE_PROJECT_SEGREGATION_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
// api-tokens
export const API_TOKENS_LIST = (workspaceSlug: string) => `API_TOKENS_LIST_${workspaceSlug.toUpperCase()}`;
export const API_TOKENS_LIST = `API_TOKENS_LIST`;

View File

@@ -1,41 +0,0 @@
import { API_BASE_URL } from "@plane/constants";
import { IApiToken } from "@plane/types";
import { APIService } from "./api.service";
export class APITokenService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getApiTokens(workspaceSlug: string): Promise<IApiToken[]> {
return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieveApiToken(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;
});
}
async createApiToken(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;
});
}
async deleteApiToken(workspaceSlug: string, tokenId: string): Promise<IApiToken> {
return this.delete(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -1,9 +1,9 @@
import { action, observable, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
import { APITokenService } from "@plane/services";
import { IApiToken } from "@plane/types";
// services
import { APITokenService } from "@/services/api_token.service";
// store
import { CoreRootStore } from "../root.store";
@@ -13,11 +13,11 @@ export interface IApiTokenStore {
// computed actions
getApiTokenById: (apiTokenId: string) => IApiToken | null;
// fetch actions
fetchApiTokens: (workspaceSlug: string) => Promise<IApiToken[]>;
fetchApiTokenDetails: (workspaceSlug: string, tokenId: string) => Promise<IApiToken>;
fetchApiTokens: () => Promise<IApiToken[]>;
fetchApiTokenDetails: (tokenId: string) => Promise<IApiToken>;
// crud actions
createApiToken: (workspaceSlug: string, data: Partial<IApiToken>) => Promise<IApiToken>;
deleteApiToken: (workspaceSlug: string, tokenId: string) => Promise<void>;
createApiToken: (data: Partial<IApiToken>) => Promise<IApiToken>;
deleteApiToken: (tokenId: string) => Promise<void>;
}
export class ApiTokenStore implements IApiTokenStore {
@@ -55,11 +55,10 @@ export class ApiTokenStore implements IApiTokenStore {
});
/**
* fetch all the API tokens for a workspace
* @param workspaceSlug
* fetch all the API tokens
*/
fetchApiTokens = async (workspaceSlug: string) =>
await this.apiTokenService.getApiTokens(workspaceSlug).then((response) => {
fetchApiTokens = async () =>
await this.apiTokenService.list().then((response) => {
const apiTokensObject: { [apiTokenId: string]: IApiToken } = response.reduce((accumulator, currentWebhook) => {
if (currentWebhook && currentWebhook.id) {
return { ...accumulator, [currentWebhook.id]: currentWebhook };
@@ -74,11 +73,10 @@ export class ApiTokenStore implements IApiTokenStore {
/**
* fetch API token details using token id
* @param workspaceSlug
* @param tokenId
*/
fetchApiTokenDetails = async (workspaceSlug: string, tokenId: string) =>
await this.apiTokenService.retrieveApiToken(workspaceSlug, tokenId).then((response) => {
fetchApiTokenDetails = async (tokenId: string) =>
await this.apiTokenService.retrieve(tokenId).then((response) => {
runInAction(() => {
this.apiTokens = { ...this.apiTokens, [response.id]: response };
});
@@ -87,11 +85,10 @@ export class ApiTokenStore implements IApiTokenStore {
/**
* create API token using data
* @param workspaceSlug
* @param data
*/
createApiToken = async (workspaceSlug: string, data: Partial<IApiToken>) =>
await this.apiTokenService.createApiToken(workspaceSlug, data).then((response) => {
createApiToken = async (data: Partial<IApiToken>) =>
await this.apiTokenService.create(data).then((response) => {
runInAction(() => {
this.apiTokens = { ...this.apiTokens, [response.id]: response };
});
@@ -100,11 +97,10 @@ export class ApiTokenStore implements IApiTokenStore {
/**
* delete API token using token id
* @param workspaceSlug
* @param tokenId
*/
deleteApiToken = async (workspaceSlug: string, tokenId: string) =>
await this.apiTokenService.deleteApiToken(workspaceSlug, tokenId).then(() => {
deleteApiToken = async (tokenId: string) =>
await this.apiTokenService.destroy(tokenId).then(() => {
const updatedApiTokens = { ...this.apiTokens };
delete updatedApiTokens[tokenId];
runInAction(() => {