feat: api webhooks (#2543)

* dev: initiate external apis

* dev: external api

* dev: external public api implementation

* dev: add prefix to all api tokens

* dev: flag to enable disable api token api access

* dev: webhook model create and apis

* dev: webhook settings

* fix: webhook logs

* chore: removed drf spectacular

* dev: remove retry_count and fix api logging for get requests

* dev: refactor webhook logic

* fix: celery retry mechanism

* chore: event and action change

* chore: migrations changes

* dev: proxy setup for apis

* chore: changed retry time and cleanup

* chore: added issue comment and inbox issue api endpoints

* fix: migration files

* fix: added env variables

* fix: removed issue attachment from proxy

* fix: added new migration file

* fix: restricted wehbook access

* chore: changed urls

* chore: fixed porject serializer

* fix: set expire for api token

* fix: retrive endpoint for api token

* feat: Api Token screens & api integration

* dev: webhook endpoint changes

* dev: add fields for webhook updates

* feat: Download Api secret key

* chore: removed BASE API URL

* feat: revoke token access

* dev: migration fixes

* feat: workspace webhooks (#2748)

* feat: workspace webhook store, services integeration and rendered webhook list and create

* chore: handled webhook update and rengenerate token in workspace webhooks

* feat: regenerate key and delete functionality

---------

Co-authored-by: Ramesh Kumar <rameshkumar@rameshs-MacBook-Pro.local>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Ramesh Kumar Chandra <rameshkumar2299@gmail.com>

* fix: url validation added

* fix: seperated env for webhook and api

* Web hooks refactoring

* add show option for generated hook key

* Api token restructure

* webhook minor fixes

* fix build errors

* chore: improvements in file structring

* dev: rate limiting the open apis

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com>
Co-authored-by: rahulramesha <71900764+rahulramesha@users.noreply.github.com>
Co-authored-by: Ramesh Kumar <rameshkumar@rameshs-MacBook-Pro.local>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Ramesh Kumar Chandra <rameshkumar2299@gmail.com>
Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: rahulramesha <rahulramesham@gmail.com>
This commit is contained in:
Bavisetti Narayan 2023-11-15 15:56:57 +05:30 committed by GitHub
parent 7b965179d8
commit 79347ec62b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
94 changed files with 3743 additions and 163 deletions

View File

@ -33,3 +33,8 @@ USE_MINIO=1
# Nginx Configuration # Nginx Configuration
NGINX_PORT=80 NGINX_PORT=80
# Set it to 0, to disable it
ENABLE_WEBHOOK=1
# Set it to 0, to disable it
ENABLE_API=1

View File

@ -70,5 +70,12 @@ ENABLE_MAGIC_LINK_LOGIN="0"
# Email redirections and minio domain settings # Email redirections and minio domain settings
WEB_URL="http://localhost" WEB_URL="http://localhost"
# Set it to 0, to disable it
ENABLE_WEBHOOK=1
# Set it to 0, to disable it
ENABLE_API=1
# Gunicorn Workers # Gunicorn Workers
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2

View File

@ -1,2 +1,17 @@
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission, WorkspaceViewerPermission, WorkspaceUserPermission
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission from .workspace import (
WorkSpaceBasePermission,
WorkspaceOwnerPermission,
WorkSpaceAdminPermission,
WorkspaceEntityPermission,
WorkspaceViewerPermission,
WorkspaceUserPermission,
)
from .project import (
ProjectBasePermission,
ProjectEntityPermission,
ProjectMemberPermission,
ProjectLitePermission,
)

View File

@ -45,6 +45,18 @@ class WorkSpaceBasePermission(BasePermission):
).exists() ).exists()
class WorkspaceOwnerPermission(BasePermission):
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role=Owner,
).exists()
class WorkSpaceAdminPermission(BasePermission): class WorkSpaceAdminPermission(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
if request.user.is_anonymous: if request.user.is_anonymous:
@ -93,10 +105,12 @@ class WorkspaceViewerPermission(BasePermission):
class WorkspaceUserPermission(BasePermission): class WorkspaceUserPermission(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
if request.user.is_anonymous:
return False
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
member=request.user, member=request.user,
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
is_active=True, is_active=True,
) ).exists()

View File

@ -71,7 +71,7 @@ from .module import (
ModuleFavoriteSerializer, ModuleFavoriteSerializer,
) )
from .api_token import APITokenSerializer from .api import APITokenSerializer, APITokenReadSerializer
from .integration import ( from .integration import (
IntegrationSerializer, IntegrationSerializer,
@ -100,3 +100,5 @@ from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer from .notification import NotificationSerializer
from .exporter import ExporterHistorySerializer from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer

View File

@ -0,0 +1,31 @@
from .base import BaseSerializer
from plane.db.models import APIToken, APIActivityLog
class APITokenSerializer(BaseSerializer):
class Meta:
model = APIToken
fields = "__all__"
read_only_fields = [
"token",
"expired_at",
"created_at",
"updated_at",
"workspace",
"user",
]
class APITokenReadSerializer(BaseSerializer):
class Meta:
model = APIToken
exclude = ('token',)
class APIActivityLogSerializer(BaseSerializer):
class Meta:
model = APIActivityLog
fields = "__all__"

View File

@ -1,14 +0,0 @@
from .base import BaseSerializer
from plane.db.models import APIToken
class APITokenSerializer(BaseSerializer):
class Meta:
model = APIToken
fields = [
"label",
"user",
"user_type",
"workspace",
"created_at",
]

View File

@ -112,7 +112,7 @@ class ProjectListSerializer(DynamicBaseSerializer):
"member__display_name", "member__display_name",
"member__avatar", "member__avatar",
) )
return project_members return list(project_members)
class Meta: class Meta:
model = Project model = Project

View File

@ -0,0 +1,30 @@
# Third party imports
from rest_framework import serializers
# Module imports
from .base import DynamicBaseSerializer
from plane.db.models import Webhook, WebhookLog
from plane.db.models.webhook import validate_domain, validate_schema
class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain])
class Meta:
model = Webhook
fields = "__all__"
read_only_fields = [
"workspace",
"secret_key",
]
class WebhookLogSerializer(DynamicBaseSerializer):
class Meta:
model = WebhookLog
fields = "__all__"
read_only_fields = [
"workspace",
"webhook"
]

View File

