Compare commits

...

24 Commits

Author SHA1 Message Date
rahulramesha 5a249f28e1 fix sorting of existing values and add sorting to all fields 2023-11-29 11:33:12 +05:30
rahulramesha 1fadcdd1f4 fix profile draft and archived issues 2023-11-28 17:43:52 +05:30
rahulramesha edd1f6e423 fix profile issue filters and kanban 2023-11-27 17:05:08 +05:30
guru_sainath 2bf7e63625 issues rendering in all issue layouts fir profile and project issues and global issues store implementation (#2886)
* dev: draft and archived issue store

* connect draft and archived issues

* kanban for draft issues

* fix filter store for calendar and kanban

* dev: profile issues store and draft issues filters in header

* disble issue creation for draft issues

* dev: profile issues store filters

* disable kanban properties in draft issues

* dev: profile issues store filters

* dev: seperated adding issues to the cycle and module as seperate methds in cycle and module store

* dev: workspace profile issues store

* dev: sub group issues in the swimlanes

* profile issues and create issue connection

* fix profile issues

* fix spreadsheet issues

* fix dissapearing project from create issue modal

* page level modifications

* fix additional bugs

* dev: issues profile and global iisues and filters update

* fix issue related bugs

* fix project views for list and kanban

* fix build errors

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2023-11-27 14:15:33 +05:30
Anmol Singh Bhatia eb78fd6088 fix: resolve modal overlapping issue (#2885) 2023-11-27 12:16:59 +05:30
Lakhan Baheti 202ecd21df fix: bug fixes & UI improvements (#2884)
* chore: access restriction for api tokens

* fix: on create module total issues undefined

* fix: cycle board card typo

* chore: fetch modules after creation

* fix: peek module on delete

* fix: peek cycle on delete

* fix: cycle detail sidebar copy link toast

* chore: router replace -> push
2023-11-27 12:15:10 +05:30
Aaryan Khandelwal b2ac7b9ac6 chore: revamp the API tokens workflow (#2880)
* chore: added getLayout method to api tokens pages

* revamp: api tokens workflow

* chore: add title validation and update types

* chore: minor UI updates

* chore: update route
2023-11-27 12:14:06 +05:30
Lakhan Baheti 51dff31926 fix: user state after logout (#2849)
* fix: user state after logout

* chore: user state handle with mobx

* chore: signout update for profile setting

* fix: minor fixes

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-25 23:04:56 +05:30
sriram veeraghanta e89f152779 fix: remove slack notification on build branch workflow (#2881) 2023-11-25 22:43:27 +05:30
Lakhan Baheti 3c9f57f8f4 fix: workspace & user avatar tooltip (#2851)
* fix: workspace & user avatar tooltip

* chore: user name update while typing on top right avatar

* chore: imports placement

* fix: rendering condition

* chore: component re-arrangement

* fix: imports
2023-11-25 21:31:09 +05:30
Bavisetti Narayan 1bc859c68c chore: seperated delete endpoint for file upload (#2870) 2023-11-25 21:28:03 +05:30
Ramesh Kumar Chandra 11d57a5bf0 fix: track events updated, extra parameters added, added events for issues, pages, states, cycles (#2875)
* fix: event tracking method updated to store, chore: updated and added events for workspace, projects and create issue

* fix: posthog auth event tracking

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-11-25 21:26:26 +05:30
Prateek Shourya 2980c7b00d Feat: God Mode UI Updates and More Config Settings (#2877)
* feat: Images in Plane config screen.
* feat: Enable/ Disable Magic Login config toggle.
* style: UX copy and design updates across all screens.
* style: SSO and OAuth Screen revamp.
* style: Enter God Mode button for Profile Settings sidebar.
* fix: update input type to password for password fields.
2023-11-25 21:23:50 +05:30
Anmol Singh Bhatia 5c6a59ba35 dev: badge component added in planu ui package (#2876) 2023-11-25 21:21:03 +05:30
Anmol Singh Bhatia a3ea7c8f10 fix: issue peek overview state select dropdown overflow fix (#2873) 2023-11-25 21:18:54 +05:30
Anmol Singh Bhatia cb922fb113 fix: module sidebar date select fix and code refactor (#2872) 2023-11-25 21:18:16 +05:30
sriram veeraghanta 06564ee856 fix: remove slack notify (#2871)
* fix: remove slack notifications on workflows

* fix: bugfix
2023-11-24 14:31:44 +05:30
Nikhil c7e6118804 refactor: image upload modals, file size limit added to config (#2868)
* chore: add file size limit as config in the config api

* refactor: image upload modals

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-11-24 13:23:46 +05:30
Manish Gupta 069b8b3ed9 Updated the slack notification message to PR Title (#2869) 2023-11-24 13:11:21 +05:30
Lakhan Baheti 38a5b7bec0 chore: added error toast for invitation (#2853) 2023-11-24 12:47:02 +05:30
Bavisetti Narayan 236caaafe8 chore: user deactivation and login restriction (#2855)
* chore: user deactivation

* chore: deactivation and login disabled

* chore: added get configuration value

* chore: serializer message change

* chore: instance admin passowrd change

* chore: removed triage

* chore: v3 endpoint for user profile

* chore: added enable signin

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-24 12:22:24 +05:30
Nikhil a6d5eab634 chore: api and webhook refactor (#2861)
* chore: bug fix

* dev: changes in api endpoints for invitations and inbox

* chore: improvements

* dev: update webhook send

* dev: webhook validation and fix webhook flow for app

* dev: error messages for deactivation

* chore: api fixes

* dev: update webhook and workspace leave

* chore: issue comment

* dev: default values for environment variables

* dev: make the user active if he was already part of project member

* chore: webhook cycle and module event

* dev: disable ssl for emails

* dev: webhooks restructuring

* dev: updated webhook configuration

* dev: webhooks

* dev: state get object

* dev: update workspace slug validation

* dev: remove deactivation flag if max retries exceeded

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-11-24 12:19:26 +05:30
sriram veeraghanta 8d76c96a6f fix: adding slack notification when build is failed to upload to docker (#2862)
* fix: removing logs

* fix: adding slack notification when build is failed to upload to docker

* minor changes

---------

Co-authored-by: Manish Gupta <59428681+manishg3@users.noreply.github.com>
2023-11-24 12:17:31 +05:30
Aaryan Khandelwal 97be4b60ae chore: update profile and God mode routes (#2860)
* chore: update profile and god mode routes

* fix: profile activity loader

* chore: update profile route in the change password page
2023-11-24 12:16:37 +05:30
293 changed files with 7680 additions and 3209 deletions
+16 -18
View File
@@ -1,11 +1,10 @@
name: Branch Build
on:
pull_request:
types:
types:
- closed
branches:
branches:
- master
- release
- qa
@@ -23,7 +22,7 @@ jobs:
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
# - name: Set Target Branch Name on PR close
# if: ${{ github.event_name == 'pull_request' && github.event.action =='closed' }}
# run: echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV
@@ -31,7 +30,7 @@ jobs:
# - name: Set Target Branch Name on other than PR close
# if: ${{ github.event_name == 'push' }}
# run: echo "TARGET_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- uses: ASzc/change-string-case-action@v2
id: gh_branch_upper_lower
with:
@@ -41,22 +40,22 @@ jobs:
id: gh_branch_replace_slash
with:
source: ${{ steps.gh_branch_upper_lower.outputs.lowercase }}
find: '/'
replace: '-'
find: "/"
replace: "-"
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_dot
with:
source: ${{ steps.gh_branch_replace_slash.outputs.value }}
find: '.'
replace: ''
find: "."
replace: ""
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_clean
with:
source: ${{ steps.gh_branch_replace_dot.outputs.value }}
find: '_'
replace: ''
find: "_"
replace: ""
- name: Uploading Proxy Source
uses: actions/upload-artifact@v3
with:
@@ -77,7 +76,6 @@ jobs:
!./nginx
!./deploy
!./space
- name: Uploading Space Source
uses: actions/upload-artifact@v3
with:
@@ -89,11 +87,11 @@ jobs:
!./deploy
!./web
outputs:
gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }}
gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }}
branch_build_push_frontend:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
needs: [branch_build_and_push]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -123,7 +121,7 @@ jobs:
branch_build_push_space:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
needs: [branch_build_and_push]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -153,7 +151,7 @@ jobs:
branch_build_push_backend:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
needs: [branch_build_and_push]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -183,7 +181,7 @@ jobs:
branch_build_push_proxy:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
needs: [branch_build_and_push]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
+3 -2
View File
@@ -9,8 +9,9 @@ from .issue import (
IssueCommentSerializer,
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueExpandSerializer,
)
from .state import StateLiteSerializer, StateSerializer
from .cycle import CycleSerializer, CycleIssueSerializer
from .module import ModuleSerializer, ModuleIssueSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
from .inbox import InboxIssueSerializer
+8 -1
View File
@@ -46,4 +46,11 @@ class CycleIssueSerializer(BaseSerializer):
"workspace",
"project",
"cycle",
]
]
class CycleLiteSerializer(BaseSerializer):
class Meta:
model = Cycle
fields = "__all__"
+7 -1
View File
@@ -8,6 +8,12 @@ class InboxIssueSerializer(BaseSerializer):
model = InboxIssue
fields = "__all__"
read_only_fields = [
"project",
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
+59 -26
View File
@@ -19,6 +19,8 @@ from plane.db.models import (
ProjectMember,
)
from .base import BaseSerializer
from .cycle import CycleSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleLiteSerializer
class IssueSerializer(BaseSerializer):
@@ -42,6 +44,7 @@ class IssueSerializer(BaseSerializer):
model = Issue
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
@@ -60,9 +63,9 @@ class IssueSerializer(BaseSerializer):
# Validate assignees are from project
if data.get("assignees", []):
print(data.get("assignees"))
data["assignees"] = ProjectMember.objects.filter(
project_id=self.context.get("project_id"),
is_active=True,
member_id__in=data["assignees"],
).values_list("member_id", flat=True)
@@ -88,7 +91,7 @@ class IssueSerializer(BaseSerializer):
if (
data.get("parent")
and not Issue.objects.filter(
workspce_id=self.context.get("workspace_id"), pk=data.get("parent")
workspace_id=self.context.get("workspace_id"), pk=data.get("parent")
).exists()
):
raise serializers.ValidationError(
@@ -231,8 +234,13 @@ class LabelSerializer(BaseSerializer):
model = Label
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
@@ -241,13 +249,14 @@ class IssueLinkSerializer(BaseSerializer):
model = IssueLink
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"issue",
]
# Validation if url already exists
@@ -266,13 +275,14 @@ class IssueAttachmentSerializer(BaseSerializer):
model = IssueAttachment
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
"issue",
]
@@ -282,38 +292,61 @@ class IssueCommentSerializer(BaseSerializer):
class Meta:
model = IssueComment
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueAttachmentSerializer(BaseSerializer):
class Meta:
model = IssueAttachment
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
"issue",
]
class IssueActivitySerializer(BaseSerializer):
class Meta:
model = IssueActivity
fields = "__all__"
exclude = [
"created_by",
"udpated_by",
"updated_by",
]
class CycleIssueSerializer(BaseSerializer):
cycle = CycleSerializer(read_only=True)
class Meta:
fields = [
"cycle",
]
class ModuleIssueSerializer(BaseSerializer):
module = ModuleSerializer(read_only=True)
class Meta:
fields = [
"module",
]
class IssueExpandSerializer(BaseSerializer):
# Serialize the related cycle. It's a OneToOne relation.
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
# Serialize the related module. It's a OneToOne relation.
module = ModuleLiteSerializer(source="issue_module.module", read_only=True)
class Meta:
model = Issue
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
+9 -2
View File
@@ -21,7 +21,6 @@ class ModuleSerializer(BaseSerializer):
write_only=True,
required=False,
)
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
@@ -33,6 +32,7 @@ class ModuleSerializer(BaseSerializer):
model = Module
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
@@ -152,4 +152,11 @@ class ModuleLinkSerializer(BaseSerializer):
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
return ModuleLink.objects.create(**validated_data)
return ModuleLink.objects.create(**validated_data)
class ModuleLiteSerializer(BaseSerializer):
class Meta:
model = Module
fields = "__all__"
+5 -1
View File
@@ -20,8 +20,12 @@ class ProjectSerializer(BaseSerializer):
model = Project
fields = "__all__"
read_only_fields = [
"workspace",
"id",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
def validate(self, data):
+2 -2
View File
@@ -13,7 +13,7 @@ urlpatterns = [
name="cycles",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/",
CycleAPIEndpoint.as_view(),
name="cycles",
),
@@ -23,7 +23,7 @@ urlpatterns = [
name="cycle-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:issue_id>/",
CycleIssueAPIEndpoint.as_view(),
name="cycle-issues",
),
+2 -2
View File
@@ -5,12 +5,12 @@ from plane.api.views import InboxIssueAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
"workspaces/<str:slug>/projects/<uuid:project_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>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/",
InboxIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
+7 -7
View File
@@ -15,27 +15,27 @@ urlpatterns = [
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueAPIEndpoint.as_view(),
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
LabelAPIEndpoint.as_view(),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
LabelAPIEndpoint.as_view(),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
IssueLinkAPIEndpoint.as_view(),
name="link",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
IssueLinkAPIEndpoint.as_view(),
name="link",
),
@@ -50,12 +50,12 @@ urlpatterns = [
name="comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activites/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activites/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
+1 -1
View File
@@ -19,7 +19,7 @@ urlpatterns = [
name="module-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
ModuleIssueAPIEndpoint.as_view(),
name="module-issues",
),
+1 -1
View File
@@ -9,7 +9,7 @@ urlpatterns = [
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/",
ProjectAPIEndpoint.as_view(),
name="project",
),
+5
View File
@@ -8,4 +8,9 @@ urlpatterns = [
StateAPIEndpoint.as_view(),
name="states",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:state_id>/",
StateAPIEndpoint.as_view(),
name="states",
),
]
+12 -8
View File
@@ -7,7 +7,6 @@ from django.conf import settings
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework.views import APIView
@@ -36,28 +35,33 @@ class TimezoneMixin:
else:
timezone.deactivate()
class WebhookMixin:
webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
event_data=json.dumps(response.data, cls=DjangoJSONEncoder),
payload=response.data,
kw=self.kwargs,
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
)
return response
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
authentication_classes = [
APIKeyAuthentication,
@@ -139,13 +143,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
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')
ratelimit_remaining = request.META.get("X-RateLimit-Remaining")
if ratelimit_remaining is not None:
response['X-RateLimit-Remaining'] = ratelimit_remaining
response["X-RateLimit-Remaining"] = ratelimit_remaining
ratelimit_reset = request.META.get('X-RateLimit-Reset')
ratelimit_reset = request.META.get("X-RateLimit-Reset")
if ratelimit_reset is not None:
response['X-RateLimit-Reset'] = ratelimit_reset
response["X-RateLimit-Reset"] = ratelimit_reset
return response
@@ -169,4 +173,4 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
expand = [
expand for expand in self.request.GET.get("expand", "").split(",") if expand
]
return expand if expand else None
return expand if expand else None
+9 -10
View File
@@ -17,7 +17,6 @@ from plane.app.permissions import ProjectEntityPermission
from plane.api.serializers import (
CycleSerializer,
CycleIssueSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -142,7 +141,6 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
)
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
queryset = queryset.order_by("-is_favorite", "-created_at")
# Current Cycle
if cycle_view == "current":
@@ -293,7 +291,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
@@ -305,14 +303,15 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle issues.
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
"""
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle"
webhook_event = "cycle_issue"
bulk = True
permission_classes = [
ProjectEntityPermission,
]
@@ -457,7 +456,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": issues}),
requested_data=json.dumps({"cycles_list": str(issues)}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
@@ -478,9 +477,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, cycle_id, pk):
def delete(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
issue_id=issue_id, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
)
issue_id = cycle_issue.issue_id
cycle_issue.delete()
@@ -493,7 +492,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
issue_id=str(issue_id),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
+99 -22
View File
@@ -14,7 +14,7 @@ from rest_framework.response import Response
from .base import BaseAPIView
from plane.app.permissions import ProjectLitePermission
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
from plane.db.models import InboxIssue, Issue, State, ProjectMember
from plane.db.models import InboxIssue, Issue, State, ProjectMember, Project, Inbox
from plane.bgtasks.issue_activites_task import issue_activity
@@ -37,29 +37,39 @@ class InboxIssueAPIEndpoint(BaseAPIView):
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(
inbox = Inbox.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
).first()
project = Project.objects.get(
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
)
if inbox is None and not project.inbox_view:
return InboxIssue.objects.none()
return (
InboxIssue.objects.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
inbox_id=self.kwargs.get("inbox_id"),
inbox_id=inbox.id,
)
.select_related("issue", "workspace", "project")
.order_by(self.kwargs.get("order_by", "-created_at"))
)
def get(self, request, slug, project_id, inbox_id, pk=None):
def get(self, request, slug, project_id, pk=None):
if pk:
issue_queryset = self.get_queryset().get(pk=pk)
issues_data = InboxIssueSerializer(
issue_queryset,
inbox_issue_queryset = self.get_queryset().get(pk=pk)
inbox_issue_data = InboxIssueSerializer(
inbox_issue_queryset,
fields=self.fields,
expand=self.expand,
).data
return Response(
issues_data,
inbox_issue_data,
status=status.HTTP_200_OK,
)
issue_queryset = self.get_queryset()
@@ -74,12 +84,30 @@ class InboxIssueAPIEndpoint(BaseAPIView):
).data,
)
def post(self, request, slug, project_id, inbox_id):
def post(self, request, slug, project_id):
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
)
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project settings"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", "none") in [
"low",
@@ -123,21 +151,45 @@ class InboxIssueAPIEndpoint(BaseAPIView):
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# create an inbox issue
InboxIssue.objects.create(
inbox_id=inbox_id,
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox.id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
serializer = IssueSerializer(issue)
serializer = InboxIssueSerializer(inbox_issue)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, inbox_id, pk):
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
def patch(self, request, slug, project_id, pk):
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project settings"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the inbox issue
inbox_issue = InboxIssue.objects.get(
pk=pk,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox.id,
)
# Get the project member
project_member = ProjectMember.objects.get(
workspace__slug=slug,
@@ -145,6 +197,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
member=request.user,
is_active=True,
)
# Only project members admins and created_by users can access this endpoint
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
request.user.id
@@ -244,10 +297,33 @@ class InboxIssueAPIEndpoint(BaseAPIView):
InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK
)
def delete(self, request, slug, project_id, inbox_id, pk):
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
def delete(self, request, slug, project_id, pk):
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project settings"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the inbox issue
inbox_issue = InboxIssue.objects.get(
pk=pk,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox.id,
)
# Get the project member
project_member = ProjectMember.objects.get(
workspace__slug=slug,
@@ -256,6 +332,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
is_active=True,
)
# Check the inbox issue created
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
request.user.id
):
@@ -272,4 +349,4 @@ class InboxIssueAPIEndpoint(BaseAPIView):
).delete()
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(status=status.HTTP_204_NO_CONTENT)
+41 -40
View File
@@ -22,7 +22,6 @@ from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from .base import BaseAPIView, WebhookMixin
@@ -41,14 +40,12 @@ from plane.db.models import (
IssueComment,
IssueActivity,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
from plane.api.serializers import (
IssueSerializer,
LabelSerializer,
IssueLinkSerializer,
IssueCommentSerializer,
IssueAttachmentSerializer,
IssueActivitySerializer,
)
@@ -103,7 +100,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
@@ -112,7 +108,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
@@ -278,7 +273,7 @@ class LabelAPIEndpoint(BaseAPIView):
def get_queryset(self):
return (
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
Label.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
@@ -302,29 +297,29 @@ class LabelAPIEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
if pk:
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(
label,
fields=self.fields,
expand=self.expand,
if pk is None:
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda labels: LabelSerializer(
labels,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda labels: LabelSerializer(
labels,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(label, fields=self.fields, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, pk=None):
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(label, data=request.data, partial=True)
return Response(serializer.data, status=status.HTTP_200_OK)
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, project_id, pk=None):
label = self.get_queryset().get(pk=pk)
@@ -356,25 +351,31 @@ class IssueLinkAPIEndpoint(BaseAPIView):
.distinct()
)
def get(self, request, slug, project_id, pk=None):
if pk:
label = self.get_queryset().get(pk=pk)
def get(self, request, slug, project_id, issue_id, pk=None):
if pk is None:
issue_links = self.get_queryset()
serializer = IssueLinkSerializer(
label,
issue_links,
fields=self.fields,
expand=self.expand,
)
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links,
many=True,
fields=self.fields,
expand=self.expand,
).data,
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
issue_link = self.get_queryset().get(pk=pk)
serializer = IssueLinkSerializer(
issue_link,
fields=self.fields,
expand=self.expand,
)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug, project_id, issue_id):
serializer = IssueLinkSerializer(data=request.data)
@@ -449,7 +450,7 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue-comment"
webhook_event = "issue_comment"
permission_classes = [
ProjectLitePermission,
]
@@ -587,7 +588,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
serializer = IssueActivitySerializer(issue_activities)
return Response(serializer.data, status=status.HTTP_200_OK)
self.paginate(
return self.paginate(
request=request,
queryset=(issue_activities),
on_results=lambda issue_activity: IssueActivitySerializer(
+15 -6
View File
@@ -129,6 +129,14 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, project_id, pk):
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
serializer = ModuleSerializer(module, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, project_id, pk=None):
if pk:
@@ -168,7 +176,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
@@ -186,7 +194,8 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module"
webhook_event = "module_issue"
bulk = True
permission_classes = [
ProjectEntityPermission,
@@ -323,7 +332,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
# Capture Issue Activity
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"modules_list": issues}),
requested_data=json.dumps({"modules_list": str(issues)}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
@@ -343,9 +352,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, module_id, pk):
def delete(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk
workspace__slug=slug, project_id=project_id, module_id=module_id, issue_id=issue_id
)
module_issue.delete()
issue_activity.delay(
@@ -357,7 +366,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
+15 -12
View File
@@ -94,8 +94,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.distinct()
)
def get(self, request, slug, pk=None):
if pk is None:
def get(self, request, slug, project_id=None):
if project_id is None:
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
@@ -114,7 +114,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
).select_related("member"),
)
)
.order_by("sort_order", "name")
.order_by(request.GET.get("order_by", "sort_order"))
)
return self.paginate(
request=request,
@@ -123,15 +123,13 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
projects, many=True, fields=self.fields, expand=self.expand,
).data,
)
else:
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
project = self.get_queryset().get(workspace__slug=slug, pk=project_id)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
serializer = ProjectSerializer(
data={**request.data}, context={"workspace_id": workspace.id}
)
@@ -236,10 +234,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_410_GONE,
)
def patch(self, request, slug, pk=None):
def patch(self, request, slug, project_id=None):
try:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
project = Project.objects.get(pk=project_id)
serializer = ProjectSerializer(
project,
@@ -260,7 +258,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
name="Triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=pk,
project_id=project_id,
color="#ff7700",
)
@@ -282,4 +280,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
)
)
def delete(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+10 -12
View File
@@ -23,10 +23,8 @@ class StateAPIEndpoint(BaseAPIView):
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
return (
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.filter(~Q(name="Triage"))
@@ -42,9 +40,9 @@ class StateAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, project_id, pk=None):
if pk:
serializer = StateSerializer(self.get_queryset().get(pk=pk))
def get(self, request, slug, project_id, state_id=None):
if state_id:
serializer = StateSerializer(self.get_queryset().get(pk=state_id))
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
@@ -57,10 +55,10 @@ class StateAPIEndpoint(BaseAPIView):
).data,
)
def delete(self, request, slug, project_id, pk):
def delete(self, request, slug, project_id, state_id):
state = State.objects.get(
~Q(name="Triage"),
pk=pk,
pk=state_id,
project_id=project_id,
workspace__slug=slug,
)
@@ -69,7 +67,7 @@ class StateAPIEndpoint(BaseAPIView):
return Response({"error": "Default state cannot be deleted"}, status=False)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=pk).exists()
issue_exist = Issue.issue_objects.filter(state=state_id).exists()
if issue_exist:
return Response(
@@ -80,8 +78,8 @@ class StateAPIEndpoint(BaseAPIView):
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, project_id, pk=None):
state = State.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk)
def patch(self, request, slug, project_id, state_id=None):
state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id)
serializer = StateSerializer(state, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
+2 -2
View File
@@ -159,10 +159,10 @@ class ChangePasswordSerializer(serializers.Serializer):
def validate(self, data):
if data.get("old_password") == data.get("new_password"):
raise serializers.ValidationError("New password cannot be same as old password.")
raise serializers.ValidationError({"error": "New password cannot be same as old password."})
if data.get("new_password") != data.get("confirm_password"):
raise serializers.ValidationError("confirm password should be same as the new password.")
raise serializers.ValidationError({"error": "Confirm password should be same as the new password."})
return data
@@ -1,3 +1,9 @@
# Python imports
import urllib
import socket
import ipaddress
from urllib.parse import urlparse
# Third party imports
from rest_framework import serializers
@@ -9,6 +15,42 @@ from plane.db.models.webhook import validate_domain, validate_schema
class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain])
def validate(self, data):
url = data.get("url", None)
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Additional validation for multiple request domains and their subdomains
request = self.context.get('request')
disallowed_domains = ['plane.so',] # Add your disallowed domains here
if request:
request_host = request.get_host().split(':')[0] # Remove port if present
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
return data
class Meta:
model = Webhook
fields = "__all__"
@@ -21,6 +21,22 @@ class WorkSpaceSerializer(BaseSerializer):
total_members = serializers.IntegerField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
def validated(self, data):
if data.get("slug") in [
"404",
"accounts",
"api",
"create-workspace",
"god-mode",
"installations",
"invitations",
"onboarding",
"profile",
"spaces",
"workspace-invitations",
]:
raise serializers.ValidationError({"slug": "Slug is not valid"})
class Meta:
model = Workspace
fields = "__all__"
+10
View File
@@ -4,6 +4,7 @@ from django.urls import path
from plane.app.views import (
FileAssetEndpoint,
UserAssetsEndpoint,
FileAssetViewSet,
)
@@ -28,4 +29,13 @@ urlpatterns = [
UserAssetsEndpoint.as_view(),
name="user-file-assets",
),
path(
"workspaces/file-assets/<uuid:workspace_id>/<str:asset_key>/restore/",
FileAssetViewSet.as_view(
{
"post": "restore",
}
),
name="file-assets-restore",
),
]
+2 -2
View File
@@ -65,7 +65,7 @@ urlpatterns = [
name="project-member-invite",
),
path(
"users/me/invitations/projects/",
"users/me/workspaces/<str:slug>/projects/invitations/",
UserProjectInvitationsViewset.as_view(
{
"get": "list",
@@ -75,7 +75,7 @@ urlpatterns = [
name="user-project-invitations",
),
path(
"workspaces/<str:slug>/projects/join/",
"workspaces/<str:slug>/projects/<uuid:project_id>/join/<uuid:pk>/",
ProjectJoinEndpoint.as_view(),
name="project-join",
),
+6
View File
@@ -18,6 +18,7 @@ from plane.app.views import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceUserProfileIssuesGroupedEndpoint
)
@@ -189,6 +190,11 @@ urlpatterns = [
WorkspaceUserProfileIssuesEndpoint.as_view(),
name="workspace-user-profile-issues",
),
path(
"v3/workspaces/<str:slug>/user-issues/<uuid:user_id>/",
WorkspaceUserProfileIssuesGroupedEndpoint.as_view(),
name="workspace-user-profile-issues",
),
path(
"workspaces/<str:slug>/labels/",
WorkspaceLabelsEndpoint.as_view(),
+2 -1
View File
@@ -44,6 +44,7 @@ from .workspace import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceUserProfileIssuesGroupedEndpoint
)
from .state import StateViewSet
from .view import (
@@ -60,7 +61,7 @@ from .cycle import (
TransferCycleIssueEndpoint,
CycleIssueGroupedEndpoint,
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue import (
IssueViewSet,
IssueListEndpoint,
+15 -7
View File
@@ -4,7 +4,7 @@ from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
# Module imports
from .base import BaseAPIView
from .base import BaseAPIView, BaseViewSet
from plane.db.models import FileAsset, Workspace
from plane.app.serializers import FileAssetSerializer
@@ -34,10 +34,20 @@ class FileAssetEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, workspace_id, asset_key):
def delete(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
file_asset.is_deleted = request.data.get("is_deleted", file_asset.is_deleted)
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class FileAssetViewSet(BaseViewSet):
def restore(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
file_asset.is_deleted = False
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -63,8 +73,6 @@ class UserAssetsEndpoint(BaseAPIView):
def delete(self, request, asset_key):
file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user)
# Delete the file from storage
file_asset.asset.delete(save=False)
# Delete the file object
file_asset.delete()
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
+1 -1
View File
@@ -133,7 +133,7 @@ class ChangePasswordEndpoint(BaseAPIView):
if serializer.is_valid():
if not user.check_password(serializer.data.get("old_password")):
return Response(
{"old_password": ["Wrong password."]},
{"error": "Old password is not correct"},
status=status.HTTP_400_BAD_REQUEST,
)
# set_password also hashes the password that the user will get
+95 -110
View File
@@ -1,4 +1,5 @@
# Python imports
import os
import uuid
import random
import string
@@ -32,7 +33,9 @@ from plane.db.models import (
)
from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.event_tracking_task import auth_events
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
@@ -46,7 +49,17 @@ class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
def post(self, request):
if not settings.ENABLE_SIGNUP:
instance_configuration = InstanceConfiguration.objects.values("key", "value")
if (
not get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
@@ -140,7 +153,8 @@ class SignUpEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -148,30 +162,17 @@ class SignUpEndpoint(BaseAPIView):
workspace_member_invites.delete()
project_member_invites.delete()
try:
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=True
)
access_token, refresh_token = get_tokens_for_user(user)
@@ -224,15 +225,9 @@ class SignInEndpoint(BaseAPIView):
},
status=status.HTTP_403_FORBIDDEN,
)
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
# settings last active for the user
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -288,7 +283,8 @@ class SignInEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -296,30 +292,17 @@ class SignInEndpoint(BaseAPIView):
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()
try:
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=False
)
access_token, refresh_token = get_tokens_for_user(user)
data = {
@@ -360,6 +343,31 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
def post(self, request):
email = request.data.get("email", False)
instance_configuration = InstanceConfiguration.objects.values("key", "value")
if (
not get_configuration_value(
instance_configuration,
"ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
)
and not (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
if not email:
return Response(
{"error": "Please provide a valid email address"},
@@ -410,8 +418,7 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
ri.set(key, json.dumps(value), ex=expiry)
current_site = request.META.get('HTTP_ORIGIN')
current_site = request.META.get("HTTP_ORIGIN")
magic_link.delay(email, key, token, current_site)
return Response({"key": key}, status=status.HTTP_200_OK)
@@ -450,30 +457,18 @@ class MagicSignInEndpoint(BaseAPIView):
},
status=status.HTTP_403_FORBIDDEN,
)
try:
# Send event to Jitsu for tracking
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "code",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=False
)
else:
user = User.objects.create(
email=email,
@@ -481,31 +476,20 @@ class MagicSignInEndpoint(BaseAPIView):
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
try:
# Send event to Jitsu for tracking
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "code",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=True
)
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -561,7 +545,8 @@ class MagicSignInEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
+6 -1
View File
@@ -43,20 +43,25 @@ class TimezoneMixin:
class WebhookMixin:
webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
event_data=json.dumps(response.data, cls=DjangoJSONEncoder),
payload=response.data,
kw=self.kwargs,
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
)
return response
+7 -4
View File
@@ -45,22 +45,23 @@ class ConfigurationEndpoint(BaseAPIView):
get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("GITHUB_APP_NAME", None),
os.environ.get("EMAIL_HOST_USER", None),
),
)
and bool(
get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("GITHUB_APP_NAME", None),
os.environ.get("EMAIL_HOST_PASSWORD", None),
)
)
) and get_configuration_value(
instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "0"
instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "1"
) == "1"
data["email_password_login"] = (
get_configuration_value(
instance_configuration, "ENABLE_EMAIL_PASSWORD", "0"
instance_configuration, "ENABLE_EMAIL_PASSWORD", "1"
)
== "1"
)
@@ -101,4 +102,6 @@ class ConfigurationEndpoint(BaseAPIView):
)
)
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
return Response(data, status=status.HTTP_200_OK)
+6 -3
View File
@@ -502,7 +502,10 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle"
webhook_event = "cycle_issue"
bulk = True
permission_classes = [
ProjectEntityPermission,
]
@@ -688,7 +691,6 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
)
issue_id = cycle_issue.issue_id
cycle_issue.delete()
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
@@ -698,11 +700,12 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
issue_id=str(cycle_issue.issue_id),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
cycle_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+13 -9
View File
@@ -1,6 +1,6 @@
# Python imports
import requests
import os
# Third party imports
from openai import OpenAI
from rest_framework.response import Response
@@ -27,8 +27,8 @@ class GPTIntegrationEndpoint(BaseAPIView):
# Get the configuration value
instance_configuration = InstanceConfiguration.objects.values("key", "value")
api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY")
gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE")
api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY", os.environ.get("OPENAI_API_KEY"))
gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE", os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"))
# Check the keys
if not api_key or not gpt_engine:
@@ -47,10 +47,6 @@ class GPTIntegrationEndpoint(BaseAPIView):
final_text = task + "\n" + prompt
instance_configuration = InstanceConfiguration.objects.values("key", "value")
gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE")
client = OpenAI(
api_key=api_key,
)
@@ -85,14 +81,22 @@ class ReleaseNotesEndpoint(BaseAPIView):
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
instance_configuration = InstanceConfiguration.objects.values("key", "value")
unsplash_access_key = get_configuration_value(instance_configuration, "UNSPLASH_ACCESS_KEY", os.environ.get("UNSPLASH_ACCESS_KEY"))
# Check unsplash access key
if not unsplash_access_key:
return Response([], status=status.HTTP_200_OK)
# Query parameters
query = request.GET.get("query", False)
page = request.GET.get("page", 1)
per_page = request.GET.get("per_page", 20)
url = (
f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
f"https://api.unsplash.com/search/photos/?client_id={unsplash_access_key}&query={query}&page=${page}&per_page={per_page}"
if query
else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
else f"https://api.unsplash.com/photos/?client_id={unsplash_access_key}&page={page}&per_page={per_page}"
)
headers = {
+1 -1
View File
@@ -596,7 +596,7 @@ class IssueActivityEndpoint(BaseAPIView):
class IssueCommentViewSet(WebhookMixin, BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue-comment"
webhook_event = "issue_comment"
permission_classes = [
ProjectLitePermission,
]
+6 -3
View File
@@ -283,9 +283,12 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueViewSet(BaseViewSet):
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
filterset_fields = [
"issue__labels__id",
@@ -461,7 +464,6 @@ class ModuleIssueViewSet(BaseViewSet):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk
)
module_issue.delete()
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
@@ -471,11 +473,12 @@ class ModuleIssueViewSet(BaseViewSet):
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=str(module_issue.issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
module_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+83 -58
View File
@@ -29,7 +29,10 @@ from plane.db.models import (
ProjectMemberInvite,
ProjectMember,
)
from plane.bgtasks.event_tracking_task import auth_events
from .base import BaseAPIView
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
def get_tokens_for_user(user):
@@ -137,6 +140,40 @@ class OauthEndpoint(BaseAPIView):
id_token = request.data.get("credential", False)
client_id = request.data.get("clientId", False)
instance_configuration = InstanceConfiguration.objects.values(
"key", "value"
)
if (
(
not get_configuration_value(
instance_configuration,
"GOOGLE_CLIENT_ID",
os.environ.get("GOOGLE_CLIENT_ID"),
)
or not get_configuration_value(
instance_configuration,
"GITHUB_CLIENT_ID",
os.environ.get("GITHUB_CLIENT_ID"),
)
)
and not (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
if not medium or not id_token:
return Response(
{
@@ -174,15 +211,7 @@ class OauthEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
## Login Case
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -239,7 +268,8 @@ class OauthEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -256,29 +286,18 @@ class OauthEndpoint(BaseAPIView):
"last_login_at": timezone.now(),
},
)
try:
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": f"oauth-{medium}",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium=medium.upper(),
first_time=False
)
access_token, refresh_token = get_tokens_for_user(user)
@@ -291,6 +310,23 @@ class OauthEndpoint(BaseAPIView):
except User.DoesNotExist:
## Signup Case
if (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
username = uuid.uuid4().hex
if "@" in email:
@@ -373,7 +409,8 @@ class OauthEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -381,29 +418,17 @@ class OauthEndpoint(BaseAPIView):
workspace_member_invites.delete()
project_member_invites.delete()
try:
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": f"oauth-{medium}",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium=medium.upper(),
first_time=True
)
SocialLoginConnection.objects.update_or_create(
medium=medium,
@@ -420,4 +445,4 @@ class OauthEndpoint(BaseAPIView):
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_201_CREATED)
return Response(data, status=status.HTTP_201_CREATED)
+10 -3
View File
@@ -388,7 +388,7 @@ class ProjectInvitationsViewset(BaseViewSet):
{"error": "You cannot invite a user with higher role"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
project_invitations = []
@@ -424,7 +424,7 @@ class ProjectInvitationsViewset(BaseViewSet):
project_invitations = ProjectMemberInvite.objects.bulk_create(
project_invitations, batch_size=10, ignore_conflicts=True
)
current_site = request.META.get('HTTP_ORIGIN')
current_site = request.META.get("HTTP_ORIGIN")
# Send invitations
for invitation in project_invitations:
@@ -469,6 +469,13 @@ class UserProjectInvitationsViewset(BaseViewSet):
workspace_role = workspace_member.role
workspace = workspace_member.workspace
# If the user was already part of workspace
_ = ProjectMember.objects.filter(
workspace__slug=slug,
project_id__in=project_ids,
member=request.user,
).update(is_active=True)
ProjectMember.objects.bulk_create(
[
ProjectMember(
@@ -1040,4 +1047,4 @@ class ProjectDeployBoardViewSet(BaseViewSet):
project_deploy_board.save()
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.data, status=status.HTTP_200_OK)
+66 -8
View File
@@ -12,11 +12,14 @@ from plane.app.serializers import (
)
from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.db.models import User, IssueActivity, WorkspaceMember
from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember
from plane.license.models import Instance, InstanceAdmin
from plane.utils.paginator import BasePaginator
from django.db.models import Q, F, Count, Case, When, Value, IntegerField
class UserEndpoint(BaseViewSet):
serializer_class = UserSerializer
model = User
@@ -45,13 +48,68 @@ class UserEndpoint(BaseViewSet):
def deactivate(self, request):
# Check all workspace user is active
user = self.get_object()
if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists():
return Response(
{
"error": "User cannot deactivate account as user is active in some workspaces"
},
status=status.HTTP_400_BAD_REQUEST,
)
projects_to_deactivate = []
workspaces_to_deactivate = []
projects = ProjectMember.objects.filter(
member=request.user, is_active=True
).annotate(
other_admin_exists=Count(
Case(
When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1),
default=0,
output_field=IntegerField(),
)
),
total_members=Count("id"),
)
for project in projects:
if project.other_admin_exists > 0 or (project.total_members == 1):
project.is_active = False
projects_to_deactivate.append(project)
else:
return Response(
{
"error": "You cannot deactivate account as you are the only admin in some projects."
},
status=status.HTTP_400_BAD_REQUEST,
)
workspaces = WorkspaceMember.objects.filter(
member=request.user, is_active=True
).annotate(
other_admin_exists=Count(
Case(
When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1),
default=0,
output_field=IntegerField(),
)
),
total_members=Count("id"),
)
for workspace in workspaces:
if workspace.other_admin_exists > 0 or (workspace.total_members == 1):
workspace.is_active = False
workspaces_to_deactivate.append(workspace)
else:
return Response(
{
"error": "You cannot deactivate account as you are the only admin in some workspaces."
},
status=status.HTTP_400_BAD_REQUEST,
)
ProjectMember.objects.bulk_update(
projects_to_deactivate, ["is_active"], batch_size=100
)
WorkspaceMember.objects.bulk_update(
workspaces_to_deactivate, ["is_active"], batch_size=100
)
# Deactivate the user
user.is_active = False
+4 -2
View File
@@ -20,9 +20,10 @@ class WebhookEndpoint(BaseAPIView):
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
try:
serializer = WebhookSerializer(data=request.data)
serializer = WebhookSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -79,6 +80,7 @@ class WebhookEndpoint(BaseAPIView):
serializer = WebhookSerializer(
webhook,
data=request.data,
context={request: request},
partial=True,
fields=(
"id",
+60 -4
View File
@@ -590,7 +590,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
member_with_role=Count(
"project_projectmember",
filter=Q(
project_projectmember__member_id=request.user.id,
project_projectmember__member_id=workspace_member.id,
project_projectmember__role=20,
),
),
@@ -600,7 +600,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "User is part of some projects where they are the only admin you should leave that project first"
"error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin."
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -635,7 +635,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "You cannot leave the workspace as your the only admin of the workspace you will have to either delete the workspace or create an another admin"
"error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin."
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -656,7 +656,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "User is part of some projects where they are the only admin you should leave that project first"
"error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin."
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -1313,6 +1313,62 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
return Response(issues, status=status.HTTP_200_OK)
class WorkspaceUserProfileIssuesGroupedEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug, user_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
issue_queryset = (
Issue.issue_objects.filter(
Q(assignees__in=[user_id])
| Q(created_by_id=user_id)
| Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug,
project__project_projectmember__member=request.user,
)
.filter(**filters)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.order_by("-created_at")
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
class WorkspaceLabelsEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
@@ -1,6 +1,7 @@
# Python imports
import csv
import io
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
@@ -32,7 +33,7 @@ row_mapping = {
"priority": "Priority",
"estimate": "Estimate",
"issue_cycle__cycle_id": "Cycle",
"issue_module__module_id": "Module"
"issue_module__module_id": "Module",
}
ASSIGNEE_ID = "assignees__id"
@@ -51,17 +52,48 @@ def send_export_email(email, slug, csv_buffer):
csv_buffer.seek(0)
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
use_ssl=bool(get_configuration_value(instance_configuration, "EMAIL_USE_SSL", "0")),
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
msg.send(fail_silently=False)
@@ -1,3 +1,6 @@
# Python imports
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -14,15 +17,13 @@ from sentry_sdk import capture_exception
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task
def email_verification(first_name, email, token, current_site):
try:
realtivelink = "/request-email-verification/" + "?token=" + str(token)
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = "Verify your Email!"
context = {
@@ -35,17 +36,49 @@ def email_verification(first_name, email, token, current_site):
text_content = strip_tags(html_content)
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
# Initiate email alternatives
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
@@ -0,0 +1,31 @@
import uuid
from posthog import Posthog
from django.conf import settings
#third party imports
from celery import shared_task
from sentry_sdk import capture_exception
@shared_task
def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
print(user, email, user_agent, ip, event_name, medium, first_time)
try:
posthog = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST)
posthog.capture(
email,
event=event_name,
properties={
"event_id": uuid.uuid4().hex,
"user": {"email": email, "id": str(user)},
"device_ctx": {
"ip": ip,
"user_agent": user_agent,
},
"medium": medium,
"first_time": first_time
}
)
except Exception as e:
capture_exception(e)
@@ -1,3 +1,6 @@
# Python import
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -19,8 +22,6 @@ def forgot_password(first_name, email, uidb64, token, current_site):
realtivelink = f"/accounts/reset-password/?uidb64={uidb64}&token={token}"
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = "Reset Your Password - Plane"
context = {
@@ -34,14 +35,44 @@ def forgot_password(first_name, email, uidb64, token, current_site):
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
# Initiate email alternatives
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
@@ -1,3 +1,6 @@
# Python imports
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -12,6 +15,7 @@ from sentry_sdk import capture_exception
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task
def magic_link(email, key, token, current_site):
try:
@@ -26,17 +30,48 @@ def magic_link(email, key, token, current_site):
text_content = strip_tags(html_content)
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
# Initiate email alternatives
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
@@ -190,6 +190,7 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
issue_activities_created) if issue_activities_created is not None else None
)
if type not in [
"issue.activity.deleted",
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
@@ -1,3 +1,6 @@
# Python import
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -25,8 +28,6 @@ def project_invitation(email, project_id, token, current_site, invitor):
relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}"
abs_url = current_site + relativelink
from_email_string = settings.EMAIL_FROM
subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane"
context = {
@@ -48,14 +49,45 @@ def project_invitation(email, project_id, token, current_site, invitor):
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
# Initiate email alternatives
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
+105 -21
View File
@@ -2,15 +2,67 @@ import requests
import uuid
import hashlib
import json
import hmac
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from plane.db.models import Webhook, WebhookLog
from plane.db.models import (
Webhook,
WebhookLog,
Project,
Issue,
Cycle,
Module,
ModuleIssue,
CycleIssue,
IssueComment,
)
from plane.api.serializers import (
ProjectSerializer,
IssueSerializer,
CycleSerializer,
ModuleSerializer,
CycleIssueSerializer,
ModuleIssueSerializer,
IssueCommentSerializer,
IssueExpandSerializer,
)
SERIALIZER_MAPPER = {
"project": ProjectSerializer,
"issue": IssueExpandSerializer,
"cycle": CycleSerializer,
"module": ModuleSerializer,
"cycle_issue": CycleIssueSerializer,
"module_issue": ModuleIssueSerializer,
"issue_comment": IssueCommentSerializer,
}
MODEL_MAPPER = {
"project": Project,
"issue": Issue,
"cycle": Cycle,
"module": Module,
"cycle_issue": CycleIssue,
"module_issue": ModuleIssue,
"issue_comment": IssueComment,
}
def get_model_data(event, event_id, many=False):
model = MODEL_MAPPER.get(event)
if many:
queryset = model.objects.filter(pk__in=event_id)
else:
queryset = model.objects.get(pk=event_id)
serializer = SERIALIZER_MAPPER.get(event)
return serializer(queryset, many=many).data
@shared_task(
@@ -31,19 +83,24 @@ def webhook_task(self, webhook, slug, event, event_data, action):
"X-Plane-Event": event,
}
# Your secret key
# # Your secret key
event_data = (
json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
if event_data is not None
else None
)
# Use HMAC for generating signature
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()
event_data_json = json.dumps(event_data) if event_data is not None else "{}"
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
event_data_json.encode("utf-8"),
hashlib.sha256,
)
signature = hmac_signature.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",
@@ -96,10 +153,6 @@ def webhook_task(self, webhook, slug, event, event_data, action):
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:
@@ -110,7 +163,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
@shared_task()
def send_webhook(event, event_data, action, slug):
def send_webhook(event, payload, kw, action, slug, bulk):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
@@ -120,17 +173,48 @@ def send_webhook(event, event_data, action, slug):
if event == "issue":
webhooks = webhooks.filter(issue=True)
if event == "module":
if event == "module" or event == "module_issue":
webhooks = webhooks.filter(module=True)
if event == "cycle":
if event == "cycle" or event == "cycle_issue":
webhooks = webhooks.filter(cycle=True)
if event == "issue-comment":
if event == "issue_comment":
webhooks = webhooks.filter(issue_comment=True)
for webhook in webhooks:
webhook_task.delay(webhook.id, slug, event, event_data, action)
if webhooks:
if action in ["POST", "PATCH"]:
if bulk and event in ["cycle_issue", "module_issue"]:
event_data = IssueExpandSerializer(
Issue.objects.filter(
pk__in=[
str(event.get("issue")) for event in payload
]
).prefetch_related("issue_cycle", "issue_module"), many=True
).data
event = "issue"
action = "PATCH"
else:
event_data = [
get_model_data(
event=event,
event_id=payload.get("id") if isinstance(payload, dict) else None,
many=False,
)
]
if action == "DELETE":
event_data = [{"id": kw.get("pk")}]
for webhook in webhooks:
for data in event_data:
webhook_task.delay(
webhook=webhook.id,
slug=slug,
event=event,
event_data=data,
action=action,
)
except Exception as e:
if settings.DEBUG:
@@ -1,3 +1,6 @@
# Python imports
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -32,9 +35,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
# The complete url including the domain
abs_url = current_site + relative_link
# The email from
from_email_string = settings.EMAIL_FROM
# Subject of the email
subject = f"{user.first_name or user.display_name or user.email} invited you to join {workspace.name} on Plane"
@@ -58,23 +58,41 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
key__startswith="EMAIL_"
).values("key", "value")
connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(
get_configuration_value(instance_configuration, "EMAIL_PORT", "587")
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(
instance_configuration, "EMAIL_HOST_PASSWORD"
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
)
# Initiate email alternatives
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"),
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
@@ -0,0 +1,19 @@
# Generated by Django 4.2.5 on 2023-11-23 14:57
from django.db import migrations, models
import plane.db.models.workspace
class Migration(migrations.Migration):
dependencies = [
('db', '0051_fileasset_is_deleted'),
]
operations = [
migrations.AlterField(
model_name='workspace',
name='slug',
field=models.SlugField(max_length=48, unique=True, validators=[plane.db.models.workspace.slug_validator]),
),
]
+3 -3
View File
@@ -51,9 +51,9 @@ class Module(ProjectBaseModel):
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Module.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
smallest_sort_order = Module.objects.filter(project=self.project).aggregate(
smallest=models.Min("sort_order")
)["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
-1
View File
@@ -16,7 +16,6 @@ def generate_token():
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.")
+20 -2
View File
@@ -1,6 +1,7 @@
# Django imports
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
# Module imports
from . import BaseModel
@@ -50,7 +51,7 @@ def get_default_props():
"state": True,
"sub_issue_count": True,
"updated_on": True,
}
},
}
@@ -63,6 +64,23 @@ def get_issue_props():
}
def slug_validator(value):
if value in [
"404",
"accounts",
"api",
"create-workspace",
"god-mode",
"installations",
"invitations",
"onboarding",
"profile",
"spaces",
"workspace-invitations",
]:
raise ValidationError("Slug is not valid")
class Workspace(BaseModel):
name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.URLField(verbose_name="Logo", blank=True, null=True)
@@ -71,7 +89,7 @@ class Workspace(BaseModel):
on_delete=models.CASCADE,
related_name="owner_workspace",
)
slug = models.SlugField(max_length=48, db_index=True, unique=True)
slug = models.SlugField(max_length=48, db_index=True, unique=True, validators=[slug_validator,])
organization_size = models.CharField(max_length=20, blank=True, null=True)
def __str__(self):
@@ -30,9 +30,11 @@ class Command(BaseCommand):
"EMAIL_USE_TLS": os.environ.get("EMAIL_USE_TLS", "1"),
"EMAIL_USE_SSL": os.environ.get("EMAIL_USE_SSL", "0"),
# Open AI Settings
"OPENAI_API_BASE": os.environ.get("", "https://api.openai.com/v1"),
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "sk-"),
"OPENAI_API_BASE": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"),
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", ""),
"GPT_ENGINE": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
# Unsplash Access Key
"UNSPLASH_ACCESS_KEY": os.environ.get("UNSPLASH_ACESS_KEY", "")
}
for key, value in config_keys.items():
@@ -45,7 +45,7 @@ class Command(BaseCommand):
# If the user does not exist create the user and add him to the database
if user is None:
user = User.objects.create(email=admin_email, username=uuid.uuid4().hex)
user.set_password(uuid.uuid4().hex)
user.set_password(admin_email)
user.save()
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL")
@@ -88,13 +88,11 @@ class Command(BaseCommand):
self.stdout.write(
self.style.SUCCESS(
f"Instance succesfully registered with owner: {instance.primary_owner.email}"
f"Instance successfully registered with owner: {instance.primary_owner.email}"
)
)
return
self.stdout.write(self.style.WARNING("Instance could not be registered"))
return
raise CommandError("Instance could not be registered")
else:
self.stdout.write(
self.style.SUCCESS(
+5 -1
View File
@@ -322,4 +322,8 @@ ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
# Use Minio settings
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
# Posthog settings
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
+1
View File
@@ -36,3 +36,4 @@ scout-apm==2.26.1
openpyxl==3.1.2
beautifulsoup4==4.12.2
dj-database-url==2.1.0
posthog==3.0.2
@@ -44,7 +44,7 @@ export const SummaryPopover: React.FC<Props> = (props) => {
</button>
{!sidePeekVisible && (
<div
className="hidden group-hover/summary-popover:block z-10 max-h-80 w-64 shadow-custom-shadow-rg rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3"
className="hidden group-hover/summary-popover:block z-10 max-h-80 w-64 shadow-custom-shadow-rg rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 overflow-y-auto"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react";
import {
getIconStyling,
getBadgeStyling,
TBadgeVariant,
TBadgeSizes,
} from "./helper";
export interface BadgeProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: TBadgeVariant;
size?: TBadgeSizes;
className?: string;
loading?: boolean;
disabled?: boolean;
appendIcon?: any;
prependIcon?: any;
children: React.ReactNode;
}
const Badge = React.forwardRef<HTMLButtonElement, BadgeProps>((props, ref) => {
const {
variant = "primary",
size = "md",
className = "",
type = "button",
loading = false,
disabled = false,
prependIcon = null,
appendIcon = null,
children,
...rest
} = props;
const buttonStyle = getBadgeStyling(variant, size, disabled || loading);
const buttonIconStyle = getIconStyling(size);
return (
<button
ref={ref}
type={type}
className={`${buttonStyle} ${className}`}
disabled={disabled || loading}
{...rest}
>
{prependIcon && (
<div className={buttonIconStyle}>
{React.cloneElement(prependIcon, { strokeWidth: 2 })}
</div>
)}
{children}
{appendIcon && (
<div className={buttonIconStyle}>
{React.cloneElement(appendIcon, { strokeWidth: 2 })}
</div>
)}
</button>
);
});
Badge.displayName = "plane-ui-badge";
export { Badge };
+145
View File
@@ -0,0 +1,145 @@
export type TBadgeVariant =
| "primary"
| "accent-primary"
| "outline-primary"
| "neutral"
| "accent-neutral"
| "outline-neutral"
| "success"
| "accent-success"
| "outline-success"
| "warning"
| "accent-warning"
| "outline-warning"
| "destructive"
| "accent-destructive"
| "outline-destructive";
export type TBadgeSizes = "sm" | "md" | "lg" | "xl";
export interface IBadgeStyling {
[key: string]: {
default: string;
hover: string;
disabled: string;
};
}
enum badgeSizeStyling {
sm = `px-2.5 py-1 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
lg = `px-4 py-2 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
xl = `px-5 py-3 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
}
enum badgeIconStyling {
sm = "h-3 w-3 flex justify-center items-center overflow-hidden flex-shrink-0",
md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden flex-shrink-0",
lg = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0",
xl = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0",
}
export const badgeStyling: IBadgeStyling = {
primary: {
default: `text-white bg-custom-primary-100`,
hover: `hover:bg-custom-primary-200`,
disabled: `cursor-not-allowed !bg-custom-primary-60 hover:bg-custom-primary-60`,
},
"accent-primary": {
default: `bg-custom-primary-10 text-custom-primary-100`,
hover: `hover:bg-custom-primary-20 hover:text-custom-primary-200`,
disabled: `cursor-not-allowed !text-custom-primary-60`,
},
"outline-primary": {
default: `text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100`,
hover: `hover:border-custom-primary-80 hover:bg-custom-primary-10`,
disabled: `cursor-not-allowed !text-custom-primary-60 !border-custom-primary-60 `,
},
neutral: {
default: `text-custom-background-100 bg-custom-text-100 border border-custom-border-200`,
hover: `hover:bg-custom-text-200`,
disabled: `cursor-not-allowed bg-custom-border-200 !text-custom-text-400`,
},
"accent-neutral": {
default: `text-custom-text-200 bg-custom-background-80`,
hover: `hover:bg-custom-border-200 hover:text-custom-text-100`,
disabled: `cursor-not-allowed !text-custom-text-400`,
},
"outline-neutral": {
default: `text-custom-text-200 bg-custom-background-100 border border-custom-border-200`,
hover: `hover:text-custom-text-100 hover:bg-custom-border-200`,
disabled: `cursor-not-allowed !text-custom-text-400`,
},
success: {
default: `text-white bg-green-500`,
hover: `hover:bg-green-600`,
disabled: `cursor-not-allowed !bg-green-300`,
},
"accent-success": {
default: `text-green-500 bg-green-50`,
hover: `hover:bg-green-100 hover:text-green-600`,
disabled: `cursor-not-allowed !text-green-300`,
},
"outline-success": {
default: `text-green-500 bg-custom-background-100 border border-green-500`,
hover: `hover:text-green-600 hover:bg-green-50`,
disabled: `cursor-not-allowed !text-green-300 border-green-300`,
},
warning: {
default: `text-white bg-amber-500`,
hover: `hover:bg-amber-600`,
disabled: `cursor-not-allowed !bg-amber-300`,
},
"accent-warning": {
default: `text-amber-500 bg-amber-50`,
hover: `hover:bg-amber-100 hover:text-amber-600`,
disabled: `cursor-not-allowed !text-amber-300`,
},
"outline-warning": {
default: `text-amber-500 bg-custom-background-100 border border-amber-500`,
hover: `hover:text-amber-600 hover:bg-amber-50`,
disabled: `cursor-not-allowed !text-amber-300 border-amber-300`,
},
destructive: {
default: `text-white bg-red-500`,
hover: `hover:bg-red-600`,
disabled: `cursor-not-allowed !bg-red-300`,
},
"accent-destructive": {
default: `text-red-500 bg-red-50`,
hover: `hover:bg-red-100 hover:text-red-600`,
disabled: `cursor-not-allowed !text-red-300`,
},
"outline-destructive": {
default: `text-red-500 bg-custom-background-100 border border-red-500`,
hover: `hover:text-red-600 hover:bg-red-50`,
disabled: `cursor-not-allowed !text-red-300 border-red-300`,
},
};
export const getBadgeStyling = (
variant: TBadgeVariant,
size: TBadgeSizes,
disabled: boolean = false
): string => {
let _variant: string = ``;
const currentVariant = badgeStyling[variant];
_variant = `${currentVariant.default} ${
disabled ? currentVariant.disabled : currentVariant.hover
}`;
let _size: string = ``;
if (size) _size = badgeSizeStyling[size];
return `${_variant} ${_size}`;
};
export const getIconStyling = (size: TBadgeSizes): string => {
let icon: string = ``;
if (size) icon = badgeIconStyling[size];
return icon;
};
+1
View File
@@ -0,0 +1 @@
export * from "./badge";
+2 -2
View File
@@ -51,11 +51,11 @@ const CustomSelect = (props: ICustomSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
className={`flex items-center justify-between gap-1 text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
} ${customButtonClassName}`}
>
{customButton}
</button>
+1
View File
@@ -1,5 +1,6 @@
export * from "./avatar";
export * from "./breadcrumbs";
export * from "./badge";
export * from "./button";
export * from "./dropdowns";
export * from "./form-fields";
@@ -10,16 +10,12 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { Button } from "@plane/ui";
// hooks
import useToast from "hooks/use-toast";
// services
import { AuthService } from "services/auth.service";
type Props = {
isOpen: boolean;
onClose: () => void;
};
const authService = new AuthService();
export const DeactivateAccountModal: React.FC<Props> = (props) => {
const { isOpen, onClose } = props;
@@ -28,7 +24,7 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
const [isDeactivating, setIsDeactivating] = useState(false);
const {
user: { deactivateAccount },
user: { deactivateAccount, signOut },
} = useMobxStore();
const router = useRouter();
@@ -46,8 +42,7 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
const handleSwitchAccount = async () => {
setSwitchingAccount(true);
await authService
.signOut()
await signOut()
.then(() => {
mutate("CURRENT_USER_DETAILS", null);
setTheme("system");
+49 -37
View File
@@ -1,61 +1,71 @@
//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 { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import { APITokenService } from "services/api_token.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// types
import { IApiToken } from "types/api_token";
// fetch-keys
import { API_TOKENS_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
tokenId?: string;
onClose: () => void;
tokenId: string;
};
const apiTokenService = new APITokenService();
export const DeleteTokenModal: FC<Props> = (props) => {
const { isOpen, handleClose, tokenId } = props;
export const DeleteApiTokenModal: FC<Props> = (props) => {
const { isOpen, onClose, tokenId } = props;
// states
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
// hooks
const { setToastAlert } = useToast();
// router
const router = useRouter();
const { workspaceSlug, tokenId: tokenIdFromQuery } = router.query;
const { workspaceSlug } = router.query;
const handleClose = () => {
onClose();
setDeleteLoading(false);
};
const handleDeletion = () => {
if (!workspaceSlug || (!tokenIdFromQuery && !tokenId)) return;
const token = tokenId || tokenIdFromQuery;
if (!workspaceSlug) return;
setDeleteLoading(true);
apiTokenService
.deleteApiToken(workspaceSlug.toString(), token!.toString())
.deleteApiToken(workspaceSlug.toString(), tokenId)
.then(() => {
setToastAlert({
message: "Token deleted successfully",
type: "success",
title: "Success",
title: "Success!",
message: "Token deleted successfully.",
});
router.replace(`/${workspaceSlug}/settings/api-tokens/`);
mutate<IApiToken[]>(
API_TOKENS_LIST(workspaceSlug.toString()),
(prevData) => (prevData ?? []).filter((token) => token.id !== tokenId),
false
);
handleClose();
})
.catch((err) => {
.catch((err) =>
setToastAlert({
message: err?.message,
type: "error",
title: "Error",
});
})
.finally(() => {
setDeleteLoading(false);
handleClose();
});
message: err?.message ?? "Something went wrong. Please try again.",
})
)
.finally(() => setDeleteLoading(false));
};
return (
@@ -85,22 +95,24 @@ export const DeleteTokenModal: FC<Props> = (props) => {
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 flex-col gap-3 p-4">
<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>
<h3 className="text-lg font-medium leading-6 text-custom-text-100">
Are you sure you want to delete the token?
</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 className="text-sm text-custom-text-400">
Any application using this token 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}>
<Button variant="neutral-primary" onClick={handleClose} size="sm">
Cancel
</Button>
<Button variant="primary" onClick={handleDeletion} loading={deleteLoading} disabled={deleteLoading}>
{deleteLoading ? "Revoking..." : "Revoke"}
<Button variant="danger" onClick={handleDeletion} loading={deleteLoading} size="sm">
{deleteLoading ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
+12 -18
View File
@@ -1,15 +1,16 @@
// 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";
export const APITokenEmptyState = () => {
const router = useRouter();
type Props = {
onClick: () => void;
};
export const ApiTokenEmptyState: React.FC<Props> = (props) => {
const { onClick } = props;
return (
<div
@@ -17,19 +18,12 @@ export const APITokenEmptyState = () => {
>
<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
<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={onClick}>
Add token
</Button>
</div>
</div>
-160
View File
@@ -1,160 +0,0 @@
import { Dispatch, SetStateAction, useState, FC } from "react";
import { useForm } from "react-hook-form";
import { useRouter } from "next/router";
// helpers
import { addDays, renderDateFormat } from "helpers/date-time.helper";
import { csvDownload } from "helpers/download.helper";
// types
import { IApiToken } from "types/api_token";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
import useToast from "hooks/use-toast";
// services
import { APITokenService } from "services/api_token.service";
// components
import { APITokenTitle } from "./token-title";
import { APITokenDescription } from "./token-description";
import { APITokenExpiry, EXPIRY_OPTIONS } from "./token-expiry";
import { APITokenKeySection } from "./token-key-section";
// ui
import { Button } from "@plane/ui";
interface APITokenFormProps {
generatedToken: IApiToken | null | undefined;
setGeneratedToken: Dispatch<SetStateAction<IApiToken | null | undefined>>;
setDeleteTokenModal: Dispatch<SetStateAction<boolean>>;
}
export interface APIFormFields {
never_expires: boolean;
title: string;
description: string;
}
const apiTokenService = new APITokenService();
export const APITokenForm: FC<APITokenFormProps> = (props) => {
const { generatedToken, setGeneratedToken, setDeleteTokenModal } = props;
// states
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);
// hooks
const { setToastAlert } = useToast();
// store
const {
theme: { sidebarCollapsed },
} = useMobxStore();
// router
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: EXPIRY_OPTIONS[selectedExpiry].days }).toISOString();
};
function renderExpiry(): string {
return renderDateFormat(addDays({ date: new Date(), days: EXPIRY_OPTIONS[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={`${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>
);
};
@@ -1,56 +0,0 @@
import { Dispatch, FC, SetStateAction } from "react";
import { Control, Controller } from "react-hook-form";
// ui
import { TextArea } from "@plane/ui";
// types
import { IApiToken } from "types/api_token";
import type { APIFormFields } from "./index";
interface APITokenDescriptionProps {
generatedToken: IApiToken | null | undefined;
control: Control<APIFormFields, any>;
focusDescription: boolean;
setFocusTitle: Dispatch<SetStateAction<boolean>>;
setFocusDescription: Dispatch<SetStateAction<boolean>>;
}
export const APITokenDescription: FC<APITokenDescriptionProps> = (props) => {
const { generatedToken, control, focusDescription, setFocusTitle, setFocusDescription } = props;
return (
<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);
}}
role="button"
className={`${value.length === 0 ? "text-custom-text-400/60" : "text-custom-text-300"} text-lg pt-3`}
>
{value.length != 0 ? value : "Description"}
</p>
)
}
/>
);
};
@@ -1,111 +0,0 @@
import { Dispatch, Fragment, SetStateAction, FC } from "react";
import { Control, Controller } from "react-hook-form";
import { Menu, Transition } from "@headlessui/react";
// ui
import { ToggleSwitch } from "@plane/ui";
// types
import { APIFormFields } from "./index";
interface APITokenExpiryProps {
neverExpires: boolean;
selectedExpiry: number;
setSelectedExpiry: Dispatch<SetStateAction<number>>;
setNeverExpire: Dispatch<SetStateAction<boolean>>;
renderExpiry: () => string;
control: Control<APIFormFields, any>;
}
export const EXPIRY_OPTIONS = [
{
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: FC<APITokenExpiryProps> = (props) => {
const { neverExpires, selectedExpiry, setSelectedExpiry, setNeverExpire, renderExpiry, control } = props;
return (
<>
<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" : ""}`}>
{EXPIRY_OPTIONS[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">
{EXPIRY_OPTIONS.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>
</>
);
};
@@ -1,59 +0,0 @@
import { Dispatch, SetStateAction, FC } from "react";
// icons
import { Copy } from "lucide-react";
// ui
import { Button } from "@plane/ui";
// hooks
import useToast from "hooks/use-toast";
// types
import { IApiToken } from "types/api_token";
interface APITokenKeySectionProps {
generatedToken: IApiToken | null | undefined;
renderExpiry: () => string;
setDeleteTokenModal: Dispatch<SetStateAction<boolean>>;
}
export const APITokenKeySection: FC<APITokenKeySectionProps> = (props) => {
const { generatedToken, renderExpiry, setDeleteTokenModal } = props;
// hooks
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;
};
@@ -1,69 +0,0 @@
import { Dispatch, FC, SetStateAction } from "react";
import { Control, Controller, FieldErrors } from "react-hook-form";
// ui
import { Input } from "@plane/ui";
// types
import { IApiToken } from "types/api_token";
import type { APIFormFields } from "./index";
interface APITokenTitleProps {
generatedToken: IApiToken | null | undefined;
errors: FieldErrors<APIFormFields>;
control: Control<APIFormFields, any>;
focusTitle: boolean;
setFocusTitle: Dispatch<SetStateAction<boolean>>;
setFocusDescription: Dispatch<SetStateAction<boolean>>;
}
export const APITokenTitle: FC<APITokenTitleProps> = (props) => {
const { generatedToken, errors, control, focusTitle, setFocusTitle, setFocusDescription } = props;
return (
<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
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);
}}
role="button"
className={`${value.length === 0 ? "text-custom-text-400/60" : ""} font-medium text-[24px]`}
>
{value.length != 0 ? value : "Api Title"}
</p>
)
}
/>
);
};
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./modal";
export * from "./delete-token-modal";
export * from "./empty-state";
export * from "./token-list-item";
export * from "./form";
@@ -0,0 +1,133 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import { APITokenService } from "services/api_token.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { CreateApiTokenForm, GeneratedTokenDetails } from "components/api-token";
// helpers
import { csvDownload } from "helpers/download.helper";
import { renderFormattedDate } from "helpers/date-time.helper";
// types
import { IApiToken } from "types/api_token";
// fetch-keys
import { API_TOKENS_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
onClose: () => void;
};
// services
const apiTokenService = new APITokenService();
export const CreateApiTokenModal: React.FC<Props> = (props) => {
const { isOpen, onClose } = props;
// states
const [neverExpires, setNeverExpires] = useState<boolean>(false);
const [generatedToken, setGeneratedToken] = useState<IApiToken | null | undefined>(null);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// toast alert
const { setToastAlert } = useToast();
const handleClose = () => {
onClose();
setTimeout(() => {
setNeverExpires(false);
setGeneratedToken(null);
}, 350);
};
const downloadSecretKey = (data: IApiToken) => {
const csvData = {
Title: data.label,
Description: data.description,
Expiry: data.expired_at ? renderFormattedDate(data.expired_at) : "Never expires",
"Secret key": data.token ?? "",
};
csvDownload(csvData, `secret-key-${Date.now()}`);
};
const handleCreateToken = async (data: Partial<IApiToken>) => {
if (!workspaceSlug) return;
// make the request to generate the token
await apiTokenService
.createApiToken(workspaceSlug.toString(), data)
.then((res) => {
setGeneratedToken(res);
downloadSecretKey(res);
mutate<IApiToken[]>(
API_TOKENS_LIST(workspaceSlug.toString()),
(prevData) => {
if (!prevData) return;
return [res, ...prevData];
},
false
);
})
.catch((err) => {
setToastAlert({
message: err.message,
type: "error",
title: "Error",
});
throw err;
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={() => {}}>
<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 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="h-full w-full grid place-items-center text-center p-4">
<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 rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all px-4 sm:w-full sm:max-w-2xl">
{generatedToken ? (
<GeneratedTokenDetails handleClose={handleClose} tokenDetails={generatedToken} />
) : (
<CreateApiTokenForm
handleClose={handleClose}
neverExpires={neverExpires}
toggleNeverExpires={() => setNeverExpires((prevData) => !prevData)}
onSubmit={handleCreateToken}
/>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
+247
View File
@@ -0,0 +1,247 @@
import { useState } from "react";
import { add } from "date-fns";
import { Controller, useForm } from "react-hook-form";
import { Calendar } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// components
import { CustomDatePicker } from "components/ui";
// ui
import { Button, CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui";
// helpers
import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { IApiToken } from "types/api_token";
type Props = {
handleClose: () => void;
neverExpires: boolean;
toggleNeverExpires: () => void;
onSubmit: (data: Partial<IApiToken>) => Promise<void>;
};
const EXPIRY_DATE_OPTIONS = [
{
key: "1_week",
label: "1 week",
value: { weeks: 1 },
},
{
key: "1_month",
label: "1 month",
value: { months: 1 },
},
{
key: "3_months",
label: "3 months",
value: { months: 3 },
},
{
key: "1_year",
label: "1 year",
value: { years: 1 },
},
];
const defaultValues: Partial<IApiToken> = {
label: "",
description: "",
expired_at: null,
};
const getExpiryDate = (val: string): string | null => {
const today = new Date();
const dateToAdd = EXPIRY_DATE_OPTIONS.find((option) => option.key === val)?.value;
if (dateToAdd) {
const expiryDate = add(today, dateToAdd);
return renderFormattedDate(expiryDate);
}
return null;
};
export const CreateApiTokenForm: React.FC<Props> = (props) => {
const { handleClose, neverExpires, toggleNeverExpires, onSubmit } = props;
// states
const [customDate, setCustomDate] = useState<Date | null>(null);
// toast alert
const { setToastAlert } = useToast();
// form
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
} = useForm<IApiToken>({ defaultValues });
const handleFormSubmit = async (data: IApiToken) => {
// if never expires is toggled off, and the user has not selected a custom date or a predefined date, show an error
if (!neverExpires && (!data.expired_at || (data.expired_at === "custom" && !customDate)))
return setToastAlert({
type: "error",
title: "Error!",
message: "Please select an expiration date.",
});
const payload: Partial<IApiToken> = {
label: data.label,
description: data.description,
};
// if never expires is toggled on, set expired_at to null
if (neverExpires) payload.expired_at = null;
// if never expires is toggled off, and the user has selected a custom date, set expired_at to the custom date
else if (data.expired_at === "custom") payload.expired_at = renderFormattedPayloadDate(customDate ?? new Date());
// if never expires is toggled off, and the user has selected a predefined date, set expired_at to the predefined date
else {
const expiryDate = getExpiryDate(data.expired_at ?? "");
if (expiryDate) payload.expired_at = renderFormattedPayloadDate(expiryDate);
}
await onSubmit(payload).then(() => {
reset(defaultValues);
setCustomDate(null);
});
};
const today = new Date();
const tomorrow = add(today, { days: 1 });
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-4">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">Create token</h3>
<div className="space-y-3">
<div>
<Controller
control={control}
name="label"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
validate: (val) => val.trim() !== "" || "Title is required",
}}
render={({ field: { value, onChange } }) => (
<Input
type="text"
value={value}
onChange={onChange}
hasError={Boolean(errors.label)}
placeholder="Token title"
className="text-sm font-medium w-full"
/>
)}
/>
{errors.label && <span className="text-xs text-red-500">{errors.label.message}</span>}
</div>
<Controller
control={control}
name="description"
render={({ field: { value, onChange } }) => (
<TextArea
value={value}
onChange={onChange}
hasError={Boolean(errors.description)}
placeholder="Token description"
className="text-sm h-24 w-full"
/>
)}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Controller
control={control}
name="expired_at"
render={({ field: { onChange, value } }) => {
const selectedOption = EXPIRY_DATE_OPTIONS.find((option) => option.key === value);
return (
<CustomSelect
customButton={
<div
className={`flex items-center gap-2 border-[0.5px] border-custom-border-200 rounded py-1 px-2 ${
neverExpires ? "text-custom-text-400" : ""
}`}
>
<Calendar className="h-3 w-3" />
{value === "custom"
? "Custom date"
: selectedOption
? selectedOption.label
: "Set expiration date"}
</div>
}
value={value}
onChange={onChange}
disabled={neverExpires}
>
{EXPIRY_DATE_OPTIONS.map((option) => (
<CustomSelect.Option key={option.key} value={option.key}>
{option.label}
</CustomSelect.Option>
))}
<CustomSelect.Option value="custom">Custom</CustomSelect.Option>
</CustomSelect>
);
}}
/>
{watch("expired_at") === "custom" && (
<CustomDatePicker
value={customDate}
onChange={(date) => setCustomDate(date ? new Date(date) : null)}
minDate={tomorrow}
customInput={
<div
className={`flex items-center gap-2 py-1 px-2 text-xs cursor-pointer !rounded border-[0.5px] border-custom-border-200 !shadow-none !duration-0 ${
customDate ? "w-[7.5rem]" : ""
} ${neverExpires ? "text-custom-text-400 !cursor-not-allowed" : "hover:bg-custom-background-80"}`}
>
<Calendar className="h-3 w-3" />
{customDate ? renderFormattedDate(customDate) : "Set date"}
</div>
}
disabled={neverExpires}
/>
)}
</div>
{!neverExpires && (
<span className="text-xs text-custom-text-400">
{watch("expired_at") === "custom"
? customDate
? `Expires ${renderFormattedDate(customDate)}`
: null
: watch("expired_at")
? `Expires ${getExpiryDate(watch("expired_at") ?? "")}`
: null}
</span>
)}
</div>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<div className="flex cursor-pointer items-center gap-1.5" onClick={toggleNeverExpires}>
<div className="flex cursor-pointer items-center justify-center">
<ToggleSwitch value={neverExpires} onChange={() => {}} size="sm" />
</div>
<span className="text-xs">Never expires</span>
</div>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Discard
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{isSubmitting ? "Generating..." : "Generate token"}
</Button>
</div>
</div>
</form>
);
};
@@ -0,0 +1,61 @@
import { Copy } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Tooltip } from "@plane/ui";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IApiToken } from "types/api_token";
type Props = {
handleClose: () => void;
tokenDetails: IApiToken;
};
export const GeneratedTokenDetails: React.FC<Props> = (props) => {
const { handleClose, tokenDetails } = props;
const { setToastAlert } = useToast();
const copyApiToken = (token: string) => {
copyTextToClipboard(token).then(() =>
setToastAlert({
type: "success",
title: "Success!",
message: "Token copied to clipboard.",
})
);
};
return (
<div>
<div className="space-y-3">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">Key created</h3>
<p className="text-sm text-custom-text-400">
Copy and save this secret key in Plane Pages. You can{"'"}t see this key after you hit Close. A CSV file
containing the key has been downloaded.
</p>
</div>
<button
type="button"
onClick={() => copyApiToken(tokenDetails.token ?? "")}
className="mt-4 w-full border-[0.5px] border-custom-border-200 py-2 px-3 flex items-center justify-between font-medium rounded-md text-sm outline-none"
>
{tokenDetails.token}
<Tooltip tooltipContent="Copy secret key">
<Copy className="h-4 w-4 text-custom-text-400" />
</Tooltip>
</button>
<div className="mt-6 flex items-center justify-between">
<p className="text-custom-text-400 text-xs">
{tokenDetails.expired_at ? `Expires ${renderFormattedDate(tokenDetails.expired_at)}` : "Never expires"}
</p>
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Close
</Button>
</div>
</div>
);
};
+3
View File
@@ -0,0 +1,3 @@
export * from "./create-token-modal";
export * from "./form";
export * from "./generated-token-details";
+49 -34
View File
@@ -1,43 +1,58 @@
import Link from "next/link";
// helpers
import { formatLongDateDistance, timeAgo } from "helpers/date-time.helper";
// icons
import { useState } from "react";
import { XCircle } from "lucide-react";
// components
import { DeleteApiTokenModal } from "components/api-token";
// ui
import { Tooltip } from "@plane/ui";
// helpers
import { renderFormattedDate, timeAgo } from "helpers/date-time.helper";
// types
import { IApiToken } from "types/api_token";
interface IApiTokenListItem {
workspaceSlug: string | string[] | undefined;
type Props = {
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>
)}
{
export const ApiTokenListItem: React.FC<Props> = (props) => {
const { token } = props;
// states
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
return (
<>
<DeleteApiTokenModal isOpen={deleteModalOpen} onClose={() => setDeleteModalOpen(false)} tokenId={token.id} />
<div className="group relative border-b border-custom-border-200 flex flex-col justify-center py-3 px-4">
<Tooltip tooltipContent="Delete token">
<button
onClick={() => setDeleteModalOpen(true)}
className="hidden group-hover:grid absolute right-4 place-items-center"
>
<XCircle className="h-4 w-4 text-red-500" />
</button>
</Tooltip>
<div className="flex items-center w-4/5">
<h5 className="text-sm font-medium truncate">{token.label}</h5>
<span
className={`${
token.is_active ? "bg-green-500/10 text-green-500" : "bg-custom-background-80 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 flex-col justify-center w-full mt-1">
{token.description.trim() !== "" && (
<p className="text-sm mb-1 break-words max-w-[70%]">{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)}
? token.expired_at
? `Expires ${renderFormattedDate(token.expired_at!)}`
: "Never expires"
: `Expired ${timeAgo(token.expired_at)}`}
</p>
}
</div>
</div>
</div>
</Link>
);
</>
);
};
@@ -14,6 +14,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
const {
commandPalette: { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal },
trackEvent: { setTrackElement }
} = useMobxStore();
return (
@@ -22,6 +23,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE")
toggleCreateCycleModal(true);
}}
className="focus:outline-none"
@@ -62,6 +62,7 @@ export const CommandModal: React.FC = observer(() => {
toggleCreateIssueModal,
toggleCreateProjectModal,
},
trackEvent: { setTrackElement }
} = useMobxStore();
// router
@@ -273,6 +274,7 @@ export const CommandModal: React.FC = observer(() => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE");
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
@@ -290,6 +292,7 @@ export const CommandModal: React.FC = observer(() => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE");
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
@@ -33,6 +33,7 @@ export const CommandPalette: FC = observer(() => {
commandPalette,
theme: { toggleSidebar },
user: { currentUser },
trackEvent: { setTrackElement },
} = useMobxStore();
const {
toggleCommandPaletteModal,
@@ -54,8 +55,22 @@ export const CommandPalette: FC = observer(() => {
toggleBulkDeleteIssueModal,
isDeleteIssueModalOpen,
toggleDeleteIssueModal,
createIssueStoreType,
} = commandPalette;
const isAnyModalOpen = Boolean(
isCreateIssueModalOpen ||
isCreateCycleModalOpen ||
isCreatePageModalOpen ||
isCreateProjectModalOpen ||
isCreateModuleModalOpen ||
isCreateViewModalOpen ||
isShortcutModalOpen ||
isBulkDeleteIssueModalOpen ||
isDeleteIssueModalOpen
);
const { setToastAlert } = useToast();
const { data: issueDetails } = useSWR(
@@ -110,10 +125,12 @@ export const CommandPalette: FC = observer(() => {
e.preventDefault();
toggleSidebar();
}
} else {
} else if (!isAnyModalOpen) {
if (keyPressed === "c") {
setTrackElement("SHORTCUT_KEY");
toggleCreateIssueModal(true);
} else if (keyPressed === "p") {
setTrackElement("SHORTCUT_KEY");
toggleCreateProjectModal(true);
} else if (keyPressed === "h") {
toggleShortcutModal(true);
@@ -145,6 +162,7 @@ export const CommandPalette: FC = observer(() => {
toggleCreateIssueModal,
projectId,
workspaceSlug,
isAnyModalOpen,
]
);
@@ -208,6 +226,7 @@ export const CommandPalette: FC = observer(() => {
prePopulateData={
cycleId ? { cycle: cycleId.toString() } : moduleId ? { module: moduleId.toString() } : undefined
}
currentStore={createIssueStoreType}
/>
{issueId && issueDetails && (
+8 -4
View File
@@ -14,6 +14,8 @@ import { FileService } from "services/file.service";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { Button, Input, Loader } from "@plane/ui";
// constants
import { MAX_FILE_SIZE } from "constants/common";
const tabOptions = [
{
@@ -58,8 +60,10 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspace: workspaceStore } = useMobxStore();
const { currentWorkspace: workspaceDetails } = workspaceStore;
const {
workspace: { currentWorkspace },
appConfig: { envConfig },
} = useMobxStore();
const { data: unsplashImages, error: unsplashError } = useSWR(
`UNSPLASH_IMAGES_${searchParams}`,
@@ -86,7 +90,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: 5 * 1024 * 1024,
maxSize: envConfig?.file_size_limit ?? MAX_FILE_SIZE,
});
const handleSubmit = async () => {
@@ -112,7 +116,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
if (isUnsplashImage) return;
if (oldValue && workspaceDetails) fileService.deleteFile(workspaceDetails.id, oldValue);
if (oldValue && currentWorkspace) fileService.deleteFile(currentWorkspace.id, oldValue);
})
.catch((err) => {
console.log(err);
+2 -1
View File
@@ -1,5 +1,6 @@
export * from "./bulk-delete-issues-modal";
export * from "./existing-issues-list-modal";
export * from "./gpt-assistant-modal";
export * from "./image-upload-modal";
export * from "./link-modal";
export * from "./user-image-upload-modal";
export * from "./workspace-image-upload-modal";
@@ -0,0 +1,199 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { useDropzone } from "react-dropzone";
import { Transition, Dialog } from "@headlessui/react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { FileService } from "services/file.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// icons
import { UserCircle2 } from "lucide-react";
// constants
import { MAX_FILE_SIZE } from "constants/common";
type Props = {
handleDelete?: () => void;
isOpen: boolean;
isRemoving: boolean;
onClose: () => void;
onSuccess: (url: string) => void;
value: string | null;
};
// services
const fileService = new FileService();
export const UserImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete } = props;
// states
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
const { setToastAlert } = useToast();
const {
appConfig: { envConfig },
} = useMobxStore();
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: envConfig?.file_size_limit ?? MAX_FILE_SIZE,
multiple: false,
});
const handleClose = () => {
setImage(null);
setIsImageUploading(false);
onClose();
};
const handleSubmit = async () => {
console.log("Submit triggered");
if (!image) return;
console.log("Inside submit");
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
fileService
.uploadUserFile(formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setImage(null);
if (value) fileService.deleteUserFile(value);
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
)
.finally(() => setIsImageUploading(false));
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" 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 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-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 bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-xl sm:p-6">
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Upload Image
</Dialog.Title>
<div className="space-y-3">
<div className="flex items-center justify-center gap-3">
<div
{...getRootProps()}
className={`relative grid h-80 w-80 cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
(image === null && isDragActive) || !value
? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90"
: ""
}`}
>
{image !== null || (value && value !== "") ? (
<>
<button
type="button"
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-custom-background-90 px-2 py-0.5 text-xs font-medium text-custom-text-200"
>
Edit
</button>
<img
src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image"
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
/>
</>
) : (
<div>
<UserCircle2 className="mx-auto h-16 w-16 text-custom-text-200" />
<span className="mt-2 block text-sm font-medium text-custom-text-200">
{isDragActive ? "Drop image here to upload" : "Drag & drop image here"}
</span>
</div>
)}
<input {...getInputProps()} type="text" />
</div>
</div>
{fileRejections.length > 0 && (
<p className="text-sm text-red-500">
{fileRejections[0].errors[0].code === "file-too-large"
? "The image size cannot exceed 5 MB."
: "Please upload a file in a valid format."}
</p>
)}
</div>
</div>
<p className="my-4 text-custom-text-200 text-sm">
File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p>
<div className="flex items-center justify-between">
{handleDelete && (
<Button variant="danger" size="sm" onClick={handleDelete} disabled={!value}>
{isRemoving ? "Removing..." : "Remove"}
</Button>
)}
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from "react";
import React, { useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { useDropzone } from "react-dropzone";
@@ -7,89 +7,89 @@ import { Transition, Dialog } from "@headlessui/react";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { FileService } from "services/file.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// icons
import { UserCircle2 } from "lucide-react";
// constants
import { MAX_FILE_SIZE } from "constants/common";
type Props = {
value?: string | null;
onClose: () => void;
handleRemove?: () => void;
isOpen: boolean;
onSuccess: (url: string) => void;
isRemoving: boolean;
handleDelete: () => void;
userImage?: boolean;
onClose: () => void;
onSuccess: (url: string) => void;
value: string | null;
};
// services
const fileService = new FileService();
export const ImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete, userImage } = props;
export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleRemove } = props;
// states
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspace: workspaceStore } = useMobxStore();
const { currentWorkspace: workspaceDetails } = workspaceStore;
const { setToastAlert } = useToast();
const onDrop = useCallback((acceptedFiles: File[]) => {
setImage(acceptedFiles[0]);
}, []);
const {
workspace: { currentWorkspace },
appConfig: { envConfig },
} = useMobxStore();
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: 5 * 1024 * 1024,
maxSize: envConfig?.file_size_limit ?? MAX_FILE_SIZE,
multiple: false,
});
const handleClose = () => {
setImage(null);
setIsImageUploading(false);
onClose();
};
const handleSubmit = async () => {
if (!image || (!workspaceSlug && router.pathname != "/onboarding")) return;
if (!image || (!workspaceSlug && router.pathname !== "/onboarding")) return;
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
if (userImage) {
fileService
.uploadUserFile(formData)
.then((res) => {
const imageUrl = res.asset;
if (!workspaceSlug) return;
onSuccess(imageUrl);
setIsImageUploading(false);
setImage(null);
fileService
.uploadFile(workspaceSlug.toString(), formData)
.then((res) => {
const imageUrl = res.asset;
if (value) fileService.deleteUserFile(value);
onSuccess(imageUrl);
setImage(null);
if (value && currentWorkspace) fileService.deleteFile(currentWorkspace.id, value);
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
.catch((err) => {
console.error(err);
});
} else
fileService
.uploadFile(workspaceSlug as string, formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setIsImageUploading(false);
setImage(null);
if (value && workspaceDetails) fileService.deleteFile(workspaceDetails.id, value);
})
.catch((err) => {
console.error(err);
});
};
const handleClose = () => {
setImage(null);
onClose();
)
.finally(() => setIsImageUploading(false));
};
return (
@@ -172,11 +172,11 @@ export const ImageUploadModal: React.FC<Props> = observer((props) => {
File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p>
<div className="flex items-center justify-between">
<div className="flex items-center">
<Button variant="danger" size="sm" onClick={handleDelete} disabled={!value}>
{handleRemove && (
<Button variant="danger" size="sm" onClick={handleRemove} disabled={!value}>
{isRemoving ? "Removing..." : "Remove"}
</Button>
</div>
)}
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
+3 -2
View File
@@ -33,7 +33,7 @@ export interface ICyclesBoardCard {
export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
const { cycle: cycleStore, trackEvent: { setTrackElement } } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
@@ -119,6 +119,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
setTrackElement("CYCLE_PAGE_BOARD_LAYOUT");
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
@@ -252,7 +253,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</>
+2 -1
View File
@@ -38,7 +38,7 @@ type TCyclesListItem = {
export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
const { cycle: cycleStore, trackEvent: { setTrackElement } } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
@@ -119,6 +119,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
setTrackElement("CYCLE_PAGE_LIST_LAYOUT");
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
+6 -2
View File
@@ -19,7 +19,7 @@ export interface ICyclesList {
export const CyclesList: FC<ICyclesList> = observer((props) => {
const { cycles, filter, workspaceSlug, projectId } = props;
const { commandPalette: commandPaletteStore } = useMobxStore();
const { commandPalette: commandPaletteStore, trackEvent: { setTrackElement } } = useMobxStore();
return (
<>
@@ -57,7 +57,11 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
<button
type="button"
className="text-custom-primary-100 text-sm outline-none"
onClick={() => commandPaletteStore.toggleCreateCycleModal(true)}
onClick={() => {
setTrackElement("CYCLES_PAGE_EMPTY-STATE");
commandPaletteStore.toggleCreateCycleModal(true)
}
}
>
Create a new cycle
</button>
+22 -8
View File
@@ -24,26 +24,40 @@ interface ICycleDelete {
export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
const { isOpen, handleClose, cycle, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
const { cycle: cycleStore, trackEvent: { postHogEventTracker } } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
const [loader, setLoader] = useState(false);
const router = useRouter();
const { cycleId } = router.query;
const { cycleId, peekCycle } = router.query;
const formSubmit = async () => {
setLoader(true);
if (cycle?.id)
try {
await cycleStore.removeCycle(workspaceSlug, projectId, cycle?.id);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle deleted successfully.",
await cycleStore.removeCycle(workspaceSlug, projectId, cycle?.id).then((res) => {
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle deleted successfully.",
});
postHogEventTracker(
"CYCLE_DELETE",
{
state: "SUCCESS"
}
);
}).catch((error) => {
postHogEventTracker(
"CYCLE_DELETE",
{
state: "FAILED"
}
);
});
if (cycleId) router.replace(`/${workspaceSlug}/projects/${projectId}/cycles`);
if (cycleId || peekCycle) router.push(`/${workspaceSlug}/projects/${projectId}/cycles`);
handleClose();
} catch (error) {
+15 -2
View File
@@ -24,7 +24,7 @@ const cycleService = new CycleService();
export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const { isOpen, handleClose, data, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
const { cycle: cycleStore, trackEvent: { postHogEventTracker } } = useMobxStore();
// states
const [activeProject, setActiveProject] = useState<string>(projectId);
// toast
@@ -35,12 +35,19 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const selectedProjectId = payload.project ?? projectId.toString();
await cycleStore
.createCycle(workspaceSlug, selectedProjectId, payload)
.then(() => {
.then((res) => {
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle created successfully.",
});
postHogEventTracker(
"CYCLE_CREATE",
{
...res,
state: "SUCCESS",
}
);
})
.catch(() => {
setToastAlert({
@@ -48,6 +55,12 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
title: "Error!",
message: "Error in creating cycle. Please try again.",
});
postHogEventTracker(
"CYCLE_CREATE",
{
state: "FAILED",
}
);
});
};
+37 -23
View File
@@ -52,7 +52,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query;
const { cycle: cycleDetailsStore } = useMobxStore();
const { cycle: cycleDetailsStore, trackEvent: { setTrackElement, postHogEventTracker } } = useMobxStore();
const cycleDetails = cycleDetailsStore.cycle_details[cycleId] ?? undefined;
@@ -74,8 +74,27 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
cycleService
.patchCycle(workspaceSlug as string, projectId as string, cycleId as string, data)
.then(() => mutate(CYCLE_DETAILS(cycleId as string)))
.catch((e) => console.log(e));
.then((res) => {
mutate(CYCLE_DETAILS(cycleId as string));
postHogEventTracker(
"CYCLE_UPDATE",
{
...res,
state: "SUCCESS"
}
);
}
)
.catch((e) => {
console.log(e);
postHogEventTracker(
"CYCLE_UPDATE",
{
state: "FAILED"
}
);
}
);
};
const handleCopyText = () => {
@@ -83,7 +102,8 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
.then(() => {
setToastAlert({
type: "success",
title: "Cycle link copied to clipboard",
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
})
.catch(() => {
@@ -112,13 +132,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const handleStartDateChange = async (date: string) => {
setValue("start_date", date);
if (
watch("start_date") &&
watch("end_date") &&
watch("start_date") !== "" &&
watch("end_date") &&
watch("start_date") !== ""
) {
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
setToastAlert({
type: "error",
@@ -186,13 +200,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const handleEndDateChange = async (date: string) => {
setValue("end_date", date);
if (
watch("start_date") &&
watch("end_date") &&
watch("start_date") !== "" &&
watch("end_date") &&
watch("start_date") !== ""
) {
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
setToastAlert({
type: "error",
@@ -296,10 +304,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
cycleDetails.total_issues === 0
? "0 Issue"
: cycleDetails.total_issues === cycleDetails.completed_issues
? cycleDetails.total_issues > 1
? `${cycleDetails.total_issues}`
: `${cycleDetails.total_issues}`
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
? cycleDetails.total_issues > 1
? `${cycleDetails.total_issues}`
: `${cycleDetails.total_issues}`
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
return (
<>
@@ -329,7 +337,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</button>
{!isCompleted && (
<CustomMenu width="lg" placement="bottom-end" ellipsis>
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
<CustomMenu.MenuItem onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR");
setCycleDeleteModal(true)
}
}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
@@ -379,6 +391,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_START_DATE_BUTTON");
handleStartDateChange(val);
}
}}
@@ -414,6 +427,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_END_DATE_BUTTON");
handleEndDateChange(val);
}
}}
+10 -3
View File
@@ -20,6 +20,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
import { EFilterType } from "store/issues/types";
import { EProjectStore } from "store/command-palette.store";
export const CycleIssuesHeader: React.FC = observer(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
@@ -33,14 +34,13 @@ export const CycleIssuesHeader: React.FC = observer(() => {
const {
cycle: cycleStore,
cycleIssueFilters: cycleIssueFiltersStore,
projectIssuesFilter: projectIssueFiltersStore,
project: { currentProjectDetails },
projectMember: { projectMembers },
projectLabel: { projectLabels },
projectState: projectStateStore,
commandPalette: commandPaletteStore,
trackEvent: { setTrackElement },
cycleIssuesFilter: { issueFilters, updateFilters },
} = useMobxStore();
@@ -190,7 +190,14 @@ export const CycleIssuesHeader: React.FC = observer(() => {
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics
</Button>
<Button onClick={() => commandPaletteStore.toggleCreateIssueModal(true)} size="sm" prependIcon={<Plus />}>
<Button
onClick={() => {
setTrackElement("CYCLE_PAGE_HEADER");
commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.CYCLE);
}}
size="sm"
prependIcon={<Plus />}
>
Add Issue
</Button>
<button
+6 -2
View File
@@ -14,7 +14,7 @@ export const CyclesHeader: FC = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query;
// store
const { project: projectStore, commandPalette: commandPaletteStore } = useMobxStore();
const { project: projectStore, commandPalette: commandPaletteStore, trackEvent: { setTrackElement } } = useMobxStore();
const { currentProjectDetails } = projectStore;
return (
@@ -51,7 +51,11 @@ export const CyclesHeader: FC = observer(() => {
variant="primary"
size="sm"
prependIcon={<Plus />}
onClick={() => commandPaletteStore.toggleCreateCycleModal(true)}
onClick={() => {
setTrackElement("CYCLES_PAGE_HEADER");
commandPaletteStore.toggleCreateCycleModal(true);
}
}
>
Add Cycle
</Button>
+10 -2
View File
@@ -20,6 +20,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
import { EFilterType } from "store/issues/types";
import { EProjectStore } from "store/command-palette.store";
export const ModuleIssuesHeader: React.FC = observer(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
@@ -33,11 +34,11 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
const {
module: moduleStore,
projectIssuesFilter: projectIssueFiltersStore,
project: projectStore,
projectMember: { projectMembers },
projectState: projectStateStore,
commandPalette: commandPaletteStore,
trackEvent: { setTrackElement },
projectLabel: { projectLabels },
moduleIssuesFilter: { issueFilters, updateFilters },
} = useMobxStore();
@@ -190,7 +191,14 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics
</Button>
<Button onClick={() => commandPaletteStore.toggleCreateIssueModal(true)} size="sm" prependIcon={<Plus />}>
<Button
onClick={() => {
setTrackElement("MODULE_PAGE_HEADER");
commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.MODULE);
}}
size="sm"
prependIcon={<Plus />}
>
Add Issue
</Button>
<button
+1 -1
View File
@@ -19,7 +19,7 @@ export const ProfileSettingsHeader: FC<IProfileSettingHeader> = (props) => {
type="text"
label="My Profile"
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
link={`/me/profile`}
link="/profile"
/>
<Breadcrumbs.BreadcrumbItem type="text" label={title} />
</Breadcrumbs>
@@ -1,20 +1,74 @@
import { FC } from "react";
import { FC, useCallback } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues";
// ui
import { Breadcrumbs, LayersIcon } from "@plane/ui";
// helper
import { renderEmoji } from "helpers/emoji.helper";
import { EFilterType } from "store/issues/types";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types";
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
export const ProjectDraftIssueHeader: FC = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string };
const { project: projectStore } = useMobxStore();
const { currentProjectDetails } = projectStore;
const {
project: { currentProjectDetails },
projectLabel: { projectLabels },
projectMember: { projectMembers },
projectState: projectStateStore,
projectDraftIssuesFilter: { issueFilters, updateFilters },
} = useMobxStore();
const activeLayout = issueFilters?.displayFilters?.layout;
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(workspaceSlug, projectId, EFilterType.FILTERS, { [key]: newValues });
},
[workspaceSlug, projectId, issueFilters, updateFilters]
);
const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_FILTERS, { layout: layout });
},
[workspaceSlug, projectId, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
},
[workspaceSlug, projectId, updateFilters]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_PROPERTIES, property);
},
[workspaceSlug, projectId, updateFilters]
);
return (
<div className="relative flex w-full flex-shrink-0 flex-row z-10 h-[3.75rem] items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
@@ -44,6 +98,37 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
/>
</Breadcrumbs>
</div>
<div className="ml-auto flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectLabels ?? undefined}
members={projectMembers?.map((m) => m.member)}
states={projectStateStore.states?.[projectId ?? ""] ?? undefined}
/>
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
/>
</FiltersDropdown>
</div>
</div>
</div>
);
+10 -1
View File
@@ -17,6 +17,7 @@ import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
// helper
import { renderEmoji } from "helpers/emoji.helper";
import { EFilterType } from "store/issues/types";
import { EProjectStore } from "store/command-palette.store";
export const ProjectIssuesHeader: React.FC = observer(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
@@ -31,6 +32,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
projectState: projectStateStore,
inbox: inboxStore,
commandPalette: commandPaletteStore,
trackEvent: { setTrackElement },
// issue filters
projectIssuesFilter: { issueFilters, updateFilters },
projectIssues: {},
@@ -198,7 +200,14 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics
</Button>
<Button onClick={() => commandPaletteStore.toggleCreateIssueModal(true)} size="sm" prependIcon={<Plus />}>
<Button
onClick={() => {
setTrackElement("PROJECT_PAGE_HEADER");
commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT);
}}
size="sm"
prependIcon={<Plus />}
>
Add Issue
</Button>
</div>
+5 -2
View File
@@ -11,7 +11,7 @@ export const ProjectsHeader = observer(() => {
const { workspaceSlug } = router.query;
// store
const { project: projectStore, commandPalette: commandPaletteStore } = useMobxStore();
const { project: projectStore, commandPalette: commandPaletteStore, trackEvent: {setTrackElement} } = useMobxStore();
const projectsList = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : [];
@@ -41,7 +41,10 @@ export const ProjectsHeader = observer(() => {
</div>
)}
<Button prependIcon={<Plus />} size="sm" onClick={() => commandPaletteStore.toggleCreateProjectModal(true)}>
<Button prependIcon={<Plus />} size="sm" onClick={() => {
setTrackElement("PROJECTS_PAGE_HEADER");
commandPaletteStore.toggleCreateProjectModal(true)
}}>
Add Project
</Button>
</div>
+1 -1
View File
@@ -6,7 +6,7 @@ export const UserProfileHeader = () => (
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem type="text" label="Activity Overview" link="/me/profile" />
<Breadcrumbs.BreadcrumbItem type="text" label="Activity Overview" link="/profile" />
</Breadcrumbs>
</div>
</div>
@@ -8,9 +8,11 @@ import githubWhiteImage from "/public/logos/github-white.png";
// components
import { ProductUpdatesModal } from "components/common";
import { Breadcrumbs } from "@plane/ui";
import { useMobxStore } from "lib/mobx/store-provider";
export const WorkspaceDashboardHeader = () => {
const [isProductUpdatesModalOpen, setIsProductUpdatesModalOpen] = useState(false);
const { trackEvent: { postHogEventTracker } } = useMobxStore();
// theme
const { resolvedTheme } = useTheme();

Some files were not shown because too many files have changed in this diff Show More