mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
[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:
@@ -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/",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
372
apiserver/plane/tests/contract/app/test_api_token.py
Normal file
372
apiserver/plane/tests/contract/app/test_api_token.py
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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}/`}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user