@ -19,6 +19,12 @@ from .state import urlpatterns as state_urls
from .user import urlpatterns as user_urls from .user import urlpatterns as user_urls
from .views import urlpatterns as view_urls from .views import urlpatterns as view_urls
from .workspace import urlpatterns as workspace_urls from .workspace import urlpatterns as workspace_urls
from .api import urlpatterns as api_urls
from .webhook import urlpatterns as webhook_urls
# Django imports
from django.conf import settings
urlpatterns = [ urlpatterns = [
@ -44,3 +50,9 @@ urlpatterns = [
*view_urls, *view_urls,
*workspace_urls, *workspace_urls,
] ]
if settings.ENABLE_WEBHOOK:
urlpatterns += webhook_urls
if settings.ENABLE_API:
urlpatterns += api_urls

View File

@ -0,0 +1,17 @@
from django.urls import path
from plane.api.views import ApiTokenEndpoint
urlpatterns = [
# API Tokens
path(
"workspaces/<str:slug>/api-tokens/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
## End API Tokens
]

View File

@ -0,0 +1,31 @@
from django.urls import path
from plane.api.views import (
WebhookEndpoint,
WebhookLogsEndpoint,
WebhookSecretRegenerateEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/webhooks/",
WebhookEndpoint.as_view(),
name="webhooks",
),
path(
"workspaces/<str:slug>/webhooks/<uuid:pk>/",
WebhookEndpoint.as_view(),
name="webhooks",
),
path(
"workspaces/<str:slug>/webhooks/<uuid:pk>/regenerate/",
WebhookSecretRegenerateEndpoint.as_view(),
name="webhooks",
),
path(
"workspaces/<str:slug>/webhook-logs/<uuid:webhook_id>/",
WebhookLogsEndpoint.as_view(),
name="webhooks",
),
]

View File

@ -23,7 +23,7 @@ from .user import (
from .oauth import OauthEndpoint from .oauth import OauthEndpoint
from .base import BaseAPIView, BaseViewSet from .base import BaseAPIView, BaseViewSet, WebhookMixin
from .workspace import ( from .workspace import (
WorkSpaceViewSet, WorkSpaceViewSet,
@ -115,7 +115,7 @@ from .module import (
ModuleFavoriteViewSet, ModuleFavoriteViewSet,
) )
from .api_token import ApiTokenEndpoint from .api import ApiTokenEndpoint
from .integration import ( from .integration import (
WorkspaceIntegrationViewSet, WorkspaceIntegrationViewSet,
@ -172,3 +172,5 @@ from .notification import (
from .exporter import ExportIssuesEndpoint from .exporter import ExportIssuesEndpoint
from .config import ConfigurationEndpoint from .config import ConfigurationEndpoint
from .webhook import WebhookEndpoint, WebhookLogsEndpoint, WebhookSecretRegenerateEndpoint

View File

@ -0,0 +1,78 @@
# Python import
from uuid import uuid4
# Third party
from rest_framework.response import Response
from rest_framework import status
# Module import
from .base import BaseAPIView
from plane.db.models import APIToken, Workspace
from plane.api.serializers import APITokenSerializer, APITokenReadSerializer
from plane.api.permissions import WorkspaceOwnerPermission
class ApiTokenEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
def post(self, request, slug):
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
user_type = 1 if request.user.is_bot else 0
api_token = APIToken.objects.create(
label=label,
description=description,
user=request.user,
workspace=workspace,
user_type=user_type,
expired_at=expired_at,
)
serializer = APITokenSerializer(api_token)
# Token will be only visible while creating
return Response(
serializer.data,
status=status.HTTP_201_CREATED,
)
def get(self, request, slug, pk=None):
if pk == None:
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug
)
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
)
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,
)
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,
)
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@ -1,47 +0,0 @@
# Python import
from uuid import uuid4
# Third party
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module import
from .base import BaseAPIView
from plane.db.models import APIToken
from plane.api.serializers import APITokenSerializer
class ApiTokenEndpoint(BaseAPIView):
def post(self, request):
label = request.data.get("label", str(uuid4().hex))
workspace = request.data.get("workspace", False)
if not workspace:
return Response(
{"error": "Workspace is required"}, status=status.HTTP_200_OK
)
api_token = APIToken.objects.create(
label=label, user=request.user, workspace_id=workspace
)
serializer = APITokenSerializer(api_token)
# Token will be only vissible while creating
return Response(
{"api_token": serializer.data, "token": api_token.token},
status=status.HTTP_201_CREATED,
)
def get(self, request):
api_tokens = APIToken.objects.filter(user=request.user)
serializer = APITokenSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def delete(self, request, pk):
api_token = APIToken.objects.get(pk=pk)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -1,5 +1,6 @@
# Python imports # Python imports
import zoneinfo import zoneinfo
import json
# Django imports # Django imports
from django.urls import resolve from django.urls import resolve
@ -7,6 +8,7 @@ from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.db import IntegrityError from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.serializers.json import DjangoJSONEncoder
# Third part imports # Third part imports
from rest_framework import status from rest_framework import status
@ -22,6 +24,7 @@ from django_filters.rest_framework import DjangoFilterBackend
# Module imports # Module imports
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
from plane.bgtasks.webhook_task import send_webhook
class TimezoneMixin: class TimezoneMixin:
@ -29,6 +32,7 @@ class TimezoneMixin:
This enables timezone conversion according This enables timezone conversion according
to the user set timezone to the user set timezone
""" """
def initial(self, request, *args, **kwargs): def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs) super().initial(request, *args, **kwargs)
if request.user.is_authenticated: if request.user.is_authenticated:
@ -37,8 +41,29 @@ class TimezoneMixin:
timezone.deactivate() timezone.deactivate()
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): class WebhookMixin:
webhook_event = None
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
and settings.ENABLE_WEBHOOK
):
send_webhook.delay(
event=self.webhook_event,
event_data=json.dumps(response.data, cls=DjangoJSONEncoder),
action=self.request.method,
slug=self.workspace_slug,
)
return response
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
model = None model = None
permission_classes = [ permission_classes = [
@ -60,7 +85,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
except Exception as e: except Exception as e:
capture_exception(e) capture_exception(e)
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)
def handle_exception(self, exc): def handle_exception(self, exc):
""" """
Handle any exception that occurs, by returning an appropriate response, Handle any exception that occurs, by returning an appropriate response,
@ -71,18 +96,30 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
return response return response
except Exception as e: except Exception as e:
if isinstance(e, IntegrityError): if isinstance(e, IntegrityError):
return Response({"error": "The payload is not valid"}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ValidationError): if isinstance(e, ValidationError):
return Response({"error": "Please provide valid detail"}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ObjectDoesNotExist): if isinstance(e, ObjectDoesNotExist):
model_name = str(exc).split(" matching query does not exist.")[0] model_name = str(exc).split(" matching query does not exist.")[0]
return Response({"error": f"{model_name} does not exist."}, status=status.HTTP_404_NOT_FOUND) return Response(
{"error": f"{model_name} does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError): if isinstance(e, KeyError):
capture_exception(e) capture_exception(e)
return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": f"key {e} does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
print(e) if settings.DEBUG else print("Server Error") print(e) if settings.DEBUG else print("Server Error")
capture_exception(e) capture_exception(e)
@ -99,8 +136,8 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
print( print(
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
) )
return response
return response
except Exception as exc: except Exception as exc:
response = self.handle_exception(exc) response = self.handle_exception(exc)
return exc return exc
@ -120,7 +157,6 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
class BaseAPIView(TimezoneMixin, APIView, BasePaginator): class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
permission_classes = [ permission_classes = [
IsAuthenticated, IsAuthenticated,
] ]
@ -139,7 +175,6 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
queryset = backend().filter_queryset(self.request, queryset, self) queryset = backend().filter_queryset(self.request, queryset, self)
return queryset return queryset
def handle_exception(self, exc): def handle_exception(self, exc):
""" """
Handle any exception that occurs, by returning an appropriate response, Handle any exception that occurs, by returning an appropriate response,
@ -150,19 +185,29 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
return response return response
except Exception as e: except Exception as e:
if isinstance(e, IntegrityError): if isinstance(e, IntegrityError):
return Response({"error": "The payload is not valid"}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ValidationError): if isinstance(e, ValidationError):
return Response({"error": "Please provide valid detail"}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ObjectDoesNotExist): if isinstance(e, ObjectDoesNotExist):
model_name = str(exc).split(" matching query does not exist.")[0] model_name = str(exc).split(" matching query does not exist.")[0]
return Response({"error": f"{model_name} does not exist."}, status=status.HTTP_404_NOT_FOUND) return Response(
{"error": f"{model_name} does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError): if isinstance(e, KeyError):
return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST)
print(e) if settings.DEBUG else print("Server Error") if settings.DEBUG:
print(e)
capture_exception(e) capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@ -23,7 +23,7 @@ from rest_framework import status
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
# Module imports # Module imports
from . import BaseViewSet, BaseAPIView from . import BaseViewSet, BaseAPIView, WebhookMixin
from plane.api.serializers import ( from plane.api.serializers import (
CycleSerializer, CycleSerializer,
CycleIssueSerializer, CycleIssueSerializer,
@ -48,9 +48,10 @@ from plane.utils.issue_filters import issue_filters
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
class CycleViewSet(BaseViewSet): class CycleViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleSerializer serializer_class = CycleSerializer
model = Cycle model = Cycle
webhook_event = "cycle"
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
@ -499,10 +500,10 @@ class CycleViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class CycleIssueViewSet(BaseViewSet): class CycleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleIssueSerializer serializer_class = CycleIssueSerializer
model = CycleIssue model = CycleIssue
webhook_event = "cycle"
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]

View File

@ -33,7 +33,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
# Module imports # Module imports
from . import BaseViewSet, BaseAPIView from . import BaseViewSet, BaseAPIView, WebhookMixin
from plane.api.serializers import ( from plane.api.serializers import (
IssueCreateSerializer, IssueCreateSerializer,
IssueActivitySerializer, IssueActivitySerializer,
@ -84,7 +84,7 @@ from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
class IssueViewSet(BaseViewSet): class IssueViewSet(WebhookMixin, BaseViewSet):
def get_serializer_class(self): def get_serializer_class(self):
return ( return (
IssueCreateSerializer IssueCreateSerializer
@ -93,6 +93,7 @@ class IssueViewSet(BaseViewSet):
) )
model = Issue model = Issue
webhook_event = "issue"
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
@ -594,9 +595,10 @@ class IssueActivityEndpoint(BaseAPIView):
return Response(result_list, status=status.HTTP_200_OK) return Response(result_list, status=status.HTTP_200_OK)
class IssueCommentViewSet(BaseViewSet): class IssueCommentViewSet(WebhookMixin, BaseViewSet):
serializer_class = IssueCommentSerializer serializer_class = IssueCommentSerializer
model = IssueComment model = IssueComment
webhook_event = "issue-comment"
permission_classes = [ permission_classes = [
ProjectLitePermission, ProjectLitePermission,
] ]

View File

@ -15,7 +15,7 @@ from rest_framework import status
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
# Module imports # Module imports
from . import BaseViewSet from . import BaseViewSet, WebhookMixin
from plane.api.serializers import ( from plane.api.serializers import (
ModuleWriteSerializer, ModuleWriteSerializer,
ModuleSerializer, ModuleSerializer,
@ -41,11 +41,12 @@ from plane.utils.issue_filters import issue_filters
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
class ModuleViewSet(BaseViewSet): class ModuleViewSet(WebhookMixin, BaseViewSet):
model = Module model = Module
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
webhook_event = "module"
def get_serializer_class(self): def get_serializer_class(self):
return ( return (

View File

@ -26,7 +26,7 @@ from rest_framework import serializers
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
# Module imports # Module imports
from .base import BaseViewSet, BaseAPIView from .base import BaseViewSet, BaseAPIView, WebhookMixin
from plane.api.serializers import ( from plane.api.serializers import (
ProjectSerializer, ProjectSerializer,
ProjectListSerializer, ProjectListSerializer,
@ -67,9 +67,10 @@ from plane.db.models import (
from plane.bgtasks.project_invitation_task import project_invitation from plane.bgtasks.project_invitation_task import project_invitation
class ProjectViewSet(BaseViewSet): class ProjectViewSet(WebhookMixin, BaseViewSet):
serializer_class = ProjectSerializer serializer_class = ProjectSerializer
model = Project model = Project
webhook_event = "project"
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,

View File

@ -0,0 +1,130 @@
# Django imports
from django.db import IntegrityError
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.db.models import Webhook, WebhookLog, Workspace
from plane.db.models.webhook import generate_token
from .base import BaseAPIView
from plane.api.permissions import WorkspaceOwnerPermission
from plane.api.serializers import WebhookSerializer, WebhookLogSerializer
class WebhookEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
try:
serializer = WebhookSerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"error": "URL already exists for the workspace"},
status=status.HTTP_410_GONE,
)
raise IntegrityError
def get(self, request, slug, pk=None):
if pk == None:
webhooks = Webhook.objects.filter(workspace__slug=slug)
serializer = WebhookSerializer(
webhooks,
fields=(
"id",
"url",
"is_active",
"created_at",
"updated_at",
"project",
"issue",
"cycle",
"module",
"issue_comment",
),
many=True,
)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
webhook = Webhook.objects.get(workspace__slug=slug, pk=pk)
serializer = WebhookSerializer(
webhook,
fields=(
"id",
"url",
"is_active",
"created_at",
"updated_at",
"project",
"issue",
"cycle",
"module",
"issue_comment",
),
)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, pk):
webhook = Webhook.objects.get(workspace__slug=slug, pk=pk)
serializer = WebhookSerializer(
webhook,
data=request.data,
partial=True,
fields=(
"id",
"url",
"is_active",
"created_at",
"updated_at",
"project",
"issue",
"cycle",
"module",
"issue_comment",
),
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, pk):
webhook = Webhook.objects.get(pk=pk, workspace__slug=slug)
webhook.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class WebhookSecretRegenerateEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
def post(self, request, slug, pk):
webhook = Webhook.objects.get(workspace__slug=slug, pk=pk)
webhook.secret_key = generate_token()
webhook.save()
serializer = WebhookSerializer(webhook)
return Response(serializer.data, status=status.HTTP_200_OK)
class WebhookLogsEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
def get(self, request, slug, webhook_id):
webhook_logs = WebhookLog.objects.filter(
workspace__slug=slug, webhook_id=webhook_id
)
serializer = WebhookLogSerializer(webhook_logs, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -0,0 +1,47 @@
# Django imports
from django.utils import timezone
from django.db.models import Q
# Third party imports
from rest_framework import authentication
from rest_framework.exceptions import AuthenticationFailed
# Module imports
from plane.db.models import APIToken
class APIKeyAuthentication(authentication.BaseAuthentication):
"""
Authentication with an API Key
"""
www_authenticate_realm = "api"
media_type = "application/json"
auth_header_name = "X-Api-Key"
def get_api_token(self, request):
return request.headers.get(self.auth_header_name)
def validate_api_token(self, token):
try:
api_token = APIToken.objects.get(
Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)),
token=token,
is_active=True,
)
except APIToken.DoesNotExist:
raise AuthenticationFailed("Given API token is not valid")
# save api token last used
api_token.last_used = timezone.now()
api_token.save(update_fields=["last_used"])
return (api_token.user, api_token.token)
def authenticate(self, request):
token = self.get_api_token(request=request)
if not token:
return None
# Validate the API token
user, token = self.validate_api_token(token)
return user, token

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "plane.authentication"

View File

@ -0,0 +1,139 @@
import requests
import uuid
import hashlib
import json
# Django imports
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from plane.db.models import Webhook, WebhookLog
@shared_task(
bind=True,
autoretry_for=(requests.RequestException,),
retry_backoff=600,
max_retries=5,
retry_jitter=True,
)
def webhook_task(self, webhook, slug, event, event_data, action):
try:
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
headers = {
"Content-Type": "application/json",
"User-Agent": "Autopilot",
"X-Plane-Delivery": str(uuid.uuid4()),
"X-Plane-Event": event,
}
# Your secret key
if webhook.secret_key:
# Concatenate the data and the secret key
message = event_data + webhook.secret_key
# Create a SHA-256 hash of the message
sha256 = hashlib.sha256()
sha256.update(message.encode("utf-8"))
signature = sha256.hexdigest()
headers["X-Plane-Signature"] = signature
event_data = json.loads(event_data) if event_data is not None else None
action = {
"POST": "create",
"PATCH": "update",
"PUT": "update",
"DELETE": "delete",
}.get(action, action)
payload = {
"event": event,
"action": action,
"webhook_id": str(webhook.id),
"workspace_id": str(webhook.workspace_id),
"data": event_data,
}
# Send the webhook event
response = requests.post(
webhook.url,
headers=headers,
json=payload,
timeout=30,
)
# Log the webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=str(response.status_code),
response_headers=str(response.headers),
response_body=str(response.text),
retry_count=str(self.request.retries),
)
except requests.RequestException as e:
# Log the failed webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook_id=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=500,
response_headers="",
response_body=str(e),
retry_count=str(self.request.retries),
)
# Retry logic
if self.request.retries >= self.max_retries:
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
return
raise requests.RequestException()
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)
return
@shared_task()
def send_webhook(event, event_data, action, slug):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
if event == "project":
webhooks = webhooks.filter(project=True)
if event == "issue":
webhooks = webhooks.filter(issue=True)
if event == "module":
webhooks = webhooks.filter(module=True)
if event == "cycle":
webhooks = webhooks.filter(cycle=True)
if event == "issue-comment":
webhooks = webhooks.filter(issue_comment=True)
for webhook in webhooks:
webhook_task.delay(webhook.id, slug, event, event_data, action)
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@ -3,7 +3,7 @@
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import plane.db.models.api_token import plane.db.models.api
import uuid import uuid
@ -40,8 +40,8 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('token', models.CharField(default=plane.db.models.api_token.generate_token, max_length=255, unique=True)), ('token', models.CharField(default=plane.db.models.api.generate_token, max_length=255, unique=True)),
('label', models.CharField(default=plane.db.models.api_token.generate_label_token, max_length=255)), ('label', models.CharField(default=plane.db.models.api.generate_label_token, max_length=255)),
('user_type', models.PositiveSmallIntegerField(choices=[(0, 'Human'), (1, 'Bot')], default=0)), ('user_type', models.PositiveSmallIntegerField(choices=[(0, 'Human'), (1, 'Bot')], default=0)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),

View File

@ -0,0 +1,116 @@
# Generated by Django 4.2.5 on 2023-10-20 12:16
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.api
import plane.db.models.webhook
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0046_alter_analyticview_created_by_and_more'),
]
operations = [
migrations.CreateModel(
name='Webhook',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('url', models.URLField(validators=[plane.db.models.webhook.validate_schema, plane.db.models.webhook.validate_domain])),
('is_active', models.BooleanField(default=True)),
('secret_key', models.CharField(default=plane.db.models.webhook.generate_token, max_length=255)),
('project', models.BooleanField(default=False)),
('issue', models.BooleanField(default=False)),
('module', models.BooleanField(default=False)),
('cycle', models.BooleanField(default=False)),
('issue_comment', models.BooleanField(default=False)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_webhooks', to='db.workspace')),
],
options={
'verbose_name': 'Webhook',
'verbose_name_plural': 'Webhooks',
'db_table': 'webhooks',
'ordering': ('-created_at',),
'unique_together': {('workspace', 'url')},
},
),
migrations.AddField(
model_name='apitoken',
name='description',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='apitoken',
name='expired_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='apitoken',
name='is_active',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='apitoken',
name='last_used',
field=models.DateTimeField(null=True),
),
migrations.CreateModel(
name='WebhookLog',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('event_type', models.CharField(blank=True, max_length=255, null=True)),
('request_method', models.CharField(blank=True, max_length=10, null=True)),
('request_headers', models.TextField(blank=True, null=True)),
('request_body', models.TextField(blank=True, null=True)),
('response_status', models.TextField(blank=True, null=True)),
('response_headers', models.TextField(blank=True, null=True)),
('response_body', models.TextField(blank=True, null=True)),
('retry_count', models.PositiveSmallIntegerField(default=0)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='db.webhook')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhook_logs', to='db.workspace')),
],
options={
'verbose_name': 'Webhook Log',
'verbose_name_plural': 'Webhook Logs',
'db_table': 'webhook_logs',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='APIActivityLog',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('token_identifier', models.CharField(max_length=255)),
('path', models.CharField(max_length=255)),
('method', models.CharField(max_length=10)),
('query_params', models.TextField(blank=True, null=True)),
('headers', models.TextField(blank=True, null=True)),
('body', models.TextField(blank=True, null=True)),
('response_code', models.PositiveIntegerField()),
('response_body', models.TextField(blank=True, null=True)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, max_length=512, null=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
],
options={
'verbose_name': 'API Activity Log',
'verbose_name_plural': 'API Activity Logs',
'db_table': 'api_activity_logs',
'ordering': ('-created_at',),
},
),
]

View File

@ -54,7 +54,7 @@ from .view import GlobalView, IssueView, IssueViewFavorite
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
from .api_token import APIToken from .api import APIToken, APIActivityLog
from .integration import ( from .integration import (
WorkspaceIntegration, WorkspaceIntegration,
@ -79,3 +79,5 @@ from .analytic import AnalyticView
from .notification import Notification from .notification import Notification
from .exporter import ExporterHistory from .exporter import ExporterHistory
from .webhook import Webhook, WebhookLog

View File

@ -0,0 +1,80 @@
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from .base import BaseModel
def generate_label_token():
return uuid4().hex
def generate_token():
return "plane_api_" + uuid4().hex
class APIToken(BaseModel):
# Meta information
label = models.CharField(max_length=255, default=generate_label_token)
description = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
last_used = models.DateTimeField(null=True)
# Token
token = models.CharField(
max_length=255, unique=True, default=generate_token, db_index=True
)
# User Information
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="bot_tokens",
)
user_type = models.PositiveSmallIntegerField(
choices=((0, "Human"), (1, "Bot")), default=0
)
workspace = models.ForeignKey(
"db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True
)
expired_at = models.DateTimeField(blank=True, null=True)
class Meta:
verbose_name = "API Token"
verbose_name_plural = "API Tokems"
db_table = "api_tokens"
ordering = ("-created_at",)
def __str__(self):
return str(self.user.id)
class APIActivityLog(BaseModel):
token_identifier = models.CharField(max_length=255)
# Request Info
path = models.CharField(max_length=255)
method = models.CharField(max_length=10)
query_params = models.TextField(null=True, blank=True)
headers = models.TextField(null=True, blank=True)
body = models.TextField(null=True, blank=True)
# Response info
response_code = models.PositiveIntegerField()
response_body = models.TextField(null=True, blank=True)
# Meta information
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.CharField(max_length=512, null=True, blank=True)
class Meta:
verbose_name = "API Activity Log"
verbose_name_plural = "API Activity Logs"
db_table = "api_activity_logs"
ordering = ("-created_at",)
def __str__(self):
return str(self.token_identifier)

View File

@ -1,41 +0,0 @@
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from .base import BaseModel
def generate_label_token():
return uuid4().hex
def generate_token():
return uuid4().hex + uuid4().hex
class APIToken(BaseModel):
token = models.CharField(max_length=255, unique=True, default=generate_token)
label = models.CharField(max_length=255, default=generate_label_token)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="bot_tokens",
)
user_type = models.PositiveSmallIntegerField(
choices=((0, "Human"), (1, "Bot")), default=0
)
workspace = models.ForeignKey(
"db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True
)
class Meta:
verbose_name = "API Token"
verbose_name_plural = "API Tokems"
db_table = "api_tokens"
ordering = ("-created_at",)
def __str__(self):
return str(self.user.name)

View File

@ -0,0 +1,90 @@
# Python imports
from uuid import uuid4
from urllib.parse import urlparse
# Django imports
from django.db import models
from django.core.exceptions import ValidationError
# Module imports
from plane.db.models import BaseModel
def generate_token():
return "plane_wh_" + uuid4().hex
def validate_schema(value):
parsed_url = urlparse(value)
print(parsed_url)
if parsed_url.scheme not in ["http", "https"]:
raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.")
def validate_domain(value):
parsed_url = urlparse(value)
domain = parsed_url.netloc
if domain in ["localhost", "127.0.0.1"]:
raise ValidationError("Local URLs are not allowed.")
class Webhook(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_webhooks",
)
url = models.URLField(
validators=[
validate_schema,
validate_domain,
]
)
is_active = models.BooleanField(default=True)
secret_key = models.CharField(max_length=255, default=generate_token)
project = models.BooleanField(default=False)
issue = models.BooleanField(default=False)
module = models.BooleanField(default=False)
cycle = models.BooleanField(default=False)
issue_comment = models.BooleanField(default=False)
def __str__(self):
return f"{self.workspace.slug} {self.url}"
class Meta:
unique_together = ["workspace", "url"]
verbose_name = "Webhook"
verbose_name_plural = "Webhooks"
db_table = "webhooks"
ordering = ("-created_at",)
class WebhookLog(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs"
)
# Associated webhook
webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="logs")
# Basic request details
event_type = models.CharField(max_length=255, blank=True, null=True)
request_method = models.CharField(max_length=10, blank=True, null=True)
request_headers = models.TextField(blank=True, null=True)
request_body = models.TextField(blank=True, null=True)
# Response details
response_status = models.TextField(blank=True, null=True)
response_headers = models.TextField(blank=True, null=True)
response_body = models.TextField(blank=True, null=True)
# Retry Count
retry_count = models.PositiveSmallIntegerField(default=0)
class Meta:
verbose_name = "Webhook Log"
verbose_name_plural = "Webhook Logs"
db_table = "webhook_logs"
ordering = ("-created_at",)
def __str__(self):
return f"{self.event_type} {str(self.webhook.url)}"

View File

@ -0,0 +1,40 @@
from plane.db.models import APIToken, APIActivityLog
class APITokenLogMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request_body = request.body
response = self.get_response(request)
self.process_request(request, response, request_body)
return response
def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
# If the API key is present, log the request
if api_key:
try:
APIActivityLog.objects.create(
token_identifier=api_key,
path=request.path,
method=request.method,
query_params=request.META.get("QUERY_STRING", ""),
headers=str(request.headers),
body=(request_body.decode('utf-8') if request_body else None),
response_body=(
response.content.decode("utf-8") if response.content else None
),
response_code=response.status_code,
ip_address=request.META.get("REMOTE_ADDR", None),
user_agent=request.META.get("HTTP_USER_AGENT", None),
)
except Exception as e:
print(e)
# If the token does not exist, you can decide whether to log this as an invalid attempt
pass
return None

View File

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ProxyConfig(AppConfig):
name = "plane.proxy"

View File

@ -0,0 +1,45 @@
from django.utils import timezone
from rest_framework.throttling import SimpleRateThrottle
class ApiKeyRateThrottle(SimpleRateThrottle):
scope = 'api_key'
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
api_key = request.headers.get('X-Api-Key')
if not api_key:
return None # Allow the request if there's no API key
# Use the API key as part of the cache key
return f'{self.scope}:{api_key}'
def allow_request(self, request, view):
# Calculate the current time as a Unix timestamp
now = timezone.now().timestamp()
# Use the parent class's method to check if the request is allowed
allowed = super().allow_request(request, view)
if allowed:
# Calculate the remaining limit and reset time
history = self.cache.get(self.key, [])
# Remove old histories
while history and history[-1] <= now - self.duration:
history.pop()
# Calculate the requests
num_requests = len(history)
# Check available requests
available = self.num_requests - num_requests
# Unix timestamp for when the rate limit will reset
reset_time = int(now + self.duration)
# Add headers
request.META['X-RateLimit-Remaining'] = max(0, available)
request.META['X-RateLimit-Reset'] = reset_time
return allowed

View File

@ -0,0 +1,13 @@
from .cycle import urlpatterns as cycle_patterns
from .inbox import urlpatterns as inbox_patterns
from .issue import urlpatterns as issue_patterns
from .module import urlpatterns as module_patterns
from .project import urlpatterns as project_patterns
urlpatterns = [
*cycle_patterns,
*inbox_patterns,
*issue_patterns,
*module_patterns,
*project_patterns,
]

View File

@ -0,0 +1,35 @@
from django.urls import path
from plane.proxy.views.cycle import (
CycleAPIEndpoint,
CycleIssueAPIEndpoint,
TransferCycleIssueAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/",
CycleAPIEndpoint.as_view(),
name="cycles",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/",
CycleAPIEndpoint.as_view(),
name="cycles",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/",
CycleIssueAPIEndpoint.as_view(),
name="cycle-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:pk>/",
CycleIssueAPIEndpoint.as_view(),
name="cycle-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/transfer-issues/",
TransferCycleIssueAPIEndpoint.as_view(),
name="transfer-issues",
),
]

View File

@ -0,0 +1,17 @@
from django.urls import path
from plane.proxy.views import InboxIssueAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
InboxIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
]

View File

@ -0,0 +1,51 @@
from django.urls import path
from plane.proxy.views import (
IssueAPIEndpoint,
LabelAPIEndpoint,
IssueLinkAPIEndpoint,
IssueCommentAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueAPIEndpoint.as_view(),
name="issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/",
IssueAPIEndpoint.as_view(),
name="issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelAPIEndpoint.as_view(),
name="labels",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/<uuid:pk>/",
LabelAPIEndpoint.as_view(),
name="labels",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/",
IssueLinkAPIEndpoint.as_view(),
name="issue-links",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/<uuid:pk>/",
IssueLinkAPIEndpoint.as_view(),
name="issue-links",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentAPIEndpoint.as_view(),
name="project-issue-comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentAPIEndpoint.as_view(),
name="project-issue-comment",
),
]

View File

@ -0,0 +1,26 @@
from django.urls import path
from plane.proxy.views import ModuleAPIEndpoint, ModuleIssueAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/",
ModuleAPIEndpoint.as_view(),
name="modules",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/",
ModuleAPIEndpoint.as_view(),
name="modules",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
ModuleIssueAPIEndpoint.as_view(),
name="module-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:pk>/",
ModuleIssueAPIEndpoint.as_view(),
name="module-issues",
),
]

