[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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 469 additions and 146 deletions

View File

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

View File

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

View File

@ -27,7 +27,7 @@ def user_data():
"email": "test@plane.so", "email": "test@plane.so",
"password": "test-password", "password": "test-password",
"first_name": "Test", "first_name": "Test",
"last_name": "User" "last_name": "User",
} }
@ -37,7 +37,7 @@ def create_user(db, user_data):
user = User.objects.create( user = User.objects.create(
email=user_data["email"], email=user_data["email"],
first_name=user_data["first_name"], 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.set_password(user_data["password"])
user.save() user.save()
@ -69,10 +69,52 @@ def session_client(api_client, create_user):
return api_client 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 @pytest.fixture
def plane_server(live_server): def plane_server(live_server):
""" """
Renamed version of live_server fixture to avoid name clashes. Renamed version of live_server fixture to avoid name clashes.
Returns a live Django server for testing HTTP requests. 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 * 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 * @returns {Promise<IApiToken[]>} Array of API tokens associated with the workspace
* @throws {Error} Throws response data if the request fails * @throws {Error} Throws response data if the request fails
*/ */
async list(workspaceSlug: string): Promise<IApiToken[]> { async list(): Promise<IApiToken[]> {
return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/`) return this.get(`/api/users/api-tokens/`)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;
@ -23,13 +22,12 @@ export class APITokenService extends APIService {
/** /**
* Retrieves a specific API token by its ID * 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 * @param {string} tokenId - The unique identifier of the API token
* @returns {Promise<IApiToken>} The requested API token's details * @returns {Promise<IApiToken>} The requested API token's details
* @throws {Error} Throws response data if the request fails * @throws {Error} Throws response data if the request fails
*/ */
async retrieve(workspaceSlug: string, tokenId: string): Promise<IApiToken> { async retrieve(tokenId: string): Promise<IApiToken> {
return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`) return this.get(`/api/users/api-tokens/${tokenId}`)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;
@ -38,13 +36,12 @@ export class APITokenService extends APIService {
/** /**
* Creates a new API token for a workspace * 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 * @param {Partial<IApiToken>} data - The data for creating the new API token
* @returns {Promise<IApiToken>} The newly created API token * @returns {Promise<IApiToken>} The newly created API token
* @throws {Error} Throws response data if the request fails * @throws {Error} Throws response data if the request fails
*/ */
async create(workspaceSlug: string, data: Partial<IApiToken>): Promise<IApiToken> { async create(data: Partial<IApiToken>): Promise<IApiToken> {
return this.post(`/api/workspaces/${workspaceSlug}/api-tokens/`, data) return this.post(`/api/users/api-tokens/`, data)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;
@ -53,13 +50,12 @@ export class APITokenService extends APIService {
/** /**
* Deletes a specific API token from the workspace * 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 * @param {string} tokenId - The unique identifier of the API token to delete
* @returns {Promise<IApiToken>} The deleted API token's details * @returns {Promise<IApiToken>} The deleted API token's details
* @throws {Error} Throws response data if the request fails * @throws {Error} Throws response data if the request fails
*/ */
async destroy(workspaceSlug: string, tokenId: string): Promise<IApiToken> { async destroy(tokenId: string): Promise<IApiToken> {
return this.delete(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`) return this.delete(`/api/users/api-tokens/${tokenId}`)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;

View File

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

View File

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

View File

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

View File

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

View File

@ -20,14 +20,14 @@ export const SettingsHeading = ({
customButton, customButton,
showButton = true, showButton = true,
}: Props) => ( }: 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"> <div className="flex flex-col items-start gap-1">
{typeof title === "string" ? <h3 className="text-xl font-medium">{title}</h3> : title} {typeof title === "string" ? <h3 className="text-xl font-medium">{title}</h3> : title}
{description && <div className="text-sm text-custom-text-300">{description}</div>} {description && <div className="text-sm text-custom-text-300">{description}</div>}
</div> </div>
{showButton && customButton} {showButton && customButton}
{button && showButton && ( {button && showButton && (
<Button variant="primary" onClick={button.onClick} size="sm"> <Button variant="primary" onClick={button.onClick} size="sm" className="w-fit">
{button.label} {button.label}
</Button> </Button>
)} )}

View File

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