mirror of
https://github.com/gosticks/plane.git
synced 2025-10-16 12:45: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:
parent
c7d17d00b7
commit
d65f0e264e
@ -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/",
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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
|
* 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;
|
||||||
|
|||||||
@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}/`}
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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`;
|
||||||
|
|||||||
@ -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 { 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(() => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user