Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b97a84671 | ||
|
|
bfce5c860a | ||
|
|
2610e88274 | ||
|
|
4466ec046c | ||
|
|
3235186374 |
@@ -66,7 +66,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
|
||||
)
|
||||
|
||||
if intake is None or not project.intake_view:
|
||||
if intake is None and not project.intake_view:
|
||||
return IntakeIssue.objects.none()
|
||||
|
||||
return (
|
||||
@@ -230,7 +230,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
|
||||
)
|
||||
|
||||
if intake is None or not project.intake_view:
|
||||
if intake is None and not project.intake_view:
|
||||
return IntakeIssue.objects.none()
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,4 +13,3 @@ from .project import (
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from .base import allow_permission, ROLE
|
||||
from .page import ProjectPagePermission
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
from plane.db.models import ProjectMember, Page
|
||||
from plane.app.permissions import ROLE
|
||||
|
||||
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
|
||||
|
||||
# Permission Mappings for workspace members
|
||||
ADMIN = ROLE.ADMIN.value
|
||||
MEMBER = ROLE.MEMBER.value
|
||||
GUEST = ROLE.GUEST.value
|
||||
|
||||
|
||||
class ProjectPagePermission(BasePermission):
|
||||
"""
|
||||
Custom permission to control access to pages within a workspace
|
||||
based on user roles, page visibility (public/private), and feature flags.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Check basic project-level permissions before checking object-level permissions.
|
||||
"""
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
user_id = request.user.id
|
||||
slug = view.kwargs.get("slug")
|
||||
page_id = view.kwargs.get("page_id")
|
||||
project_id = view.kwargs.get("project_id")
|
||||
|
||||
# Hook for extended validation
|
||||
extended_access, role = self._check_access_and_get_role(
|
||||
request, slug, project_id
|
||||
)
|
||||
if extended_access is False:
|
||||
return False
|
||||
|
||||
if page_id:
|
||||
page = Page.objects.get(id=page_id, workspace__slug=slug)
|
||||
|
||||
# Allow access if the user is the owner of the page
|
||||
if page.owned_by_id == user_id:
|
||||
return True
|
||||
|
||||
# Handle private page access
|
||||
if page.access == Page.PRIVATE_ACCESS:
|
||||
return self._has_private_page_action_access(
|
||||
request, slug, page, project_id
|
||||
)
|
||||
|
||||
# Handle public page access
|
||||
return self._has_public_page_action_access(request, role)
|
||||
|
||||
def _check_project_member_access(self, request, slug, project_id):
|
||||
"""
|
||||
Check if the user is a project member.
|
||||
"""
|
||||
return (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
project_id=project_id,
|
||||
)
|
||||
.values_list("role", flat=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
def _check_access_and_get_role(self, request, slug, project_id):
|
||||
"""
|
||||
Hook for extended access checking
|
||||
Returns: True (allow), False (deny), None (continue with normal flow)
|
||||
"""
|
||||
role = self._check_project_member_access(request, slug, project_id)
|
||||
if not role:
|
||||
return False, None
|
||||
return True, role
|
||||
|
||||
def _has_private_page_action_access(self, request, slug, page, project_id):
|
||||
"""
|
||||
Check access to private pages. Override for feature flag logic.
|
||||
"""
|
||||
# Base implementation: only owner can access private pages
|
||||
return False
|
||||
|
||||
def _check_project_action_access(self, request, role):
|
||||
method = request.method
|
||||
|
||||
# Only admins can create (POST) pages
|
||||
if method == "POST":
|
||||
if role in [ADMIN, MEMBER]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Safe methods (GET, HEAD, OPTIONS) allowed for all active roles
|
||||
if method in SAFE_METHODS:
|
||||
if role in [ADMIN, MEMBER, GUEST]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# PUT/PATCH: Admins and members can update
|
||||
if method in ["PUT", "PATCH"]:
|
||||
if role in [ADMIN, MEMBER]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# DELETE: Only admins can delete
|
||||
if method == "DELETE":
|
||||
if role in [ADMIN]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Deny by default
|
||||
return False
|
||||
|
||||
def _has_public_page_action_access(self, request, role):
|
||||
"""
|
||||
Check if the user has permission to access a public page
|
||||
and can perform operations on the page.
|
||||
"""
|
||||
project_member_exists = self._check_project_action_access(request, role)
|
||||
if not project_member_exists:
|
||||
return False
|
||||
return True
|
||||
@@ -92,6 +92,8 @@ from .importer import ImporterSerializer
|
||||
|
||||
from .page import (
|
||||
PageSerializer,
|
||||
PageLogSerializer,
|
||||
SubPageSerializer,
|
||||
PageDetailSerializer,
|
||||
PageVersionSerializer,
|
||||
PageBinaryUpdateSerializer,
|
||||
|
||||
@@ -130,6 +130,32 @@ class PageDetailSerializer(PageSerializer):
|
||||
fields = PageSerializer.Meta.fields + ["description_html"]
|
||||
|
||||
|
||||
class SubPageSerializer(BaseSerializer):
|
||||
entity_details = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PageLog
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "page"]
|
||||
|
||||
def get_entity_details(self, obj):
|
||||
entity_name = obj.entity_name
|
||||
if entity_name == "forward_link" or entity_name == "back_link":
|
||||
try:
|
||||
page = Page.objects.get(pk=obj.entity_identifier)
|
||||
return PageSerializer(page).data
|
||||
except Page.DoesNotExist:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class PageLogSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = PageLog
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "page"]
|
||||
|
||||
|
||||
class PageVersionSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = PageVersion
|
||||
|
||||
@@ -4,24 +4,22 @@ from django.urls import path
|
||||
from plane.app.views import (
|
||||
PageViewSet,
|
||||
PageFavoriteViewSet,
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageVersionEndpoint,
|
||||
PageDuplicateEndpoint,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages-summary/",
|
||||
PageViewSet.as_view({"get": "summary"}),
|
||||
name="project-pages-summary",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
|
||||
PageViewSet.as_view({"get": "list", "post": "create"}),
|
||||
name="project-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/",
|
||||
PageViewSet.as_view(
|
||||
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
|
||||
),
|
||||
@@ -29,30 +27,45 @@ urlpatterns = [
|
||||
),
|
||||
# favorite pages
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:page_id>/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:pk>/",
|
||||
PageFavoriteViewSet.as_view({"post": "create", "delete": "destroy"}),
|
||||
name="user-favorite-pages",
|
||||
),
|
||||
# archived pages
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/archive/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/archive/",
|
||||
PageViewSet.as_view({"post": "archive", "delete": "unarchive"}),
|
||||
name="project-page-archive-unarchive",
|
||||
),
|
||||
# lock and unlock
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/lock/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/lock/",
|
||||
PageViewSet.as_view({"post": "lock", "delete": "unlock"}),
|
||||
name="project-pages-lock-unlock",
|
||||
),
|
||||
# private and public page
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/access/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/access/",
|
||||
PageViewSet.as_view({"post": "access"}),
|
||||
name="project-pages-access",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/description/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
|
||||
PageLogEndpoint.as_view(),
|
||||
name="page-transactions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/<uuid:transaction>/",
|
||||
PageLogEndpoint.as_view(),
|
||||
name="page-transactions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/sub-pages/",
|
||||
SubPagesEndpoint.as_view(),
|
||||
name="sub-page",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/description/",
|
||||
PagesDescriptionViewSet.as_view({"get": "retrieve", "patch": "partial_update"}),
|
||||
name="page-description",
|
||||
),
|
||||
|
||||
@@ -165,6 +165,8 @@ from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
from .page.base import (
|
||||
PageViewSet,
|
||||
PageFavoriteViewSet,
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageDuplicateEndpoint,
|
||||
)
|
||||
|
||||
@@ -504,6 +504,19 @@ class CycleViewSet(BaseViewSet):
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=Cycle)
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
if cycle.owned_by_id != request.user.id and not (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or owner can delete the cycle"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
|
||||
@@ -1080,9 +1093,6 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
|
||||
cycle_properties.rich_filters = request.data.get(
|
||||
"rich_filters", cycle_properties.rich_filters
|
||||
)
|
||||
cycle_properties.display_filters = request.data.get(
|
||||
"display_filters", cycle_properties.display_filters
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Python imports
|
||||
import copy
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
@@ -29,15 +28,11 @@ from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.filters import ComplexFilterBackend
|
||||
from plane.utils.filters import IssueFilterSet
|
||||
|
||||
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
serializer_class = CycleIssueSerializer
|
||||
model = CycleIssue
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
webhook_event = "cycle_issue"
|
||||
bulk = True
|
||||
@@ -70,9 +65,24 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def apply_annotations(self, issues):
|
||||
return (
|
||||
issues.annotate(
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def list(self, request, slug, project_id, cycle_id):
|
||||
order_by_param = request.GET.get("order_by", "created_at")
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
|
||||
)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@@ -100,36 +110,11 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def list(self, request, slug, project_id, cycle_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
|
||||
)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
)
|
||||
|
||||
# Apply filtering from filterset
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
|
||||
# Total count queryset
|
||||
total_issue_queryset = copy.deepcopy(issue_queryset)
|
||||
|
||||
# Applying annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset, order_by_param=order_by_param
|
||||
@@ -160,7 +145,6 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -195,7 +179,6 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -222,7 +205,6 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
|
||||
@@ -28,7 +28,6 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
CycleIssue,
|
||||
IssueDescriptionVersion,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueCreateSerializer,
|
||||
@@ -349,32 +348,17 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
project_id=project_id,
|
||||
intake_id=intake_id,
|
||||
)
|
||||
|
||||
project_member = ProjectMember.objects.filter(
|
||||
# Get the project member
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).first()
|
||||
|
||||
is_workspace_admin = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
member=request.user,
|
||||
role=ROLE.ADMIN.value,
|
||||
).exists()
|
||||
|
||||
if not project_member and not is_workspace_admin:
|
||||
return Response(
|
||||
{"error": "Only admin or creator can update the intake work items"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
)
|
||||
# Only project members admins and created_by users can access this endpoint
|
||||
if (
|
||||
(project_member and project_member.role <= ROLE.GUEST.value)
|
||||
and not is_workspace_admin
|
||||
) and str(intake_issue.created_by_id) != str(request.user.id):
|
||||
if project_member.role <= 5 and str(intake_issue.created_by_id) != str(
|
||||
request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot edit intake issues"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -407,8 +391,8 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
).get(pk=intake_issue.issue_id, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
if project_member and project_member.role <= ROLE.GUEST.value:
|
||||
# Only allow guests to edit name and description
|
||||
if project_member.role <= 5:
|
||||
issue_data = {
|
||||
"name": issue_data.get("name", issue.name),
|
||||
"description_html": issue_data.get(
|
||||
@@ -416,7 +400,6 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
),
|
||||
"description": issue_data.get("description", issue.description),
|
||||
}
|
||||
|
||||
current_instance = json.dumps(
|
||||
IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
@@ -453,10 +436,8 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Only project admins can edit intake issue attributes
|
||||
if (
|
||||
project_member and project_member.role > ROLE.MEMBER.value
|
||||
) or is_workspace_admin:
|
||||
# Only project admins and members can edit intake issue attributes
|
||||
if project_member.role > 15:
|
||||
serializer = IntakeIssueSerializer(
|
||||
intake_issue, data=request.data, partial=True
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Python imports
|
||||
import copy
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
@@ -42,20 +41,27 @@ from plane.utils.host import base_host
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView
|
||||
from plane.utils.filters import ComplexFilterBackend
|
||||
from plane.utils.filters import IssueFilterSet
|
||||
|
||||
|
||||
class IssueArchiveViewSet(BaseViewSet):
|
||||
serializer_class = IssueFlatSerializer
|
||||
model = Issue
|
||||
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
def apply_annotations(self, issues):
|
||||
def get_queryset(self):
|
||||
return (
|
||||
issues.annotate(
|
||||
Issue.objects.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(deleted_at__isnull=True)
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@@ -89,15 +95,6 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.objects.filter(Q(type__isnull=True) | Q(type__is_epic=False))
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@@ -108,25 +105,26 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = self.get_queryset()
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
total_issue_queryset = Issue.objects.filter(
|
||||
deleted_at__isnull=True,
|
||||
archived_at__isnull=False,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
).filter(**filters)
|
||||
|
||||
total_issue_queryset = (
|
||||
total_issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
else total_issue_queryset.filter(parent__isnull=True)
|
||||
)
|
||||
|
||||
issue_queryset = (
|
||||
issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
else issue_queryset.filter(parent__isnull=True)
|
||||
)
|
||||
# Apply filtering from filterset
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
|
||||
# Total count queryset
|
||||
total_issue_queryset = copy.deepcopy(issue_queryset)
|
||||
|
||||
# Applying annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset, order_by_param=order_by_param
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Python imports
|
||||
import copy
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
@@ -7,16 +6,16 @@ from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import (
|
||||
Count,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
UUIDField,
|
||||
Value,
|
||||
Subquery,
|
||||
Count,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
@@ -28,55 +27,50 @@ from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.serializers import (
|
||||
IssueCreateSerializer,
|
||||
IssueDetailSerializer,
|
||||
IssueListDetailSerializer,
|
||||
IssueSerializer,
|
||||
IssueUserPropertySerializer,
|
||||
IssueSerializer,
|
||||
IssueListDetailSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.bgtasks.issue_description_version_task import issue_description_version_task
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.db.models import (
|
||||
CycleIssue,
|
||||
FileAsset,
|
||||
IntakeIssue,
|
||||
Issue,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
IssueReaction,
|
||||
IssueRelation,
|
||||
IssueSubscriber,
|
||||
IssueUserProperty,
|
||||
ModuleIssue,
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
ProjectMember,
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
ModuleIssue,
|
||||
IssueRelation,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
IntakeIssue,
|
||||
)
|
||||
from plane.utils.filters import ComplexFilterBackend, IssueFilterSet
|
||||
from plane.utils.global_paginator import paginate
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.global_paginator import paginate
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.bgtasks.issue_description_version_task import issue_description_version_task
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueListEndpoint(BaseAPIView):
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id):
|
||||
issue_ids = request.GET.get("issues", False)
|
||||
@@ -88,27 +82,14 @@ class IssueListEndpoint(BaseAPIView):
|
||||
|
||||
issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""]
|
||||
|
||||
# Base queryset with basic filters
|
||||
queryset = Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
|
||||
)
|
||||
|
||||
# Apply filtering from filterset
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = queryset.filter(**filters)
|
||||
|
||||
# Add select_related, prefetch_related if fields or expand is not None
|
||||
if self.fields or self.expand:
|
||||
issue_queryset = issue_queryset.select_related(
|
||||
"workspace", "project", "state", "parent"
|
||||
).prefetch_related("assignees", "labels", "issue_module__module")
|
||||
|
||||
# Add annotations
|
||||
issue_queryset = (
|
||||
issue_queryset.annotate(
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@@ -136,10 +117,12 @@ class IssueListEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
).distinct()
|
||||
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
issue_queryset = queryset.filter(**filters)
|
||||
# Issue queryset
|
||||
issue_queryset, _ = order_issue_queryset(
|
||||
issue_queryset=issue_queryset, order_by_param=order_by_param
|
||||
@@ -203,12 +186,6 @@ class IssueListEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class IssueViewSet(BaseViewSet):
|
||||
model = Issue
|
||||
webhook_event = "issue"
|
||||
search_fields = ["name"]
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
def get_serializer_class(self):
|
||||
return (
|
||||
IssueCreateSerializer
|
||||
@@ -216,17 +193,20 @@ class IssueViewSet(BaseViewSet):
|
||||
else IssueSerializer
|
||||
)
|
||||
|
||||
model = Issue
|
||||
webhook_event = "issue"
|
||||
|
||||
search_fields = ["name"]
|
||||
|
||||
filterset_fields = ["state__name", "assignees__id", "workspace__id"]
|
||||
|
||||
def get_queryset(self):
|
||||
issues = Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
).distinct()
|
||||
|
||||
return issues
|
||||
|
||||
def apply_annotations(self, issues):
|
||||
issues = (
|
||||
issues.annotate(
|
||||
return (
|
||||
Issue.issue_objects.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@@ -262,8 +242,6 @@ class IssueViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
@@ -272,24 +250,15 @@ class IssueViewSet(BaseViewSet):
|
||||
extra_filters = {"updated_at__gt": request.GET.get("updated_at__gt")}
|
||||
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
query_params = request.query_params.copy()
|
||||
|
||||
filters = issue_filters(query_params, "GET")
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = self.get_queryset()
|
||||
issue_queryset = self.get_queryset().filter(**filters, **extra_filters)
|
||||
# Custom ordering for priority and state
|
||||
|
||||
# Apply rich filters
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
issue_queryset = issue_queryset.filter(**filters, **extra_filters)
|
||||
|
||||
# Keeping a copy of the queryset before applying annotations
|
||||
filtered_issue_queryset = copy.deepcopy(issue_queryset)
|
||||
|
||||
# Applying annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
total_issue_queryset = Issue.issue_objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).filter(**filters, **extra_filters)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
@@ -323,16 +292,14 @@ class IssueViewSet(BaseViewSet):
|
||||
and not project.guest_view_all_features
|
||||
):
|
||||
issue_queryset = issue_queryset.filter(created_by=request.user)
|
||||
filtered_issue_queryset = filtered_issue_queryset.filter(
|
||||
created_by=request.user
|
||||
)
|
||||
total_issue_queryset = total_issue_queryset.filter(created_by=request.user)
|
||||
|
||||
if group_by:
|
||||
if sub_group_by:
|
||||
if group_by == sub_group_by:
|
||||
return Response(
|
||||
{
|
||||
"error": "Group by and sub group by cannot have same parameters" # noqa: E501
|
||||
"error": "Group by and sub group by cannot have same parameters"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -341,7 +308,7 @@ class IssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=filtered_issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -351,14 +318,12 @@ class IssueViewSet(BaseViewSet):
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=filtered_issue_queryset,
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=filtered_issue_queryset,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
sub_group_by_field_name=sub_group_by,
|
||||
@@ -377,7 +342,7 @@ class IssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=filtered_issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -387,7 +352,6 @@ class IssueViewSet(BaseViewSet):
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=filtered_issue_queryset,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
count_filter=Q(
|
||||
@@ -404,7 +368,7 @@ class IssueViewSet(BaseViewSet):
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=filtered_issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -438,11 +402,9 @@ class IssueViewSet(BaseViewSet):
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
queryset = self.get_queryset()
|
||||
queryset = self.apply_annotations(queryset)
|
||||
issue = (
|
||||
issue_queryset_grouper(
|
||||
queryset=queryset.filter(pk=serializer.data["id"]),
|
||||
queryset=self.get_queryset().filter(pk=serializer.data["id"]),
|
||||
group_by=None,
|
||||
sub_group_by=None,
|
||||
)
|
||||
@@ -647,10 +609,9 @@ class IssueViewSet(BaseViewSet):
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], creator=True, model=Issue
|
||||
)
|
||||
def partial_update(self, request, slug, project_id, pk=None):
|
||||
queryset = self.get_queryset()
|
||||
queryset = self.apply_annotations(queryset)
|
||||
issue = (
|
||||
queryset.annotate(
|
||||
self.get_queryset()
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
@@ -769,9 +730,6 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
|
||||
user=request.user, project_id=project_id
|
||||
)
|
||||
|
||||
issue_property.rich_filters = request.data.get(
|
||||
"rich_filters", issue_property.rich_filters
|
||||
)
|
||||
issue_property.filters = request.data.get("filters", issue_property.filters)
|
||||
issue_property.display_filters = request.data.get(
|
||||
"display_filters", issue_property.display_filters
|
||||
@@ -1011,59 +969,6 @@ class IssuePaginatedViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class IssueDetailEndpoint(BaseAPIView):
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
def apply_annotations(self, issues):
|
||||
return (
|
||||
issues.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
@@ -1097,9 +1002,56 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
.values("id")
|
||||
)
|
||||
# Main issue query
|
||||
issue = Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).filter(Exists(permission_subquery))
|
||||
issue = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
.filter(Exists(permission_subquery))
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
# Add additional prefetch based on expand parameter
|
||||
if self.expand:
|
||||
@@ -1118,20 +1070,8 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
|
||||
# Apply filtering from filterset
|
||||
issue = self.filter_queryset(issue)
|
||||
|
||||
# Apply legacy filters
|
||||
issue = issue.filter(**filters)
|
||||
|
||||
# Total count queryset
|
||||
total_issue_queryset = copy.deepcopy(issue)
|
||||
|
||||
# Applying annotations to the issue queryset
|
||||
issue = self.apply_annotations(issue)
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
# Issue queryset
|
||||
issue, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue, order_by_param=order_by_param
|
||||
@@ -1139,8 +1079,7 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
queryset=(issue),
|
||||
on_results=lambda issue: IssueListDetailSerializer(
|
||||
issue, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
|
||||
@@ -232,6 +232,9 @@ class ModuleViewSet(BaseViewSet):
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.annotate(is_favorite=Exists(favorite_subquery))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("lead")
|
||||
.prefetch_related("members")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
@@ -312,10 +315,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
ArrayAgg(
|
||||
"members__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
members__id__isnull=False,
|
||||
modulemember__deleted_at__isnull=True,
|
||||
),
|
||||
filter=~Q(members__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
@@ -904,9 +904,6 @@ class ModuleUserPropertiesEndpoint(BaseAPIView):
|
||||
module_properties.filters = request.data.get(
|
||||
"filters", module_properties.filters
|
||||
)
|
||||
module_properties.rich_filters = request.data.get(
|
||||
"rich_filters", module_properties.rich_filters
|
||||
)
|
||||
module_properties.display_filters = request.data.get(
|
||||
"display_filters", module_properties.display_filters
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Python imports
|
||||
import copy
|
||||
import json
|
||||
|
||||
from django.db.models import F, Func, OuterRef, Q, Subquery
|
||||
@@ -32,8 +31,8 @@ from plane.utils.grouper import (
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||
from plane.utils.filters import ComplexFilterBackend
|
||||
from plane.utils.filters import IssueFilterSet
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from plane.utils.host import base_host
|
||||
|
||||
@@ -43,12 +42,20 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
model = ModuleIssue
|
||||
webhook_event = "module_issue"
|
||||
bulk = True
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
def apply_annotations(self, issues):
|
||||
filterset_fields = ["issue__labels__id", "issue__assignees__id"]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
issues.annotate(
|
||||
Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
issue_module__module_id=self.kwargs.get("module_id"),
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@@ -76,37 +83,13 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
issue_module__module_id=self.kwargs.get("module_id"),
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
).distinct()
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def list(self, request, slug, project_id, module_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = self.get_queryset()
|
||||
|
||||
# Apply filtering from filterset
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
|
||||
# Total count queryset
|
||||
total_issue_queryset = copy.deepcopy(issue_queryset)
|
||||
|
||||
# Apply annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
order_by_param = request.GET.get("order_by", "created_at")
|
||||
|
||||
# Issue queryset
|
||||
@@ -139,7 +122,6 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -149,14 +131,12 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
sub_group_by_field_name=sub_group_by,
|
||||
@@ -176,7 +156,6 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -186,7 +165,6 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
count_filter=Q(
|
||||
@@ -204,7 +182,6 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -305,11 +282,9 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"module_name": (
|
||||
module_issue.first().module.name
|
||||
if (module_issue.first() and module_issue.first().module)
|
||||
else None
|
||||
)
|
||||
"module_name": module_issue.first().module.name
|
||||
if (module_issue.first() and module_issue.first().module)
|
||||
else None
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
|
||||
@@ -6,17 +6,9 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Django imports
|
||||
from django.db import connection
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
OuterRef,
|
||||
Q,
|
||||
Value,
|
||||
UUIDField,
|
||||
Count,
|
||||
Case,
|
||||
When,
|
||||
IntegerField,
|
||||
)
|
||||
from django.db.models import Exists, OuterRef, Q, Value, UUIDField
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
@@ -29,7 +21,9 @@ from rest_framework.response import Response
|
||||
# Module imports
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.serializers import (
|
||||
PageLogSerializer,
|
||||
PageSerializer,
|
||||
SubPageSerializer,
|
||||
PageDetailSerializer,
|
||||
PageBinaryUpdateSerializer,
|
||||
)
|
||||
@@ -43,14 +37,11 @@ from plane.db.models import (
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.error_codes import ERROR_CODES
|
||||
|
||||
# Local imports
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.page_transaction_task import page_transaction
|
||||
from plane.bgtasks.page_version_task import page_version
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets
|
||||
from plane.app.permissions import ProjectPagePermission
|
||||
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
@@ -72,7 +63,6 @@ def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
class PageViewSet(BaseViewSet):
|
||||
serializer_class = PageSerializer
|
||||
model = Page
|
||||
permission_classes = [ProjectPagePermission]
|
||||
search_fields = ["name"]
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -127,6 +117,7 @@ class PageViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id):
|
||||
serializer = PageSerializer(
|
||||
data=request.data,
|
||||
@@ -148,10 +139,11 @@ class PageViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def partial_update(self, request, slug, project_id, page_id):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
page = Page.objects.get(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
|
||||
if page.is_locked:
|
||||
@@ -189,7 +181,7 @@ class PageViewSet(BaseViewSet):
|
||||
{"description_html": page_description},
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
page_id=page_id,
|
||||
page_id=pk,
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -202,8 +194,9 @@ class PageViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, page_id=None):
|
||||
page = self.get_queryset().filter(pk=page_id).first()
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
page = self.get_queryset().filter(pk=pk).first()
|
||||
project = Project.objects.get(pk=project_id)
|
||||
track_visit = request.query_params.get("track_visit", "true").lower() == "true"
|
||||
|
||||
@@ -234,7 +227,7 @@ class PageViewSet(BaseViewSet):
|
||||
)
|
||||
else:
|
||||
issue_ids = PageLog.objects.filter(
|
||||
page_id=page_id, entity_name="issue"
|
||||
page_id=pk, entity_name="issue"
|
||||
).values_list("entity_identifier", flat=True)
|
||||
data = PageDetailSerializer(page).data
|
||||
data["issue_ids"] = issue_ids
|
||||
@@ -242,24 +235,26 @@ class PageViewSet(BaseViewSet):
|
||||
recent_visited_task.delay(
|
||||
slug=slug,
|
||||
entity_name="page",
|
||||
entity_identifier=page_id,
|
||||
entity_identifier=pk,
|
||||
user_id=request.user.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
def lock(self, request, slug, project_id, page_id):
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def lock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
).first()
|
||||
|
||||
page.is_locked = True
|
||||
page.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def unlock(self, request, slug, project_id, page_id):
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def unlock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
).first()
|
||||
|
||||
page.is_locked = False
|
||||
@@ -267,10 +262,11 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def access(self, request, slug, project_id, page_id):
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def access(self, request, slug, project_id, pk):
|
||||
access = request.data.get("access", 0)
|
||||
page = Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
).first()
|
||||
|
||||
# Only update access if the page owner is the requesting user
|
||||
@@ -289,6 +285,7 @@ class PageViewSet(BaseViewSet):
|
||||
page.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = self.get_queryset()
|
||||
project = Project.objects.get(pk=project_id)
|
||||
@@ -306,10 +303,9 @@ class PageViewSet(BaseViewSet):
|
||||
pages = PageSerializer(queryset, many=True).data
|
||||
return Response(pages, status=status.HTTP_200_OK)
|
||||
|
||||
def archive(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.get(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def archive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||
|
||||
# only the owner or admin can archive the page
|
||||
if (
|
||||
@@ -325,19 +321,18 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
UserFavorite.objects.filter(
|
||||
entity_type="page",
|
||||
entity_identifier=page_id,
|
||||
entity_identifier=pk,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
).delete()
|
||||
|
||||
unarchive_archive_page_and_descendants(page_id, datetime.now())
|
||||
unarchive_archive_page_and_descendants(pk, datetime.now())
|
||||
|
||||
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
|
||||
|
||||
def unarchive(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.get(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def unarchive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||
|
||||
# only the owner or admin can un archive the page
|
||||
if (
|
||||
@@ -351,19 +346,18 @@ class PageViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# if parent archived then page will be un archived breaking hierarchy
|
||||
# if parent page is archived then the page will be un archived breaking the hierarchy
|
||||
if page.parent_id and page.parent.archived_at:
|
||||
page.parent = None
|
||||
page.save(update_fields=["parent"])
|
||||
|
||||
unarchive_archive_page_and_descendants(page_id, None)
|
||||
unarchive_archive_page_and_descendants(pk, None)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def destroy(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.get(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||
|
||||
if page.archived_at is None:
|
||||
return Response(
|
||||
@@ -387,7 +381,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
# remove parent from all the children
|
||||
_ = Page.objects.filter(
|
||||
parent_id=page_id, projects__id=project_id, workspace__slug=slug
|
||||
parent_id=pk, projects__id=project_id, workspace__slug=slug
|
||||
).update(parent=None)
|
||||
|
||||
page.delete()
|
||||
@@ -395,109 +389,105 @@ class PageViewSet(BaseViewSet):
|
||||
UserFavorite.objects.filter(
|
||||
project=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=page_id,
|
||||
entity_identifier=pk,
|
||||
entity_type="page",
|
||||
).delete()
|
||||
# Delete the page from recent visit
|
||||
UserRecentVisit.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=page_id,
|
||||
entity_identifier=pk,
|
||||
entity_name="page",
|
||||
).delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def summary(self, request, slug, project_id):
|
||||
queryset = (
|
||||
Page.objects.filter(workspace__slug=slug)
|
||||
.filter(
|
||||
projects__project_projectmember__member=self.request.user,
|
||||
projects__project_projectmember__is_active=True,
|
||||
projects__archived_at__isnull=True,
|
||||
)
|
||||
.filter(parent__isnull=True)
|
||||
.filter(Q(owned_by=request.user) | Q(access=0))
|
||||
.annotate(
|
||||
project=Exists(
|
||||
ProjectPage.objects.filter(
|
||||
page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")
|
||||
)
|
||||
)
|
||||
)
|
||||
.filter(project=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=ROLE.GUEST.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and not project.guest_view_all_features
|
||||
):
|
||||
queryset = queryset.filter(owned_by=request.user)
|
||||
|
||||
stats = queryset.aggregate(
|
||||
public_pages=Count(
|
||||
Case(
|
||||
When(access=Page.PUBLIC_ACCESS, archived_at__isnull=True, then=1),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
),
|
||||
private_pages=Count(
|
||||
Case(
|
||||
When(access=Page.PRIVATE_ACCESS, archived_at__isnull=True, then=1),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
),
|
||||
archived_pages=Count(
|
||||
Case(
|
||||
When(archived_at__isnull=False, then=1), output_field=IntegerField()
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
return Response(stats, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class PageFavoriteViewSet(BaseViewSet):
|
||||
model = UserFavorite
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id, page_id):
|
||||
def create(self, request, slug, project_id, pk):
|
||||
_ = UserFavorite.objects.create(
|
||||
project_id=project_id,
|
||||
entity_identifier=page_id,
|
||||
entity_identifier=pk,
|
||||
entity_type="page",
|
||||
user=request.user,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def destroy(self, request, slug, project_id, page_id):
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
page_favorite = UserFavorite.objects.get(
|
||||
project=project_id,
|
||||
user=request.user,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=page_id,
|
||||
entity_identifier=pk,
|
||||
entity_type="page",
|
||||
)
|
||||
page_favorite.delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class PagesDescriptionViewSet(BaseViewSet):
|
||||
permission_classes = [ProjectPagePermission]
|
||||
class PageLogEndpoint(BaseAPIView):
|
||||
serializer_class = PageLogSerializer
|
||||
model = PageLog
|
||||
|
||||
def retrieve(self, request, slug, project_id, page_id):
|
||||
page = (
|
||||
Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
def post(self, request, slug, project_id, page_id):
|
||||
serializer = PageLogSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, page_id=page_id)
|
||||
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, page_id, transaction):
|
||||
page_transaction = PageLog.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
page_id=page_id,
|
||||
transaction=transaction,
|
||||
)
|
||||
serializer = PageLogSerializer(
|
||||
page_transaction, data=request.data, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, slug, project_id, page_id, transaction):
|
||||
transaction = PageLog.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
page_id=page_id,
|
||||
transaction=transaction,
|
||||
)
|
||||
# Delete the transaction object
|
||||
transaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class SubPagesEndpoint(BaseAPIView):
|
||||
@method_decorator(gzip_page)
|
||||
def get(self, request, slug, project_id, page_id):
|
||||
pages = (
|
||||
PageLog.objects.filter(
|
||||
page_id=page_id,
|
||||
workspace__slug=slug,
|
||||
entity_name__in=["forward_link", "back_link"],
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
)
|
||||
return Response(
|
||||
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class PagesDescriptionViewSet(BaseViewSet):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
page = (
|
||||
Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.first()
|
||||
)
|
||||
@@ -517,11 +507,10 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
response["Content-Disposition"] = 'attachment; filename="page_description.bin"'
|
||||
return response
|
||||
|
||||
def partial_update(self, request, slug, project_id, page_id):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
page = (
|
||||
Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.first()
|
||||
)
|
||||
@@ -558,7 +547,7 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
# Capture the page transaction
|
||||
if request.data.get("description_html"):
|
||||
page_transaction.delay(
|
||||
new_value=request.data, old_value=existing_instance, page_id=page_id
|
||||
new_value=request.data, old_value=existing_instance, page_id=pk
|
||||
)
|
||||
|
||||
# Update the page using serializer
|
||||
@@ -576,8 +565,7 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class PageDuplicateEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectPagePermission]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
|
||||
@@ -7,12 +7,10 @@ from plane.db.models import PageVersion
|
||||
from ..base import BaseAPIView
|
||||
from plane.app.serializers import PageVersionSerializer, PageVersionDetailSerializer
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.permissions import ProjectPagePermission
|
||||
|
||||
|
||||
class PageVersionEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [ProjectPagePermission]
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, page_id, pk=None):
|
||||
# Check if pk is provided
|
||||
if pk:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import copy
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
@@ -41,8 +39,6 @@ from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from .. import BaseViewSet
|
||||
from plane.db.models import UserFavorite
|
||||
from plane.utils.filters import ComplexFilterBackend
|
||||
from plane.utils.filters import IssueFilterSet
|
||||
|
||||
|
||||
class WorkspaceViewViewSet(BaseViewSet):
|
||||
@@ -60,6 +56,7 @@ class WorkspaceViewViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project__isnull=True)
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=1))
|
||||
.select_related("workspace")
|
||||
.order_by(self.request.GET.get("order_by", "-created_at"))
|
||||
.distinct()
|
||||
)
|
||||
@@ -148,9 +145,6 @@ class WorkspaceViewViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
def _get_project_permission_filters(self):
|
||||
"""
|
||||
Get common project permission filters for guest users and role-based access control.
|
||||
@@ -173,9 +167,35 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
|
||||
def apply_annotations(self, issues):
|
||||
def get_queryset(self):
|
||||
return (
|
||||
issues.annotate(
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("state")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@@ -203,56 +223,31 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return Issue.issue_objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
def list(self, request, slug):
|
||||
issue_queryset = self.get_queryset()
|
||||
|
||||
# Apply filtering from filterset
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
# Apply legacy filters
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
# Get common project permission filters
|
||||
permission_filters = self._get_project_permission_filters()
|
||||
# Apply project permission filters to the issue queryset
|
||||
issue_queryset = issue_queryset.filter(permission_filters)
|
||||
|
||||
# Base query for the counts
|
||||
total_issue_count_queryset = copy.deepcopy(issue_queryset)
|
||||
total_issue_count_queryset = total_issue_count_queryset.only("id")
|
||||
total_issue_count = (
|
||||
Issue.issue_objects.filter(**filters)
|
||||
.filter(workspace__slug=slug)
|
||||
.filter(permission_filters)
|
||||
.only("id")
|
||||
)
|
||||
|
||||
# Apply annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
# Apply project permission filters to the issue queryset
|
||||
issue_queryset = issue_queryset.filter(permission_filters)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
@@ -265,7 +260,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
|
||||
total_count_queryset=total_issue_count_queryset,
|
||||
total_count_queryset=total_issue_count,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Python imports
|
||||
import copy
|
||||
from datetime import date
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@@ -57,8 +56,6 @@ from plane.utils.grouper import (
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||
from plane.utils.filters import ComplexFilterBackend
|
||||
from plane.utils.filters import IssueFilterSet
|
||||
|
||||
|
||||
class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
|
||||
@@ -94,12 +91,23 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
|
||||
class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceViewerPermission]
|
||||
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
def get(self, request, slug, user_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
def apply_annotations(self, issues):
|
||||
return (
|
||||
issues.annotate(
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
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,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@@ -127,36 +135,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
)
|
||||
|
||||
def get(self, request, slug, user_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
issue_queryset = Issue.issue_objects.filter(
|
||||
id__in=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,
|
||||
).values_list("id", flat=True),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
|
||||
# Apply filtering from filterset
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
|
||||
# Total count queryset
|
||||
total_issue_queryset = copy.deepcopy(issue_queryset)
|
||||
|
||||
# Apply annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
.order_by("created_at")
|
||||
).distinct()
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
@@ -177,7 +157,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
if group_by == sub_group_by:
|
||||
return Response(
|
||||
{
|
||||
"error": "Group by and sub group by cannot have same parameters" # noqa: E501
|
||||
"error": "Group by and sub group by cannot have same parameters"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -186,22 +166,15 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
paginator_cls=SubGroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by,
|
||||
slug=slug,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
field=group_by, slug=slug, filters=filters
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
field=sub_group_by, slug=slug, filters=filters
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
sub_group_by_field_name=sub_group_by,
|
||||
@@ -220,16 +193,12 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
paginator_cls=GroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by,
|
||||
slug=slug,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
field=group_by, slug=slug, filters=filters
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
count_filter=Q(
|
||||
@@ -246,7 +215,6 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -264,9 +232,6 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
|
||||
workspace_properties.filters = request.data.get(
|
||||
"filters", workspace_properties.filters
|
||||
)
|
||||
workspace_properties.rich_filters = request.data.get(
|
||||
"rich_filters", workspace_properties.rich_filters
|
||||
)
|
||||
workspace_properties.display_filters = request.data.get(
|
||||
"display_filters", workspace_properties.display_filters
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
|
||||
|
||||
|
||||
class SignInAuthSpaceEndpoint(View):
|
||||
@@ -100,13 +100,13 @@ class SignInAuthSpaceEndpoint(View):
|
||||
user = provider.authenticate()
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
# redirect to next path
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params={}
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
url = get_safe_redirect_url(
|
||||
@@ -200,7 +200,7 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
|
||||
@@ -4,7 +4,6 @@ import uuid
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.provider.oauth.github import GitHubOAuthProvider
|
||||
@@ -15,7 +14,7 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
|
||||
|
||||
|
||||
class GitHubOauthInitiateSpaceEndpoint(View):
|
||||
@@ -95,12 +94,8 @@ class GitHubCallbackSpaceEndpoint(View):
|
||||
# Process workspace and project invitations
|
||||
# redirect to referer path
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
url = get_safe_redirect_url(
|
||||
|
||||
@@ -4,7 +4,6 @@ import uuid
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
|
||||
@@ -15,8 +14,7 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
|
||||
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
|
||||
|
||||
|
||||
class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
@@ -97,12 +95,8 @@ class GitLabCallbackSpaceEndpoint(View):
|
||||
# Process workspace and project invitations
|
||||
# redirect to referer path
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
url = get_safe_redirect_url(
|
||||
|
||||
@@ -4,7 +4,6 @@ import uuid
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.provider.oauth.google import GoogleOAuthProvider
|
||||
@@ -15,7 +14,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
|
||||
|
||||
|
||||
class GoogleOauthInitiateSpaceEndpoint(View):
|
||||
@@ -92,12 +91,8 @@ class GoogleCallbackSpaceEndpoint(View):
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
url = get_safe_redirect_url(
|
||||
|
||||
@@ -96,7 +96,7 @@ class MagicSignInSpaceEndpoint(View):
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
@@ -158,7 +158,7 @@ class MagicSignUpSpaceEndpoint(View):
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
|
||||
@@ -21,14 +21,13 @@ from plane.db.models import (
|
||||
PageVersion,
|
||||
APIActivityLog,
|
||||
IssueDescriptionVersion,
|
||||
WebhookLog,
|
||||
)
|
||||
from plane.settings.mongo import MongoConnection
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
BATCH_SIZE = 500
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def get_mongo_collection(collection_name: str) -> Optional[Collection]:
|
||||
@@ -248,27 +247,6 @@ def transform_issue_description_version(record: Dict) -> Dict:
|
||||
}
|
||||
|
||||
|
||||
def transform_webhook_log(record: Dict):
|
||||
"""Transfer webhook logs to a new destination."""
|
||||
return {
|
||||
"id": str(record["id"]),
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"workspace_id": str(record["workspace_id"]),
|
||||
"webhook": str(record["webhook"]),
|
||||
# Request
|
||||
"event_type": str(record["event_type"]),
|
||||
"request_method": str(record["request_method"]),
|
||||
"request_headers": str(record["request_headers"]),
|
||||
"request_body": str(record["request_body"]),
|
||||
# Response
|
||||
"response_status": str(record["response_status"]),
|
||||
"response_body": str(record["response_body"]),
|
||||
"response_headers": str(record["response_headers"]),
|
||||
# retry count
|
||||
"retry_count": str(record["retry_count"]),
|
||||
}
|
||||
|
||||
|
||||
# Queryset functions for each cleanup task
|
||||
def get_api_logs_queryset():
|
||||
"""Get API logs older than cutoff days."""
|
||||
@@ -396,34 +374,7 @@ def get_issue_description_versions_queryset():
|
||||
)
|
||||
|
||||
|
||||
def get_webhook_logs_queryset():
|
||||
"""Get email logs older than cutoff days."""
|
||||
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
|
||||
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
|
||||
logger.info(f"Webhook logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
WebhookLog.all_objects.filter(created_at__lte=cutoff_time)
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"workspace_id",
|
||||
"webhook",
|
||||
"event_type",
|
||||
# Request
|
||||
"request_method",
|
||||
"request_headers",
|
||||
"request_body",
|
||||
# Response
|
||||
"response_status",
|
||||
"response_body",
|
||||
"response_headers",
|
||||
"retry_count",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
# Celery tasks - now much simpler!
|
||||
@shared_task
|
||||
def delete_api_logs():
|
||||
"""Delete old API activity logs."""
|
||||
@@ -470,15 +421,3 @@ def delete_issue_description_versions():
|
||||
task_name="Issue Description Version",
|
||||
collection_name="issue_description_versions",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_webhook_logs():
|
||||
"""Delete old webhook logs"""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_webhook_logs_queryset,
|
||||
transform_func=transform_webhook_log,
|
||||
model=WebhookLog,
|
||||
task_name="Webhook Log",
|
||||
collection_name="webhook_logs",
|
||||
)
|
||||
|
||||
@@ -34,14 +34,6 @@ from plane.utils.issue_relation_mapper import get_inverse_relation
|
||||
from plane.utils.uuid import is_valid_uuid
|
||||
|
||||
|
||||
def extract_ids(data: dict | None, primary_key: str, fallback_key: str) -> set[str]:
|
||||
if not data:
|
||||
return set()
|
||||
if primary_key in data:
|
||||
return {str(x) for x in data.get(primary_key, [])}
|
||||
return {str(x) for x in data.get(fallback_key, [])}
|
||||
|
||||
|
||||
# Track Changes in name
|
||||
def track_name(
|
||||
requested_data,
|
||||
@@ -124,32 +116,18 @@ def track_parent(
|
||||
issue_activities,
|
||||
epoch,
|
||||
):
|
||||
current_parent_id = current_instance.get("parent_id") or current_instance.get(
|
||||
"parent"
|
||||
)
|
||||
requested_parent_id = requested_data.get("parent_id") or requested_data.get(
|
||||
"parent"
|
||||
)
|
||||
|
||||
# Validate UUIDs before database queries
|
||||
if current_parent_id is not None and not is_valid_uuid(current_parent_id):
|
||||
return
|
||||
if requested_parent_id is not None and not is_valid_uuid(requested_parent_id):
|
||||
return
|
||||
|
||||
if current_parent_id != requested_parent_id:
|
||||
if current_instance.get("parent_id") != requested_data.get("parent_id"):
|
||||
old_parent = (
|
||||
Issue.objects.filter(pk=current_parent_id).first()
|
||||
if current_parent_id is not None
|
||||
Issue.objects.filter(pk=current_instance.get("parent_id")).first()
|
||||
if current_instance.get("parent_id") is not None
|
||||
else None
|
||||
)
|
||||
new_parent = (
|
||||
Issue.objects.filter(pk=requested_parent_id).first()
|
||||
if requested_parent_id is not None
|
||||
Issue.objects.filter(pk=requested_data.get("parent_id")).first()
|
||||
if requested_data.get("parent_id") is not None
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
@@ -215,31 +193,23 @@ def track_state(
|
||||
issue_activities,
|
||||
epoch,
|
||||
):
|
||||
current_state_id = current_instance.get("state_id") or current_instance.get("state")
|
||||
requested_state_id = requested_data.get("state_id") or requested_data.get("state")
|
||||
|
||||
if current_state_id is not None and not is_valid_uuid(current_state_id):
|
||||
current_state_id = None
|
||||
if requested_state_id is not None and not is_valid_uuid(requested_state_id):
|
||||
requested_state_id = None
|
||||
|
||||
if current_state_id != requested_state_id:
|
||||
new_state = State.objects.filter(pk=requested_state_id, project_id=project_id).first()
|
||||
old_state = State.objects.filter(pk=current_state_id, project_id=project_id).first()
|
||||
if current_instance.get("state_id") != requested_data.get("state_id"):
|
||||
new_state = State.objects.get(pk=requested_data.get("state_id", None))
|
||||
old_state = State.objects.get(pk=current_instance.get("state_id", None))
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
verb="updated",
|
||||
old_value=old_state.name if old_state else None,
|
||||
new_value=new_state.name if new_state else None,
|
||||
old_value=old_state.name,
|
||||
new_value=new_state.name,
|
||||
field="state",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment="updated the state to",
|
||||
old_identifier=old_state.id if old_state else None,
|
||||
new_identifier=new_state.id if new_state else None,
|
||||
old_identifier=old_state.id,
|
||||
new_identifier=new_state.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
@@ -328,10 +298,8 @@ def track_labels(
|
||||
issue_activities,
|
||||
epoch,
|
||||
):
|
||||
|
||||
# Labels
|
||||
requested_labels = extract_ids(requested_data, "label_ids", "labels")
|
||||
current_labels = extract_ids(current_instance, "label_ids", "labels")
|
||||
requested_labels = set([str(lab) for lab in requested_data.get("label_ids", [])])
|
||||
current_labels = set([str(lab) for lab in current_instance.get("label_ids", [])])
|
||||
|
||||
added_labels = requested_labels - current_labels
|
||||
dropped_labels = current_labels - requested_labels
|
||||
@@ -396,9 +364,16 @@ def track_assignees(
|
||||
issue_activities,
|
||||
epoch,
|
||||
):
|
||||
# Assignees
|
||||
requested_assignees = extract_ids(requested_data, "assignee_ids", "assignees")
|
||||
current_assignees = extract_ids(current_instance, "assignee_ids", "assignees")
|
||||
requested_assignees = (
|
||||
set([str(asg) for asg in requested_data.get("assignee_ids", [])])
|
||||
if requested_data is not None
|
||||
else set()
|
||||
)
|
||||
current_assignees = (
|
||||
set([str(asg) for asg in current_instance.get("assignee_ids", [])])
|
||||
if current_instance is not None
|
||||
else set()
|
||||
)
|
||||
|
||||
added_assignees = requested_assignees - current_assignees
|
||||
dropped_assginees = current_assignees - requested_assignees
|
||||
@@ -656,11 +631,6 @@ def update_issue_activity(
|
||||
"estimate_point": track_estimate_points,
|
||||
"archived_at": track_archive_at,
|
||||
"closed_to": track_closed_to,
|
||||
# External endpoint keys
|
||||
"parent": track_parent,
|
||||
"state": track_state,
|
||||
"assignees": track_assignees,
|
||||
"labels": track_labels,
|
||||
}
|
||||
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
|
||||
@@ -55,23 +55,15 @@ app.conf.beat_schedule = {
|
||||
},
|
||||
"check-every-day-to-delete-email-notification-logs": {
|
||||
"task": "plane.bgtasks.cleanup_task.delete_email_notification_logs",
|
||||
"schedule": crontab(hour=2, minute=45), # UTC 02:45
|
||||
"schedule": crontab(hour=3, minute=0), # UTC 03:00
|
||||
},
|
||||
"check-every-day-to-delete-page-versions": {
|
||||
"task": "plane.bgtasks.cleanup_task.delete_page_versions",
|
||||
"schedule": crontab(hour=3, minute=0), # UTC 03:00
|
||||
"schedule": crontab(hour=3, minute=30), # UTC 03:30
|
||||
},
|
||||
"check-every-day-to-delete-issue-description-versions": {
|
||||
"task": "plane.bgtasks.cleanup_task.delete_issue_description_versions",
|
||||
"schedule": crontab(hour=3, minute=15), # UTC 03:15
|
||||
},
|
||||
"check-every-day-to-delete-webhook-logs": {
|
||||
"task": "plane.bgtasks.cleanup_task.delete_webhook_logs",
|
||||
"schedule": crontab(hour=3, minute=30), # UTC 03:30
|
||||
},
|
||||
"check-every-day-to-delete-exporter-history": {
|
||||
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
|
||||
"schedule": crontab(hour=3, minute=45), # UTC 03:45
|
||||
"schedule": crontab(hour=4, minute=0), # UTC 04:00
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
from django.db import migrations
|
||||
|
||||
from plane.utils.filters import LegacyToRichFiltersConverter
|
||||
from plane.utils.filters.filter_migrations import (
|
||||
migrate_models_filters_to_rich_filters,
|
||||
clear_models_rich_filters,
|
||||
)
|
||||
|
||||
|
||||
# Define all models that need migration in one place
|
||||
MODEL_NAMES = [
|
||||
"IssueView",
|
||||
"WorkspaceUserProperties",
|
||||
"ModuleUserProperties",
|
||||
"IssueUserProperty",
|
||||
"CycleUserProperties",
|
||||
]
|
||||
|
||||
|
||||
def migrate_filters_to_rich_filters(apps, schema_editor):
|
||||
"""
|
||||
Migrate legacy filters to rich_filters format for all models that have both fields.
|
||||
"""
|
||||
# Get the model classes from model names
|
||||
models_to_migrate = {}
|
||||
|
||||
for model_name in MODEL_NAMES:
|
||||
try:
|
||||
model_class = apps.get_model("db", model_name)
|
||||
models_to_migrate[model_name] = model_class
|
||||
except Exception as e:
|
||||
# Log error but continue with other models
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to get model {model_name}: {str(e)}")
|
||||
|
||||
converter = LegacyToRichFiltersConverter()
|
||||
# Migrate all models
|
||||
migrate_models_filters_to_rich_filters(models_to_migrate, converter)
|
||||
|
||||
|
||||
def reverse_migrate_rich_filters_to_filters(apps, schema_editor):
|
||||
"""
|
||||
Reverse migration to clear rich_filters field for all models.
|
||||
"""
|
||||
# Get the model classes from model names
|
||||
models_to_clear = {}
|
||||
|
||||
for model_name in MODEL_NAMES:
|
||||
try:
|
||||
model_class = apps.get_model("db", model_name)
|
||||
models_to_clear[model_name] = model_class
|
||||
except Exception as e:
|
||||
# Log error but continue with other models
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to get model {model_name}: {str(e)}")
|
||||
|
||||
# Clear rich_filters for all models
|
||||
clear_models_rich_filters(models_to_clear)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0106_auto_20250912_0845'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
migrate_filters_to_rich_filters,
|
||||
reverse_code=reverse_migrate_rich_filters_to_filters,
|
||||
),
|
||||
]
|
||||
@@ -184,7 +184,6 @@ def issue_group_values(
|
||||
slug: str,
|
||||
project_id: Optional[str] = None,
|
||||
filters: Dict[str, Any] = {},
|
||||
queryset: Optional[QuerySet] = None,
|
||||
) -> List[Union[str, Any]]:
|
||||
if field == "state_id":
|
||||
queryset = State.objects.filter(
|
||||
@@ -239,20 +238,35 @@ def issue_group_values(
|
||||
if field == "state__group":
|
||||
return ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
if field == "target_date":
|
||||
queryset = queryset.values_list("target_date", flat=True).distinct()
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.filter(**filters)
|
||||
.values_list("target_date", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
if field == "start_date":
|
||||
queryset = queryset.values_list("start_date", flat=True).distinct()
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.filter(**filters)
|
||||
.values_list("start_date", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
|
||||
if field == "created_by":
|
||||
queryset = queryset.values_list("created_by", flat=True).distinct()
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.filter(**filters)
|
||||
.values_list("created_by", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Filters module for handling complex filtering operations
|
||||
|
||||
# Import all utilities from base modules
|
||||
from .filter_backend import ComplexFilterBackend
|
||||
from .converters import LegacyToRichFiltersConverter
|
||||
from .filterset import IssueFilterSet
|
||||
|
||||
|
||||
# Public API exports
|
||||
__all__ = ["ComplexFilterBackend", "LegacyToRichFiltersConverter", "IssueFilterSet"]
|
||||
@@ -1,438 +0,0 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from dateutil.parser import parse as dateutil_parse
|
||||
|
||||
|
||||
class LegacyToRichFiltersConverter:
|
||||
# Default mapping from legacy filter names to new rich filter field names
|
||||
DEFAULT_FIELD_MAPPINGS = {
|
||||
"state": "state_id",
|
||||
"labels": "label_id",
|
||||
"cycle": "cycle_id",
|
||||
"module": "module_id",
|
||||
"assignees": "assignee_id",
|
||||
"mentions": "mention_id",
|
||||
"created_by": "created_by_id",
|
||||
"state_group": "state_group",
|
||||
"priority": "priority",
|
||||
"project": "project_id",
|
||||
"start_date": "start_date",
|
||||
"target_date": "target_date",
|
||||
}
|
||||
|
||||
# Default fields that expect UUID values
|
||||
DEFAULT_UUID_FIELDS = {
|
||||
"state_id",
|
||||
"label_id",
|
||||
"cycle_id",
|
||||
"module_id",
|
||||
"assignee_id",
|
||||
"mention_id",
|
||||
"created_by_id",
|
||||
"project_id",
|
||||
}
|
||||
|
||||
# Default valid choices for choice fields
|
||||
DEFAULT_VALID_CHOICES = {
|
||||
"state_group": ["backlog", "unstarted", "started", "completed", "cancelled"],
|
||||
"priority": ["urgent", "high", "medium", "low", "none"],
|
||||
}
|
||||
|
||||
# Default date fields
|
||||
DEFAULT_DATE_FIELDS = {"start_date", "target_date"}
|
||||
|
||||
# Pattern for relative date strings like "2_weeks" or "3_months"
|
||||
DATE_PATTERN = re.compile(r"(\d+)_(weeks|months)$")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
field_mappings: Dict[str, str] = None,
|
||||
uuid_fields: set = None,
|
||||
valid_choices: Dict[str, List[str]] = None,
|
||||
date_fields: set = None,
|
||||
extend_defaults: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the converter with optional custom configurations.
|
||||
|
||||
Args:
|
||||
field_mappings: Custom field mappings (legacy_key -> rich_field_name)
|
||||
uuid_fields: Set of field names that should be validated as UUIDs
|
||||
valid_choices: Dict of valid choices for choice fields
|
||||
date_fields: Set of field names that should be treated as dates
|
||||
extend_defaults: If True, merge with defaults; if False, replace defaults
|
||||
|
||||
Examples:
|
||||
# Use defaults
|
||||
converter = LegacyToRichFiltersConverter()
|
||||
|
||||
# Add custom field mapping
|
||||
converter = LegacyToRichFiltersConverter(
|
||||
field_mappings={"custom_field": "custom_field_id"}
|
||||
)
|
||||
|
||||
# Override priority choices
|
||||
converter = LegacyToRichFiltersConverter(
|
||||
valid_choices={"priority": ["critical", "high", "medium", "low"]}
|
||||
)
|
||||
|
||||
# Complete replacement (not extending defaults)
|
||||
converter = LegacyToRichFiltersConverter(
|
||||
field_mappings={"state": "status_id"},
|
||||
extend_defaults=False
|
||||
)
|
||||
"""
|
||||
if extend_defaults:
|
||||
# Merge with defaults
|
||||
self.FIELD_MAPPINGS = {**self.DEFAULT_FIELD_MAPPINGS}
|
||||
if field_mappings:
|
||||
self.FIELD_MAPPINGS.update(field_mappings)
|
||||
|
||||
self.UUID_FIELDS = {*self.DEFAULT_UUID_FIELDS}
|
||||
if uuid_fields:
|
||||
self.UUID_FIELDS.update(uuid_fields)
|
||||
|
||||
self.VALID_CHOICES = {**self.DEFAULT_VALID_CHOICES}
|
||||
if valid_choices:
|
||||
self.VALID_CHOICES.update(valid_choices)
|
||||
|
||||
self.DATE_FIELDS = {*self.DEFAULT_DATE_FIELDS}
|
||||
if date_fields:
|
||||
self.DATE_FIELDS.update(date_fields)
|
||||
else:
|
||||
# Replace defaults entirely
|
||||
self.FIELD_MAPPINGS = field_mappings or {}
|
||||
self.UUID_FIELDS = uuid_fields or set()
|
||||
self.VALID_CHOICES = valid_choices or {}
|
||||
self.DATE_FIELDS = date_fields or set()
|
||||
|
||||
def add_field_mapping(self, legacy_key: str, rich_field_name: str) -> None:
|
||||
"""Add or update a single field mapping."""
|
||||
self.FIELD_MAPPINGS[legacy_key] = rich_field_name
|
||||
|
||||
def add_uuid_field(self, field_name: str) -> None:
|
||||
"""Add a field that should be validated as UUID."""
|
||||
self.UUID_FIELDS.add(field_name)
|
||||
|
||||
def add_choice_field(self, field_name: str, choices: List[str]) -> None:
|
||||
"""Add or update valid choices for a choice field."""
|
||||
self.VALID_CHOICES[field_name] = choices
|
||||
|
||||
def add_date_field(self, field_name: str) -> None:
|
||||
"""Add a field that should be treated as a date field."""
|
||||
self.DATE_FIELDS.add(field_name)
|
||||
|
||||
def update_mappings(
|
||||
self,
|
||||
field_mappings: Dict[str, str] = None,
|
||||
uuid_fields: set = None,
|
||||
valid_choices: Dict[str, List[str]] = None,
|
||||
date_fields: set = None,
|
||||
) -> None:
|
||||
"""
|
||||
Update multiple configurations at once.
|
||||
|
||||
Args:
|
||||
field_mappings: Additional field mappings to add/update
|
||||
uuid_fields: Additional UUID fields to add
|
||||
valid_choices: Additional choice fields to add/update
|
||||
date_fields: Additional date fields to add
|
||||
"""
|
||||
if field_mappings:
|
||||
self.FIELD_MAPPINGS.update(field_mappings)
|
||||
if uuid_fields:
|
||||
self.UUID_FIELDS.update(uuid_fields)
|
||||
if valid_choices:
|
||||
self.VALID_CHOICES.update(valid_choices)
|
||||
if date_fields:
|
||||
self.DATE_FIELDS.update(date_fields)
|
||||
|
||||
def _validate_uuid(self, value: str) -> bool:
|
||||
"""Validate if a string is a valid UUID"""
|
||||
try:
|
||||
uuid.UUID(str(value))
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
def _validate_choice(self, field_name: str, value: str) -> bool:
|
||||
"""Validate if a value is valid for a choice field"""
|
||||
if field_name not in self.VALID_CHOICES:
|
||||
return True # No validation needed for this field
|
||||
return value in self.VALID_CHOICES[field_name]
|
||||
|
||||
def _validate_date(self, value: Union[str, datetime]) -> bool:
|
||||
"""Validate if a value is a valid date using dateutil parser"""
|
||||
if isinstance(value, datetime):
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
# Use dateutil for flexible date parsing
|
||||
dateutil_parse(value)
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return False
|
||||
|
||||
def _validate_value(self, rich_field_name: str, value: Any) -> bool:
|
||||
"""Validate a single value based on field type"""
|
||||
if rich_field_name in self.UUID_FIELDS:
|
||||
return self._validate_uuid(value)
|
||||
elif rich_field_name in self.VALID_CHOICES:
|
||||
return self._validate_choice(rich_field_name, value)
|
||||
elif rich_field_name in self.DATE_FIELDS:
|
||||
return self._validate_date(value)
|
||||
return True # No specific validation needed
|
||||
|
||||
def _filter_valid_values(
|
||||
self, rich_field_name: str, values: List[Any]
|
||||
) -> List[Any]:
|
||||
"""Filter out invalid values from a list and return only valid ones"""
|
||||
valid_values = []
|
||||
for value in values:
|
||||
if self._validate_value(rich_field_name, value):
|
||||
valid_values.append(value)
|
||||
return valid_values
|
||||
|
||||
def _add_validation_error(
|
||||
self, strict: bool, validation_errors: List[str], message: str
|
||||
) -> None:
|
||||
"""Add validation error if in strict mode."""
|
||||
if strict:
|
||||
validation_errors.append(message)
|
||||
|
||||
def _add_rich_filter(
|
||||
self, rich_filters: Dict[str, Any], field_name: str, operator: str, value: Any
|
||||
) -> None:
|
||||
"""Add a rich filter with proper field name formatting."""
|
||||
# Convert lists to comma-separated strings for 'in' and 'range' operations
|
||||
if operator in ("in", "range") and isinstance(value, list):
|
||||
value = ",".join(str(v) for v in value)
|
||||
rich_filters[f"{field_name}__{operator}"] = value
|
||||
|
||||
def _handle_value_error(
|
||||
self, e: ValueError, strict: bool, validation_errors: List[str]
|
||||
) -> None:
|
||||
"""Handle ValueError with consistent strict/non-strict behavior."""
|
||||
if strict:
|
||||
validation_errors.append(str(e))
|
||||
# In non-strict mode, we just skip (no action needed)
|
||||
|
||||
def _process_date_field(
|
||||
self,
|
||||
rich_field_name: str,
|
||||
values: List[str],
|
||||
strict: bool,
|
||||
validation_errors: List[str],
|
||||
rich_filters: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Process date field with basic functionality (exact, range)."""
|
||||
if rich_field_name not in self.DATE_FIELDS:
|
||||
return False
|
||||
|
||||
try:
|
||||
date_filter_result = self._convert_date_value(
|
||||
rich_field_name, values, strict
|
||||
)
|
||||
if date_filter_result:
|
||||
rich_filters.update(date_filter_result)
|
||||
return True
|
||||
except ValueError as e:
|
||||
self._handle_value_error(e, strict, validation_errors)
|
||||
return True
|
||||
|
||||
def _convert_date_value(
|
||||
self, field_name: str, values: List[str], strict: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert legacy date values to rich filter format - basic implementation.
|
||||
|
||||
Supports:
|
||||
- Simple dates: "2023-01-01" -> __exact
|
||||
- Basic ranges: ["2023-01-01;after", "2023-12-31;before"] -> __range
|
||||
- Skips complex or relative date patterns
|
||||
|
||||
Args:
|
||||
field_name: Name of the rich filter field
|
||||
values: List of legacy date values
|
||||
strict: If True, raise errors for validation failures
|
||||
|
||||
Raises:
|
||||
ValueError: For malformed date patterns (strict mode)
|
||||
"""
|
||||
# Check for relative dates and skip the entire field if found
|
||||
for value in values:
|
||||
if ";" in value:
|
||||
parts = value.split(";")
|
||||
if len(parts) > 0 and self.DATE_PATTERN.match(parts[0]):
|
||||
# Skip relative date patterns entirely
|
||||
return {}
|
||||
|
||||
# Skip complex conditions (more than 2 values)
|
||||
if len(values) > 2:
|
||||
return {}
|
||||
|
||||
# Process each date value
|
||||
exact_dates = []
|
||||
after_dates = []
|
||||
before_dates = []
|
||||
|
||||
for value in values:
|
||||
if ";" not in value:
|
||||
# Simple date string
|
||||
if not self._validate_date(value):
|
||||
if strict:
|
||||
raise ValueError(f"Invalid date format: {value}")
|
||||
continue
|
||||
exact_dates.append(value)
|
||||
else:
|
||||
# Directional date - only handle basic after/before
|
||||
parts = value.split(";")
|
||||
if len(parts) < 2:
|
||||
if strict:
|
||||
raise ValueError(f"Invalid date format: {value}")
|
||||
continue
|
||||
|
||||
date_part = parts[0]
|
||||
direction = parts[1]
|
||||
|
||||
if not self._validate_date(date_part):
|
||||
if strict:
|
||||
raise ValueError(f"Invalid date format: {date_part}")
|
||||
continue
|
||||
|
||||
if direction == "after":
|
||||
after_dates.append(date_part)
|
||||
elif direction == "before":
|
||||
before_dates.append(date_part)
|
||||
# Skip unsupported directions
|
||||
|
||||
# Determine return format
|
||||
result = {}
|
||||
if len(after_dates) == 1 and len(before_dates) == 1 and len(exact_dates) == 0:
|
||||
# Simple range: one after and one before
|
||||
start_date = min(after_dates[0], before_dates[0])
|
||||
end_date = max(after_dates[0], before_dates[0])
|
||||
self._add_rich_filter(result, field_name, "range", [start_date, end_date])
|
||||
elif len(exact_dates) == 1 and len(after_dates) == 0 and len(before_dates) == 0:
|
||||
# Single exact date
|
||||
self._add_rich_filter(result, field_name, "exact", exact_dates[0])
|
||||
# Skip all other combinations
|
||||
|
||||
return result
|
||||
|
||||
def convert(self, legacy_filters: dict, strict: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert legacy filters to rich filters format with validation
|
||||
|
||||
Args:
|
||||
legacy_filters: Dictionary of legacy filters
|
||||
strict: If True, raise exception on validation errors.
|
||||
If False, skip invalid values (default behavior)
|
||||
|
||||
Returns:
|
||||
Dictionary of rich filters
|
||||
|
||||
Raises:
|
||||
ValueError: If strict=True and validation fails
|
||||
"""
|
||||
rich_filters = {}
|
||||
validation_errors = []
|
||||
|
||||
for legacy_key, value in legacy_filters.items():
|
||||
# Skip if value is None or empty
|
||||
if value is None or (isinstance(value, list) and len(value) == 0):
|
||||
continue
|
||||
|
||||
# Skip if legacy key is not in our mappings (not supported in filterset)
|
||||
if legacy_key not in self.FIELD_MAPPINGS:
|
||||
self._add_validation_error(
|
||||
strict, validation_errors, f"Unsupported filter key: {legacy_key}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Get the new field name
|
||||
rich_field_name = self.FIELD_MAPPINGS[legacy_key]
|
||||
|
||||
# Handle list values
|
||||
if isinstance(value, list):
|
||||
# Process date fields with helper method
|
||||
if self._process_date_field(
|
||||
rich_field_name, value, strict, validation_errors, rich_filters
|
||||
):
|
||||
continue
|
||||
|
||||
# Regular non-date field processing
|
||||
# Filter out invalid values
|
||||
valid_values = self._filter_valid_values(rich_field_name, value)
|
||||
|
||||
if not valid_values:
|
||||
self._add_validation_error(
|
||||
strict,
|
||||
validation_errors,
|
||||
f"No valid values found for {legacy_key}: {value}",
|
||||
)
|
||||
continue
|
||||
|
||||
# Check for invalid values if in strict mode
|
||||
if strict and len(valid_values) != len(value):
|
||||
invalid_values = [v for v in value if v not in valid_values]
|
||||
self._add_validation_error(
|
||||
strict,
|
||||
validation_errors,
|
||||
f"Invalid values for {legacy_key}: {invalid_values}",
|
||||
)
|
||||
|
||||
# For list values, always use __in operator for non-date fields
|
||||
self._add_rich_filter(rich_filters, rich_field_name, "in", valid_values)
|
||||
|
||||
else:
|
||||
# Handle single values
|
||||
# Process date fields with helper method
|
||||
if self._process_date_field(
|
||||
rich_field_name, [value], strict, validation_errors, rich_filters
|
||||
):
|
||||
continue
|
||||
|
||||
# For non-list values, use __exact operator for non-date fields
|
||||
if self._validate_value(rich_field_name, value):
|
||||
self._add_rich_filter(rich_filters, rich_field_name, "exact", value)
|
||||
else:
|
||||
error_msg = f"Invalid value for {legacy_key}: {value}"
|
||||
self._add_validation_error(strict, validation_errors, error_msg)
|
||||
|
||||
# Raise validation errors if in strict mode
|
||||
if strict and validation_errors:
|
||||
error_message = f"Filter validation errors: {'; '.join(validation_errors)}"
|
||||
raise ValueError(error_message)
|
||||
|
||||
# Convert flat dict to rich filter format
|
||||
return self._format_as_rich_filter(rich_filters)
|
||||
|
||||
def _format_as_rich_filter(self, flat_filters: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert a flat dictionary of filters to the proper rich filter format.
|
||||
|
||||
Args:
|
||||
flat_filters: Dictionary with field__lookup keys and values
|
||||
|
||||
Returns:
|
||||
Rich filter format using logical operators (and/or/not)
|
||||
"""
|
||||
if not flat_filters:
|
||||
return {}
|
||||
|
||||
# If only one filter, return as leaf node
|
||||
if len(flat_filters) == 1:
|
||||
key, value = next(iter(flat_filters.items()))
|
||||
return {key: value}
|
||||
|
||||
# Multiple filters: wrap in 'and' operator
|
||||
filter_conditions = []
|
||||
for key, value in flat_filters.items():
|
||||
filter_conditions.append({key: value})
|
||||
|
||||
return {"and": filter_conditions}
|
||||
@@ -1,380 +0,0 @@
|
||||
import json
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import QueryDict
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters
|
||||
|
||||
|
||||
class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
"""
|
||||
Filter backend that supports complex JSON filtering.
|
||||
|
||||
For full, up-to-date examples and usage, see the package README
|
||||
at `plane/utils/filters/README.md`.
|
||||
"""
|
||||
|
||||
filter_param = "filters"
|
||||
default_max_depth = 5
|
||||
|
||||
def filter_queryset(self, request, queryset, view, filter_data=None):
|
||||
"""Normalize filter input and apply JSON-based filtering.
|
||||
|
||||
Accepts explicit `filter_data` (dict or JSON string) or reads the
|
||||
`filter` query parameter. Enforces JSON-only filtering.
|
||||
"""
|
||||
try:
|
||||
if filter_data is not None:
|
||||
normalized = self._normalize_filter_data(filter_data, "filter_data")
|
||||
return self._apply_json_filter(queryset, normalized, view)
|
||||
|
||||
filter_string = request.query_params.get(self.filter_param, None)
|
||||
if not filter_string:
|
||||
return queryset
|
||||
|
||||
normalized = self._normalize_filter_data(filter_string, "filter")
|
||||
return self._apply_json_filter(queryset, normalized, view)
|
||||
except ValidationError:
|
||||
# Propagate validation errors unchanged
|
||||
raise
|
||||
except Exception as e:
|
||||
raise
|
||||
# Convert unexpected errors to ValidationError to keep response consistent
|
||||
raise ValidationError(f"Filter error: {str(e)}")
|
||||
|
||||
def _normalize_filter_data(self, raw_filter, source_label):
|
||||
"""Return a dict from raw filter input or raise a ValidationError.
|
||||
|
||||
- raw_filter may be a dict or a JSON string
|
||||
- source_label is used in error messages (e.g., 'filter_data' or 'filter')
|
||||
"""
|
||||
try:
|
||||
if isinstance(raw_filter, str):
|
||||
return json.loads(raw_filter)
|
||||
if isinstance(raw_filter, dict):
|
||||
return raw_filter
|
||||
raise ValidationError(f"'{source_label}' must be a dict or a JSON string.")
|
||||
except json.JSONDecodeError:
|
||||
raise ValidationError(
|
||||
f"Invalid JSON for '{source_label}'. Expected a valid JSON object."
|
||||
)
|
||||
|
||||
def _apply_json_filter(self, queryset, filter_data, view):
|
||||
"""Process a JSON filter structure using OR/AND/NOT set operations."""
|
||||
if not filter_data:
|
||||
return queryset
|
||||
|
||||
# Validate structure and depth before field allowlist checks
|
||||
max_depth = self._get_max_depth(view)
|
||||
self._validate_structure(filter_data, max_depth=max_depth, current_depth=1)
|
||||
|
||||
# Validate against the view's FilterSet (only declared filters are allowed)
|
||||
self._validate_fields(filter_data, view)
|
||||
|
||||
# Build combined queryset using FilterSet-driven leaf evaluation
|
||||
combined_qs = self._evaluate_node(filter_data, queryset, view)
|
||||
if combined_qs is None:
|
||||
return queryset
|
||||
return combined_qs
|
||||
|
||||
def _validate_fields(self, filter_data, view):
|
||||
"""Validate that filtered fields are defined in the view's FilterSet."""
|
||||
filterset_class = getattr(view, "filterset_class", None)
|
||||
allowed_fields = (
|
||||
set(filterset_class.base_filters.keys()) if filterset_class else None
|
||||
)
|
||||
if not allowed_fields:
|
||||
# If no FilterSet is configured, reject filtering to avoid unintended exposure # noqa: E501
|
||||
raise ValidationError(
|
||||
"Filtering is not enabled for this endpoint (missing filterset_class)"
|
||||
)
|
||||
|
||||
# Extract field names from the filter data
|
||||
fields = self._extract_field_names(filter_data)
|
||||
|
||||
# Check if all fields are allowed
|
||||
for field in fields:
|
||||
# Field keys must match FilterSet filter names (including any lookups)
|
||||
# Example: 'sequence_id__gte' should be declared in base_filters
|
||||
# Special-case __range: require the '<base>__range' filter itself
|
||||
if field not in allowed_fields:
|
||||
raise ValidationError(f"Filtering on field '{field}' is not allowed")
|
||||
|
||||
def _extract_field_names(self, filter_data):
|
||||
"""Extract all field names from a nested filter structure"""
|
||||
if isinstance(filter_data, dict):
|
||||
fields = []
|
||||
for key, value in filter_data.items():
|
||||
if key.lower() in ("or", "and", "not"):
|
||||
# This is a logical operator, process its children
|
||||
if key.lower() == "not":
|
||||
# 'not' has a dict as its value, not a list
|
||||
if isinstance(value, dict):
|
||||
fields.extend(self._extract_field_names(value))
|
||||
else:
|
||||
# 'or' and 'and' have lists as their values
|
||||
for item in value:
|
||||
fields.extend(self._extract_field_names(item))
|
||||
else:
|
||||
# This is a field name
|
||||
fields.append(key)
|
||||
return fields
|
||||
return []
|
||||
|
||||
def _evaluate_node(self, node, base_queryset, view):
|
||||
"""
|
||||
Recursively evaluate a JSON node into a combined queryset using branch-based filtering.
|
||||
|
||||
Rules:
|
||||
- leaf dict → evaluated through DjangoFilterBackend as a mini-querystring
|
||||
- {"or": [...]} → union (|) of children
|
||||
- {"and": [...]} → collect field conditions per branch and apply together
|
||||
- {"not": {...}} → exclude child's rows from the base queryset
|
||||
(complement within base scope)
|
||||
"""
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
|
||||
# 'or' combination - requires set operations between children
|
||||
if "or" in node:
|
||||
children = node["or"]
|
||||
if not isinstance(children, list) or not children:
|
||||
return None
|
||||
combined = None
|
||||
for child in children:
|
||||
child_qs = self._evaluate_node(child, base_queryset, view)
|
||||
if child_qs is None:
|
||||
continue
|
||||
combined = child_qs if combined is None else (combined | child_qs)
|
||||
return combined
|
||||
|
||||
# 'and' combination - collect field conditions per branch
|
||||
if "and" in node:
|
||||
children = node["and"]
|
||||
if not isinstance(children, list) or not children:
|
||||
return None
|
||||
return self._evaluate_and_branch(children, base_queryset, view)
|
||||
|
||||
# 'not' negation
|
||||
if "not" in node:
|
||||
child = node["not"]
|
||||
if not isinstance(child, dict):
|
||||
return None
|
||||
child_qs = self._evaluate_node(child, base_queryset, view)
|
||||
if child_qs is None:
|
||||
return None
|
||||
# Use subquery instead of pk__in for better performance
|
||||
# This avoids evaluating child_qs and creating large IN clauses
|
||||
return base_queryset.exclude(pk__in=child_qs.values("pk"))
|
||||
|
||||
# Leaf dict: evaluate via DjangoFilterBackend using FilterSet
|
||||
return self._filter_leaf_via_backend(node, base_queryset, view)
|
||||
|
||||
def _evaluate_and_branch(self, children, base_queryset, view):
|
||||
"""
|
||||
Evaluate an AND branch by collecting field conditions and applying them together.
|
||||
|
||||
This approach is more efficient than individual leaf evaluation because:
|
||||
- Field conditions within the same AND branch are collected and applied together
|
||||
- Only logical operation children require separate evaluation and set intersection
|
||||
- Reduces the number of intermediate querysets and database queries
|
||||
"""
|
||||
collected_conditions = {}
|
||||
logical_querysets = []
|
||||
|
||||
# Separate field conditions from logical operations
|
||||
for child in children:
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
|
||||
# Check if this child contains logical operators
|
||||
has_logical = any(
|
||||
k.lower() in ("or", "and", "not")
|
||||
for k in child.keys()
|
||||
if isinstance(k, str)
|
||||
)
|
||||
|
||||
if has_logical:
|
||||
# This child has logical operators, evaluate separately
|
||||
child_qs = self._evaluate_node(child, base_queryset, view)
|
||||
if child_qs is not None:
|
||||
logical_querysets.append(child_qs)
|
||||
else:
|
||||
# This is a leaf with field conditions, collect them
|
||||
collected_conditions.update(child)
|
||||
|
||||
# Start with base queryset
|
||||
result_qs = base_queryset
|
||||
|
||||
# Apply collected field conditions together if any exist
|
||||
if collected_conditions:
|
||||
result_qs = self._filter_leaf_via_backend(
|
||||
collected_conditions, result_qs, view
|
||||
)
|
||||
if result_qs is None:
|
||||
return None
|
||||
|
||||
# Intersect with any logical operation results
|
||||
for logical_qs in logical_querysets:
|
||||
result_qs = result_qs & logical_qs
|
||||
|
||||
return result_qs
|
||||
|
||||
def _filter_leaf_via_backend(self, leaf_conditions, base_queryset, view):
|
||||
"""Evaluate a leaf dict by delegating to DjangoFilterBackend once.
|
||||
|
||||
We serialize the leaf dict into a mini querystring and let the view's
|
||||
filterset_class perform validation, conversion, and filtering. This returns
|
||||
a lazy queryset suitable for set-operations with siblings.
|
||||
"""
|
||||
if not leaf_conditions:
|
||||
return None
|
||||
|
||||
# Build a QueryDict from the leaf conditions
|
||||
qd = QueryDict(mutable=True)
|
||||
for key, value in leaf_conditions.items():
|
||||
# Default serialization to string; QueryDict expects strings
|
||||
if isinstance(value, list):
|
||||
# Repeat key for list values (e.g., __in)
|
||||
qd.setlist(key, [str(v) for v in value])
|
||||
else:
|
||||
qd[key] = "" if value is None else str(value)
|
||||
|
||||
qd = qd.copy()
|
||||
qd._mutable = False
|
||||
|
||||
# Temporarily patch request.GET and delegate to DjangoFilterBackend
|
||||
backend = DjangoFilterBackend()
|
||||
request = view.request
|
||||
original_get = request._request.GET if hasattr(request, "_request") else None
|
||||
try:
|
||||
if hasattr(request, "_request"):
|
||||
request._request.GET = qd
|
||||
return backend.filter_queryset(request, base_queryset, view)
|
||||
finally:
|
||||
if hasattr(request, "_request") and original_get is not None:
|
||||
request._request.GET = original_get
|
||||
|
||||
def _get_max_depth(self, view):
|
||||
"""Return the maximum allowed nesting depth for complex filters.
|
||||
|
||||
Falls back to class default if the view does not specify it or has
|
||||
an invalid value.
|
||||
"""
|
||||
value = getattr(view, "complex_filter_max_depth", self.default_max_depth)
|
||||
try:
|
||||
value_int = int(value)
|
||||
if value_int <= 0:
|
||||
return self.default_max_depth
|
||||
return value_int
|
||||
except Exception:
|
||||
return self.default_max_depth
|
||||
|
||||
def _validate_structure(self, node, max_depth, current_depth):
|
||||
"""Validate JSON structure and enforce nesting depth.
|
||||
|
||||
Rules:
|
||||
- Each object may contain only one logical operator:
|
||||
or/and/not (case-insensitive)
|
||||
- Logical operator objects cannot contain field keys alongside the
|
||||
operator
|
||||
- or/and values must be non-empty lists of dicts
|
||||
- not value must be a dict
|
||||
- Leaf objects must only contain field keys and acceptable values
|
||||
- Depth must not exceed max_depth
|
||||
"""
|
||||
if current_depth > max_depth:
|
||||
raise ValidationError(
|
||||
f"Filter nesting is too deep (max {max_depth}); found depth"
|
||||
f" {current_depth}"
|
||||
)
|
||||
|
||||
if not isinstance(node, dict):
|
||||
raise ValidationError("Each filter node must be a JSON object")
|
||||
|
||||
if not node:
|
||||
raise ValidationError("Filter objects must not be empty")
|
||||
|
||||
logical_keys = [
|
||||
k
|
||||
for k in node.keys()
|
||||
if isinstance(k, str) and k.lower() in ("or", "and", "not")
|
||||
]
|
||||
|
||||
if len(logical_keys) > 1:
|
||||
raise ValidationError(
|
||||
"A filter object cannot contain multiple logical operators at"
|
||||
" the same level"
|
||||
)
|
||||
|
||||
if len(logical_keys) == 1:
|
||||
op_key = logical_keys[0]
|
||||
# must not mix operator with other keys
|
||||
if len(node) != 1:
|
||||
raise ValidationError(
|
||||
f"Cannot mix logical operator '{op_key}' with field keys at"
|
||||
f" the same level"
|
||||
)
|
||||
|
||||
op = op_key.lower()
|
||||
value = node[op_key]
|
||||
|
||||
if op in ("or", "and"):
|
||||
if not isinstance(value, list) or len(value) == 0:
|
||||
raise ValidationError(
|
||||
f"'{op}' must be a non-empty list of filter objects"
|
||||
)
|
||||
for child in value:
|
||||
if not isinstance(child, dict):
|
||||
raise ValidationError(
|
||||
f"All children of '{op}' must be JSON objects"
|
||||
)
|
||||
self._validate_structure(
|
||||
child,
|
||||
max_depth=max_depth,
|
||||
current_depth=current_depth + 1,
|
||||
)
|
||||
return
|
||||
|
||||
if op == "not":
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError("'not' must be a single JSON object")
|
||||
self._validate_structure(
|
||||
value, max_depth=max_depth, current_depth=current_depth + 1
|
||||
)
|
||||
return
|
||||
|
||||
# Leaf node: validate fields and values
|
||||
self._validate_leaf(node)
|
||||
|
||||
def _validate_leaf(self, leaf):
|
||||
"""Validate a leaf dict containing field lookups and values."""
|
||||
if not isinstance(leaf, dict) or not leaf:
|
||||
raise ValidationError("Leaf filter must be a non-empty JSON object")
|
||||
|
||||
for key, value in leaf.items():
|
||||
if isinstance(key, str) and key.lower() in ("or", "and", "not"):
|
||||
raise ValidationError(
|
||||
"Logical operators cannot appear in a leaf filter object"
|
||||
)
|
||||
|
||||
# Lists/Tuples must contain only scalar values
|
||||
if isinstance(value, (list, tuple)):
|
||||
if len(value) == 0:
|
||||
raise ValidationError(f"List value for '{key}' must not be empty")
|
||||
for item in value:
|
||||
if not self._is_scalar(item):
|
||||
raise ValidationError(
|
||||
f"List value for '{key}' must contain only scalar items"
|
||||
)
|
||||
continue
|
||||
|
||||
# Scalars and None are allowed
|
||||
if not self._is_scalar(value):
|
||||
raise ValidationError(
|
||||
f"Value for '{key}' must be a scalar, null, or list/tuple of"
|
||||
f" scalars"
|
||||
)
|
||||
|
||||
def _is_scalar(self, value):
|
||||
return value is None or isinstance(value, (str, int, float, bool))
|
||||
@@ -1,146 +0,0 @@
|
||||
"""
|
||||
Utilities for migrating legacy filters to rich filters format.
|
||||
|
||||
This module contains helper functions for data migrations that convert
|
||||
filters fields to rich_filters fields using the LegacyToRichFiltersConverter.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from .converters import LegacyToRichFiltersConverter
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.api.filters.migration")
|
||||
|
||||
|
||||
def migrate_single_model_filters(
|
||||
model_class, model_name: str, converter: LegacyToRichFiltersConverter
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Migrate filters to rich_filters for a single model.
|
||||
|
||||
Args:
|
||||
model_class: Django model class
|
||||
model_name: Human-readable name for logging
|
||||
converter: Instance of LegacyToRichFiltersConverter
|
||||
|
||||
Returns:
|
||||
Tuple of (updated_count, error_count)
|
||||
"""
|
||||
# Find records that need migration - have filters but empty rich_filters
|
||||
records_to_migrate = model_class.objects.exclude(filters={}).filter(rich_filters={})
|
||||
|
||||
if records_to_migrate.count() == 0:
|
||||
logger.info(f"No {model_name} records need migration")
|
||||
return 0, 0
|
||||
|
||||
logger.info(f"Found {records_to_migrate.count()} {model_name} records to migrate")
|
||||
|
||||
updated_records = []
|
||||
conversion_errors = 0
|
||||
|
||||
for record in records_to_migrate:
|
||||
try:
|
||||
if record.filters: # Double check that filters is not empty
|
||||
rich_filters = converter.convert(record.filters, strict=False)
|
||||
record.rich_filters = rich_filters
|
||||
updated_records.append(record)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to convert filters for {model_name} ID {record.id}: {str(e)}"
|
||||
)
|
||||
conversion_errors += 1
|
||||
continue
|
||||
|
||||
# Bulk update all successfully converted records
|
||||
if updated_records:
|
||||
model_class.objects.bulk_update(
|
||||
updated_records, ["rich_filters"], batch_size=1000
|
||||
)
|
||||
logger.info(f"Successfully updated {len(updated_records)} {model_name} records")
|
||||
|
||||
return len(updated_records), conversion_errors
|
||||
|
||||
|
||||
def migrate_models_filters_to_rich_filters(
|
||||
models_to_migrate: Dict[str, Any],
|
||||
converter: LegacyToRichFiltersConverter,
|
||||
) -> Dict[str, Tuple[int, int]]:
|
||||
"""
|
||||
Migrate legacy filters to rich_filters format for provided models.
|
||||
|
||||
Args:
|
||||
models_to_migrate: Dict mapping model names to model classes
|
||||
|
||||
Returns:
|
||||
Dictionary mapping model names to (updated_count, error_count) tuples
|
||||
"""
|
||||
# Initialize the converter with default settings
|
||||
|
||||
logger.info("Starting filters to rich_filters migration for all models")
|
||||
|
||||
results = {}
|
||||
total_updated = 0
|
||||
total_errors = 0
|
||||
|
||||
for model_name, model_class in models_to_migrate.items():
|
||||
try:
|
||||
updated_count, error_count = migrate_single_model_filters(
|
||||
model_class, model_name, converter
|
||||
)
|
||||
|
||||
results[model_name] = (updated_count, error_count)
|
||||
total_updated += updated_count
|
||||
total_errors += error_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to migrate {model_name}: {str(e)}")
|
||||
results[model_name] = (0, 1)
|
||||
total_errors += 1
|
||||
continue
|
||||
|
||||
# Log final summary
|
||||
logger.info(
|
||||
f"Migration completed for all models. Total updated: {total_updated}, "
|
||||
f"Total errors: {total_errors}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def clear_models_rich_filters(models_to_clear: Dict[str, Any]) -> Dict[str, int]:
|
||||
"""
|
||||
Clear rich_filters field for provided models (for reverse migration).
|
||||
|
||||
Args:
|
||||
models_to_clear: Dictionary mapping model names to model classes
|
||||
|
||||
Returns:
|
||||
Dictionary mapping model names to count of cleared records
|
||||
"""
|
||||
logger.info("Starting reverse migration - clearing rich_filters for all models")
|
||||
|
||||
results = {}
|
||||
total_cleared = 0
|
||||
|
||||
for model_name, model_class in models_to_clear.items():
|
||||
try:
|
||||
# Clear rich_filters for all records that have them
|
||||
updated_count = model_class.objects.exclude(rich_filters={}).update(
|
||||
rich_filters={}
|
||||
)
|
||||
results[model_name] = updated_count
|
||||
total_cleared += updated_count
|
||||
logger.info(
|
||||
f"Cleared rich_filters for {updated_count} {model_name} records"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear rich_filters for {model_name}: {str(e)}")
|
||||
results[model_name] = 0
|
||||
continue
|
||||
|
||||
logger.info(f"Reverse migration completed. Total cleared: {total_cleared}")
|
||||
return results
|
||||
@@ -1,180 +0,0 @@
|
||||
import copy
|
||||
|
||||
from django_filters import FilterSet, filters
|
||||
|
||||
from plane.db.models import Issue
|
||||
|
||||
|
||||
class UUIDInFilter(filters.BaseInFilter, filters.UUIDFilter):
|
||||
pass
|
||||
|
||||
|
||||
class CharInFilter(filters.BaseInFilter, filters.CharFilter):
|
||||
pass
|
||||
|
||||
|
||||
class BaseFilterSet(FilterSet):
|
||||
@classmethod
|
||||
def get_filters(cls):
|
||||
"""
|
||||
Get all filters for the filterset, including dynamically created __exact filters.
|
||||
"""
|
||||
# Get the standard filters first
|
||||
filters = super().get_filters()
|
||||
|
||||
# Add __exact versions for filters that have 'exact' lookup
|
||||
exact_filters = {}
|
||||
for filter_name, filter_obj in filters.items():
|
||||
if hasattr(filter_obj, "lookup_expr") and filter_obj.lookup_expr == "exact":
|
||||
exact_field_name = f"{filter_name}__exact"
|
||||
if exact_field_name not in filters:
|
||||
# Copy the filter object as-is and assign it to the new name
|
||||
exact_filters[exact_field_name] = copy.deepcopy(filter_obj)
|
||||
|
||||
# Add the exact filters to the main filters dict
|
||||
filters.update(exact_filters)
|
||||
return filters
|
||||
|
||||
|
||||
class IssueFilterSet(BaseFilterSet):
|
||||
# Custom filter methods to handle soft delete exclusion for relations
|
||||
|
||||
assignee_id = filters.UUIDFilter(method="filter_assignee_id")
|
||||
assignee_id__in = UUIDInFilter(method="filter_assignee_id_in", lookup_expr="in")
|
||||
|
||||
cycle_id = filters.UUIDFilter(method="filter_cycle_id")
|
||||
cycle_id__in = UUIDInFilter(method="filter_cycle_id_in", lookup_expr="in")
|
||||
|
||||
module_id = filters.UUIDFilter(method="filter_module_id")
|
||||
module_id__in = UUIDInFilter(method="filter_module_id_in", lookup_expr="in")
|
||||
|
||||
mention_id = filters.UUIDFilter(method="filter_mention_id")
|
||||
mention_id__in = UUIDInFilter(method="filter_mention_id_in", lookup_expr="in")
|
||||
|
||||
label_id = filters.UUIDFilter(method="filter_label_id")
|
||||
label_id__in = UUIDInFilter(method="filter_label_id_in", lookup_expr="in")
|
||||
|
||||
# Direct field lookups remain the same
|
||||
created_by_id = filters.UUIDFilter(field_name="created_by_id")
|
||||
created_by_id__in = UUIDInFilter(field_name="created_by_id", lookup_expr="in")
|
||||
|
||||
is_archived = filters.BooleanFilter(method="filter_is_archived")
|
||||
|
||||
state_group = filters.CharFilter(field_name="state__group")
|
||||
state_group__in = CharInFilter(field_name="state__group", lookup_expr="in")
|
||||
|
||||
state_id = filters.UUIDFilter(field_name="state_id")
|
||||
state_id__in = UUIDInFilter(field_name="state_id", lookup_expr="in")
|
||||
|
||||
project_id = filters.UUIDFilter(field_name="project_id")
|
||||
project_id__in = UUIDInFilter(field_name="project_id", lookup_expr="in")
|
||||
|
||||
subscriber_id = filters.UUIDFilter(method="filter_subscriber_id")
|
||||
subscriber_id__in = UUIDInFilter(method="filter_subscriber_id_in", lookup_expr="in")
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = {
|
||||
"start_date": ["exact", "range"],
|
||||
"target_date": ["exact", "range"],
|
||||
"created_at": ["exact", "range"],
|
||||
"is_draft": ["exact"],
|
||||
"priority": ["exact", "in"],
|
||||
}
|
||||
|
||||
def filter_is_archived(self, queryset, name, value):
|
||||
"""
|
||||
Convenience filter: archived=true -> archived_at is not null,
|
||||
archived=false -> archived_at is null
|
||||
"""
|
||||
if value in (True, "true", "True", 1, "1"):
|
||||
return queryset.filter(archived_at__isnull=False)
|
||||
if value in (False, "false", "False", 0, "0"):
|
||||
return queryset.filter(archived_at__isnull=True)
|
||||
return queryset
|
||||
|
||||
# Filter methods with soft delete exclusion for relations
|
||||
|
||||
def filter_assignee_id(self, queryset, name, value):
|
||||
"""Filter by assignee ID, excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_assignee__assignee_id=value,
|
||||
issue_assignee__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_assignee_id_in(self, queryset, name, value):
|
||||
"""Filter by assignee IDs (in), excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_assignee__assignee_id__in=value,
|
||||
issue_assignee__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_cycle_id(self, queryset, name, value):
|
||||
"""Filter by cycle ID, excluding soft deleted cycles"""
|
||||
return queryset.filter(
|
||||
issue_cycle__cycle_id=value,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_cycle_id_in(self, queryset, name, value):
|
||||
"""Filter by cycle IDs (in), excluding soft deleted cycles"""
|
||||
return queryset.filter(
|
||||
issue_cycle__cycle_id__in=value,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_module_id(self, queryset, name, value):
|
||||
"""Filter by module ID, excluding soft deleted modules"""
|
||||
return queryset.filter(
|
||||
issue_module__module_id=value,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_module_id_in(self, queryset, name, value):
|
||||
"""Filter by module IDs (in), excluding soft deleted modules"""
|
||||
return queryset.filter(
|
||||
issue_module__module_id__in=value,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_mention_id(self, queryset, name, value):
|
||||
"""Filter by mention ID, excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_mention__mention_id=value,
|
||||
issue_mention__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_mention_id_in(self, queryset, name, value):
|
||||
"""Filter by mention IDs (in), excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_mention__mention_id__in=value,
|
||||
issue_mention__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_label_id(self, queryset, name, value):
|
||||
"""Filter by label ID, excluding soft deleted labels"""
|
||||
return queryset.filter(
|
||||
label_issue__label_id=value,
|
||||
label_issue__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_label_id_in(self, queryset, name, value):
|
||||
"""Filter by label IDs (in), excluding soft deleted labels"""
|
||||
return queryset.filter(
|
||||
label_issue__label_id__in=value,
|
||||
label_issue__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_subscriber_id(self, queryset, name, value):
|
||||
"""Filter by subscriber ID, excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_subscribers__subscriber_id=value,
|
||||
issue_subscribers__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_subscriber_id_in(self, queryset, name, value):
|
||||
"""Filter by subscriber IDs (in), excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_subscribers__subscriber_id__in=value,
|
||||
issue_subscribers__deleted_at__isnull=True,
|
||||
)
|
||||
@@ -148,7 +148,6 @@ def issue_group_values(
|
||||
slug: str,
|
||||
project_id: Optional[str] = None,
|
||||
filters: Dict[str, Any] = {},
|
||||
queryset: Optional[QuerySet] = None,
|
||||
) -> List[Union[str, Any]]:
|
||||
if field == "state_id":
|
||||
queryset = State.objects.filter(
|
||||
@@ -208,24 +207,36 @@ def issue_group_values(
|
||||
return ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
|
||||
if field == "target_date":
|
||||
queryset = queryset.values_list("target_date", flat=True).distinct()
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.filter(**filters)
|
||||
.values_list("target_date", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
return list(queryset)
|
||||
|
||||
if field == "start_date":
|
||||
queryset = queryset.values_list("start_date", flat=True).distinct()
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.filter(**filters)
|
||||
.values_list("start_date", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
return list(queryset)
|
||||
|
||||
if field == "created_by":
|
||||
queryset = queryset.values_list("created_by", flat=True).distinct()
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.filter(**filters)
|
||||
.values_list("created_by", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
return list(queryset)
|
||||
|
||||
return []
|
||||
|
||||
@@ -46,11 +46,7 @@ def _contains_suspicious_patterns(path: str) -> bool:
|
||||
def get_allowed_hosts() -> list[str]:
|
||||
"""Get the allowed hosts from the settings."""
|
||||
base_origin = settings.WEB_URL or settings.APP_BASE_URL
|
||||
|
||||
allowed_hosts = []
|
||||
if base_origin:
|
||||
host = urlparse(base_origin).netloc
|
||||
allowed_hosts.append(host)
|
||||
allowed_hosts = [base_origin]
|
||||
if settings.ADMIN_BASE_URL:
|
||||
# Get only the host
|
||||
host = urlparse(settings.ADMIN_BASE_URL).netloc
|
||||
@@ -111,33 +107,19 @@ def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {})
|
||||
|
||||
# Validate the next path
|
||||
validated_path = validate_next_path(next_path)
|
||||
|
||||
|
||||
# Add the next path to the parameters
|
||||
base_url = base_url.rstrip('/')
|
||||
|
||||
# Prepare the query parameters
|
||||
query_parts = []
|
||||
encoded_params = ""
|
||||
|
||||
# Add the next path to the parameters
|
||||
if validated_path:
|
||||
query_parts.append(f"next_path={validated_path}")
|
||||
|
||||
# Add additional parameters
|
||||
if params:
|
||||
encoded_params = urlencode(params)
|
||||
query_parts.append(encoded_params)
|
||||
|
||||
# Construct the url query string
|
||||
if query_parts:
|
||||
query_string = "&".join(query_parts)
|
||||
url = f"{base_url}/?{query_string}"
|
||||
url = f"{base_url}/?next_path={validated_path}&{encoded_params}"
|
||||
else:
|
||||
url = base_url
|
||||
url = f"{base_url}/?next_path={validated_path}"
|
||||
|
||||
# Check if the URL is allowed
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return url
|
||||
|
||||
# Return the base URL if the URL is not allowed
|
||||
return base_url + (f"?{encoded_params}" if encoded_params else "")
|
||||
return base_url
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.react-router
|
||||
build
|
||||
node_modules
|
||||
README.md
|
||||
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS development-dependencies-env
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS production-dependencies-env
|
||||
COPY ./package.json package-lock.json /app/
|
||||
WORKDIR /app
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:20-alpine AS build-env
|
||||
COPY . /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
COPY ./package.json package-lock.json /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -0,0 +1,87 @@
|
||||
# Welcome to React Router!
|
||||
|
||||
A modern, production-ready template for building full-stack React applications using React Router.
|
||||
|
||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Server-side rendering
|
||||
- ⚡️ Hot Module Replacement (HMR)
|
||||
- 📦 Asset bundling and optimization
|
||||
- 🔄 Data loading and mutations
|
||||
- 🔒 TypeScript by default
|
||||
- 🎉 TailwindCSS for styling
|
||||
- 📖 [React Router docs](https://reactrouter.com/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server with HMR:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your application will be available at `http://localhost:5173`.
|
||||
|
||||
## Building for Production
|
||||
|
||||
Create a production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
To build and run using Docker:
|
||||
|
||||
```bash
|
||||
docker build -t my-app .
|
||||
|
||||
# Run the container
|
||||
docker run -p 3000:3000 my-app
|
||||
```
|
||||
|
||||
The containerized application can be deployed to any platform that supports Docker, including:
|
||||
|
||||
- AWS ECS
|
||||
- Google Cloud Run
|
||||
- Azure Container Apps
|
||||
- Digital Ocean App Platform
|
||||
- Fly.io
|
||||
- Railway
|
||||
|
||||
### DIY Deployment
|
||||
|
||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||
|
||||
Make sure to deploy the output of `npm run build`
|
||||
|
||||
```
|
||||
├── package.json
|
||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||
├── build/
|
||||
│ ├── client/ # Static assets
|
||||
│ └── server/ # Server-side code
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ using React Router.
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type InstanceProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const InstanceProvider: FC<InstanceProviderProps> = observer((props) => {
|
||||
const { children } = props;
|
||||
// store hooks
|
||||
const { fetchInstanceInfo } = useInstance();
|
||||
// fetching instance details
|
||||
useSWR("INSTANCE_DETAILS", () => fetchInstanceInfo(), {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
errorRetryCount: 0,
|
||||
});
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration, type MetaFunction } from "react-router";
|
||||
import type { Route } from "./+types/root";
|
||||
import "@/styles/globals.css";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { ToastWithTheme } from "./toast.provider";
|
||||
import { StoreProvider } from "./store.provider";
|
||||
import { InstanceProvider } from "./instance.provider";
|
||||
import { UserProvider } from "./user.provider";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: "Plane | Simple, extensible, open-source project management tool.",
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
|
||||
},
|
||||
{
|
||||
name: "openGraph:title",
|
||||
content: "Plane | Simple, extensible, open-source project management tool.",
|
||||
},
|
||||
{
|
||||
name: "openGraph:description",
|
||||
content:
|
||||
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
|
||||
},
|
||||
{ name: "openGraph:url", content: "https://plane.so/" },
|
||||
{
|
||||
name: "keywords",
|
||||
content:
|
||||
"software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration",
|
||||
},
|
||||
{ name: "twitter:site", content: "@planepowers" },
|
||||
{
|
||||
name: "twitter:title",
|
||||
content: "Plane | Simple, extensible, open-source project management tool.",
|
||||
},
|
||||
{
|
||||
name: "twitter:description",
|
||||
content:
|
||||
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
|
||||
},
|
||||
{ name: "twitter:url", content: "https://plane.so/" },
|
||||
{ name: "twitter:image", content: "https://plane.so/og-image.png" },
|
||||
{ name: "twitter:image:alt", content: "Plane - Modern project management" },
|
||||
{ name: "twitter:image:width", content: "1200" },
|
||||
{ name: "twitter:image:height", content: "630" },
|
||||
];
|
||||
};
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href={`/favicon/apple-touch-icon.png`} />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href={`/favicon/favicon-32x32.png`} />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href={`/favicon/favicon-16x16.png`} />
|
||||
<link rel="manifest" href={`/site.webmanifest.json`} />
|
||||
<link rel="shortcut icon" href={`/favicon/favicon.ico`} />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className={`antialiased`}>
|
||||
{/* <ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
|
||||
|
||||
</ThemeProvider> */}
|
||||
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
/* <ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
|
||||
<ToastWithTheme />
|
||||
<StoreProvider>
|
||||
<InstanceProvider>
|
||||
<UserProvider>{children}</UserProvider>
|
||||
</InstanceProvider>
|
||||
</StoreProvider>
|
||||
</ThemeProvider> */
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = "Oops!";
|
||||
let details = "An unexpected error occurred.";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "Error";
|
||||
details = error.status === 404 ? "The requested page could not be found." : error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { type RouteConfig } from "@react-router/dev/routes";
|
||||
import { flatRoutes } from "@react-router/fs-routes";
|
||||
|
||||
export default flatRoutes() satisfies RouteConfig;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { InstanceSignInForm } from "@/components/instance/sign-in/form";
|
||||
|
||||
export default function HomePage() {
|
||||
return <InstanceSignInForm />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { InstanceSignInForm } from "@/components/instance/sign-in/form";
|
||||
|
||||
export default function GeneralPage() {
|
||||
return <InstanceSignInForm />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export default function GeneralLayout() {
|
||||
return (
|
||||
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { createContext } from "react";
|
||||
// plane admin store
|
||||
import { RootStore } from "@/plane-admin/store/root.store";
|
||||
|
||||
let rootStore = new RootStore();
|
||||
|
||||
export const StoreContext = createContext(rootStore);
|
||||
|
||||
function initializeStore(initialData = {}) {
|
||||
const singletonRootStore = rootStore ?? new RootStore();
|
||||
// If your page has Next.js data fetching methods that use a Mobx store, it will
|
||||
// get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details
|
||||
if (initialData) {
|
||||
singletonRootStore.hydrate(initialData);
|
||||
}
|
||||
// For SSG and SSR always create a new store
|
||||
if (typeof window === "undefined") return singletonRootStore;
|
||||
// Create the store once in the client
|
||||
if (!rootStore) rootStore = singletonRootStore;
|
||||
return singletonRootStore;
|
||||
}
|
||||
|
||||
export type StoreProviderProps = {
|
||||
children: React.ReactNode;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
initialState?: any;
|
||||
};
|
||||
|
||||
export const StoreProvider = ({ children, initialState = {} }: StoreProviderProps) => {
|
||||
const store = initializeStore(initialState);
|
||||
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
// import { Toast } from "@plane/propel/toast";
|
||||
// import { resolveGeneralTheme } from "@plane/utils";
|
||||
|
||||
export const ToastWithTheme = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
// return <Toast theme={resolveGeneralTheme(resolvedTheme)} />;
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useInstance, useTheme, useUser } from "@/hooks/store";
|
||||
|
||||
interface IUserProvider {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const UserProvider: FC<IUserProvider> = observer(({ children }) => {
|
||||
// hooks
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
const { currentUser, fetchCurrentUser } = useUser();
|
||||
const { fetchInstanceAdmins } = useInstance();
|
||||
|
||||
useSWR("CURRENT_USER", () => fetchCurrentUser(), {
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
|
||||
useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins());
|
||||
|
||||
useEffect(() => {
|
||||
// Only access localStorage after component has mounted to prevent hydration mismatch
|
||||
if (typeof window !== "undefined") {
|
||||
const localValue = localStorage.getItem("god_mode_sidebar_collapsed");
|
||||
const localBoolValue = localValue ? (localValue === "true" ? true : false) : false;
|
||||
if (isSidebarCollapsed === undefined && localBoolValue != isSidebarCollapsed) toggleSidebar(localBoolValue);
|
||||
}
|
||||
}, [isSidebarCollapsed, currentUser, toggleSidebar]);
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { KeyRound, Mails } from "lucide-react";
|
||||
// types
|
||||
import type {
|
||||
TGetBaseAuthenticationModeProps,
|
||||
TInstanceAuthenticationMethodKeys,
|
||||
TInstanceAuthenticationModes,
|
||||
} from "@plane/types";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
|
||||
import { GithubConfiguration } from "@/components/authentication/github-config";
|
||||
import { GitlabConfiguration } from "@/components/authentication/gitlab-config";
|
||||
import { GoogleConfiguration } from "@/components/authentication/google-config";
|
||||
import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch";
|
||||
// plane admin components
|
||||
import { UpgradeButton } from "@/plane-admin/components/common";
|
||||
// assets
|
||||
import githubLightModeImage from "@/public/logos/github-black.png";
|
||||
import githubDarkModeImage from "@/public/logos/github-white.png";
|
||||
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
|
||||
import GoogleLogo from "@/public/logos/google-logo.svg";
|
||||
import OIDCLogo from "@/public/logos/oidc-logo.svg";
|
||||
import SAMLLogo from "@/public/logos/saml-logo.svg";
|
||||
|
||||
export type TAuthenticationModeProps = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
// Authentication methods
|
||||
export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
|
||||
disabled,
|
||||
updateConfig,
|
||||
resolvedTheme,
|
||||
}) => [
|
||||
{
|
||||
key: "unique-codes",
|
||||
name: "Unique codes",
|
||||
description:
|
||||
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
|
||||
icon: <Mails className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
|
||||
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "passwords-login",
|
||||
name: "Passwords",
|
||||
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
|
||||
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "google",
|
||||
name: "Google",
|
||||
description: "Allow members to log in or sign up for Plane with their Google accounts.",
|
||||
icon: <img src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
|
||||
icon: (
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
),
|
||||
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
|
||||
icon: <img src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "oidc",
|
||||
name: "OIDC",
|
||||
description: "Authenticate your users via the OpenID Connect protocol.",
|
||||
icon: <img src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
{
|
||||
key: "saml",
|
||||
name: "SAML",
|
||||
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
|
||||
icon: <img src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const AuthenticationModes: React.FC<TAuthenticationModeProps> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
{getAuthenticationModes({ disabled, updateConfig, resolvedTheme }).map((method) => (
|
||||
<AuthenticationMethodCard
|
||||
key={method.key}
|
||||
name={method.name}
|
||||
description={method.description}
|
||||
icon={method.icon}
|
||||
config={method.config}
|
||||
disabled={disabled}
|
||||
unavailable={method.unavailable}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./authentication-modes";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./upgrade-button";
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
// icons
|
||||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export const UpgradeButton: React.FC = () => (
|
||||
<a href="https://plane.so/pricing?mode=self-hosted" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
|
||||
Upgrade
|
||||
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
|
||||
</a>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { enableStaticRendering } from "mobx-react";
|
||||
// stores
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
export class RootStore extends CoreRootStore {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
hydrate(initialData: any) {
|
||||
super.hydrate(initialData);
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
super.resetOnSignOut();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
config: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
withBorder?: boolean;
|
||||
unavailable?: boolean;
|
||||
};
|
||||
|
||||
export const AuthenticationMethodCard: FC<Props> = (props) => {
|
||||
const { name, description, icon, config, disabled = false, withBorder = true, unavailable = false } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full flex items-center gap-14 rounded", {
|
||||
"px-4 py-3 border border-custom-border-200": withBorder,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn("flex grow items-center gap-4", {
|
||||
"opacity-50": unavailable,
|
||||
})}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-custom-background-80">{icon}</div>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div
|
||||
className={cn("font-medium leading-5 text-custom-text-100", {
|
||||
"text-sm": withBorder,
|
||||
"text-xl": !withBorder,
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div
|
||||
className={cn("font-normal leading-5 text-custom-text-300", {
|
||||
"text-xs": withBorder,
|
||||
"text-sm": !withBorder,
|
||||
})}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`shrink-0 ${disabled && "opacity-70"}`}>{config}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// ui
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const EmailCodesConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableMagicLogin = formattedConfig?.ENABLE_MAGIC_LINK_LOGIN ?? "";
|
||||
|
||||
return (
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableMagicLogin))}
|
||||
onChange={() => {
|
||||
const newEnableMagicLogin = Boolean(parseInt(enableMagicLogin)) === true ? "0" : "1";
|
||||
updateConfig("ENABLE_MAGIC_LINK_LOGIN", newEnableMagicLogin);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router";
|
||||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GithubConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? "";
|
||||
const isGithubConfigured = !!formattedConfig?.GITHUB_CLIENT_ID && !!formattedConfig?.GITHUB_CLIENT_SECRET;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isGithubConfigured ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/authentication/github" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
|
||||
Edit
|
||||
</Link>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGithubConfig))}
|
||||
onChange={() => {
|
||||
const newEnableGithubConfig = Boolean(parseInt(enableGithubConfig)) === true ? "0" : "1";
|
||||
updateConfig("IS_GITHUB_ENABLED", newEnableGithubConfig);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/authentication/github"
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router";
|
||||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GitlabConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
|
||||
const isGitlabConfigured = !!formattedConfig?.GITLAB_CLIENT_ID && !!formattedConfig?.GITLAB_CLIENT_SECRET;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isGitlabConfigured ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/authentication/gitlab" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
|
||||
Edit
|
||||
</Link>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGitlabConfig))}
|
||||
onChange={() => {
|
||||
const newEnableGitlabConfig = Boolean(parseInt(enableGitlabConfig)) === true ? "0" : "1";
|
||||
updateConfig("IS_GITLAB_ENABLED", newEnableGitlabConfig);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/authentication/gitlab"
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router";
|
||||
// icons
|
||||
import { Settings2 } from "lucide-react";
|
||||
// plane internal packages
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GoogleConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? "";
|
||||
const isGoogleConfigured = !!formattedConfig?.GOOGLE_CLIENT_ID && !!formattedConfig?.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isGoogleConfigured ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/authentication/google" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
|
||||
Edit
|
||||
</Link>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGoogleConfig))}
|
||||
onChange={() => {
|
||||
const newEnableGoogleConfig = Boolean(parseInt(enableGoogleConfig)) === true ? "0" : "1";
|
||||
updateConfig("IS_GOOGLE_ENABLED", newEnableGoogleConfig);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/authentication/google"
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
|
||||
Configure
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Mails, KeyRound } from "lucide-react";
|
||||
import { type TInstanceAuthenticationModes } from "@plane/types";
|
||||
import { type TGetBaseAuthenticationModeProps } from "@plane/types";
|
||||
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
|
||||
import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch";
|
||||
import { GoogleConfiguration } from "@/components/authentication/google-config";
|
||||
import { GithubConfiguration } from "@/components/authentication/github-config";
|
||||
import { GitlabConfiguration } from "@/components/authentication/gitlab-config";
|
||||
// images
|
||||
import githubLightModeImage from "@/public/logos/github-black.png";
|
||||
import githubDarkModeImage from "@/public/logos/github-white.png";
|
||||
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
|
||||
import GoogleLogo from "@/public/logos/google-logo.svg";
|
||||
|
||||
export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
|
||||
disabled,
|
||||
updateConfig,
|
||||
resolvedTheme,
|
||||
}) => [
|
||||
{
|
||||
key: "unique-codes",
|
||||
name: "Unique codes",
|
||||
description:
|
||||
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
|
||||
icon: <Mails className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
|
||||
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "passwords-login",
|
||||
name: "Passwords",
|
||||
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
|
||||
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "google",
|
||||
name: "Google",
|
||||
description: "Allow members to log in or sign up for Plane with their Google accounts.",
|
||||
icon: <img src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
|
||||
icon: (
|
||||
<img
|
||||
src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
),
|
||||
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
|
||||
icon: <img src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const PasswordLoginConfiguration: React.FC<Props> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
// derived values
|
||||
const enableEmailPassword = formattedConfig?.ENABLE_EMAIL_PASSWORD ?? "";
|
||||
|
||||
return (
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableEmailPassword))}
|
||||
onChange={() => {
|
||||
const newEnableEmailPassword = Boolean(parseInt(enableEmailPassword)) === true ? "0" : "1";
|
||||
updateConfig("ENABLE_EMAIL_PASSWORD", newEnableEmailPassword);
|
||||
}}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { FC } from "react";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
|
||||
type TBanner = {
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const Banner: FC<TBanner> = (props) => {
|
||||
const { type, message } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md p-2 w-full border ${type === "error" ? "bg-red-500/5 border-red-400" : "bg-green-500/5 border-green-400"}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex-shrink-0">
|
||||
{type === "error" ? (
|
||||
<span className="flex items-center justify-center h-6 w-6 rounded-full">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-1">
|
||||
<p className={`text-sm font-medium ${type === "error" ? "text-red-600" : "text-green-600"}`}>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { Link } from "react-router";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
|
||||
type Props = {
|
||||
label?: string;
|
||||
href?: string;
|
||||
icon?: React.ReactNode | undefined;
|
||||
};
|
||||
|
||||
export const BreadcrumbLink: React.FC<Props> = (props) => {
|
||||
const { href, label, icon } = props;
|
||||
return (
|
||||
<Tooltip tooltipContent={label} position="bottom">
|
||||
<li className="flex items-center space-x-2" tabIndex={-1}>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{href ? (
|
||||
<Link
|
||||
className="flex items-center gap-1 text-sm font-medium text-custom-text-300 hover:text-custom-text-100"
|
||||
to={href}
|
||||
>
|
||||
{icon ? (
|
||||
<div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex cursor-default items-center gap-1 text-sm font-medium text-custom-text-100">
|
||||
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden">{icon}</div>}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type TProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
darkerShade?: boolean;
|
||||
};
|
||||
|
||||
export const CodeBlock = ({ children, className, darkerShade }: TProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"px-0.5 text-xs text-custom-text-300 bg-custom-background-90 font-semibold rounded-md border border-custom-border-100",
|
||||
{
|
||||
"text-custom-text-200 bg-custom-background-80 border-custom-border-200": darkerShade,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
// headless ui
|
||||
import { Dialog } from "@plane/propel/dialog";
|
||||
// ui
|
||||
import { Button, getButtonStyling } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
onDiscardHref: string;
|
||||
};
|
||||
|
||||
export const ConfirmDiscardModal: React.FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, onDiscardHref } = props;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-32">
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[30rem]">
|
||||
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<Dialog.Title className="text-lg font-medium leading-6 text-custom-text-300">
|
||||
You have unsaved changes
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-custom-text-400">
|
||||
Changes you made will be lost if you go back. Do you wish to go back?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end items-center p-4 sm:px-6 gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Keep editing
|
||||
</Button>
|
||||
<Link to={onDiscardHref} className={getButtonStyling("primary", "sm")}>
|
||||
Go back
|
||||
</Link>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Controller, Control } from "react-hook-form";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { Input } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
control: Control<any>;
|
||||
type: "text" | "password";
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string | React.ReactNode;
|
||||
placeholder: string;
|
||||
error: boolean;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export type TControllerInputFormField = {
|
||||
key: string;
|
||||
type: "text" | "password";
|
||||
label: string;
|
||||
description?: string | React.ReactNode;
|
||||
placeholder: string;
|
||||
error: boolean;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export const ControllerInput: React.FC<Props> = (props) => {
|
||||
const { name, control, type, label, description, placeholder, error, required } = props;
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm text-custom-text-300">{label}</h4>
|
||||
<div className="relative">
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
rules={{ required: required ? `${label} is required.` : false }}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type === "password" && showPassword ? "text" : type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={error}
|
||||
placeholder={placeholder}
|
||||
className={cn("w-full rounded-md font-medium", {
|
||||
"pr-10": type === "password",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{type === "password" &&
|
||||
(showPassword ? (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-2.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => setShowPassword(false)}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-2.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => setShowPassword(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{description && <p className="pt-0.5 text-xs text-custom-text-300">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
// ui
|
||||
import { Copy } from "lucide-react";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// icons
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
url: string;
|
||||
description: string | React.ReactNode;
|
||||
};
|
||||
|
||||
export type TCopyField = {
|
||||
key: string;
|
||||
label: string;
|
||||
url: string;
|
||||
description: string | React.ReactNode;
|
||||
};
|
||||
|
||||
export const CopyField: React.FC<Props> = (props) => {
|
||||
const { label, url, description } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm text-custom-text-200">{label}</h4>
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
className="flex items-center justify-between py-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: "Copied to clipboard",
|
||||
message: `The ${label} has been successfully copied to your clipboard`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium">{url}</p>
|
||||
<Copy size={18} color="#B9B9B9" />
|
||||
</Button>
|
||||
<div className="text-xs text-custom-text-300">{description}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: React.ReactNode;
|
||||
image?: any;
|
||||
primaryButton?: {
|
||||
icon?: any;
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
secondaryButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const EmptyState: React.FC<Props> = ({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
disabled = false,
|
||||
}) => (
|
||||
<div className={`flex h-full w-full items-center justify-center`}>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
{image && (
|
||||
<img
|
||||
src={image}
|
||||
className="w-52 sm:w-60"
|
||||
alt={primaryButton?.text || "button image"}
|
||||
/>
|
||||
)}
|
||||
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">{title}</h6>
|
||||
{description && (
|
||||
<p className="mb-7 px-5 text-custom-text-300 sm:mb-8">{description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
{primaryButton && (
|
||||
<Button
|
||||
variant="primary"
|
||||
prependIcon={primaryButton.icon}
|
||||
onClick={primaryButton.onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{primaryButton.text}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif";
|
||||
import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
|
||||
|
||||
export const LogoSpinner = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<img src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
type TPageHeader = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const PageHeader: React.FC<TPageHeader> = (props) => {
|
||||
const { title = "God Mode - Plane", description = "Plane god mode" } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
import { type FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@plane/ui";
|
||||
// assets
|
||||
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
|
||||
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
|
||||
import InstanceFailureImage from "@/public/instance/instance-failure.svg";
|
||||
|
||||
export const InstanceFailureView: FC = observer(() => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const instanceImage =
|
||||
resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
|
||||
|
||||
const handleRetry = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<img src={instanceImage} alt="Plane Logo" />
|
||||
<h3 className="font-medium text-2xl text-white text-center">
|
||||
Unable to fetch instance details.
|
||||
</h3>
|
||||
<p className="font-medium text-base text-center">
|
||||
We were unable to fetch the details of the instance. Fret not, it
|
||||
might just be a connectivity issue.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="md" onClick={handleRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
export const FormHeader = ({ heading, subHeading }: { heading: string; subHeading: string }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-2xl font-semibold text-custom-text-100 leading-7">{heading}</span>
|
||||
<span className="text-lg font-semibold text-custom-text-400 leading-7">{subHeading}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { type FC } from "react";
|
||||
|
||||
import { Link } from "react-router";
|
||||
import { Button } from "@plane/ui";
|
||||
// assets
|
||||
import PlaneTakeOffImage from "@/public/images/plane-takeoff.png";
|
||||
|
||||
export const InstanceNotReady: FC = () => (
|
||||
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
|
||||
<div className="w-auto max-w-2xl relative space-y-8 py-10">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
|
||||
<img src={PlaneTakeOffImage} alt="Plane Logo" />
|
||||
<p className="font-medium text-base text-custom-text-400">
|
||||
Get started by setting up your instance and workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Link to={"/setup/?auth_enabled=0"}>
|
||||
<Button size="lg" className="w-full">
|
||||
Get started
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif";
|
||||
import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
|
||||
|
||||
export const InstanceLoading = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<img src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { type FC, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Button, Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// components
|
||||
import { Banner } from "@/components/common/banner";
|
||||
import { FormHeader } from "@/components/instance/form-header";
|
||||
import { AuthHeader } from "@/components/instance/sign-in/header";
|
||||
|
||||
// service initialization
|
||||
const authService = new AuthService();
|
||||
|
||||
// error codes
|
||||
enum EErrorCodes {
|
||||
INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED",
|
||||
ADMIN_ALREADY_EXIST = "ADMIN_ALREADY_EXIST",
|
||||
REQUIRED_EMAIL_PASSWORD_FIRST_NAME = "REQUIRED_EMAIL_PASSWORD_FIRST_NAME",
|
||||
INVALID_EMAIL = "INVALID_EMAIL",
|
||||
INVALID_PASSWORD = "INVALID_PASSWORD",
|
||||
USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS",
|
||||
}
|
||||
|
||||
type TError = {
|
||||
type: EErrorCodes | undefined;
|
||||
message: string | undefined;
|
||||
};
|
||||
|
||||
// form data
|
||||
type TFormData = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
company_name: string;
|
||||
password: string;
|
||||
confirm_password?: string;
|
||||
is_telemetry_enabled: boolean;
|
||||
};
|
||||
|
||||
const defaultFromData: TFormData = {
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
company_name: "",
|
||||
password: "",
|
||||
is_telemetry_enabled: true,
|
||||
};
|
||||
|
||||
export const InstanceSetupForm: FC = (props) => {
|
||||
const {} = props;
|
||||
// search params
|
||||
const [searchParams] = useSearchParams();
|
||||
const firstNameParam = searchParams.get("first_name") || undefined;
|
||||
const lastNameParam = searchParams.get("last_name") || undefined;
|
||||
const companyParam = searchParams.get("company") || undefined;
|
||||
const emailParam = searchParams.get("email") || undefined;
|
||||
const isTelemetryEnabledParam = (searchParams.get("is_telemetry_enabled") === "True" ? true : false) || true;
|
||||
const errorCode = searchParams.get("error_code") || undefined;
|
||||
const errorMessage = searchParams.get("error_message") || undefined;
|
||||
// state
|
||||
const [showPassword, setShowPassword] = useState({
|
||||
password: false,
|
||||
retypePassword: false,
|
||||
});
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [formData, setFormData] = useState<TFormData>(defaultFromData);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
|
||||
|
||||
const handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (firstNameParam) setFormData((prev) => ({ ...prev, first_name: firstNameParam }));
|
||||
if (lastNameParam) setFormData((prev) => ({ ...prev, last_name: lastNameParam }));
|
||||
if (companyParam) setFormData((prev) => ({ ...prev, company_name: companyParam }));
|
||||
if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam }));
|
||||
if (isTelemetryEnabledParam) setFormData((prev) => ({ ...prev, is_telemetry_enabled: isTelemetryEnabledParam }));
|
||||
}, [firstNameParam, lastNameParam, companyParam, emailParam, isTelemetryEnabledParam]);
|
||||
|
||||
// derived values
|
||||
const errorData: TError = useMemo(() => {
|
||||
if (errorCode && errorMessage) {
|
||||
switch (errorCode) {
|
||||
case EErrorCodes.INSTANCE_NOT_CONFIGURED:
|
||||
return { type: EErrorCodes.INSTANCE_NOT_CONFIGURED, message: errorMessage };
|
||||
case EErrorCodes.ADMIN_ALREADY_EXIST:
|
||||
return { type: EErrorCodes.ADMIN_ALREADY_EXIST, message: errorMessage };
|
||||
case EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME:
|
||||
return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME, message: errorMessage };
|
||||
case EErrorCodes.INVALID_EMAIL:
|
||||
return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage };
|
||||
case EErrorCodes.INVALID_PASSWORD:
|
||||
return { type: EErrorCodes.INVALID_PASSWORD, message: errorMessage };
|
||||
case EErrorCodes.USER_ALREADY_EXISTS:
|
||||
return { type: EErrorCodes.USER_ALREADY_EXISTS, message: errorMessage };
|
||||
default:
|
||||
return { type: undefined, message: undefined };
|
||||
}
|
||||
} else return { type: undefined, message: undefined };
|
||||
}, [errorCode, errorMessage]);
|
||||
|
||||
const isButtonDisabled = useMemo(
|
||||
() =>
|
||||
!isSubmitting &&
|
||||
formData.first_name &&
|
||||
formData.email &&
|
||||
formData.password &&
|
||||
getPasswordStrength(formData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID &&
|
||||
formData.password === formData.confirm_password
|
||||
? false
|
||||
: true,
|
||||
[formData.confirm_password, formData.email, formData.first_name, formData.password, isSubmitting]
|
||||
);
|
||||
|
||||
const password = formData?.password ?? "";
|
||||
const confirmPassword = formData?.confirm_password ?? "";
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<FormHeader
|
||||
heading="Setup your Plane Instance"
|
||||
subHeading="Post setup you will be able to manage this Plane instance."
|
||||
/>
|
||||
{errorData.type &&
|
||||
errorData?.message &&
|
||||
![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && (
|
||||
<Banner type="error" message={errorData?.message} />
|
||||
)}
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/api/instances/admins/sign-up/`}
|
||||
onSubmit={() => setIsSubmitting(true)}
|
||||
onError={() => setIsSubmitting(false)}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<input type="hidden" name="is_telemetry_enabled" value={formData.is_telemetry_enabled ? "True" : "False"} />
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="first_name">
|
||||
First name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
inputSize="md"
|
||||
placeholder="Wilber"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleFormChange("first_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="last_name">
|
||||
Last name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
inputSize="md"
|
||||
placeholder="Wright"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleFormChange("last_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputSize="md"
|
||||
placeholder="name@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
|
||||
autoComplete="on"
|
||||
/>
|
||||
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
|
||||
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="company_name">
|
||||
Company name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
type="text"
|
||||
inputSize="md"
|
||||
placeholder="Company name"
|
||||
value={formData.company_name}
|
||||
onChange={(e) => handleFormChange("company_name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
|
||||
Set a password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword.password ? "text" : "password"}
|
||||
inputSize="md"
|
||||
placeholder="New password..."
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
{showPassword.password ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
|
||||
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
|
||||
)}
|
||||
<PasswordStrengthIndicator password={formData.password} isFocused={isPasswordInputFocused} />
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="confirm_password">
|
||||
Confirm password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword.retypePassword ? "text" : "password"}
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
inputSize="md"
|
||||
value={formData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
{showPassword.retypePassword ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!!formData.confirm_password &&
|
||||
formData.password !== formData.confirm_password &&
|
||||
renderPasswordMatchError && <span className="text-sm text-red-500">Passwords don{"'"}t match</span>}
|
||||
</div>
|
||||
|
||||
<div className="relative flex gap-2">
|
||||
<div>
|
||||
<Checkbox
|
||||
className="w-4 h-4"
|
||||
iconClassName="w-3 h-3"
|
||||
id="is_telemetry_enabled"
|
||||
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
|
||||
checked={formData.is_telemetry_enabled}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-sm text-custom-text-300 font-medium cursor-pointer" htmlFor="is_telemetry_enabled">
|
||||
Allow Plane to anonymously collect usage events.{" "}
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://developers.plane.so/self-hosting/telemetry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-blue-500 hover:text-blue-600 flex-shrink-0"
|
||||
>
|
||||
See More
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Info, X } from "lucide-react";
|
||||
// plane constants
|
||||
import type { TAdminAuthErrorInfo } from "@plane/constants";
|
||||
|
||||
type TAuthBanner = {
|
||||
bannerData: TAdminAuthErrorInfo | undefined;
|
||||
handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
|
||||
};
|
||||
|
||||
export const AuthBanner = (props: TAuthBanner) => {
|
||||
const { bannerData, handleBannerData } = props;
|
||||
|
||||
if (!bannerData) return <></>;
|
||||
return (
|
||||
<div className="relative flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10">
|
||||
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
|
||||
<Info size={16} className="text-custom-primary-100" />
|
||||
</div>
|
||||
<div className="w-full text-sm font-medium text-custom-primary-100">
|
||||
{bannerData?.message}
|
||||
</div>
|
||||
<div
|
||||
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80"
|
||||
onClick={() => handleBannerData && handleBannerData(undefined)}
|
||||
>
|
||||
<X className="w-4 h-4 flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL, EAdminAuthErrorCodes, type TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Button, Input, Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { Banner } from "@/components/common/banner";
|
||||
// local components
|
||||
import { FormHeader } from "../form-header";
|
||||
import { AuthBanner } from "./banner";
|
||||
import { AuthHeader } from "./header";
|
||||
import { authErrorHandler } from "./helpers";
|
||||
|
||||
// service initialization
|
||||
const authService = new AuthService();
|
||||
|
||||
// error codes
|
||||
enum EErrorCodes {
|
||||
INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED",
|
||||
REQUIRED_EMAIL_PASSWORD = "REQUIRED_EMAIL_PASSWORD",
|
||||
INVALID_EMAIL = "INVALID_EMAIL",
|
||||
USER_DOES_NOT_EXIST = "USER_DOES_NOT_EXIST",
|
||||
AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED",
|
||||
}
|
||||
|
||||
type TError = {
|
||||
type: EErrorCodes | undefined;
|
||||
message: string | undefined;
|
||||
};
|
||||
|
||||
// form data
|
||||
type TFormData = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const defaultFromData: TFormData = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
export const InstanceSignInForm = () => {
|
||||
// search params
|
||||
const [searchParams] = useSearchParams();
|
||||
const emailParam = searchParams.get("email") || undefined;
|
||||
const errorCode = searchParams.get("error_code") || undefined;
|
||||
const errorMessage = searchParams.get("error_message") || undefined;
|
||||
// state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [formData, setFormData] = useState<TFormData>(defaultFromData);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorInfo, setErrorInfo] = useState<TAdminAuthErrorInfo | undefined>(undefined);
|
||||
|
||||
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam }));
|
||||
}, [emailParam]);
|
||||
|
||||
// derived values
|
||||
const errorData: TError = useMemo(() => {
|
||||
if (errorCode && errorMessage) {
|
||||
switch (errorCode) {
|
||||
case EErrorCodes.INSTANCE_NOT_CONFIGURED:
|
||||
return {
|
||||
type: EErrorCodes.INSTANCE_NOT_CONFIGURED,
|
||||
message: errorMessage,
|
||||
};
|
||||
case EErrorCodes.REQUIRED_EMAIL_PASSWORD:
|
||||
return {
|
||||
type: EErrorCodes.REQUIRED_EMAIL_PASSWORD,
|
||||
message: errorMessage,
|
||||
};
|
||||
case EErrorCodes.INVALID_EMAIL:
|
||||
return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage };
|
||||
case EErrorCodes.USER_DOES_NOT_EXIST:
|
||||
return {
|
||||
type: EErrorCodes.USER_DOES_NOT_EXIST,
|
||||
message: errorMessage,
|
||||
};
|
||||
case EErrorCodes.AUTHENTICATION_FAILED:
|
||||
return {
|
||||
type: EErrorCodes.AUTHENTICATION_FAILED,
|
||||
message: errorMessage,
|
||||
};
|
||||
default:
|
||||
return { type: undefined, message: undefined };
|
||||
}
|
||||
} else return { type: undefined, message: undefined };
|
||||
}, [errorCode, errorMessage]);
|
||||
|
||||
const isButtonDisabled = useMemo(
|
||||
() => (!isSubmitting && formData.email && formData.password ? false : true),
|
||||
[formData.email, formData.password, isSubmitting]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorCode) {
|
||||
const errorDetail = authErrorHandler(errorCode?.toString() as EAdminAuthErrorCodes);
|
||||
if (errorDetail) {
|
||||
setErrorInfo(errorDetail);
|
||||
}
|
||||
}
|
||||
}, [errorCode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<FormHeader
|
||||
heading="Manage your Plane instance"
|
||||
subHeading="Configure instance-wide settings to secure your instance"
|
||||
/>
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/api/instances/admins/sign-in/`}
|
||||
onSubmit={() => setIsSubmitting(true)}
|
||||
onError={() => setIsSubmitting(false)}
|
||||
>
|
||||
{errorData.type && errorData?.message ? (
|
||||
<Banner type="error" message={errorData?.message} />
|
||||
) : (
|
||||
<>
|
||||
{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}
|
||||
</>
|
||||
)}
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputSize="md"
|
||||
placeholder="name@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
inputSize="md"
|
||||
placeholder="Enter your password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
{showPassword ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => setShowPassword(false)}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => setShowPassword(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Sign in"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Link } from "react-router";
|
||||
import { PlaneLockup } from "@plane/propel/icons";
|
||||
|
||||
export const AuthHeader = () => (
|
||||
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
|
||||
<Link to="/">
|
||||
<PlaneLockup height={20} width={95} className="text-custom-text-100" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,100 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
// plane packages
|
||||
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, type TAdminAuthErrorInfo } from "@plane/constants";
|
||||
|
||||
export enum EErrorAlertType {
|
||||
BANNER_ALERT = "BANNER_ALERT",
|
||||
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
|
||||
INLINE_EMAIL = "INLINE_EMAIL",
|
||||
INLINE_PASSWORD = "INLINE_PASSWORD",
|
||||
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
|
||||
}
|
||||
|
||||
const errorCodeMessages: {
|
||||
[key in EAdminAuthErrorCodes]: {
|
||||
title: string;
|
||||
message: (email?: string | undefined) => ReactNode;
|
||||
};
|
||||
} = {
|
||||
// admin
|
||||
[EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
|
||||
title: `Admin already exists`,
|
||||
message: () => `Admin already exists. Please try again.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
|
||||
title: `Email, password and first name required`,
|
||||
message: () => `Email, password and first name required. Please try again.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL]: {
|
||||
title: `Invalid admin email`,
|
||||
message: () => `Invalid admin email. Please try again.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD]: {
|
||||
title: `Invalid admin password`,
|
||||
message: () => `Invalid admin password. Please try again.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
|
||||
title: `Email and password required`,
|
||||
message: () => `Email and password required. Please try again.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
|
||||
title: `Authentication failed`,
|
||||
message: () => `Authentication failed. Please try again.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
|
||||
title: `Admin user already exists`,
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user already exists.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" to="/">
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
|
||||
title: `Admin user does not exist`,
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user does not exist.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" to="/">
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
|
||||
title: `User account deactivated`,
|
||||
message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const authErrorHandler = (
|
||||
errorCode: EAdminAuthErrorCodes,
|
||||
email?: string | undefined
|
||||
): TAdminAuthErrorInfo | undefined => {
|
||||
const bannerAlertErrorCodes = [
|
||||
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
|
||||
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
|
||||
EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL,
|
||||
EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD,
|
||||
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
|
||||
EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED,
|
||||
EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST,
|
||||
EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
|
||||
EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED,
|
||||
];
|
||||
|
||||
if (bannerAlertErrorCodes.includes(errorCode))
|
||||
return {
|
||||
type: EErrorAlertType.BANNER_ALERT,
|
||||
code: errorCode,
|
||||
title: errorCodeMessages[errorCode]?.title || "Error",
|
||||
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
|
||||
};
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router";
|
||||
import { useTheme as nextUseTheme } from "next-themes";
|
||||
// ui
|
||||
import { Button, getButtonStyling } from "@plane/ui";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
// icons
|
||||
import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg";
|
||||
import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg";
|
||||
|
||||
export const NewUserPopup: React.FC = observer(() => {
|
||||
// hooks
|
||||
const { isNewUserPopup, toggleNewUserPopup } = useTheme();
|
||||
// theme
|
||||
const { resolvedTheme } = nextUseTheme();
|
||||
|
||||
if (!isNewUserPopup) return <></>;
|
||||
return (
|
||||
<div className="absolute bottom-8 right-8 p-6 w-96 border border-custom-border-100 shadow-md rounded-lg bg-custom-background-100">
|
||||
<div className="flex gap-4">
|
||||
<div className="grow">
|
||||
<div className="text-base font-semibold">Create workspace</div>
|
||||
<div className="py-2 text-sm font-medium text-custom-text-300">
|
||||
Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first
|
||||
workspace.
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<Link to="/workspace/create" className={getButtonStyling("primary", "sm")}>
|
||||
Create workspace
|
||||
</Link>
|
||||
<Button variant="neutral-primary" size="sm" onClick={toggleNewUserPopup}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-center">
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight}
|
||||
height={80}
|
||||
width={80}
|
||||
alt="Plane icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
type TWorkspaceListItemProps = {
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemProps) => {
|
||||
// store hooks
|
||||
const { getWorkspaceById } = useWorkspace();
|
||||
// derived values
|
||||
const workspace = getWorkspaceById(workspaceId);
|
||||
|
||||
if (!workspace) return null;
|
||||
return (
|
||||
<a
|
||||
key={workspaceId}
|
||||
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
||||
target="_blank"
|
||||
className="group flex items-center justify-between p-4 gap-2.5 truncate border border-custom-border-200/70 hover:border-custom-border-200 hover:bg-custom-background-90 rounded-md"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 mt-1 text-xs uppercase ${
|
||||
!workspace?.logo_url && "rounded bg-custom-primary-500 text-white"
|
||||
}`}
|
||||
>
|
||||
{workspace?.logo_url && workspace.logo_url !== "" ? (
|
||||
<img
|
||||
src={getFileURL(workspace.logo_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded object-cover"
|
||||
alt="Workspace Logo"
|
||||
/>
|
||||
) : (
|
||||
(workspace?.name?.[0] ?? "...")
|
||||
)}
|
||||
</span>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex flex-wrap w-full items-center gap-2.5">
|
||||
<h3 className={`text-base font-medium capitalize`}>{workspace.name}</h3>/
|
||||
<Tooltip tooltipContent="The unique URL of your workspace">
|
||||
<h4 className="text-sm text-custom-text-300">[{workspace.slug}]</h4>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{workspace.owner.email && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<h3 className="text-custom-text-200 font-medium">Owned by:</h3>
|
||||
<h4 className="text-custom-text-300">{workspace.owner.email}</h4>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2.5 text-xs">
|
||||
{workspace.total_projects !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<h3 className="text-custom-text-200 font-medium">Total projects:</h3>
|
||||
<h4 className="text-custom-text-300">{workspace.total_projects}</h4>
|
||||
</span>
|
||||
)}
|
||||
{workspace.total_members !== null && (
|
||||
<>
|
||||
•
|
||||
<span className="flex items-center gap-1">
|
||||
<h3 className="text-custom-text-200 font-medium">Total members:</h3>
|
||||
<h4 className="text-custom-text-300">{workspace.total_members}</h4>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<ExternalLink size={14} className="text-custom-text-400 group-hover:text-custom-text-200" />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./use-theme";
|
||||
export * from "./use-instance";
|
||||
export * from "./use-user";
|
||||
export * from "./use-workspace";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/store.provider";
|
||||
import type { IInstanceStore } from "@/store/instance.store";
|
||||
|
||||
export const useInstance = (): IInstanceStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useInstance must be used within StoreProvider");
|
||||
return context.instance;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/store.provider";
|
||||
import type { IThemeStore } from "@/store/theme.store";
|
||||
|
||||
export const useTheme = (): IThemeStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useTheme must be used within StoreProvider");
|
||||
return context.theme;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/store.provider";
|
||||
import { type IUserStore } from "@/store/user.store";
|
||||
|
||||
export const useUser = (): IUserStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useUser must be used within StoreProvider");
|
||||
return context.user;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/app/store.provider";
|
||||
import type { IWorkspaceStore } from "@/store/workspace.store";
|
||||
|
||||
export const useWorkspace = (): IWorkspaceStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useWorkspace must be used within StoreProvider");
|
||||
return context.workspace;
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
import set from "lodash/set";
|
||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||
// plane internal packages
|
||||
import { EInstanceStatus, type TInstanceStatus } from "@plane/constants";
|
||||
import { InstanceService } from "@plane/services";
|
||||
import type {
|
||||
IInstance,
|
||||
IInstanceAdmin,
|
||||
IInstanceConfiguration,
|
||||
IFormattedInstanceConfiguration,
|
||||
IInstanceInfo,
|
||||
IInstanceConfig,
|
||||
} from "@plane/types";
|
||||
// root store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export interface IInstanceStore {
|
||||
// issues
|
||||
isLoading: boolean;
|
||||
error: any;
|
||||
instanceStatus: TInstanceStatus | undefined;
|
||||
instance: IInstance | undefined;
|
||||
config: IInstanceConfig | undefined;
|
||||
instanceAdmins: IInstanceAdmin[] | undefined;
|
||||
instanceConfigurations: IInstanceConfiguration[] | undefined;
|
||||
// computed
|
||||
formattedConfig: IFormattedInstanceConfiguration | undefined;
|
||||
// action
|
||||
hydrate: (data: IInstanceInfo) => void;
|
||||
fetchInstanceInfo: () => Promise<IInstanceInfo | undefined>;
|
||||
updateInstanceInfo: (data: Partial<IInstance>) => Promise<IInstance | undefined>;
|
||||
fetchInstanceAdmins: () => Promise<IInstanceAdmin[] | undefined>;
|
||||
fetchInstanceConfigurations: () => Promise<IInstanceConfiguration[] | undefined>;
|
||||
updateInstanceConfigurations: (data: Partial<IFormattedInstanceConfiguration>) => Promise<IInstanceConfiguration[]>;
|
||||
disableEmail: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class InstanceStore implements IInstanceStore {
|
||||
isLoading: boolean = true;
|
||||
error: any = undefined;
|
||||
instanceStatus: TInstanceStatus | undefined = undefined;
|
||||
instance: IInstance | undefined = undefined;
|
||||
config: IInstanceConfig | undefined = undefined;
|
||||
instanceAdmins: IInstanceAdmin[] | undefined = undefined;
|
||||
instanceConfigurations: IInstanceConfiguration[] | undefined = undefined;
|
||||
// service
|
||||
instanceService;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
isLoading: observable.ref,
|
||||
error: observable.ref,
|
||||
instanceStatus: observable,
|
||||
instance: observable,
|
||||
instanceAdmins: observable,
|
||||
instanceConfigurations: observable,
|
||||
// computed
|
||||
formattedConfig: computed,
|
||||
// actions
|
||||
hydrate: action,
|
||||
fetchInstanceInfo: action,
|
||||
fetchInstanceAdmins: action,
|
||||
updateInstanceInfo: action,
|
||||
fetchInstanceConfigurations: action,
|
||||
updateInstanceConfigurations: action,
|
||||
});
|
||||
|
||||
this.instanceService = new InstanceService();
|
||||
}
|
||||
|
||||
hydrate = (data: IInstanceInfo) => {
|
||||
if (data) {
|
||||
this.instance = data.instance;
|
||||
this.config = data.config;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* computed value for instance configurations data for forms.
|
||||
* @returns configurations in the form of {key, value} pair.
|
||||
*/
|
||||
get formattedConfig() {
|
||||
if (!this.instanceConfigurations) return undefined;
|
||||
return this.instanceConfigurations?.reduce((formData: IFormattedInstanceConfiguration, config) => {
|
||||
formData[config.key] = config.value;
|
||||
return formData;
|
||||
}, {} as IFormattedInstanceConfiguration);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description fetching instance configuration
|
||||
* @returns {IInstance} instance
|
||||
*/
|
||||
fetchInstanceInfo = async () => {
|
||||
try {
|
||||
if (this.instance === undefined) this.isLoading = true;
|
||||
this.error = undefined;
|
||||
const instanceInfo = await this.instanceService.info();
|
||||
// handling the new user popup toggle
|
||||
if (this.instance === undefined && !instanceInfo?.instance?.workspaces_exist)
|
||||
this.store.theme.toggleNewUserPopup();
|
||||
runInAction(() => {
|
||||
// console.log("instanceInfo: ", instanceInfo);
|
||||
this.isLoading = false;
|
||||
this.instance = instanceInfo.instance;
|
||||
this.config = instanceInfo.config;
|
||||
});
|
||||
return instanceInfo;
|
||||
} catch (error) {
|
||||
console.error("Error fetching the instance info");
|
||||
this.isLoading = false;
|
||||
this.error = { message: "Failed to fetch the instance info" };
|
||||
this.instanceStatus = {
|
||||
status: EInstanceStatus.ERROR,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description updating instance information
|
||||
* @param {Partial<IInstance>} data
|
||||
* @returns void
|
||||
*/
|
||||
updateInstanceInfo = async (data: Partial<IInstance>) => {
|
||||
try {
|
||||
const instanceResponse = await this.instanceService.update(data);
|
||||
if (instanceResponse) {
|
||||
runInAction(() => {
|
||||
if (this.instance) set(this.instance, "instance", instanceResponse);
|
||||
});
|
||||
}
|
||||
return instanceResponse;
|
||||
} catch (error) {
|
||||
console.error("Error updating the instance info");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetching instance admins
|
||||
* @return {IInstanceAdmin[]} instanceAdmins
|
||||
*/
|
||||
fetchInstanceAdmins = async () => {
|
||||
try {
|
||||
const instanceAdmins = await this.instanceService.admins();
|
||||
if (instanceAdmins) runInAction(() => (this.instanceAdmins = instanceAdmins));
|
||||
return instanceAdmins;
|
||||
} catch (error) {
|
||||
console.error("Error fetching the instance admins");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetching instance configurations
|
||||
* @return {IInstanceAdmin[]} instanceConfigurations
|
||||
*/
|
||||
fetchInstanceConfigurations = async () => {
|
||||
try {
|
||||
const instanceConfigurations = await this.instanceService.configurations();
|
||||
if (instanceConfigurations) runInAction(() => (this.instanceConfigurations = instanceConfigurations));
|
||||
return instanceConfigurations;
|
||||
} catch (error) {
|
||||
console.error("Error fetching the instance configurations");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description updating instance configurations
|
||||
* @param data
|
||||
*/
|
||||
updateInstanceConfigurations = async (data: Partial<IFormattedInstanceConfiguration>) => {
|
||||
try {
|
||||
const response = await this.instanceService.updateConfigurations(data);
|
||||
runInAction(() => {
|
||||
this.instanceConfigurations = this.instanceConfigurations?.map((config) => {
|
||||
const item = response.find((item) => item.key === config.key);
|
||||
if (item) return item;
|
||||
return config;
|
||||
});
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error updating the instance configurations");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
disableEmail = async () => {
|
||||
const instanceConfigurations = this.instanceConfigurations;
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.instanceConfigurations = this.instanceConfigurations?.map((config) => {
|
||||
if (
|
||||
[
|
||||
"EMAIL_HOST",
|
||||
"EMAIL_PORT",
|
||||
"EMAIL_HOST_USER",
|
||||
"EMAIL_HOST_PASSWORD",
|
||||
"EMAIL_FROM",
|
||||
"ENABLE_SMTP",
|
||||
].includes(config.key)
|
||||
)
|
||||
return { ...config, value: "" };
|
||||
return config;
|
||||
});
|
||||
});
|
||||
await this.instanceService.disableEmail();
|
||||
} catch (_error) {
|
||||
console.error("Error disabling the email");
|
||||
this.instanceConfigurations = instanceConfigurations;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { enableStaticRendering } from "mobx-react";
|
||||
// stores
|
||||
import { type IInstanceStore, InstanceStore } from "./instance.store";
|
||||
import { type IThemeStore, ThemeStore } from "./theme.store";
|
||||
import { type IUserStore, UserStore } from "./user.store";
|
||||
import { type IWorkspaceStore, WorkspaceStore } from "./workspace.store";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
export abstract class CoreRootStore {
|
||||
theme: IThemeStore;
|
||||
instance: IInstanceStore;
|
||||
user: IUserStore;
|
||||
workspace: IWorkspaceStore;
|
||||
|
||||
constructor() {
|
||||
this.theme = new ThemeStore(this);
|
||||
this.instance = new InstanceStore(this);
|
||||
this.user = new UserStore(this);
|
||||
this.workspace = new WorkspaceStore(this);
|
||||
}
|
||||
|
||||
hydrate(initialData: any) {
|
||||
this.theme.hydrate(initialData.theme);
|
||||
this.instance.hydrate(initialData.instance);
|
||||
this.user.hydrate(initialData.user);
|
||||
this.workspace.hydrate(initialData.workspace);
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
// Only access localStorage on client side to prevent hydration issues
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("theme", "system");
|
||||
}
|
||||
this.instance = new InstanceStore(this);
|
||||
this.user = new UserStore(this);
|
||||
this.theme = new ThemeStore(this);
|
||||
this.workspace = new WorkspaceStore(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { action, observable, makeObservable } from "mobx";
|
||||
// root store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
type TTheme = "dark" | "light";
|
||||
export interface IThemeStore {
|
||||
// observables
|
||||
isNewUserPopup: boolean;
|
||||
theme: string | undefined;
|
||||
isSidebarCollapsed: boolean | undefined;
|
||||
// actions
|
||||
hydrate: (data: any) => void;
|
||||
toggleNewUserPopup: () => void;
|
||||
toggleSidebar: (collapsed: boolean) => void;
|
||||
setTheme: (currentTheme: TTheme) => void;
|
||||
}
|
||||
|
||||
export class ThemeStore implements IThemeStore {
|
||||
// observables
|
||||
isNewUserPopup: boolean = false;
|
||||
isSidebarCollapsed: boolean | undefined = undefined;
|
||||
theme: string | undefined = undefined;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
isNewUserPopup: observable.ref,
|
||||
isSidebarCollapsed: observable.ref,
|
||||
theme: observable.ref,
|
||||
// action
|
||||
toggleNewUserPopup: action,
|
||||
toggleSidebar: action,
|
||||
setTheme: action,
|
||||
});
|
||||
}
|
||||
|
||||
hydrate = (data: any) => {
|
||||
if (data) this.theme = data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Toggle the new user popup modal
|
||||
*/
|
||||
toggleNewUserPopup = () => (this.isNewUserPopup = !this.isNewUserPopup);
|
||||
|
||||
/**
|
||||
* @description Toggle the sidebar collapsed state
|
||||
* @param isCollapsed
|
||||
*/
|
||||
toggleSidebar = (isCollapsed: boolean) => {
|
||||
if (isCollapsed === undefined) this.isSidebarCollapsed = !this.isSidebarCollapsed;
|
||||
else this.isSidebarCollapsed = isCollapsed;
|
||||
// Only access localStorage on client side to prevent hydration issues
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("god_mode_sidebar_collapsed", isCollapsed.toString());
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Sets the user theme and applies it to the platform
|
||||
* @param currentTheme
|
||||
*/
|
||||
setTheme = async (currentTheme: TTheme) => {
|
||||
try {
|
||||
// Only access localStorage on client side to prevent hydration issues
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("theme", currentTheme);
|
||||
}
|
||||
this.theme = currentTheme;
|
||||
} catch (error) {
|
||||
console.error("setting user theme error", error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { action, observable, runInAction, makeObservable } from "mobx";
|
||||
// plane internal packages
|
||||
import { EUserStatus, type TUserStatus } from "@plane/constants";
|
||||
import { AuthService, UserService } from "@plane/services";
|
||||
import type { IUser } from "@plane/types";
|
||||
// root store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export interface IUserStore {
|
||||
// observables
|
||||
isLoading: boolean;
|
||||
userStatus: TUserStatus | undefined;
|
||||
isUserLoggedIn: boolean | undefined;
|
||||
currentUser: IUser | undefined;
|
||||
// fetch actions
|
||||
hydrate: (data: any) => void;
|
||||
fetchCurrentUser: () => Promise<IUser>;
|
||||
reset: () => void;
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
export class UserStore implements IUserStore {
|
||||
// observables
|
||||
isLoading: boolean = true;
|
||||
userStatus: TUserStatus | undefined = undefined;
|
||||
isUserLoggedIn: boolean | undefined = undefined;
|
||||
currentUser: IUser | undefined = undefined;
|
||||
// services
|
||||
userService;
|
||||
authService;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
isLoading: observable.ref,
|
||||
userStatus: observable,
|
||||
isUserLoggedIn: observable.ref,
|
||||
currentUser: observable,
|
||||
// action
|
||||
fetchCurrentUser: action,
|
||||
reset: action,
|
||||
signOut: action,
|
||||
});
|
||||
this.userService = new UserService();
|
||||
this.authService = new AuthService();
|
||||
}
|
||||
|
||||
hydrate = (data: any) => {
|
||||
if (data) this.currentUser = data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Fetches the current user
|
||||
* @returns Promise<IUser>
|
||||
*/
|
||||
fetchCurrentUser = async () => {
|
||||
try {
|
||||
if (this.currentUser === undefined) this.isLoading = true;
|
||||
const currentUser = await this.userService.adminDetails();
|
||||
if (currentUser) {
|
||||
await this.store.instance.fetchInstanceAdmins();
|
||||
runInAction(() => {
|
||||
this.isUserLoggedIn = true;
|
||||
this.currentUser = currentUser;
|
||||
this.isLoading = false;
|
||||
});
|
||||
} else {
|
||||
runInAction(() => {
|
||||
this.isUserLoggedIn = false;
|
||||
this.currentUser = undefined;
|
||||
this.isLoading = false;
|
||||
});
|
||||
}
|
||||
return currentUser;
|
||||
} catch (error: any) {
|
||||
this.isLoading = false;
|
||||
this.isUserLoggedIn = false;
|
||||
if (error.status === 403)
|
||||
this.userStatus = {
|
||||
status: EUserStatus.AUTHENTICATION_NOT_DONE,
|
||||
message: error?.message || "",
|
||||
};
|
||||
else
|
||||
this.userStatus = {
|
||||
status: EUserStatus.ERROR,
|
||||
message: error?.message || "",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
reset = async () => {
|
||||
this.isUserLoggedIn = false;
|
||||
this.currentUser = undefined;
|
||||
this.isLoading = false;
|
||||
this.userStatus = undefined;
|
||||
};
|
||||
|
||||
signOut = async () => {
|
||||
this.store.resetOnSignOut();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import set from "lodash/set";
|
||||
import { action, observable, runInAction, makeObservable, computed } from "mobx";
|
||||
// plane imports
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import type { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
|
||||
// root store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export interface IWorkspaceStore {
|
||||
// observables
|
||||
loader: TLoader;
|
||||
workspaces: Record<string, IWorkspace>;
|
||||
paginationInfo: TPaginationInfo | undefined;
|
||||
// computed
|
||||
workspaceIds: string[];
|
||||
// helper actions
|
||||
hydrate: (data: Record<string, IWorkspace>) => void;
|
||||
getWorkspaceById: (workspaceId: string) => IWorkspace | undefined;
|
||||
// fetch actions
|
||||
fetchWorkspaces: () => Promise<IWorkspace[]>;
|
||||
fetchNextWorkspaces: () => Promise<IWorkspace[]>;
|
||||
// curd actions
|
||||
createWorkspace: (data: IWorkspace) => Promise<IWorkspace>;
|
||||
}
|
||||
|
||||
export class WorkspaceStore implements IWorkspaceStore {
|
||||
// observables
|
||||
loader: TLoader = "init-loader";
|
||||
workspaces: Record<string, IWorkspace> = {};
|
||||
paginationInfo: TPaginationInfo | undefined = undefined;
|
||||
// services
|
||||
instanceWorkspaceService;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loader: observable,
|
||||
workspaces: observable,
|
||||
paginationInfo: observable,
|
||||
// computed
|
||||
workspaceIds: computed,
|
||||
// helper actions
|
||||
hydrate: action,
|
||||
getWorkspaceById: action,
|
||||
// fetch actions
|
||||
fetchWorkspaces: action,
|
||||
fetchNextWorkspaces: action,
|
||||
// curd actions
|
||||
createWorkspace: action,
|
||||
});
|
||||
this.instanceWorkspaceService = new InstanceWorkspaceService();
|
||||
}
|
||||
|
||||
// computed
|
||||
get workspaceIds() {
|
||||
return Object.keys(this.workspaces);
|
||||
}
|
||||
|
||||
// helper actions
|
||||
/**
|
||||
* @description Hydrates the workspaces
|
||||
* @param data - Record<string, IWorkspace>
|
||||
*/
|
||||
hydrate = (data: Record<string, IWorkspace>) => {
|
||||
if (data) this.workspaces = data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Gets a workspace by id
|
||||
* @param workspaceId - string
|
||||
* @returns IWorkspace | undefined
|
||||
*/
|
||||
getWorkspaceById = (workspaceId: string) => this.workspaces[workspaceId];
|
||||
|
||||
// fetch actions
|
||||
/**
|
||||
* @description Fetches all workspaces
|
||||
* @returns Promise<>
|
||||
*/
|
||||
fetchWorkspaces = async (): Promise<IWorkspace[]> => {
|
||||
try {
|
||||
if (this.workspaceIds.length > 0) {
|
||||
this.loader = "mutation";
|
||||
} else {
|
||||
this.loader = "init-loader";
|
||||
}
|
||||
const paginatedWorkspaceData = await this.instanceWorkspaceService.list();
|
||||
runInAction(() => {
|
||||
const { results, ...paginationInfo } = paginatedWorkspaceData;
|
||||
results.forEach((workspace: IWorkspace) => {
|
||||
set(this.workspaces, [workspace.id], workspace);
|
||||
});
|
||||
set(this, "paginationInfo", paginationInfo);
|
||||
});
|
||||
return paginatedWorkspaceData.results;
|
||||
} catch (error) {
|
||||
console.error("Error fetching workspaces", error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.loader = "loaded";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Fetches the next page of workspaces
|
||||
* @returns Promise<IWorkspace[]>
|
||||
*/
|
||||
fetchNextWorkspaces = async (): Promise<IWorkspace[]> => {
|
||||
if (!this.paginationInfo || this.paginationInfo.next_page_results === false) return [];
|
||||
try {
|
||||
this.loader = "pagination";
|
||||
const paginatedWorkspaceData = await this.instanceWorkspaceService.list(this.paginationInfo.next_cursor);
|
||||
runInAction(() => {
|
||||
const { results, ...paginationInfo } = paginatedWorkspaceData;
|
||||
results.forEach((workspace: IWorkspace) => {
|
||||
set(this.workspaces, [workspace.id], workspace);
|
||||
});
|
||||
set(this, "paginationInfo", paginationInfo);
|
||||
});
|
||||
return paginatedWorkspaceData.results;
|
||||
} catch (error) {
|
||||
console.error("Error fetching next workspaces", error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.loader = "loaded";
|
||||
}
|
||||
};
|
||||
|
||||
// curd actions
|
||||
/**
|
||||
* @description Creates a new workspace
|
||||
* @param data - IWorkspace
|
||||
* @returns Promise<IWorkspace>
|
||||
*/
|
||||
createWorkspace = async (data: IWorkspace): Promise<IWorkspace> => {
|
||||
try {
|
||||
this.loader = "mutation";
|
||||
const workspace = await this.instanceWorkspaceService.create(data);
|
||||
runInAction(() => {
|
||||
set(this.workspaces, [workspace.id], workspace);
|
||||
});
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
console.error("Error creating workspace", error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.loader = "loaded";
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "ce/components/authentication/authentication-modes";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./authentication-modes";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "ce/components/common";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "ce/store/root.store";
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "godmode",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/i18n": "workspace:*",
|
||||
"@plane/propel": "workspace:*",
|
||||
"@plane/services": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@react-router/fs-routes": "^7.9.1",
|
||||
"@react-router/node": "^7.7.1",
|
||||
"@react-router/serve": "^7.7.1",
|
||||
"isbot": "^5.1.27",
|
||||
"lodash": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-router": "^7.7.1",
|
||||
"swr": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@react-router/dev": "^7.7.1",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3",
|
||||
"vite-plugin-commonjs": "^0.10.4",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<svg width="1512" height="900" viewBox="0 0 1512 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4817_18724)">
|
||||
<rect width="1512" height="900" fill="#1B1C1E"/>
|
||||
<g opacity="0.09">
|
||||
<line x1="-10.6172" y1="624.328" x2="1500.96" y2="624.328" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="301.59" x2="1500.96" y2="301.59" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="462.958" x2="1500.96" y2="462.958" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="785.696" x2="1500.96" y2="785.696" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="140.22" x2="1500.96" y2="140.22" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="543.642" x2="1500.96" y2="543.642" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="866.381" x2="1500.96" y2="866.381" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="220.904" x2="1500.96" y2="220.904" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="382.272" x2="1500.96" y2="382.272" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="705.012" x2="1500.96" y2="705.013" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="59.534" x2="1500.96" y2="59.534" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="36.3273" y1="-49.8457" x2="36.3273" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="681.808" y1="-49.8457" x2="681.808" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="359.068" y1="-49.8457" x2="359.068" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1004.54" y1="-49.8457" x2="1004.54" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1327.28" y1="-49.8457" x2="1327.28" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="197.698" y1="-49.8457" x2="197.698" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="843.173" y1="-49.8457" x2="843.173" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="520.439" y1="-49.8457" x2="520.439" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1165.92" y1="-49.8457" x2="1165.92" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1488.66" y1="-49.8457" x2="1488.66" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="117.015" y1="-49.8457" x2="117.015" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="762.491" y1="-49.8457" x2="762.491" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="439.751" y1="-49.8457" x2="439.751" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1085.23" y1="-49.8457" x2="1085.23" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1407.97" y1="-49.8457" x2="1407.97" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="278.384" y1="-49.8457" x2="278.384" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="923.861" y1="-49.8457" x2="923.86" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="601.12" y1="-49.8457" x2="601.12" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1246.6" y1="-49.8457" x2="1246.6" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
</g>
|
||||
<g opacity="0.5">
|
||||
<rect x="440.141" y="785.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1165.39" y="221.433" width="80.8965" height="80" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="520.367" y="463.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1085.39" y="301.659" width="80.2262" height="80.3408" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1166" y="382" width="80.2262" height="80.08" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1247" y="301" width="80.2262" height="81.08" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="439.994" y="221.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1085.39" y="221.433" width="80" height="80" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="439.994" y="463.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="359.914" y="785.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="359.914" y="865.535" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="198.314" y="705.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1005.16" y="59.835" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="924.059" y="-20.8525" width="80.2262" height="79.5062" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="924.059" y="59.4053" width="80.2262" height="80.0285" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="116.943" y="58.6885" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="36.7168" y="59.835" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="116.943" y="138.915" width="81.3723" height="82.5184" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="440.141" y="-21.5381" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="198.316" y="300.513" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1166.76" y="705.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1246.99" y="785.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="37" y="220" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="-44" y="140" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4817_18724">
|
||||
<rect width="1512" height="900" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,68 @@
|
||||
<svg width="1512" height="900" viewBox="0 0 1512 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4817_18582)">
|
||||
<rect width="1512" height="900" fill="white"/>
|
||||
<g opacity="0.09">
|
||||
<line x1="-10.6172" y1="625.328" x2="1500.96" y2="625.328" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="302.59" x2="1500.96" y2="302.59" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="463.958" x2="1500.96" y2="463.958" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="786.696" x2="1500.96" y2="786.696" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="141.22" x2="1500.96" y2="141.22" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="544.642" x2="1500.96" y2="544.642" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="867.381" x2="1500.96" y2="867.381" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="221.904" x2="1500.96" y2="221.904" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="383.272" x2="1500.96" y2="383.272" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="706.012" x2="1500.96" y2="706.013" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="60.534" x2="1500.96" y2="60.534" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="36.3273" y1="-48.8457" x2="36.3273" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="681.808" y1="-48.8457" x2="681.808" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="359.068" y1="-48.8457" x2="359.068" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1004.54" y1="-48.8457" x2="1004.54" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1327.28" y1="-48.8457" x2="1327.28" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="197.698" y1="-48.8457" x2="197.698" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="843.173" y1="-48.8457" x2="843.173" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="520.439" y1="-48.8457" x2="520.439" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1165.92" y1="-48.8457" x2="1165.92" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1488.66" y1="-48.8457" x2="1488.66" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="117.015" y1="-48.8457" x2="117.015" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="762.491" y1="-48.8457" x2="762.491" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="439.751" y1="-48.8457" x2="439.751" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1085.23" y1="-48.8457" x2="1085.23" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1407.97" y1="-48.8457" x2="1407.97" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="278.384" y1="-48.8457" x2="278.384" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="923.861" y1="-48.8457" x2="923.86" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="601.12" y1="-48.8457" x2="601.12" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1246.6" y1="-48.8457" x2="1246.6" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
</g>
|
||||
<g opacity="0.5">
|
||||
<rect x="440.141" y="786.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1166.76" y="222.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="520.367" y="464.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1085.39" y="302.659" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1166" y="383" width="80.2262" height="80.08" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1247" y="302" width="80.2262" height="81.08" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="439.994" y="222.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1085.39" y="222.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="439.994" y="464.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="359.914" y="786.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="359.914" y="866.535" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="198.314" y="706.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1005.16" y="60.835" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="924.059" y="-19.8525" width="80.2262" height="79.5062" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="924.059" y="60.4053" width="80.2262" height="80.0285" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="116.943" y="59.6885" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="36.7168" y="60.835" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="116.943" y="139.915" width="81.3723" height="82.5184" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="440.141" y="-20.5381" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="198.316" y="301.513" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1166.76" y="706.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1246.99" y="786.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="37" y="221" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="-44" y="141" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4817_18582">
|
||||
<rect width="1512" height="900" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user