View File

@ -0,0 +1,16 @@
from django.urls import path
from plane.proxy.views import ProjectAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/",
ProjectAPIEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:pk>/",
ProjectAPIEndpoint.as_view(),
name="project",
),
]

View File

@ -0,0 +1,18 @@
from .project import ProjectAPIEndpoint
from .issue import (
IssueAPIEndpoint,
LabelAPIEndpoint,
IssueLinkAPIEndpoint,
IssueCommentAPIEndpoint,
)
from .cycle import (
CycleAPIEndpoint,
CycleIssueAPIEndpoint,
TransferCycleIssueAPIEndpoint,
)
from .module import ModuleAPIEndpoint, ModuleIssueAPIEndpoint
from .inbox import InboxIssueAPIEndpoint

View File

@ -0,0 +1,101 @@
# Python imports
import re
import json
import requests
# Django imports
from django.conf import settings
# Third party imports
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework_simplejwt.tokens import RefreshToken
# Module imports
from plane.authentication.api_authentication import APIKeyAuthentication
from plane.proxy.rate_limit import ApiKeyRateThrottle
class BaseAPIView(APIView):
authentication_classes = [
APIKeyAuthentication,
]
permission_classes = [
IsAuthenticated,
]
throttle_classes = [
ApiKeyRateThrottle,
]
def _get_jwt_token(self, request):
refresh = RefreshToken.for_user(request.user)
return str(refresh.access_token)
def _get_url_path(self, request):
match = re.search(r"/v1/(.*)", request.path)
return match.group(1) if match else ""
def _get_headers(self, request):
return {
"Authorization": f"Bearer {self._get_jwt_token(request=request)}",
"Content-Type": request.headers.get("Content-Type", "application/json"),
}
def _get_url(self, request):
path = self._get_url_path(request=request)
url = request.build_absolute_uri("/api/" + path)
return url
def _get_query_params(self, request):
query_params = request.GET
return query_params
def _get_payload(self, request):
content_type = request.headers.get("Content-Type", "application/json")
if content_type.startswith("multipart/form-data"):
files_dict = {k: v[0] for k, v in request.FILES.lists()}
return (None, files_dict)
else:
return (json.dumps(request.data), None)
def _make_request(self, request, method="GET"):
data_payload, files_payload = self._get_payload(request=request)
response = requests.request(
method=method,
url=self._get_url(request=request),
headers=self._get_headers(request=request),
params=self._get_query_params(request=request),
data=data_payload,
files=files_payload,
)
return response.json(), response.status_code
def finalize_response(self, request, response, *args, **kwargs):
# Call super to get the default response
response = super().finalize_response(request, response, *args, **kwargs)
# Add custom headers if they exist in the request META
ratelimit_remaining = request.META.get('X-RateLimit-Remaining')
if ratelimit_remaining is not None:
response['X-RateLimit-Remaining'] = ratelimit_remaining
ratelimit_reset = request.META.get('X-RateLimit-Reset')
if ratelimit_reset is not None:
response['X-RateLimit-Reset'] = ratelimit_reset
return response
def get(self, request, *args, **kwargs):
response, status_code = self._make_request(request=request, method="GET")
return Response(response, status=status_code)
def post(self, request, *args, **kwargs):
response, status_code = self._make_request(request=request, method="POST")
return Response(response, status=status_code)
def partial_update(self, request, *args, **kwargs):
response, status_code = self._make_request(request=request, method="PATCH")
return Response(response, status=status_code)

View File

@ -0,0 +1,30 @@
from .base import BaseAPIView
class CycleAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle.
"""
pass
class CycleIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle issues.
"""
pass
class TransferCycleIssueAPIEndpoint(BaseAPIView):
"""
This viewset provides `create` actions for transfering the issues into a particular cycle.
"""
pass

View File

@ -0,0 +1,10 @@
from .base import BaseAPIView
class InboxIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to inbox issues.
"""
pass

View File

@ -0,0 +1,37 @@
from .base import BaseAPIView
class IssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to issue.
"""
pass
class LabelAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to the labels.
"""
pass
class IssueLinkAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to the links of the particular issue.
"""
pass
class IssueCommentAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to comments of the particular issue.
"""
pass

View File

@ -0,0 +1,20 @@
from .base import BaseAPIView
class ModuleAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module.
"""
pass
class ModuleIssueAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module issues.
"""
pass

View File

@ -0,0 +1,5 @@
from .base import BaseAPIView
class ProjectAPIEndpoint(BaseAPIView):
pass

View File

@ -26,6 +26,13 @@ DEBUG = False
# Allowed Hosts # Allowed Hosts
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
# To access webhook
ENABLE_WEBHOOK = os.environ.get("ENABLE_WEBHOOK", "1") == "1"
# To access plane api through api tokens
ENABLE_API = os.environ.get("ENABLE_API", "1") == "1"
# Redirect if / is not present # Redirect if / is not present
APPEND_SLASH = True APPEND_SLASH = True
@ -42,6 +49,7 @@ INSTALLED_APPS = [
"plane.utils", "plane.utils",
"plane.web", "plane.web",
"plane.middleware", "plane.middleware",
"plane.proxy",
# Third-party things # Third-party things
"rest_framework", "rest_framework",
"rest_framework.authtoken", "rest_framework.authtoken",
@ -63,6 +71,7 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"crum.CurrentRequestUserMiddleware", "crum.CurrentRequestUserMiddleware",
"django.middleware.gzip.GZipMiddleware", "django.middleware.gzip.GZipMiddleware",
"plane.middleware.api_log_middleware.APITokenLogMiddleware",
] ]
# Rest Framework settings # Rest Framework settings
@ -73,6 +82,10 @@ REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
"DEFAULT_THROTTLE_CLASSES": ("plane.proxy.rate_limit.ApiKeyRateThrottle",),
"DEFAULT_THROTTLE_RATES": {
"api_key": "60/minute",
},
} }
# Django Auth Backend # Django Auth Backend
@ -284,7 +297,6 @@ CELERY_IMPORTS = (
"plane.bgtasks.exporter_expired_task", "plane.bgtasks.exporter_expired_task",
) )
# Sentry Settings # Sentry Settings
# Enable Sentry Settings # Enable Sentry Settings
if bool(os.environ.get("SENTRY_DSN", False)): if bool(os.environ.get("SENTRY_DSN", False)):
@ -330,3 +342,4 @@ SCOUT_NAME = "Plane"
# Set the variable true if running in docker environment # Set the variable true if running in docker environment
DOCKERIZED = int(os.environ.get("DOCKERIZED", 1)) == 1 DOCKERIZED = int(os.environ.get("DOCKERIZED", 1)) == 1
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1

View File

@ -14,6 +14,8 @@ urlpatterns = [
path("", include("plane.web.urls")), path("", include("plane.web.urls")),
] ]
if settings.ENABLE_API:
urlpatterns += path("api/v1/", include("plane.proxy.urls")),
if settings.DEBUG: if settings.DEBUG:
import debug_toolbar import debug_toolbar

View File

@ -10,6 +10,10 @@ x-app-env : &app-env
- SENTRY_DSN=${SENTRY_DSN:-""} - SENTRY_DSN=${SENTRY_DSN:-""}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1} - DOCKERIZED=${DOCKERIZED:-1}
# BASE WEBHOOK
- ENABLE_WEBHOOK=${ENABLE_WEBHOOK:-1}
# BASE API
- ENABLE_API=${ENABLE_API:-1}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost} - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost}
# Gunicorn Workers # Gunicorn Workers
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
@ -56,6 +60,8 @@ x-app-env : &app-env
- BUCKET_NAME=${BUCKET_NAME:-uploads} - BUCKET_NAME=${BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
services: services:
web: web:
<<: *app-env <<: *app-env

View File

@ -14,6 +14,11 @@ GITHUB_CLIENT_SECRET=""
DOCKERIZED=1 DOCKERIZED=1
CORS_ALLOWED_ORIGINS="http://localhost" CORS_ALLOWED_ORIGINS="http://localhost"
# Webhook
ENABLE_WEBHOOK=1
# API
ENABLE_API=1
#DB SETTINGS #DB SETTINGS
PGHOST=plane-db PGHOST=plane-db
PGDATABASE=plane PGDATABASE=plane

View File

@ -0,0 +1,55 @@
import { TextArea } from "@plane/ui";
import { Control, Controller, FieldErrors } from "react-hook-form";
import { IApiToken } from "types/api_token";
import { IApiFormFields } from "./types";
import { Dispatch, SetStateAction } from "react";
interface IApiTokenDescription {
generatedToken: IApiToken | null | undefined;
control: Control<IApiFormFields, any>;
focusDescription: boolean;
setFocusTitle: Dispatch<SetStateAction<boolean>>;
setFocusDescription: Dispatch<SetStateAction<boolean>>;
}
export const ApiTokenDescription = ({
generatedToken,
control,
focusDescription,
setFocusTitle,
setFocusDescription,
}: IApiTokenDescription) => (
<Controller
control={control}
name="description"
render={({ field: { value, onChange } }) =>
focusDescription ? (
<TextArea
id="description"
name="description"
autoFocus={true}
onBlur={() => {
setFocusDescription(false);
}}
value={value}
defaultValue={value}
onChange={onChange}
placeholder="Description"
className="mt-3"
rows={3}
/>
) : (
<p
onClick={() => {
if (generatedToken != null) return;
setFocusTitle(false);
setFocusDescription(true);
}}
className={`${value.length === 0 ? "text-custom-text-400/60" : "text-custom-text-300"} text-lg pt-3`}
>
{value.length != 0 ? value : "Description"}
</p>
)
}
/>
);

View File

@ -0,0 +1,110 @@
import { Menu, Transition } from "@headlessui/react";
import { ToggleSwitch } from "@plane/ui";
import { Dispatch, Fragment, SetStateAction } from "react";
import { Control, Controller } from "react-hook-form";
import { IApiFormFields } from "./types";
interface IApiTokenExpiry {
neverExpires: boolean;
selectedExpiry: number;
setSelectedExpiry: Dispatch<SetStateAction<number>>;
setNeverExpire: Dispatch<SetStateAction<boolean>>;
renderExpiry: () => string;
control: Control<IApiFormFields, any>;
}
export const expiryOptions = [
{
title: "7 Days",
days: 7,
},
{
title: "30 Days",
days: 30,
},
{
title: "1 Month",
days: 30,
},
{
title: "3 Months",
days: 90,
},
{
title: "1 Year",
days: 365,
},
];
export const ApiTokenExpiry = ({
neverExpires,
selectedExpiry,
setSelectedExpiry,
setNeverExpire,
renderExpiry,
control,
}: IApiTokenExpiry) => (
<>
<Menu>
<p className="text-sm font-medium mb-2"> Expiration Date</p>
<Menu.Button className={"w-[40%]"} disabled={neverExpires}>
<div className="py-3 w-full font-medium px-3 flex border border-custom-border-200 rounded-md justify-center items-baseline">
<p className={`text-base ${neverExpires ? "text-custom-text-400/40" : ""}`}>
{expiryOptions[selectedExpiry].title.toLocaleLowerCase()}
</p>
<p className={`text-sm mr-auto ml-2 text-custom-text-400${neverExpires ? "/40" : ""}`}>({renderExpiry()})</p>
</div>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute z-10 overflow-y-scroll whitespace-nowrap rounded-sm max-h-36 border origin-top-right mt-1 overflow-auto min-w-[10rem] border-custom-border-100 p-1 shadow-lg focus:outline-none bg-custom-background-100">
{expiryOptions.map((option, index) => (
<Menu.Item key={index}>
{({ active }) => (
<div className="py-1">
<button
type="button"
onClick={() => {
setSelectedExpiry(index);
}}
className={`w-full text-sm select-none truncate rounded px-3 py-1.5 text-left text-custom-text-300 hover:bg-custom-background-80 ${
active ? "bg-custom-background-80" : ""
}`}
>
{option.title}
</button>
</div>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
<div className="mt-4 mb-6 flex items-center">
<span className="text-sm font-medium"> Never Expires</span>
<Controller
control={control}
name="never_expires"
render={({ field: { onChange, value } }) => (
<ToggleSwitch
className="ml-3"
value={value}
onChange={(val) => {
onChange(val);
setNeverExpire(val);
}}
size="sm"
/>
)}
/>
</div>
</>
);

View File

@ -0,0 +1,53 @@
import { Button } from "@plane/ui";
import useToast from "hooks/use-toast";
import { Copy } from "lucide-react";
import { Dispatch, SetStateAction } from "react";
import { IApiToken } from "types/api_token";
interface IApiTokenKeySection {
generatedToken: IApiToken | null | undefined;
renderExpiry: () => string;
setDeleteTokenModal: Dispatch<SetStateAction<boolean>>;
}
export const ApiTokenKeySection = ({ generatedToken, renderExpiry, setDeleteTokenModal }: IApiTokenKeySection) => {
const { setToastAlert } = useToast();
return generatedToken ? (
<div className={`mt-${generatedToken ? "8" : "16"}`}>
<p className="font-medium text-base pb-2">Api key created successfully</p>
<p className="text-sm pb-4 w-[80%] text-custom-text-400/60">
Save this API key somewhere safe. You will not be able to view it again once you close this page or reload this
page.
</p>
<Button variant="neutral-primary" className="py-3 w-[85%] flex justify-between items-center">
<p className="font-medium text-base">{generatedToken.token}</p>
<Copy
size={18}
color="#B9B9B9"
onClick={() => {
navigator.clipboard.writeText(generatedToken.token);
setToastAlert({
message: "The Secret key has been successfully copied to your clipboard",
type: "success",
title: "Copied to clipboard",
});
}}
/>
</Button>
<p className="mt-2 text-sm text-custom-text-400/60">
{generatedToken.expired_at ? "Expires on " + renderExpiry() : "Never Expires"}
</p>
<button
className="border py-3 px-5 text-custom-primary-100 text-sm mt-8 rounded-md border-custom-primary-100 w-fit font-medium"
onClick={(e) => {
e.preventDefault();
setDeleteTokenModal(true);
}}
>
Revoke
</button>
</div>
) : null;
};

View File

@ -0,0 +1,69 @@
import { Input } from "@plane/ui";
import { Dispatch, SetStateAction } from "react";
import { Control, Controller, FieldErrors } from "react-hook-form";
import { IApiToken } from "types/api_token";
import { IApiFormFields } from "./types";
interface IApiTokenTitle {
generatedToken: IApiToken | null | undefined;
errors: FieldErrors<IApiFormFields>;
control: Control<IApiFormFields, any>;
focusTitle: boolean;
setFocusTitle: Dispatch<SetStateAction<boolean>>;
setFocusDescription: Dispatch<SetStateAction<boolean>>;
}
export const ApiTokenTitle = ({
generatedToken,
errors,
control,
focusTitle,
setFocusTitle,
setFocusDescription,
}: IApiTokenTitle) => (
<Controller
control={control}
name="title"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) =>
focusTitle ? (
<Input
id="title"
name="title"
type="text"
inputSize="md"
onBlur={() => {
setFocusTitle(false);
}}
onError={() => {
console.log("error");
}}
autoFocus={true}
value={value}
onChange={onChange}
ref={ref}
hasError={!!errors.title}
placeholder="Title"
className="resize-none text-xl w-full"
/>
) : (
<p
onClick={() => {
if (generatedToken != null) return;
setFocusDescription(false);
setFocusTitle(true);
}}
className={`${value.length === 0 ? "text-custom-text-400/60" : ""} font-medium text-[24px]`}
>
{value.length != 0 ? value : "Api Title"}
</p>
)
}
/>
);

View File

@ -0,0 +1,143 @@
import { useForm } from "react-hook-form";
import { addDays, renderDateFormat } from "helpers/date-time.helper";
import { IApiToken } from "types/api_token";
import { csvDownload } from "helpers/download.helper";
import { useRouter } from "next/router";
import { Dispatch, SetStateAction, useState } from "react";
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
import { ApiTokenService } from "services/api_token.service";
import { ApiTokenTitle } from "./ApiTokenTitle";
import { ApiTokenDescription } from "./ApiTokenDescription";
import { ApiTokenExpiry, expiryOptions } from "./ApiTokenExpiry";
import { Button } from "@plane/ui";
import { ApiTokenKeySection } from "./ApiTokenKeySection";
interface IApiTokenForm {
generatedToken: IApiToken | null | undefined;
setGeneratedToken: Dispatch<SetStateAction<IApiToken | null | undefined>>;
setDeleteTokenModal: Dispatch<SetStateAction<boolean>>;
}
const apiTokenService = new ApiTokenService();
export const ApiTokenForm = ({ generatedToken, setGeneratedToken, setDeleteTokenModal }: IApiTokenForm) => {
const [loading, setLoading] = useState<boolean>(false);
const [neverExpires, setNeverExpire] = useState<boolean>(false);
const [focusTitle, setFocusTitle] = useState<boolean>(false);
const [focusDescription, setFocusDescription] = useState<boolean>(false);
const [selectedExpiry, setSelectedExpiry] = useState<number>(1);
const { setToastAlert } = useToast();
const { theme: themStore } = useMobxStore();
const router = useRouter();
const { workspaceSlug } = router.query;
const {
control,
handleSubmit,
formState: { errors },
} = useForm({
defaultValues: {
never_expires: false,
title: "",
description: "",
},
});
const getExpiryDate = (): string | null => {
if (neverExpires === true) return null;
return addDays({ date: new Date(), days: expiryOptions[selectedExpiry].days }).toISOString();
};
function renderExpiry(): string {
return renderDateFormat(addDays({ date: new Date(), days: expiryOptions[selectedExpiry].days }), true);
}
const downloadSecretKey = (token: IApiToken) => {
const csvData = {
Label: token.label,
Description: token.description,
Expiry: renderDateFormat(token.expired_at ?? null),
"Secret Key": token.token,
};
csvDownload(csvData, `Secret-key-${Date.now()}`);
};
const generateToken = async (data: any) => {
if (!workspaceSlug) return;
setLoading(true);
await apiTokenService
.createApiToken(workspaceSlug.toString(), {
label: data.title,
description: data.description,
expired_at: getExpiryDate(),
})
.then((res) => {
setGeneratedToken(res);
downloadSecretKey(res);
setLoading(false);
})
.catch((err) => {
setToastAlert({
message: err.message,
type: "error",
title: "Error",
});
});
};
return (
<form
onSubmit={handleSubmit(generateToken, (err) => {
if (err.title) {
setFocusTitle(true);
}
})}
className={`${themStore.sidebarCollapsed ? "xl:w-[50%] lg:w-[60%] " : "w-[60%]"} mx-auto py-8`}
>
<div className="border-b border-custom-border-200 pb-4">
<ApiTokenTitle
generatedToken={generatedToken}
control={control}
errors={errors}
focusTitle={focusTitle}
setFocusTitle={setFocusTitle}
setFocusDescription={setFocusDescription}
/>
{errors.title && focusTitle && <p className=" text-red-600">{errors.title.message}</p>}
<ApiTokenDescription
generatedToken={generatedToken}
control={control}
focusDescription={focusDescription}
setFocusTitle={setFocusTitle}
setFocusDescription={setFocusDescription}
/>
</div>
{!generatedToken && (
<div className="mt-12">
<>
<ApiTokenExpiry
neverExpires={neverExpires}
selectedExpiry={selectedExpiry}
setSelectedExpiry={setSelectedExpiry}
setNeverExpire={setNeverExpire}
renderExpiry={renderExpiry}
control={control}
/>
<Button variant="primary" type="submit">
{loading ? "generating..." : "Add Api key"}
</Button>
</>
</div>
)}
<ApiTokenKeySection
generatedToken={generatedToken}
renderExpiry={renderExpiry}
setDeleteTokenModal={setDeleteTokenModal}
/>
</form>
);
};

View File

@ -0,0 +1,5 @@
export interface IApiFormFields {
never_expires: boolean;
title: string;
description: string;
}

View File

@ -0,0 +1,43 @@
import Link from "next/link";
// helpers
import { formatLongDateDistance, timeAgo } from "helpers/date-time.helper";
// icons
import { XCircle } from "lucide-react";
import { IApiToken } from "types/api_token";
interface IApiTokenListItem {
workspaceSlug: string | string[] | undefined;
token: IApiToken;
}
export const ApiTokenListItem = ({ token, workspaceSlug }: IApiTokenListItem) => (
<Link href={`/${workspaceSlug}/settings/api-tokens/${token.id}`} key={token.id}>
<div className="border-b flex flex-col relative justify-center items-start border-custom-border-200 py-5 hover:cursor-pointer">
<XCircle className="absolute right-5 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto justify-self-center stroke-custom-text-400 h-[15px] w-[15px]" />
<div className="flex items-center px-4">
<span className="text-sm font-medium leading-6">{token.label}</span>
<span
className={`${
token.is_active ? "bg-green-600/10 text-green-600" : "bg-custom-text-400/20 text-custom-text-400"
} flex items-center px-2 h-4 rounded-sm max-h-fit ml-2 text-xs font-medium`}
>
{token.is_active ? "Active" : "Expired"}
</span>
</div>
<div className="flex items-center px-4 w-full">
{token.description.length != 0 && (
<p className="text-sm mb-1 mr-3 font-medium leading-6 truncate max-w-[50%]">{token.description}</p>
)}
{
<p className="text-xs mb-1 leading-6 text-custom-text-400">
{token.is_active
? token.expired_at === null
? "Never Expires"
: `Expires in ${formatLongDateDistance(token.expired_at!)}`
: timeAgo(token.expired_at)}
</p>
}
</div>
</div>
</Link>
);

View File

@ -0,0 +1,111 @@
//react
import { useState, Fragment, FC } from "react";
//next
import { useRouter } from "next/router";
//ui
import { Button } from "@plane/ui";
//hooks
import useToast from "hooks/use-toast";
//services
import { ApiTokenService } from "services/api_token.service";
//headless ui
import { Dialog, Transition } from "@headlessui/react";
type Props = {
isOpen: boolean;
handleClose: () => void;
tokenId?: string;
};
const apiTokenService = new ApiTokenService();
const DeleteTokenModal: FC<Props> = ({ isOpen, handleClose, tokenId }) => {
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
const { setToastAlert } = useToast();
const router = useRouter();
const { workspaceSlug, tokenId: tokenIdFromQuery } = router.query;
const handleDeletion = () => {
if (!workspaceSlug || (!tokenIdFromQuery && !tokenId)) return;
const token = tokenId || tokenIdFromQuery;
setDeleteLoading(true);
apiTokenService
.deleteApiToken(workspaceSlug.toString(), token!.toString())
.then(() => {
setToastAlert({
message: "Token deleted successfully",
type: "success",
title: "Success",
});
router.replace(`/${workspaceSlug}/settings/api-tokens/`);
})
.catch((err) => {
setToastAlert({
message: err?.message,
type: "error",
title: "Error",
});
})
.finally(() => {
setDeleteLoading(false);
handleClose();
});
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-3 p-6">
<div className="flex w-full items-center justify-start">
<h3 className="text-xl font-semibold 2xl:text-2xl">Are you sure you want to revoke access?</h3>
</div>
<span>
<p className="text-base font-normal text-custom-text-400">
Any applications Using this developer key will no longer have the access to Plane Data. This
Action cannot be undone.
</p>
</span>
<div className="flex justify-end mt-2 gap-2">
<Button variant="neutral-primary" onClick={handleClose} disabled={deleteLoading}>
Cancel
</Button>
<Button variant="primary" onClick={handleDeletion} loading={deleteLoading} disabled={deleteLoading}>
{deleteLoading ? "Revoking..." : "Revoke"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default DeleteTokenModal;

View File

@ -0,0 +1,36 @@
// react
import React from "react";
// next
import Image from "next/image";
import { useRouter } from "next/router";
// ui
import { Button } from "@plane/ui";
// assets
import emptyApiTokens from "public/empty-state/api-token.svg";
const ApiTokenEmptyState = () => {
const router = useRouter();
return (
<div className={`flex items-center justify-center mx-auto border bg-custom-background-90 py-10 px-16 w-full`}>
<div className="text-center flex flex-col items-center w-full">
<Image src={emptyApiTokens} className="w-52 sm:w-60" alt="empty" />
<h6 className="text-xl font-semibold mt-6 sm:mt-8 mb-3">No API Tokens</h6>
{
<p className="text-custom-text-300 mb-7 sm:mb-8">
Create API tokens for safe and easy data sharing with external apps, maintaining control and security
</p>
}
<Button
className="flex items-center gap-1.5"
onClick={() => {
router.push(`${router.asPath}/create/`);
}}
>
Add Token
</Button>
</div>
</div>
);
};
export default ApiTokenEmptyState;

View File

@ -0,0 +1,117 @@
import { Dialog, Transition } from "@headlessui/react";
import { Button } from "@plane/ui";
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
import { AlertTriangle } from "lucide-react";
import { useRouter } from "next/router";
import React, { FC, useState } from "react";
import { useForm } from "react-hook-form";
interface IDeleteWebhook {
isOpen: boolean;
webhook_url: string;
onClose: () => void;
}
export const DeleteWebhookModal: FC<IDeleteWebhook> = (props) => {
const { isOpen, onClose } = props;
const router = useRouter();
const { webhook: webhookStore } = useMobxStore();
const { setToastAlert } = useToast();
const [deleting, setDelete] = useState(false);
const { workspaceSlug, webhookId } = router.query;
const handleClose = () => {
onClose();
};
const handleDelete = async () => {
setDelete(true);
if (!workspaceSlug || !webhookId) return;
webhookStore
.remove(workspaceSlug.toString(), webhookId.toString())
.then(() => {
setToastAlert({
title: "Success",
type: "success",
message: "Successfully deleted",
});
router.replace(`/${workspaceSlug}/settings/webhooks/`);
})
.catch((error) => {
console.log(error);
setToastAlert({
title: "Oops!",
type: "error",
message: error?.error,
});
})
.finally(() => {
setDelete(false);
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4">
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Webhook</h3>
</span>
</div>
<span>
<p className="text-sm leading-7 text-custom-text-200">
Are you sure you want to delete workspace <span className="break-words font-semibold" />? All of the
data related to the workspace will be permanently removed. This action cannot be undone.
</p>
</span>
<div className="flex justify-end gap-2">
<Button variant="neutral-primary" onClick={onClose}>
Cancel
</Button>
<Button variant="danger" type="submit" onClick={handleDelete}>
{deleting ? "Deleting..." : "Delete Webhook"}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,29 @@
import { FC } from "react";
import Link from "next/link";
import { Button } from "@plane/ui";
import Image from "next/image";
import EmptyWebhookLogo from "public/empty-state/issue.svg";
interface IWebHookLists {
workspaceSlug: string;
}
export const EmptyWebhooks: FC<IWebHookLists> = (props) => {
const { workspaceSlug } = props;
return (
<div className="flex items-start justify-center">
<div className="flex p-10 flex-col items-center justify-center rounded-[4px] border border-custom-border-200 bg-custom-color-background-90">
<Image width="178" height="116" src={EmptyWebhookLogo} alt="empty-webhook image" />
<div className="mt-4 text-base font-semibold">No Webhooks</div>
<p className="text-sm text-neutral-600">Create webhooks to receive real-time updates and automate actions</p>
<Link href={`/${workspaceSlug}/settings/webhooks/create`}>
<Button variant="primary" className="mt-2">
Add Webhook
</Button>
</Link>
</div>
</div>
);
};

View File

@ -0,0 +1,50 @@
import { Disclosure, Transition } from "@headlessui/react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@plane/ui";
interface IWebHookEditForm {
setOpenDeleteModal: React.Dispatch<React.SetStateAction<boolean>>;
}
export const WebHookEditForm = ({ setOpenDeleteModal }: IWebHookEditForm) => (
<Disclosure as="div" className="border-t border-custom-border-200">
{({ open }) => (
<div className="w-full">
<Disclosure.Button as="button" type="button" className="flex items-center justify-between w-full py-4">
<span className="text-lg tracking-tight">Danger Zone</span>
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8">
<span className="text-sm tracking-tight">
The danger zone of the workspace delete page is a critical area that requires careful consideration and
attention. When deleting a workspace, all of the data and resources within that workspace will be
permanently removed and cannot be recovered.
</span>
<div>
<Button
variant="danger"
onClick={() => {
setOpenDeleteModal(true);
}}
>
Delete Webhook
</Button>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
);

View File

@ -0,0 +1,139 @@
import { useState, FC } from "react";
import { useRouter } from "next/router";
import { Button } from "@plane/ui";
import { Copy, Eye, EyeOff, RefreshCw } from "lucide-react";
import { observer } from "mobx-react-lite";
// hooks
import useToast from "hooks/use-toast";
// store
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
import { csvDownload } from "helpers/download.helper";
// utils
import { getCurrentHookAsCSV } from "../utils";
// enum
import { WebHookFormTypes } from "./index";
interface IGenerateKey {
type: WebHookFormTypes.CREATE | WebHookFormTypes.EDIT;
}
export const GenerateKey: FC<IGenerateKey> = observer((props) => {
const { type } = props;
// states
const [regenerating, setRegenerate] = useState(false);
const [shouldShowKey, setShouldShowKey] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, webhookId } = router.query as { workspaceSlug: string; webhookId: string };
// store
const { webhook: webhookStore, workspace: workspaceStore }: RootStore = useMobxStore();
// hooks
const { setToastAlert } = useToast();
const handleCopySecret = () => {
if (webhookStore?.webhookSecretKey) {
copyTextToClipboard(webhookStore.webhookSecretKey);
setToastAlert({
title: "Success",
type: "success",
message: "Secret key copied",
});
} else {
setToastAlert({
title: "Oops",
type: "error",
message: "Error occurred while copying secret key",
});
}
};
function handleRegenerate() {
setRegenerate(true);
webhookStore
.regenerate(workspaceSlug, webhookId)
.then(() => {
setToastAlert({
title: "Success",
type: "success",
message: "Successfully regenerated",
});
const csvData = getCurrentHookAsCSV(
workspaceStore.currentWorkspace,
webhookStore.currentWebhook,
webhookStore.webhookSecretKey
);
csvDownload(csvData, `Secret-key-${Date.now()}`);
})
.catch((err) => {
setToastAlert({
title: "Oops!",
type: "error",
message: err?.error,
});
})
.finally(() => {
setRegenerate(false);
});
}
const toggleShowKey = () => {
setShouldShowKey((prevState) => !prevState);
};
const icons = [
{ Component: Copy, onClick: handleCopySecret, key: "copy" },
{ Component: shouldShowKey ? EyeOff : Eye, onClick: toggleShowKey, key: "eye" },
];
return (
<>
{(type === WebHookFormTypes.EDIT || (type === WebHookFormTypes.CREATE && webhookStore?.webhookSecretKey)) && (
<div className="space-y-2">
<div className="text-sm font-medium">Secret Key</div>
<div className="text-sm text-neutral-400">Genarate a token to sign-in the webhook payload</div>
<div className="flex gap-5 items-center">
<div className="relative flex items-center p-2 rounded w-full border border-custom-border-200">
<div className="flex w-full overflow-hidden h-7 px-2 font-medium select-none">
{webhookStore?.webhookSecretKey && shouldShowKey ? (
<div>{webhookStore?.webhookSecretKey}</div>
) : (
<div className="flex items-center gap-1.5">
{[...Array(41)].map((_, index) => (
<div key={index} className="w-[4px] h-[4px] bg-gray-300 rounded-full" />
))}
</div>
)}
</div>
{webhookStore?.webhookSecretKey && (
<>
{icons.map(({ Component, onClick, key }) => (
<div
className="w-7 h-7 flex-shrink-0 flex justify-center items-center cursor-pointer hover:bg-custom-background-80 rounded"
onClick={onClick}
key={key}
>
<Component className="text-custom-text-400 w-4 h-4" />
</div>
))}
</>
)}
</div>
<div>
{type != WebHookFormTypes.CREATE && (
<Button disabled={regenerating} onClick={handleRegenerate} variant="accent-primary" className="">
<RefreshCw className={`h-3 w-3`} />
{regenerating ? "Re-generating..." : "Re-genarate Key"}
</Button>
)}
</div>
</div>
</div>
)}
</>
);
});

View File

@ -0,0 +1,101 @@
import React, { FC, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { IWebhook, IExtendedWebhook } from "types";
import { GenerateKey } from "./generate-key";
import { observer } from "mobx-react-lite";
// components
import { DeleteWebhookModal } from "../delete-webhook-modal";
import { WebHookInput } from "./input";
import { WebHookToggle } from "./toggle";
import { WEBHOOK_EVENTS, WebHookOptions, WebhookTypes } from "./options";
import { WebHookIndividualOptions, individualWebhookOptions } from "./option";
import { WebHookSubmitButton } from "./submit";
import { WebHookEditForm } from "./edit-form";
export enum WebHookFormTypes {
EDIT = "edit",
CREATE = "create",
}
interface IWebHookForm {
type: WebHookFormTypes;
initialData: IWebhook;
onSubmit: (val: IExtendedWebhook) => void;
}
export const WebHookForm: FC<IWebHookForm> = observer((props) => {
const { type, initialData, onSubmit } = props;
// states
const [openDeleteModal, setOpenDeleteModal] = useState(false);
// use form
const {
reset,
watch,
handleSubmit,
control,
getValues,
formState: { isSubmitting, errors },
} = useForm<IExtendedWebhook>();
const checkWebhookEvent = (initialData: IWebhook) => {
const { project, module, cycle, issue, issue_comment } = initialData;
if (!project || !cycle || !module || !issue || !issue_comment) {
return WebhookTypes.INDIVIDUAL;
}
return WebhookTypes.ALL;
};
useEffect(() => {
if (initialData && reset) reset({ ...initialData, webhook_events: checkWebhookEvent(initialData) });
}, [initialData, reset]);
useEffect(() => {
if (!watch(WEBHOOK_EVENTS)) return;
const allWebhookOptions: { [key: string]: boolean } = {};
/**For Webhooks to return all the types */
if (watch(WEBHOOK_EVENTS) === WebhookTypes.ALL) {
individualWebhookOptions.forEach(({ name }) => {
allWebhookOptions[name] = true;
});
} /**For Webhooks to return selected individual types, retain the saved individual types */ else if (
watch(WEBHOOK_EVENTS) === WebhookTypes.INDIVIDUAL
) {
individualWebhookOptions.forEach(({ name }) => {
if (initialData[name] !== undefined) {
allWebhookOptions[name] = initialData[name]!;
} else {
allWebhookOptions[name] = true;
}
});
}
reset({ ...getValues(), ...allWebhookOptions });
}, [watch && watch(WEBHOOK_EVENTS)]);
return (
<>
<DeleteWebhookModal
isOpen={openDeleteModal}
webhook_url=""
onClose={() => {
setOpenDeleteModal(false);
}}
/>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-8 py-5">
<WebHookInput control={control} errors={errors} />
<WebHookToggle control={control} />
<div className="space-y-3">
<WebHookOptions control={control} />
{watch(WEBHOOK_EVENTS) === WebhookTypes.INDIVIDUAL && <WebHookIndividualOptions control={control} />}
</div>
<GenerateKey type={type} />
<WebHookSubmitButton isSubmitting={isSubmitting} type={type} />
{type === WebHookFormTypes.EDIT && <WebHookEditForm setOpenDeleteModal={setOpenDeleteModal} />}
</div>
</form>
</>
);
});

View File

@ -0,0 +1,33 @@
import { Control, Controller, FieldErrors } from "react-hook-form";
import { Input } from "@plane/ui";
import { IExtendedWebhook } from "types/webhook";
interface IWebHookInput {
control: Control<IExtendedWebhook, any>;
errors: FieldErrors<IExtendedWebhook>;
}
export const WebHookInput = ({ control, errors }: IWebHookInput) => (
<div>
<div className="font-medium text-sm">URL</div>
<Controller
control={control}
name="url"
rules={{
required: "URL is Required",
validate: (value) => (/^(ftp|http|https):\/\/[^ "]+$/.test(value) ? true : "Enter a valid URL"),
}}
render={({ field: { onChange, value } }) => (
<Input
className="w-full h-11"
onChange={onChange}
value={value}
id="url"
autoComplete="off"
placeholder="Enter URL"
/>
)}
/>
{errors.url && <p className="py-2 text-sm text-red-500">{errors.url.message}</p>}
</div>
);

View File

@ -0,0 +1,70 @@
import { Control, Controller } from "react-hook-form";
import { IWebhookIndividualOptions, IExtendedWebhook } from "types/webhook";
export enum IndividualWebhookTypes {
PROJECTS = "Projects",
MODULES = "Modules",
CYCLES = "Cycles",
ISSUES = "Issues",
ISSUE_COMMENTS = "Issue Comments",
}
export const individualWebhookOptions: IWebhookIndividualOptions[] = [
{
key: "project_toggle",
label: IndividualWebhookTypes.PROJECTS,
name: "project",
},
{
key: "cycle-toggle",
label: IndividualWebhookTypes.CYCLES,
name: "cycle",
},
{
key: "issue_toggle",
label: IndividualWebhookTypes.ISSUES,
name: "issue",
},
{
key: "module_toggle",
label: IndividualWebhookTypes.MODULES,
name: "module",
},
{
key: "issue_comment_toggle",
label: IndividualWebhookTypes.ISSUE_COMMENTS,
name: "issue_comment",
},
];
interface IWebHookIndividualOptions {
control: Control<IExtendedWebhook, any>;
}
export const WebHookIndividualOptions = ({ control }: IWebHookIndividualOptions) => (
<>
<div className="grid grid-cols-2 md:grid-cols-3 grid-flow-row gap-4 px-8 py-6 bg-custom-background-90">
{individualWebhookOptions.map(({ key, label, name }: IWebhookIndividualOptions) => (
<Controller
control={control}
name={name}
key={key}
render={({ field: { onChange, value } }) => (
<div className="relative flex items-center gap-2">
<input
id={key}
onChange={() => onChange(!value)}
type="checkbox"
name="selectIndividualEvents"
checked={value == true}
/>
<label className="text-sm" htmlFor={key}>
{label}
</label>
</div>
)}
/>
))}
</div>
</>
);

View File

@ -0,0 +1,54 @@
import { Control, Controller } from "react-hook-form";
import { IExtendedWebhook, IWebhookOptions } from "types/webhook";
export enum WebhookTypes {
ALL = "all",
INDIVIDUAL = "individual",
}
interface IWebHookOptionsProps {
control: Control<IExtendedWebhook, any>;
}
export const WEBHOOK_EVENTS = "webhook_events";
const webhookOptions: IWebhookOptions[] = [
{
key: WebhookTypes.ALL,
label: "Send everything",
name: WEBHOOK_EVENTS,
},
{
key: WebhookTypes.INDIVIDUAL,
label: "Select Individual events",
name: WEBHOOK_EVENTS,
},
];
export const WebHookOptions = ({ control }: IWebHookOptionsProps) => (
<>
<div className="text-sm font-medium">Which events do you like to trigger this webhook</div>
{webhookOptions.map(({ key, label, name }: IWebhookOptions) => (
<div className="flex items-center gap-2">
<Controller
control={control}
name={name}
key={key}
render={({ field: { onChange, value } }) => (
<input
id={key}
type="radio"
name={name}
value={key}
checked={value == key}
onChange={() => onChange(key)}
/>
)}
/>
<label className="text-sm" htmlFor={key}>
{label}
</label>
</div>
))}
</>
);

View File

@ -0,0 +1,13 @@
import { Button } from "@plane/ui";
import { WebHookFormTypes } from "./index";
interface IWebHookSubmitButton {
isSubmitting: boolean;
type: WebHookFormTypes;
}
export const WebHookSubmitButton = ({ isSubmitting, type }: IWebHookSubmitButton) => (
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "processing..." : type === "create" ? "Create webhook" : "Save webhook"}
</Button>
);

View File

@ -0,0 +1,26 @@
import { Control, Controller } from "react-hook-form";
import { IExtendedWebhook } from "types/webhook";
import { ToggleSwitch } from "@plane/ui";
interface IWebHookToggle {
control: Control<IExtendedWebhook, any>;
}
export const WebHookToggle = ({ control }: IWebHookToggle) => (
<div className="flex gap-6">
<div className="text-sm"> Enable webhook </div>
<Controller
control={control}
name="is_active"
render={({ field: { onChange, value } }) => (
<ToggleSwitch
value={value}
onChange={(val: boolean) => {
onChange(val);
}}
size="sm"
/>
)}
/>
</div>
);

View File

@ -0,0 +1,4 @@
export * from "./empty-webhooks";
export * from "./webhooks-list";
export * from "./webhooks-list-item";
export * from "./form";

View File

@ -0,0 +1,21 @@
import { renderDateFormat } from "helpers/date-time.helper";
import { IWebhook, IWorkspace } from "types";
export const getCurrentHookAsCSV = (
currentWorkspace: IWorkspace | null,
webhook: IWebhook | undefined,
secretKey: string | undefined
) => ({
id: webhook?.id || "",
url: webhook?.url || "",
created_at: renderDateFormat(webhook?.created_at),
updated_at: renderDateFormat(webhook?.updated_at),
is_active: webhook?.is_active?.toString() || "",
secret_key: secretKey || "",
project: webhook?.project?.toString() || "",
issue: webhook?.issue?.toString() || "",
module: webhook?.module?.toString() || "",
cycle: webhook?.cycle?.toString() || "",
issue_comment: webhook?.issue_comment?.toString() || "",
workspace: currentWorkspace?.name || "",
});

View File

@ -0,0 +1,42 @@
import { FC, useState } from "react";
import { ToggleSwitch } from "@plane/ui";
import { Pencil, XCircle } from "lucide-react";
import { IWebhook } from "types";
import Link from "next/link";
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
interface IWebhookListItem {
workspaceSlug: string;
webhook: IWebhook;
}
export const WebhooksListItem: FC<IWebhookListItem> = (props) => {
const { workspaceSlug, webhook } = props;
const { webhook: webhookStore }: RootStore = useMobxStore();
const handleToggle = () => {
if (webhook.id) {
webhookStore.update(workspaceSlug, webhook.id, { ...webhook, is_active: !webhook.is_active }).catch(() => {});
}
};
return (
<div>
<Link href={`/${workspaceSlug}/settings/webhooks/${webhook?.id}`}>
<div className="flex cursor-pointer justify-between px-3.5 py-[18px]">
<div>
<div className="text-base font-medium">{webhook?.url || "Webhook URL"}</div>
{/* <div className="text-base text-neutral-700">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
</div> */}
</div>
<div className="flex gap-4 items-center">
<ToggleSwitch value={webhook.is_active} onChange={handleToggle} />
</div>
</div>
</Link>
</div>
);
};

View File

@ -0,0 +1,38 @@
import { FC } from "react";
import Link from "next/link";
import { Button } from "@plane/ui";
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
import { observer } from "mobx-react-lite";
// components
import { WebhooksListItem } from "./webhooks-list-item";
interface IWebHookLists {
workspaceSlug: string;
}
export const WebhookLists: FC<IWebHookLists> = observer((props) => {
const { workspaceSlug } = props;
const {
webhook: { webhooks },
}: RootStore = useMobxStore();
return (
<>
<div className="flex items-center justify-between gap-4 py-3.5 border-b border-custom-border-200">
<div className="text-xl font-medium">Webhooks</div>
<Link href={`/${workspaceSlug}/settings/webhooks/create`}>
<Button variant="primary" size="sm">
Add webhook
</Button>
</Link>
</div>
<div className="divide-y divide-custom-border-200 overflow-y-scroll">
{Object.values(webhooks).map((item) => (
<WebhooksListItem workspaceSlug={workspaceSlug} webhook={item} key={item.id} />
))}
</div>
</>
);
});

View File

@ -300,3 +300,8 @@ export const ISSUE_REACTION_LIST = (workspaceSlug: string, projectId: string, is
`ISSUE_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${issueId.toUpperCase()}`; `ISSUE_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${issueId.toUpperCase()}`;
export const COMMENT_REACTION_LIST = (workspaceSlug: string, projectId: string, commendId: string) => export const COMMENT_REACTION_LIST = (workspaceSlug: string, projectId: string, commendId: string) =>
`COMMENT_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${commendId.toUpperCase()}`; `COMMENT_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${commendId.toUpperCase()}`;
// api-tokens
export const API_TOKENS_LIST = (workspaceSlug: string) => `API_TOKENS_LIST_${workspaceSlug.toUpperCase()}`;
export const API_TOKEN_DETAILS = (workspaceSlug: string, tokenId: string) =>
`API_TOKEN_DETAILS_${workspaceSlug.toUpperCase()}_${tokenId.toUpperCase()}`;

View File

@ -1,4 +1,9 @@
export const renderDateFormat = (date: string | Date | null) => { export const addDays = ({ date, days }: { date: Date; days: number }): Date => {
date.setDate(date.getDate() + days);
return date;
};
export const renderDateFormat = (date: string | Date | null | undefined, dayFirst: boolean = false) => {
if (!date) return "N/A"; if (!date) return "N/A";
var d = new Date(date), var d = new Date(date),
@ -9,7 +14,7 @@ export const renderDateFormat = (date: string | Date | null) => {
if (month.length < 2) month = "0" + month; if (month.length < 2) month = "0" + month;
if (day.length < 2) day = "0" + day; if (day.length < 2) day = "0" + day;
return [year, month, day].join("-"); return dayFirst ? [day, month, year].join("-") : [year, month, day].join("-");
}; };
export const renderShortNumericDateFormat = (date: string | Date) => export const renderShortNumericDateFormat = (date: string | Date) =>
@ -130,6 +135,39 @@ export const formatDateDistance = (date: string | Date) => {
} }
}; };
export const formatLongDateDistance = (date: string | Date) => {
const today = new Date();
const eventDate = new Date(date);
const timeDiff = Math.abs(eventDate.getTime() - today.getTime());
const days = Math.ceil(timeDiff / (1000 * 3600 * 24));
if (days < 1) {
const hours = Math.ceil(timeDiff / (1000 * 3600));
if (hours < 1) {
const minutes = Math.ceil(timeDiff / (1000 * 60));
if (minutes < 1) {
return "Just now";
} else {
return `${minutes} minutes`;
}
} else {
return `${hours} hours`;
}
} else if (days < 7) {
if (days === 1) return `${days} day`;
return `${days} days`;
} else if (days < 30) {
if (Math.floor(days / 7) === 1) return `${Math.floor(days / 7)} week`;
return `${Math.floor(days / 7)} weeks`;
} else if (days < 365) {
if (Math.floor(days / 30) === 1) return `${Math.floor(days / 30)} month`;
return `${Math.floor(days / 30)} months`;
} else {
if (Math.floor(days / 365) === 1) return `${Math.floor(days / 365)} year`;
return `${Math.floor(days / 365)} years`;
}
};
export const getDateRangeStatus = (startDate: string | null | undefined, endDate: string | null | undefined) => { export const getDateRangeStatus = (startDate: string | null | undefined, endDate: string | null | undefined) => {
if (!startDate || !endDate) return "draft"; if (!startDate || !endDate) return "draft";

View File

@ -0,0 +1,17 @@
export const csvDownload = (data: Array<Array<string>> | { [key: string]: string }, name: string) => {
let rows = [];
if (Array.isArray(data)) {
rows = [...data];
} else {
rows = [Object.keys(data), Object.values(data)];
}
let csvContent = "data:text/csv;charset=utf-8," + rows.map((e) => e.join(",")).join("\n");
var encodedUri = encodeURI(csvContent);
var link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", `${name}.csv`);
document.body.appendChild(link);
link.click();
};

View File

@ -0,0 +1,13 @@
export const generateRandomString = (length: number) => {
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let counter = 0;
while (counter < length) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return result;
}

View File

@ -1,38 +1,66 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
export enum EUserWorkspaceRoles {
GUEST = 5,
MEMBER = 15,
ADMIN = 20,
}
export const WorkspaceSettingsSidebar = () => { export const WorkspaceSettingsSidebar = () => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { user: userStore }: RootStore = useMobxStore();
const workspaceMemberInfo = userStore.currentWorkspaceRole || EUserWorkspaceRoles.GUEST;
const workspaceLinks: Array<{ const workspaceLinks: Array<{
label: string; label: string;
href: string; href: string;
access: EUserWorkspaceRoles;
}> = [ }> = [
{ {
label: "General", label: "General",
href: `/${workspaceSlug}/settings`, href: `/${workspaceSlug}/settings`,
access: EUserWorkspaceRoles.GUEST,
}, },
{ {
label: "Members", label: "Members",
href: `/${workspaceSlug}/settings/members`, href: `/${workspaceSlug}/settings/members`,
access: EUserWorkspaceRoles.GUEST,
}, },
{ {
label: "Billing & Plans", label: "Billing & Plans",
href: `/${workspaceSlug}/settings/billing`, href: `/${workspaceSlug}/settings/billing`,
access: EUserWorkspaceRoles.ADMIN,
}, },
{ {
label: "Integrations", label: "Integrations",
href: `/${workspaceSlug}/settings/integrations`, href: `/${workspaceSlug}/settings/integrations`,
access: EUserWorkspaceRoles.ADMIN,
}, },
{ {
label: "Imports", label: "Imports",
href: `/${workspaceSlug}/settings/imports`, href: `/${workspaceSlug}/settings/imports`,
access: EUserWorkspaceRoles.GUEST,
}, },
{ {
label: "Exports", label: "Exports",
href: `/${workspaceSlug}/settings/exports`, href: `/${workspaceSlug}/settings/exports`,
access: EUserWorkspaceRoles.GUEST,
},
{
label: "Webhooks",
href: `/${workspaceSlug}/settings/webhooks`,
access: EUserWorkspaceRoles.ADMIN,
},
{
label: "API Tokens",
href: `/${workspaceSlug}/settings/api-tokens`,
access: EUserWorkspaceRoles.GUEST,
}, },
]; ];
@ -54,26 +82,36 @@ export const WorkspaceSettingsSidebar = () => {
}, },
]; ];
function highlightSetting(label: string, link: string): boolean {
if (router.asPath.startsWith(link) && (label === "Imports" || label === "Api tokens")) {
return true;
}
return link === router.asPath;
}
return ( return (
<div className="flex flex-col gap-6 w-80 px-5"> <div className="flex flex-col gap-6 w-80 px-5">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xs text-custom-sidebar-text-400 font-semibold">SETTINGS</span> <span className="text-xs text-custom-sidebar-text-400 font-semibold">SETTINGS</span>
<div className="flex flex-col gap-1 w-full"> <div className="flex flex-col gap-1 w-full">
{workspaceLinks.map((link) => ( {workspaceLinks.map(
<Link key={link.href} href={link.href}> (link) =>
<a> workspaceMemberInfo >= link.access && (
<div <Link key={link.href} href={link.href}>
className={`px-4 py-2 text-sm font-medium rounded-md ${ <a>
router.pathname.split("/")?.[3] === link.href.split("/")?.[3] <div
? "bg-custom-primary-100/10 text-custom-primary-100" className={`px-4 py-2 text-sm font-medium rounded-md ${
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80" router.pathname.split("/")?.[3] === link.href.split("/")?.[3]
}`} ? "bg-custom-primary-100/10 text-custom-primary-100"
> : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
{link.label} }`}
</div> >
</a> {link.label}
</Link> </div>
))} </a>
</Link>
)
)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View File

@ -0,0 +1,70 @@
// react
import { useState } from "react";
// next
import { NextPage } from "next";
import { useRouter } from "next/router";
// layouts
import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout";
// components
import DeleteTokenModal from "components/api-token/delete-token-modal";
import { WorkspaceSettingHeader } from "components/headers";
// ui
import { Spinner } from "@plane/ui";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { ApiTokenService } from "services/api_token.service";
// helpers
import { renderDateFormat } from "helpers/date-time.helper";
// constants
import { API_TOKEN_DETAILS } from "constants/fetch-keys";
// swr
import useSWR from "swr";
const apiTokenService = new ApiTokenService();
const ApiTokenDetail: NextPage = () => {
const { theme: themStore } = useMobxStore();
const [deleteTokenModal, setDeleteTokenModal] = useState<boolean>(false);
const router = useRouter();
const { workspaceSlug, tokenId } = router.query;
const { data: token } = useSWR(
workspaceSlug && tokenId ? API_TOKEN_DETAILS(workspaceSlug.toString(), tokenId.toString()) : null,
() =>
workspaceSlug && tokenId ? apiTokenService.retrieveApiToken(workspaceSlug.toString(), tokenId.toString()) : null
);
return (
<AppLayout header={<WorkspaceSettingHeader title="Api Tokens" />}>
<WorkspaceSettingLayout>
<DeleteTokenModal isOpen={deleteTokenModal} handleClose={() => setDeleteTokenModal(false)} />
{token ? (
<div className={`${themStore.sidebarCollapsed ? "xl:w-[50%] lg:w-[60%] " : "w-[60%]"} mx-auto py-8`}>
<p className={"font-medium text-[24px]"}>{token.label}</p>
<p className={"text-custom-text-300 text-lg pt-2"}>{token.description}</p>
<div className="bg-custom-border-100 h-[1px] w-full mt-4" />
<p className="mt-2 text-sm text-custom-text-400/60">
{token.expired_at ? "Expires on " + renderDateFormat(token.expired_at, true) : "Never Expires"}
</p>
<button
className="border py-3 px-5 text-custom-primary-100 text-sm mt-6 rounded-md border-custom-primary-100 w-fit font-medium"
onClick={() => {
setDeleteTokenModal(true);
}}
>
Revoke
</button>
</div>
) : (
<div className="flex justify-center pr-9 py-8 w-full min-h-full items-center">
<Spinner />
</div>
)}
</WorkspaceSettingLayout>
</AppLayout>
);
};
export default ApiTokenDetail;

View File

@ -0,0 +1,40 @@
// react
import { useState } from "react";
// next
import { NextPage } from "next";
// layouts
import { AppLayout } from "layouts/app-layout/layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout";
//types
import { IApiToken } from "types/api_token";
//Mobx
import { observer } from "mobx-react-lite";
// components
import { WorkspaceSettingHeader } from "components/headers";
import DeleteTokenModal from "components/api-token/delete-token-modal";
import { ApiTokenForm } from "components/api-token/ApiTokenForm";
const CreateApiToken: NextPage = () => {
const [generatedToken, setGeneratedToken] = useState<IApiToken | null>();
const [deleteTokenModal, setDeleteTokenModal] = useState<boolean>(false);
return (
<AppLayout header={<WorkspaceSettingHeader title="Api Tokens" />}>
<WorkspaceSettingLayout>
<DeleteTokenModal
isOpen={deleteTokenModal}
handleClose={() => setDeleteTokenModal(false)}
tokenId={generatedToken?.id}
/>
<ApiTokenForm
generatedToken={generatedToken}
setGeneratedToken={setGeneratedToken}
setDeleteTokenModal={setDeleteTokenModal}
/>
</WorkspaceSettingLayout>
</AppLayout>
);
};
export default observer(CreateApiToken);

View File

@ -0,0 +1,68 @@
// react
import React from "react";
// next
import type { NextPage } from "next";
import { useRouter } from "next/router";
// layouts
import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout";
// component
import { WorkspaceSettingHeader } from "components/headers";
import ApiTokenEmptyState from "components/api-token/empty-state";
// ui
import { Spinner, Button } from "@plane/ui";
// services
import { ApiTokenService } from "services/api_token.service";
// constants
import { API_TOKENS_LIST } from "constants/fetch-keys";
// swr
import useSWR from "swr";
import { ApiTokenListItem } from "components/api-token/ApiTokenListItem";
const apiTokenService = new ApiTokenService();
const Api: NextPage = () => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: tokens, isLoading } = useSWR(workspaceSlug ? API_TOKENS_LIST(workspaceSlug.toString()) : null, () =>
workspaceSlug ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null
);
return (
<AppLayout header={<WorkspaceSettingHeader title="Api Tokens" />}>
<WorkspaceSettingLayout>
{!isLoading ? (
tokens && tokens.length > 0 ? (
<section className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center justify-between py-3.5 border-b border-custom-border-200 mb-2">
<h3 className="text-xl font-medium">Api Tokens</h3>
<Button
variant="primary"
onClick={() => {
router.push(`${router.asPath}/create/`);
}}
>
Add Api Token
</Button>
</div>
<div>
{tokens?.map((token) => (
<ApiTokenListItem token={token} workspaceSlug={workspaceSlug} />
))}
</div>
</section>
) : (
<div className="mx-auto py-8">
<ApiTokenEmptyState />
</div>
)
) : (
<div className="flex justify-center pr-9 py-8 w-full min-h-full items-center">
<Spinner />
</div>
)}
</WorkspaceSettingLayout>
</AppLayout>
);
};
export default Api;

View File

@ -0,0 +1,77 @@
import type { NextPage } from "next";
import { useRouter } from "next/router";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
// layout
import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout";
// components
import { WorkspaceSettingHeader } from "components/headers";
import { WebHookForm } from "components/web-hooks";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { RootStore } from "store/root";
import { IExtendedWebhook, IWebhook } from "types";
import { Spinner } from "@plane/ui";
import { useEffect } from "react";
import { WebHookFormTypes } from "components/web-hooks/form";
const Webhooks: NextPage = observer(() => {
const router = useRouter();
const { workspaceSlug, webhookId, isCreated } = router.query as {
workspaceSlug: string;
webhookId: string;
isCreated: string;
};
const { webhook: webhookStore }: RootStore = useMobxStore();
useEffect(() => {
if (isCreated !== "true") {
webhookStore.clearSecretKey();
}
}, []);
const { isLoading } = useSWR(
workspaceSlug && webhookId ? `WEBHOOKS_DETAIL_${workspaceSlug}_${webhookId}` : null,
workspaceSlug && webhookId
? async () => {
await webhookStore.fetchById(workspaceSlug, webhookId);
}
: null
);
const onSubmit = (data: IExtendedWebhook): Promise<IWebhook> => {
const payload = {
url: data?.url,
is_active: data?.is_active,
project: data?.project,
cycle: data?.cycle,
module: data?.module,
issue: data?.issue,
issue_comment: data?.issue_comment,
};
return webhookStore.update(workspaceSlug, webhookId, payload);
};
const initialPayload = webhookStore.currentWebhook as IWebhook;
return (
<AppLayout header={<WorkspaceSettingHeader title="Webhook Settings" />}>
<WorkspaceSettingLayout>
<div className="w-full overflow-y-auto py-3 pr-4">
{isLoading ? (
<div className="flex w-full h-full items-center justify-center">
<Spinner />
</div>
) : (
<WebHookForm type={WebHookFormTypes.EDIT} initialData={initialPayload} onSubmit={onSubmit} />
)}
</div>
</WorkspaceSettingLayout>
</AppLayout>
);
});
export default Webhooks;

View File

@ -0,0 +1,89 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import type { NextPage } from "next";
import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingHeader } from "components/headers";
import { WorkspaceSettingLayout } from "layouts/settings-layout";
import { WebHookForm } from "components/web-hooks";
import { IWebhook, IExtendedWebhook } from "types";
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
import { csvDownload } from "helpers/download.helper";
import useToast from "hooks/use-toast";
import { WebHookFormTypes } from "components/web-hooks/form";
import { getCurrentHookAsCSV } from "components/web-hooks/utils";
const Webhooks: NextPage = () => {
const router = useRouter();
const { workspaceSlug } = router.query as { workspaceSlug: string };
const initialWebhookPayload: IWebhook = {
url: "",
is_active: true,
created_at: "",
updated_at: "",
secret_key: "",
project: true,
issue_comment: true,
cycle: true,
module: true,
issue: true,
workspace: "",
};
const { webhook: webhookStore, workspace: workspaceStore }: RootStore = useMobxStore();
const { setToastAlert } = useToast();
const onSubmit = async (data: IExtendedWebhook) => {
const payload = {
url: data?.url,
is_active: data?.is_active,
project: data?.project,
cycle: data?.cycle,
module: data?.module,
issue: data?.issue,
issue_comment: data?.issue_comment,
};
return webhookStore
.create(workspaceSlug, payload)
.then(({ webHook, secretKey }) => {
setToastAlert({
title: "Success",
type: "success",
message: "Successfully created",
});
const csvData = getCurrentHookAsCSV(workspaceStore.currentWorkspace, webHook, secretKey);
csvDownload(csvData, `Secret-key-${Date.now()}`);
if (webHook && webHook.id) {
router.push({ pathname: `/${workspaceSlug}/settings/webhooks/${webHook.id}`, query: { isCreated: true } });
}
})
.catch((error) => {
setToastAlert({
title: "Oops!",
type: "error",
message: error?.error ?? "Something went wrong!",
});
});
};
useEffect(() => {
webhookStore.clearSecretKey();
}, []);
return (
<AppLayout header={<WorkspaceSettingHeader title="Webhook Settings" />}>
<WorkspaceSettingLayout>
<div className="w-full overflow-y-auto py-3 pr-4">
<WebHookForm type={WebHookFormTypes.CREATE} initialData={initialWebhookPayload} onSubmit={onSubmit} />
</div>
</WorkspaceSettingLayout>
</AppLayout>
);
};
export default Webhooks;

View File

@ -0,0 +1,58 @@
import React from "react";
import type { NextPage } from "next";
import { useRouter } from "next/router";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
// layout
import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout";
// components
import { WorkspaceSettingHeader } from "components/headers";
import { WebhookLists, EmptyWebhooks } from "components/web-hooks";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { RootStore } from "store/root";
import { Spinner } from "@plane/ui";
const WebhooksPage: NextPage = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query as { workspaceSlug: string };
const {
webhook: { fetchWebhooks, webhooks, loader },
}: RootStore = useMobxStore();
useSWR(
workspaceSlug ? `WEBHOOKS_LIST_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWebhooks(workspaceSlug) : null
);
return (
<AppLayout header={<WorkspaceSettingHeader title="Webhook Settings" />}>
<WorkspaceSettingLayout>
<div className="w-full overflow-y-auto py-3 pr-4">
{loader ? (
<div className="flex h-full w-ful items-center justify-center">
<Spinner />
</div>
) : (
<>
{Object.keys(webhooks).length > 0 ? (
<WebhookLists workspaceSlug={workspaceSlug} />
) : (
<div className="flex justify-center w-full h-full items-center">
<div className="w-auto h-fit">
<EmptyWebhooks workspaceSlug={workspaceSlug} />
</div>
</div>
)}
</>
)}
</div>
</WorkspaceSettingLayout>
</AppLayout>
);
});
export default WebhooksPage;

View File

@ -0,0 +1,49 @@
<svg width="128" height="130" viewBox="0 0 128 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M60.5893 36.7461C84.3294 36.7461 103.575 55.9912 103.575 79.7313C103.575 103.471 84.3294 122.716 60.5893 122.716C36.8493 122.716 3.99396 99.179 17.6042 79.7313C31.2144 60.2836 36.8493 36.7461 60.5893 36.7461Z" fill="#F2F2F2"/>
<path d="M118.122 38.7891C117.736 38.7891 117.423 39.1025 117.423 39.488V61.8543C117.423 62.2398 117.736 62.5532 118.122 62.5532C118.507 62.5532 118.821 62.2398 118.821 61.8543V39.488C118.821 39.1025 118.507 38.7891 118.122 38.7891Z" fill="#525252"/>
<path d="M0.698946 22.0195C0.313467 22.0195 0 22.333 0 22.7185V28.31C0 28.6955 0.313467 29.009 0.698946 29.009C1.08443 29.009 1.39789 28.6955 1.39789 28.31V22.7185C1.39789 22.333 1.08443 22.0195 0.698946 22.0195Z" fill="#525252"/>
<path d="M0.698946 38.7891C0.313467 38.7891 0 39.1025 0 39.488V50.3217C0 50.7072 0.313467 51.0206 0.698946 51.0206C1.08443 51.0206 1.39789 50.7072 1.39789 50.3217V39.488C1.39789 39.1025 1.08443 38.7891 0.698946 38.7891Z" fill="#525252"/>
<path d="M0.698946 54.168C0.313467 54.168 0 54.4814 0 54.8669V65.7006C0 66.0861 0.313467 66.3995 0.698946 66.3995C1.08443 66.3995 1.39789 66.0861 1.39789 65.7006V54.8669C1.39789 54.4814 1.08443 54.168 0.698946 54.168Z" fill="#525252"/>
<path d="M63.385 0C62.9995 0 62.686 0.313467 62.686 0.698946V11.5326C62.686 11.9181 62.9995 12.2316 63.385 12.2316C63.7705 12.2316 64.0839 11.9181 64.0839 11.5326V0.698946C64.0839 0.313467 63.7705 0 63.385 0Z" fill="#525252"/>
<path d="M41.7948 76.8354H17.604V76.1365H41.7948C43.8962 76.1365 45.6059 74.4267 45.6059 72.325V55.9688H46.3048V72.325C46.3048 74.812 44.2817 76.8354 41.7948 76.8354Z" fill="#396AE6"/>
<path d="M87.9532 112.289H32.9336V112.988H87.9532V112.289Z" fill="#396AE6"/>
<path d="M122.861 72.1605H93.4733C91.8874 72.1605 90.5972 70.8703 90.5972 69.2844V40.2551C90.5972 38.6691 91.8874 37.3789 93.4733 37.3789H122.861C124.447 37.3789 125.737 38.6691 125.737 40.2551V69.2844C125.737 70.8703 124.447 72.1605 122.861 72.1605Z" fill="#E6E6E6"/>
<path d="M98.9462 38.2344C94.8084 38.2344 91.4541 41.5887 91.4541 45.7265V68.667C91.4541 70.1223 92.6339 71.3021 94.0892 71.3021H112.059C119.14 71.3021 124.88 65.5618 124.88 58.4809V40.8695C124.88 39.4142 123.701 38.2344 122.245 38.2344L98.9462 38.2344Z" fill="white"/>
<path d="M116.038 46.6717H100.235C99.9333 46.6717 99.688 46.4263 99.688 46.1248C99.688 45.8233 99.9333 45.5781 100.235 45.5781H116.038C116.34 45.5781 116.585 45.8233 116.585 46.1248C116.585 46.4263 116.34 46.6717 116.038 46.6717Z" fill="#E6E6E6"/>
<path d="M116.038 58.3318H100.235C99.9333 58.3318 99.688 58.0865 99.688 57.785C99.688 57.4835 99.9333 57.2383 100.235 57.2383H116.038C116.34 57.2383 116.585 57.4835 116.585 57.785C116.585 58.0865 116.34 58.3318 116.038 58.3318Z" fill="#E6E6E6"/>
<path d="M122.456 52.5076H93.8173C93.5158 52.5076 93.2705 52.2623 93.2705 51.9608C93.2705 51.6593 93.5158 51.4141 93.8173 51.4141H122.456C122.757 51.4141 123.002 51.6593 123.002 51.9608C123.002 52.2623 122.757 52.5076 122.456 52.5076Z" fill="#E6E6E6"/>
<path d="M122.748 63.0701H112.402C112.101 63.0701 111.855 62.8248 111.855 62.5233C111.855 62.2218 112.101 61.9766 112.402 61.9766H122.748C123.049 61.9766 123.295 62.2218 123.295 62.5233C123.295 62.8248 123.049 63.0701 122.748 63.0701Z" fill="#E6E6E6"/>
<path d="M61.1334 130.002H37.7975C36.5382 130.002 35.5137 128.977 35.5137 127.718V104.667C35.5137 103.407 36.5382 102.383 37.7975 102.383H61.1334C62.3928 102.383 63.4173 103.407 63.4173 104.667V127.718C63.4173 128.977 62.3928 130.002 61.1334 130.002Z" fill="#E6E6E6"/>
<path d="M42.1436 103.062C38.8579 103.062 36.1943 105.726 36.1943 109.012V127.228C36.1943 128.384 37.1312 129.321 38.2868 129.321H52.5561C58.1788 129.321 62.737 124.762 62.737 119.14V105.155C62.737 103.999 61.8002 103.063 60.6445 103.063L42.1436 103.062Z" fill="white"/>
<path d="M55.7156 109.759H43.1666C42.9272 109.759 42.7324 109.564 42.7324 109.325C42.7324 109.085 42.9272 108.891 43.1666 108.891H55.7156C55.955 108.891 56.1497 109.085 56.1497 109.325C56.1497 109.564 55.955 109.759 55.7156 109.759Z" fill="#E6E6E6"/>
<path d="M55.7156 119.017H43.1666C42.9272 119.017 42.7324 118.822 42.7324 118.583C42.7324 118.343 42.9272 118.148 43.1666 118.148H55.7156C55.955 118.148 56.1497 118.343 56.1497 118.583C56.1497 118.822 55.955 119.017 55.7156 119.017Z" fill="#E6E6E6"/>
<path d="M60.8116 114.392H38.0709C37.8315 114.392 37.6367 114.197 37.6367 113.958C37.6367 113.718 37.8315 113.523 38.0709 113.523H60.8116C61.051 113.523 61.2457 113.718 61.2457 113.958C61.2457 114.197 61.051 114.392 60.8116 114.392Z" fill="#E6E6E6"/>
<path d="M61.0434 122.786H52.8283C52.5889 122.786 52.394 122.592 52.394 122.352C52.394 122.113 52.5888 121.918 52.8283 121.918H61.0434C61.2828 121.918 61.4775 122.113 61.4775 122.352C61.4775 122.592 61.2828 122.786 61.0434 122.786Z" fill="#E6E6E6"/>
<path d="M44.8224 55.7926H27.5645C26.6332 55.7926 25.8755 55.0349 25.8755 54.1036V37.0562C25.8755 36.1249 26.6332 35.3672 27.5645 35.3672H44.8224C45.7538 35.3672 46.5114 36.1249 46.5114 37.0562V54.1036C46.5114 55.0349 45.7538 55.7926 44.8224 55.7926Z" fill="#E6E6E6"/>
<path d="M30.7787 35.8711C28.3487 35.8711 26.3789 37.8409 26.3789 40.2708V53.7426C26.3789 54.5972 27.0717 55.29 27.9264 55.29H38.4791C42.6374 55.29 46.0084 51.9191 46.0084 47.7608V37.4186C46.0084 36.5639 45.3155 35.8711 44.4609 35.8711L30.7787 35.8711Z" fill="white"/>
<path d="M40.816 40.8219H31.5355C31.3584 40.8219 31.2144 40.6778 31.2144 40.5007C31.2144 40.3237 31.3584 40.1797 31.5355 40.1797H40.816C40.993 40.1797 41.137 40.3237 41.137 40.5007C41.137 40.6778 40.993 40.8219 40.816 40.8219Z" fill="#E6E6E6"/>
<path d="M40.816 47.6695H31.5355C31.3584 47.6695 31.2144 47.5254 31.2144 47.3484C31.2144 47.1713 31.3584 47.0273 31.5355 47.0273H40.816C40.993 47.0273 41.137 47.1713 41.137 47.3484C41.137 47.5254 40.993 47.6695 40.816 47.6695Z" fill="#E6E6E6"/>
<path d="M44.5847 44.2476H27.7669C27.5899 44.2476 27.4458 44.1036 27.4458 43.9265C27.4458 43.7495 27.5899 43.6055 27.7669 43.6055H44.5847C44.7617 43.6055 44.9057 43.7495 44.9057 43.9265C44.9057 44.1036 44.7617 44.2476 44.5847 44.2476Z" fill="#E6E6E6"/>
<path d="M44.756 50.4547H38.6805C38.5034 50.4547 38.3594 50.3106 38.3594 50.1336C38.3594 49.9565 38.5034 49.8125 38.6805 49.8125H44.756C44.933 49.8125 45.077 49.9565 45.077 50.1336C45.077 50.3106 44.933 50.4547 44.756 50.4547Z" fill="#E6E6E6"/>
<path d="M95.1871 77.2824C97.1172 77.2824 98.6818 75.7178 98.6818 73.7877C98.6818 71.8576 97.1172 70.293 95.1871 70.293C93.257 70.293 91.6924 71.8576 91.6924 73.7877C91.6924 75.7178 93.257 77.2824 95.1871 77.2824Z" fill="#396AE6"/>
<path d="M45.9117 59.4621C47.8418 59.4621 49.4065 57.8975 49.4065 55.9674C49.4065 54.0373 47.8418 52.4727 45.9117 52.4727C43.9816 52.4727 42.417 54.0373 42.417 55.9674C42.417 57.8975 43.9816 59.4621 45.9117 59.4621Z" fill="#396AE6"/>
<path d="M62.6861 107.341C64.6162 107.341 66.1809 105.776 66.1809 103.846C66.1809 101.916 64.6162 100.352 62.6861 100.352C60.756 100.352 59.1914 101.916 59.1914 103.846C59.1914 105.776 60.756 107.341 62.6861 107.341Z" fill="#396AE6"/>
<path d="M70.235 10.4378C72.6405 10.4378 74.5905 8.52881 74.5905 6.17397C74.5905 3.81913 72.6405 1.91016 70.235 1.91016C67.8295 1.91016 65.8794 3.81913 65.8794 6.17397C65.8794 8.52881 67.8295 10.4378 70.235 10.4378Z" fill="#E5E5E5"/>
<path d="M105.152 4.71422H81.7077C80.9034 4.71422 80.249 4.05984 80.249 3.25555C80.249 2.45126 80.9034 1.79688 81.7077 1.79688H105.152C105.956 1.79688 106.611 2.45126 106.611 3.25555C106.611 4.05984 105.956 4.71422 105.152 4.71422Z" fill="#E5E5E5"/>
<path d="M123.448 9.65172H81.7077C80.9034 9.65172 80.249 8.99733 80.249 8.19305C80.249 7.38876 80.9034 6.73438 81.7077 6.73438H123.448C124.252 6.73438 124.907 7.38876 124.907 8.19305C124.907 8.99733 124.252 9.65172 123.448 9.65172Z" fill="#E5E5E5"/>
<path d="M7.19143 28.3588C8.74824 28.3588 10.0103 27.1233 10.0103 25.5993C10.0103 24.0753 8.74824 22.8398 7.19143 22.8398C5.63461 22.8398 4.37256 24.0753 4.37256 25.5993C4.37256 27.1233 5.63461 28.3588 7.19143 28.3588Z" fill="#E5E5E5"/>
<path d="M29.7888 24.6537H14.6159C14.0954 24.6537 13.6719 24.2302 13.6719 23.7097C13.6719 23.1891 14.0954 22.7656 14.6159 22.7656H29.7888C30.3093 22.7656 30.7328 23.1891 30.7328 23.7097C30.7328 24.2302 30.3093 24.6537 29.7888 24.6537Z" fill="#E5E5E5"/>
<path d="M41.6297 27.849H14.6159C14.0954 27.849 13.6719 27.4255 13.6719 26.905C13.6719 26.3844 14.0954 25.9609 14.6159 25.9609H41.6297C42.1502 25.9609 42.5737 26.3844 42.5737 26.905C42.5737 27.4255 42.1502 27.849 41.6297 27.849Z" fill="#E5E5E5"/>
<path d="M91.7632 126.562C93.32 126.562 94.5821 125.326 94.5821 123.802C94.5821 122.278 93.32 121.043 91.7632 121.043C90.2064 121.043 88.9443 122.278 88.9443 123.802C88.9443 125.326 90.2064 126.562 91.7632 126.562Z" fill="#E5E5E5"/>
<path d="M114.361 122.857H99.1882C98.6677 122.857 98.2441 122.433 98.2441 121.913C98.2441 121.392 98.6677 120.969 99.1882 120.969H114.361C114.882 120.969 115.305 121.392 115.305 121.913C115.305 122.433 114.882 122.857 114.361 122.857Z" fill="#E5E5E5"/>
<path d="M126.202 126.052H99.1882C98.6677 126.052 98.2441 125.629 98.2441 125.108C98.2441 124.588 98.6677 124.164 99.1882 124.164H126.202C126.722 124.164 127.146 124.588 127.146 125.108C127.146 125.629 126.722 126.052 126.202 126.052Z" fill="#E5E5E5"/>
<path d="M57.8811 37.3906H57.1821V70.5906H57.8811V37.3906Z" fill="#E6E6E6"/>
<path d="M57.5314 72.6859C58.6895 72.6859 59.6283 71.7471 59.6283 70.589C59.6283 69.431 58.6895 68.4922 57.5314 68.4922C56.3734 68.4922 55.4346 69.431 55.4346 70.589C55.4346 71.7471 56.3734 72.6859 57.5314 72.6859Z" fill="#E6E6E6"/>
<path d="M89.3337 96.7991C90.4917 96.7991 91.4305 95.8604 91.4305 94.7023C91.4305 93.5443 90.4917 92.6055 89.3337 92.6055C88.1756 92.6055 87.2368 93.5443 87.2368 94.7023C87.2368 95.8604 88.1756 96.7991 89.3337 96.7991Z" fill="#E6E6E6"/>
<path d="M32.7189 68.1429C33.877 68.1429 34.8157 67.2041 34.8157 66.0461C34.8157 64.888 33.877 63.9492 32.7189 63.9492C31.5609 63.9492 30.6221 64.888 30.6221 66.0461C30.6221 67.2041 31.5609 68.1429 32.7189 68.1429Z" fill="#E6E6E6"/>
<path d="M31.9438 66.0513L20.4766 66.3984L20.4977 67.0974L31.9649 66.7502L31.9438 66.0513Z" fill="#E6E6E6"/>
<path d="M106.392 102.73H89.0034V94.7031H89.7024V102.031H106.392V102.73Z" fill="#E6E6E6"/>
<path d="M31.8581 91.0413C33.0161 91.0413 33.9549 90.1025 33.9549 88.9445C33.9549 87.7864 33.0161 86.8477 31.8581 86.8477C30.7 86.8477 29.7612 87.7864 29.7612 88.9445C29.7612 90.1025 30.7 91.0413 31.8581 91.0413Z" fill="#E6E6E6"/>
<path d="M48.7091 96.9687H31.3208V88.9414H32.0197V96.2698H48.7091V96.9687Z" fill="#E6E6E6"/>
<path d="M101.914 80.9648H18.3965V81.6638H101.914V80.9648Z" fill="#E6E6E6"/>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,40 @@
import { API_BASE_URL } from "helpers/common.helper";
import { APIService } from "./api.service";
import { IApiToken } from "types/api_token";
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: any): 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

@ -0,0 +1,60 @@
// api services
import { APIService } from "services/api.service";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
// types
import { IWebhook } from "types";
export class WebhookService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getAll(workspaceSlug: string): Promise<IWebhook[]> {
return this.get(`/api/workspaces/${workspaceSlug}/webhooks/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getById(workspaceSlug: string, webhook_id: string): Promise<IWebhook> {
return this.get(`/api/workspaces/${workspaceSlug}/webhooks/${webhook_id}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async create(workspaceSlug: string, data: {}): Promise<IWebhook> {
return this.post(`/api/workspaces/${workspaceSlug}/webhooks/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async update(workspaceSlug: string, webhook_id: string, data: {}): Promise<IWebhook> {
return this.patch(`/api/workspaces/${workspaceSlug}/webhooks/${webhook_id}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async remove(workspaceSlug: string, webhook_id: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/webhooks/${webhook_id}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async regenerate(workspaceSlug: string, webhook_id: string): Promise<IWebhook> {
return this.post(`/api/workspaces/${workspaceSlug}/webhooks/${webhook_id}/regenerate/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -109,6 +109,7 @@ import {
InboxIssuesStore, InboxIssuesStore,
InboxStore, InboxStore,
} from "store/inbox"; } from "store/inbox";
import { IWebhookStore, WebhookStore } from "./webhook.store";
import { IMentionsStore, MentionsStore } from "store/editor"; import { IMentionsStore, MentionsStore } from "store/editor";
@ -178,6 +179,8 @@ export class RootStore {
inboxIssueDetails: IInboxIssueDetailsStore; inboxIssueDetails: IInboxIssueDetailsStore;
inboxFilters: IInboxFiltersStore; inboxFilters: IInboxFiltersStore;
webhook: IWebhookStore;
mentionsStore: IMentionsStore; mentionsStore: IMentionsStore;
constructor() { constructor() {
@ -243,6 +246,8 @@ export class RootStore {
this.inboxIssueDetails = new InboxIssueDetailsStore(this); this.inboxIssueDetails = new InboxIssueDetailsStore(this);
this.inboxFilters = new InboxFiltersStore(this); this.inboxFilters = new InboxFiltersStore(this);
this.webhook = new WebhookStore(this);
this.mentionsStore = new MentionsStore(this); this.mentionsStore = new MentionsStore(this);
} }
} }

207
web/store/webhook.store.ts Normal file
View File

@ -0,0 +1,207 @@
// mobx
import { action, observable, makeObservable, computed, runInAction } from "mobx";
import { IWebhook } from "types";
import { WebhookService } from "services/webhook.service";
export interface IWebhookStore {
loader: boolean;
error: any | undefined;
webhooks: { [webhookId: string]: IWebhook };
currentWebhookId: string | undefined;
webhookSecretKey: string | undefined;
// computed
currentWebhook: IWebhook | undefined;
// actions
fetchWebhooks: (workspaceSlug: string) => Promise<IWebhook[]>;
fetchById: (workspaceSlug: string, webhook_id: string) => Promise<IWebhook>;
create: (workspaceSlug: string, data: IWebhook) => Promise<{ webHook: IWebhook; secretKey: string | undefined }>;
update: (workspaceSlug: string, webhook_id: string, data: Partial<IWebhook>) => Promise<IWebhook>;
remove: (workspaceSlug: string, webhook_id: string) => Promise<void>;
regenerate: (
workspaceSlug: string,
webhook_id: string
) => Promise<{ webHook: IWebhook; secretKey: string | undefined }>;
clearSecretKey: () => void;
}
export class WebhookStore implements IWebhookStore {
loader: boolean = false;
error: any | undefined = undefined;
webhooks: { [webhookId: string]: IWebhook } = {};
currentWebhookId: string | undefined = undefined;
webhookSecretKey: string | undefined = undefined;
// root store
rootStore;
webhookService;
constructor(_rootStore: any | undefined = undefined) {
makeObservable(this, {
loader: observable.ref,
error: observable.ref,
webhooks: observable.ref,
currentWebhookId: observable.ref,
webhookSecretKey: observable.ref,
currentWebhook: computed,
fetchWebhooks: action,
create: action,
fetchById: action,
update: action,
remove: action,
regenerate: action,
clearSecretKey: action,
});
this.rootStore = _rootStore;
this.webhookService = new WebhookService();
}
get currentWebhook() {
if (!this.currentWebhookId) return undefined;
const currentWebhook = this.webhooks ? this.webhooks[this.currentWebhookId] : undefined;
return currentWebhook;
}
fetchWebhooks = async (workspaceSlug: string) => {
try {
this.loader = true;
this.error = undefined;
const webhookResponse = await this.webhookService.getAll(workspaceSlug);
const webHookObject: { [webhookId: string]: IWebhook } = webhookResponse.reduce((accumulator, currentWebhook) => {
if (currentWebhook && currentWebhook.id) {
return { ...accumulator, [currentWebhook.id]: currentWebhook };
}
return accumulator;
}, {});
runInAction(() => {
this.webhooks = webHookObject;
this.loader = false;
this.error = undefined;
});
return webhookResponse;
} catch (error) {
this.loader = false;
this.error = error;
throw error;
}
};
create = async (workspaceSlug: string, data: IWebhook) => {
try {
const webhookResponse = await this.webhookService.create(workspaceSlug, data);
const _secretKey = webhookResponse?.secret_key;
delete webhookResponse?.secret_key;
const _webhooks = this.webhooks;
if (webhookResponse && webhookResponse.id) {
_webhooks[webhookResponse.id] = webhookResponse;
}
runInAction(() => {
this.webhookSecretKey = _secretKey || undefined;
this.webhooks = _webhooks;
this.currentWebhookId = webhookResponse.id;
});
return { webHook: webhookResponse, secretKey: _secretKey };
} catch (error) {
console.log(error);
throw error;
}
};
fetchById = async (workspaceSlug: string, webhook_id: string) => {
try {
const webhookResponse = await this.webhookService.getById(workspaceSlug, webhook_id);
const _webhooks = this.webhooks;
if (webhookResponse && webhookResponse.id) {
_webhooks[webhookResponse.id] = webhookResponse;
}
runInAction(() => {
this.currentWebhookId = webhook_id;
this.webhooks = _webhooks;
});
return webhookResponse;
} catch (error) {
console.log(error);
throw error;
}
};
update = async (workspaceSlug: string, webhook_id: string, data: Partial<IWebhook>) => {
try {
let _webhooks = this.webhooks;
if (webhook_id) {
_webhooks = { ..._webhooks, [webhook_id]: { ...this.webhooks[webhook_id], ...data } };
}
runInAction(() => {
this.webhooks = _webhooks;
});
const webhookResponse = await this.webhookService.update(workspaceSlug, webhook_id, data);
return webhookResponse;
} catch (error) {
console.log(error);
this.fetchWebhooks(workspaceSlug);
throw error;
}
};
remove = async (workspaceSlug: string, webhook_id: string) => {
try {
await this.webhookService.remove(workspaceSlug, webhook_id);
const _webhooks = this.webhooks;
delete _webhooks[webhook_id];
runInAction(() => {
this.webhooks = _webhooks;
});
} catch (error) {
console.log(error);
throw error;
}
};
regenerate = async (workspaceSlug: string, webhook_id: string) => {
try {
const webhookResponse = await this.webhookService.regenerate(workspaceSlug, webhook_id);
const _secretKey = webhookResponse?.secret_key;
delete webhookResponse?.secret_key;
const _webhooks = this.webhooks;
if (webhookResponse && webhookResponse.id) {
_webhooks[webhookResponse.id] = webhookResponse;
}
runInAction(() => {
this.webhookSecretKey = _secretKey || undefined;
this.webhooks = _webhooks;
});
return { webHook: webhookResponse, secretKey: _secretKey };
} catch (error) {
console.log(error);
throw error;
}
};
clearSecretKey = () => {
this.webhookSecretKey = undefined;
};
}

16
web/types/api_token.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
export interface IApiToken {
id: string;
created_at: string;
updated_at: string;
label: string;
description: string;
is_active: boolean;
last_used?: string;
token: string;
user_type: number;
expired_at?: string;
created_by: string;
updated_by: string;
user: string;
workspace: string;
}

View File

@ -20,6 +20,7 @@ export * from "./waitlist";
export * from "./reaction"; export * from "./reaction";
export * from "./view-props"; export * from "./view-props";
export * from "./workspace-views"; export * from "./workspace-views";
export * from "./webhook";
export type NestedKeyOf<ObjectType extends object> = { export type NestedKeyOf<ObjectType extends object> = {
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object

31
web/types/webhook.d.ts vendored Normal file
View File

@ -0,0 +1,31 @@
export interface IWebhook {
id?: string;
secret_key?: string;
url: string;
created_at?: string;
updated_at?: string;
is_active: boolean;
project: boolean;
cycle: boolean;
module: boolean;
issue: boolean;
issue_comment?: boolean;
workspace?: string;
}
// this interface is used to handle the webhook form state
interface IExtendedWebhook extends IWebhook {
webhook_events: string;
}
export interface IWebhookIndividualOptions {
key: string;
label: string;
name: "project" | "cycle" | "module" | "issue" | "issue_comment";
}
export interface IWebhookOptions {
key: string;
label: string;
name: "webhook_events";
}