Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 115c238681 | |||
| c3e7cfd16b | |||
| 9ffc30f7b1 | |||
| b60f12a88e | |||
| 76a0b38dd1 | |||
| 8ee665f491 | |||
| 85f23b450d | |||
| 8bf059535a | |||
| 4cfea87108 | |||
| 4fe2ef706b | |||
| 8d354b3eb2 | |||
| ec541c2557 | |||
| 11cd8d11e4 | |||
| 0f7bfdde91 | |||
| ac835bf287 | |||
| db18c3555c | |||
| b696ae91ed | |||
| 61e91bd09c | |||
| 20d773042b | |||
| 30b175108b | |||
| 6d116beea3 | |||
| b0db4fcf10 | |||
| 7e03264758 | |||
| 1c8ac3d247 | |||
| 3b8bb1effc | |||
| 5a63e6dad2 | |||
| 45688bdc72 | |||
| 43b7a6ad0a | |||
| 498613284e | |||
| 9ab3143a73 | |||
| 56cd0fc445 | |||
| 260d9a053d | |||
| 34bdc2ad76 | |||
| 99bc4262c5 | |||
| 49127ebeea | |||
| a8a6536379 | |||
| 11b83cf4f2 | |||
| fbd48c33f5 | |||
| 37ce8a9fe6 | |||
| 291101a8e5 | |||
| 0cb4976e38 | |||
| 20d139cc9e | |||
| ff181e566f | |||
| a696b6039c | |||
| af1dcd335e | |||
| a1500c2206 | |||
| 88f194ca8e | |||
| b68d2ca921 | |||
| 78c6aede52 | |||
| c6c46f9aab | |||
| 7d7e37439d | |||
| d258080ee7 | |||
| c40c7804e7 | |||
| 36c735bede | |||
| cab5d20217 |
@@ -7,8 +7,8 @@ import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
// plane internal packages
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { DiscordIcon, GithubIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { DiscordIcon, GithubIcon } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
|
||||
@@ -5,8 +5,8 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { WorkspaceIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { WorkspaceIcon } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PlaneLockup } from "@plane/ui";
|
||||
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">
|
||||
|
||||
@@ -39,13 +39,31 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
||||
).exists():
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
else:
|
||||
if ProjectMember.objects.filter(
|
||||
is_user_has_allowed_role = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
role__in=allowed_role_values,
|
||||
is_active=True,
|
||||
).exists():
|
||||
).exists()
|
||||
|
||||
# Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role
|
||||
if is_user_has_allowed_role:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
elif (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
is_active=True,
|
||||
).exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
# Return permission denied if no conditions are met
|
||||
|
||||
@@ -3,11 +3,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
# Module import
|
||||
from plane.db.models import ProjectMember, WorkspaceMember
|
||||
|
||||
# Permission Mappings
|
||||
Admin = 20
|
||||
Member = 15
|
||||
Guest = 5
|
||||
from plane.db.models.project import ROLE
|
||||
|
||||
|
||||
class ProjectBasePermission(BasePermission):
|
||||
@@ -26,18 +22,31 @@ class ProjectBasePermission(BasePermission):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Only Project Admins can update project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
project_member_qs = ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role=Admin,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
)
|
||||
|
||||
## Only project admins or workspace admin who is part of the project can access
|
||||
|
||||
if project_member_qs.filter(role=ROLE.ADMIN.value).exists():
|
||||
return True
|
||||
else:
|
||||
return (
|
||||
project_member_qs.exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class ProjectMemberPermission(BasePermission):
|
||||
@@ -55,7 +64,7 @@ class ProjectMemberPermission(BasePermission):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
@@ -63,7 +72,7 @@ class ProjectMemberPermission(BasePermission):
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
@@ -97,7 +106,7 @@ class ProjectEntityPermission(BasePermission):
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
@@ -667,16 +667,33 @@ class IssueReactionSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueReactionLiteSerializer(DynamicBaseSerializer):
|
||||
display_name = serializers.CharField(source="actor.display_name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueReaction
|
||||
fields = ["id", "actor", "issue", "reaction"]
|
||||
fields = ["id", "actor", "issue", "reaction", "display_name"]
|
||||
|
||||
|
||||
class CommentReactionSerializer(BaseSerializer):
|
||||
display_name = serializers.CharField(source="actor.display_name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CommentReaction
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
|
||||
fields = [
|
||||
"id",
|
||||
"actor",
|
||||
"comment",
|
||||
"reaction",
|
||||
"display_name",
|
||||
"deleted_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at", "created_by", "updated_by"]
|
||||
|
||||
|
||||
class IssueVoteSerializer(BaseSerializer):
|
||||
|
||||
@@ -15,7 +15,6 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_binary_data,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -356,9 +356,9 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
is_active=True,
|
||||
)
|
||||
# Only project members admins and created_by users can access this endpoint
|
||||
if project_member.role <= 5 and str(intake_issue.created_by_id) != str(
|
||||
request.user.id
|
||||
):
|
||||
if (project_member and 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,
|
||||
@@ -392,7 +392,7 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
),
|
||||
).get(pk=intake_issue.issue_id, workspace__slug=slug, project_id=project_id)
|
||||
# Only allow guests to edit name and description
|
||||
if project_member.role <= 5:
|
||||
if project_member and project_member.role <= 5:
|
||||
issue_data = {
|
||||
"name": issue_data.get("name", issue.name),
|
||||
"description_html": issue_data.get(
|
||||
@@ -437,7 +437,7 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Only project admins and members can edit intake issue attributes
|
||||
if project_member.role > 15:
|
||||
if project_member and project_member.role > 15:
|
||||
serializer = IntakeIssueSerializer(
|
||||
intake_issue, data=request.data, partial=True
|
||||
)
|
||||
|
||||
@@ -5,13 +5,12 @@ from django.utils import timezone
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
@@ -106,7 +105,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
fields = [field for field in request.GET.get("fields", "").split(",") if field]
|
||||
projects = self.get_queryset().order_by("sort_order", "name")
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=5
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.GUEST.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
@@ -114,7 +116,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=15
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.MEMBER.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
Q(
|
||||
@@ -189,7 +194,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=5
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.GUEST.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
@@ -197,7 +205,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=15
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.MEMBER.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
Q(
|
||||
@@ -250,7 +261,9 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
# Add the user as Administrator to the project
|
||||
_ = ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"], member=request.user, role=20
|
||||
project_id=serializer.data["id"],
|
||||
member=request.user,
|
||||
role=ROLE.ADMIN.value,
|
||||
)
|
||||
# Also create the issue property for the user
|
||||
_ = IssueUserProperty.objects.create(
|
||||
@@ -263,7 +276,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
member_id=serializer.data["project_lead"],
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
)
|
||||
# Also create the issue property for the user
|
||||
IssueUserProperty.objects.create(
|
||||
@@ -341,13 +354,23 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
def partial_update(self, request, slug, pk=None):
|
||||
# try:
|
||||
if not ProjectMember.objects.filter(
|
||||
is_workspace_admin = WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.ADMIN.value,
|
||||
).exists()
|
||||
|
||||
is_project_admin = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=pk,
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists():
|
||||
).exists()
|
||||
|
||||
# Return error for if the user is neither workspace admin nor project admin
|
||||
if not is_project_admin and not is_workspace_admin:
|
||||
return Response(
|
||||
{"error": "You don't have the required permissions."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
@@ -402,13 +425,16 @@ class ProjectViewSet(BaseViewSet):
|
||||
def destroy(self, request, slug, pk):
|
||||
if (
|
||||
WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=20
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.ADMIN.value,
|
||||
).exists()
|
||||
or ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=pk,
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
|
||||
@@ -107,7 +107,8 @@ class MagicSignInEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
if user.is_password_autoset and profile.is_onboarded:
|
||||
path = "accounts/set-password"
|
||||
# Redirect to the home page
|
||||
path = "/"
|
||||
else:
|
||||
# Get the redirection path
|
||||
path = (
|
||||
|
||||
@@ -30,6 +30,8 @@ def page_version(page_id, existing_instance, user_id):
|
||||
description_binary=page.description_binary,
|
||||
owned_by_id=user_id,
|
||||
last_saved_at=page.updated_at,
|
||||
description_json=page.description,
|
||||
description_stripped=page.description_stripped,
|
||||
)
|
||||
|
||||
# If page versions are greater than 20 delete the oldest one
|
||||
|
||||
@@ -92,6 +92,10 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
|
||||
name=workspace.name, # Use workspace name
|
||||
identifier=project_identifier,
|
||||
created_by_id=workspace.created_by_id,
|
||||
# Enable all views in seed data
|
||||
cycle_view=True,
|
||||
module_view=True,
|
||||
issue_views_view=True,
|
||||
)
|
||||
|
||||
# Create project members
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-10 09:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0104_cycleuserproperties_rich_filters_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="cycle_view",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="issue_views_view",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="module_view",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="user_id",
|
||||
field=models.CharField(db_index=True, max_length=50, null=True),
|
||||
),
|
||||
]
|
||||
@@ -18,6 +18,12 @@ from .base import BaseModel
|
||||
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
|
||||
|
||||
|
||||
class ROLE(Enum):
|
||||
ADMIN = 20
|
||||
MEMBER = 15
|
||||
GUEST = 5
|
||||
|
||||
|
||||
class ProjectNetwork(Enum):
|
||||
SECRET = 0
|
||||
PUBLIC = 2
|
||||
@@ -89,9 +95,9 @@ class Project(BaseModel):
|
||||
)
|
||||
emoji = models.CharField(max_length=255, null=True, blank=True)
|
||||
icon_prop = models.JSONField(null=True)
|
||||
module_view = models.BooleanField(default=True)
|
||||
cycle_view = models.BooleanField(default=True)
|
||||
issue_views_view = models.BooleanField(default=True)
|
||||
module_view = models.BooleanField(default=False)
|
||||
cycle_view = models.BooleanField(default=False)
|
||||
issue_views_view = models.BooleanField(default=False)
|
||||
page_view = models.BooleanField(default=True)
|
||||
intake_view = models.BooleanField(default=False)
|
||||
is_time_tracking_enabled = models.BooleanField(default=False)
|
||||
|
||||
@@ -13,7 +13,7 @@ VALID_KEY_CHARS = string.ascii_lowercase + string.digits
|
||||
class Session(AbstractBaseSession):
|
||||
device_info = models.JSONField(null=True, blank=True, default=None)
|
||||
session_key = models.CharField(max_length=128, primary_key=True)
|
||||
user_id = models.CharField(null=True, max_length=50)
|
||||
user_id = models.CharField(null=True, max_length=50, db_index=True)
|
||||
|
||||
@classmethod
|
||||
def get_session_store_class(cls):
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-11 08:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("license", "0005_rename_product_instance_edition_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="instance",
|
||||
name="is_current_version_deprecated",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -38,6 +38,8 @@ class Instance(BaseModel):
|
||||
is_signup_screen_visited = models.BooleanField(default=False)
|
||||
is_verified = models.BooleanField(default=False)
|
||||
is_test = models.BooleanField(default=False)
|
||||
# field for validating if the current version is deprecated
|
||||
is_current_version_deprecated = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Instance"
|
||||
|
||||
@@ -67,20 +67,9 @@ def validate_binary_data(data):
|
||||
# Combine custom components and editor-specific nodes into a single set of tags
|
||||
CUSTOM_TAGS = {
|
||||
# editor node/tag names
|
||||
"imageComponent",
|
||||
"image",
|
||||
"mention",
|
||||
"link",
|
||||
"customColor",
|
||||
"emoji",
|
||||
"tableHeader",
|
||||
"tableCell",
|
||||
"tableRow",
|
||||
"codeBlock",
|
||||
"code",
|
||||
"horizontalRule",
|
||||
"calloutComponent",
|
||||
# component-style tag used by editor embeds
|
||||
"mention-component",
|
||||
"label",
|
||||
"input",
|
||||
"image-component",
|
||||
}
|
||||
ALLOWED_TAGS = nh3.ALLOWED_TAGS | CUSTOM_TAGS
|
||||
@@ -95,6 +84,8 @@ ATTRIBUTES = {
|
||||
"aria-label",
|
||||
"aria-hidden",
|
||||
"style",
|
||||
"start",
|
||||
"type",
|
||||
# common editor data-* attributes seen in stored HTML
|
||||
# (wildcards like data-* are NOT supported by nh3; we add known keys
|
||||
# here and dynamically include all data-* seen in the input below)
|
||||
@@ -102,49 +93,64 @@ ATTRIBUTES = {
|
||||
"data-node-type",
|
||||
"data-type",
|
||||
"data-checked",
|
||||
"data-background",
|
||||
"data-background-color",
|
||||
"data-text-color",
|
||||
"data-name",
|
||||
# callout attributes
|
||||
"data-icon-name",
|
||||
"data-icon-color",
|
||||
"data-background-color",
|
||||
"data-background",
|
||||
"data-emoji-unicode",
|
||||
"data-emoji-url",
|
||||
"data-logo-in-use",
|
||||
"data-block-type",
|
||||
"data-name",
|
||||
"data-entity-id",
|
||||
"data-entity-group-id",
|
||||
},
|
||||
"a": {"href", "target"},
|
||||
# editor node/tag attributes
|
||||
"imageComponent": {"id", "width", "height", "aspectRatio", "src", "alignment"},
|
||||
"image": {"width", "height", "aspectRatio", "alignment", "src", "alt", "title"},
|
||||
"mention": {"id", "entity_identifier", "entity_name"},
|
||||
"link": {"href", "target"},
|
||||
"customColor": {"color", "backgroundColor"},
|
||||
"emoji": {"name"},
|
||||
"tableHeader": {"colspan", "rowspan", "colwidth", "background", "hideContent"},
|
||||
"tableCell": {
|
||||
"image-component": {
|
||||
"id",
|
||||
"width",
|
||||
"height",
|
||||
"aspectRatio",
|
||||
"aspectratio",
|
||||
"src",
|
||||
"alignment",
|
||||
},
|
||||
"img": {
|
||||
"width",
|
||||
"height",
|
||||
"aspectRatio",
|
||||
"aspectratio",
|
||||
"alignment",
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
},
|
||||
"mention-component": {"id", "entity_identifier", "entity_name"},
|
||||
"th": {
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"colwidth",
|
||||
"background",
|
||||
"hideContent",
|
||||
"hidecontent",
|
||||
"style",
|
||||
},
|
||||
"td": {
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"colwidth",
|
||||
"background",
|
||||
"textColor",
|
||||
"textcolor",
|
||||
"hideContent",
|
||||
"hidecontent",
|
||||
"style",
|
||||
},
|
||||
"tableRow": {"background", "textColor"},
|
||||
"codeBlock": {"language"},
|
||||
"calloutComponent": {
|
||||
"data-icon-color",
|
||||
"data-icon-name",
|
||||
"data-emoji-unicode",
|
||||
"data-emoji-url",
|
||||
"data-logo-in-use",
|
||||
"data-background",
|
||||
"data-block-type",
|
||||
},
|
||||
# image-component (from editor extension and seeds)
|
||||
"image-component": {"src", "id", "width", "height", "aspectratio", "alignment"},
|
||||
"tr": {"background", "textColor", "textcolor", "style"},
|
||||
"pre": {"language"},
|
||||
"code": {"language", "spellcheck"},
|
||||
"input": {"type", "checked"},
|
||||
}
|
||||
|
||||
SAFE_PROTOCOLS = {"http", "https", "mailto", "tel"}
|
||||
|
||||
@@ -476,6 +476,8 @@ def filter_subscribed_issues(params, issue_filter, method, prefix=""):
|
||||
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = params.get(
|
||||
"subscriber"
|
||||
)
|
||||
issue_filter[f"{prefix}issue_subscribers__deleted_at__isnull"] = True
|
||||
|
||||
return issue_filter
|
||||
|
||||
|
||||
|
||||
@@ -3,18 +3,20 @@ from urllib.parse import urlparse
|
||||
|
||||
|
||||
def validate_next_path(next_path: str) -> str:
|
||||
"""Validates that next_path is a valid path and extracts only the path component."""
|
||||
"""Validates that next_path is a safe relative path for redirection."""
|
||||
# Browsers interpret backslashes as forward slashes. Remove all backslashes.
|
||||
next_path = next_path.replace("\\", "")
|
||||
parsed_url = urlparse(next_path)
|
||||
|
||||
# Ensure next_path is not an absolute URL
|
||||
# Block absolute URLs or anything with scheme/netloc
|
||||
if parsed_url.scheme or parsed_url.netloc:
|
||||
next_path = parsed_url.path # Extract only the path component
|
||||
|
||||
# Ensure it starts with a forward slash (indicating a valid relative path)
|
||||
if not next_path.startswith("/"):
|
||||
# Must start with a forward slash and not be empty
|
||||
if not next_path or not next_path.startswith("/"):
|
||||
return ""
|
||||
|
||||
# Ensure it does not contain dangerous path traversal sequences
|
||||
# Prevent path traversal
|
||||
if ".." in next_path:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.22
|
||||
Django==4.2.24
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -61,4 +61,4 @@ ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/live/dist/start.js"]
|
||||
CMD ["node", "apps/live/dist/server.js"]
|
||||
|
||||
@@ -24,9 +24,7 @@
|
||||
"@hocuspocus/extension-logger": "^2.15.0",
|
||||
"@hocuspocus/extension-redis": "^2.15.0",
|
||||
"@hocuspocus/server": "^2.15.0",
|
||||
"@plane/decorators": "workspace:*",
|
||||
"@plane/editor": "workspace:*",
|
||||
"@plane/logger": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@tiptap/core": "^2.22.3",
|
||||
"@tiptap/html": "^2.22.3",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// types
|
||||
import { TDocumentTypes } from "@/core/types/common.js";
|
||||
|
||||
type TArgs = {
|
||||
cookie: string | undefined;
|
||||
documentType: TDocumentTypes | undefined;
|
||||
pageId: string;
|
||||
params: URLSearchParams;
|
||||
};
|
||||
|
||||
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
|
||||
const { documentType } = args;
|
||||
throw Error(`Fetch failed: Invalid document type ${documentType} provided.`);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// types
|
||||
import { TDocumentTypes } from "@/core/types/common.js";
|
||||
|
||||
type TArgs = {
|
||||
cookie: string | undefined;
|
||||
documentType: TDocumentTypes | undefined;
|
||||
pageId: string;
|
||||
params: URLSearchParams;
|
||||
updatedDescription: Uint8Array;
|
||||
};
|
||||
|
||||
export const updateDocument = async (args: TArgs): Promise<void> => {
|
||||
const { documentType } = args;
|
||||
throw Error(`Update failed: Invalid document type ${documentType} provided.`);
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export type TAdditionalDocumentTypes = never;
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Request } from "express";
|
||||
import type { Hocuspocus } from "@hocuspocus/server";
|
||||
import { logger } from "@plane/logger";
|
||||
import { Controller, WebSocket } from "@plane/decorators";
|
||||
|
||||
@Controller("/collaboration")
|
||||
export class CollaborationController {
|
||||
private metrics = {
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
constructor(private readonly hocusPocusServer: Hocuspocus) {}
|
||||
|
||||
@WebSocket("/")
|
||||
handleConnection(ws: any, req: Request) {
|
||||
try {
|
||||
// Initialize the connection with Hocuspocus
|
||||
this.hocusPocusServer.handleConnection(ws, req);
|
||||
|
||||
// Set up error handling for the connection
|
||||
ws.on("error", (error: any) => {
|
||||
logger.error("WebSocket connection error:", error);
|
||||
ws.close();
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("WebSocket connection error:", error);
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { Controller, Post } from "@plane/decorators";
|
||||
import { logger } from "@plane/logger";
|
||||
// types
|
||||
import { TConvertDocumentRequestBody } from "@/types";
|
||||
// utils
|
||||
import { convertHTMLDocumentToAllFormats } from "@/utils";
|
||||
|
||||
@Controller("/convert-document")
|
||||
export class ConvertDocumentController {
|
||||
@Post("/")
|
||||
handleConvertDocument(req: Request, res: Response) {
|
||||
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
|
||||
try {
|
||||
if (description_html === undefined || variant === undefined) {
|
||||
res.status(400).send({
|
||||
message: "Missing required fields",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { description, description_binary } = convertHTMLDocumentToAllFormats({
|
||||
document_html: description_html,
|
||||
variant,
|
||||
});
|
||||
res.status(200).json({
|
||||
description,
|
||||
description_binary,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error in /convert-document endpoint:", error);
|
||||
res.status(500).json({
|
||||
message: `Internal server error.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { Controller, Get } from "@plane/decorators";
|
||||
|
||||
@Controller("/health")
|
||||
export class HealthController {
|
||||
@Get("/")
|
||||
async healthCheck(_req: Request, res: Response) {
|
||||
res.status(200).json({
|
||||
status: "OK",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.APP_VERSION || "1.0.0",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { HealthController } from "./health.controller";
|
||||
import { CollaborationController } from "./collaboration.controller";
|
||||
import { ConvertDocumentController } from "./convert-document.controller";
|
||||
|
||||
export const CONTROLLERS = [CollaborationController, ConvertDocumentController, HealthController];
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Database } from "@hocuspocus/extension-database";
|
||||
import { Logger } from "@hocuspocus/extension-logger";
|
||||
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
|
||||
import { Extension } from "@hocuspocus/server";
|
||||
import { Redis } from "ioredis";
|
||||
// core helpers and utilities
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
// core libraries
|
||||
import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page.js";
|
||||
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
|
||||
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common.js";
|
||||
// plane live libraries
|
||||
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
|
||||
import { updateDocument } from "@/plane-live/lib/update-document.js";
|
||||
|
||||
export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
const extensions: Extension[] = [
|
||||
new Logger({
|
||||
onChange: false,
|
||||
log: (message) => {
|
||||
manualLogger.info(message);
|
||||
},
|
||||
}),
|
||||
new Database({
|
||||
fetch: async ({ context, documentName: pageId, requestParameters }) => {
|
||||
const cookie = (context as HocusPocusServerContext).cookie;
|
||||
// query params
|
||||
const params = requestParameters;
|
||||
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
|
||||
// TODO: Fix this lint error.
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
let fetchedData = null;
|
||||
if (documentType === "project_page") {
|
||||
fetchedData = await fetchPageDescriptionBinary(params, pageId, cookie);
|
||||
} else {
|
||||
fetchedData = await fetchDocument({
|
||||
cookie,
|
||||
documentType,
|
||||
pageId,
|
||||
params,
|
||||
});
|
||||
}
|
||||
resolve(fetchedData);
|
||||
} catch (error) {
|
||||
manualLogger.error("Error in fetching document", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
store: async ({ context, state, documentName: pageId, requestParameters }) => {
|
||||
const cookie = (context as HocusPocusServerContext).cookie;
|
||||
// query params
|
||||
const params = requestParameters;
|
||||
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
|
||||
|
||||
// TODO: Fix this lint error.
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async () => {
|
||||
try {
|
||||
if (documentType === "project_page") {
|
||||
await updatePageDescription(params, pageId, state, cookie);
|
||||
} else {
|
||||
await updateDocument({
|
||||
cookie,
|
||||
documentType,
|
||||
pageId,
|
||||
params,
|
||||
updatedDescription: state,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
manualLogger.error("Error in updating document:", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const redisUrl = getRedisUrl();
|
||||
|
||||
if (redisUrl) {
|
||||
try {
|
||||
const redisClient = new Redis(redisUrl);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
redisClient.on("error", (error: any) => {
|
||||
if (error?.code === "ENOTFOUND" || error.message.includes("WRONGPASS") || error.message.includes("NOAUTH")) {
|
||||
redisClient.disconnect();
|
||||
}
|
||||
manualLogger.warn(
|
||||
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
redisClient.on("ready", () => {
|
||||
extensions.push(new HocusPocusRedis({ redis: redisClient }));
|
||||
manualLogger.info("Redis Client connected ✅");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
manualLogger.warn(
|
||||
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} else {
|
||||
manualLogger.warn(
|
||||
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)"
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
// plane editor
|
||||
import {
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData,
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData,
|
||||
getBinaryDataFromDocumentEditorHTMLString,
|
||||
getBinaryDataFromRichTextEditorHTMLString,
|
||||
} from "@plane/editor";
|
||||
// plane types
|
||||
import { TDocumentPayload } from "@plane/types";
|
||||
|
||||
type TArgs = {
|
||||
document_html: string;
|
||||
variant: "rich" | "document";
|
||||
};
|
||||
|
||||
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
|
||||
const { document_html, variant } = args;
|
||||
|
||||
let allFormats: TDocumentPayload;
|
||||
|
||||
if (variant === "rich") {
|
||||
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
|
||||
allFormats = {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
} else if (variant === "document") {
|
||||
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
|
||||
allFormats = {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Invalid variant provided: ${variant}`);
|
||||
}
|
||||
|
||||
return allFormats;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ErrorRequestHandler } from "express";
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
|
||||
// Log the error
|
||||
manualLogger.error(err);
|
||||
|
||||
// Set the response status
|
||||
res.status(err.status || 500);
|
||||
|
||||
// Send the response
|
||||
res.json({
|
||||
error: {
|
||||
message: process.env.NODE_ENV === "production" ? "An unexpected error occurred" : err.message,
|
||||
...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { pinoHttp } from "pino-http";
|
||||
|
||||
const transport = {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
};
|
||||
|
||||
const hooks = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
logMethod(inputArgs: any, method: any): any {
|
||||
if (inputArgs.length >= 2) {
|
||||
const arg1 = inputArgs.shift();
|
||||
const arg2 = inputArgs.shift();
|
||||
return method.apply(this, [arg2, arg1, ...inputArgs]);
|
||||
}
|
||||
return method.apply(this, inputArgs);
|
||||
},
|
||||
};
|
||||
|
||||
export const logger = pinoHttp({
|
||||
level: "info",
|
||||
transport: transport,
|
||||
hooks: hooks,
|
||||
serializers: {
|
||||
req(req) {
|
||||
return `${req.method} ${req.url}`;
|
||||
},
|
||||
res(res) {
|
||||
return `${res.statusCode} ${res?.statusMessage || ""}`;
|
||||
},
|
||||
responseTime(time) {
|
||||
return `${time}ms`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const manualLogger: typeof logger.logger = logger.logger;
|
||||
@@ -3,53 +3,11 @@ import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
// plane editor
|
||||
import {
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData,
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData,
|
||||
getBinaryDataFromDocumentEditorHTMLString,
|
||||
getBinaryDataFromRichTextEditorHTMLString,
|
||||
} from "@plane/editor";
|
||||
// plane types
|
||||
import { TDocumentPayload } from "@plane/types";
|
||||
// plane editor
|
||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
|
||||
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
type TArgs = {
|
||||
document_html: string;
|
||||
variant: "rich" | "document";
|
||||
};
|
||||
|
||||
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
|
||||
const { document_html, variant } = args;
|
||||
|
||||
if (variant === "rich") {
|
||||
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
|
||||
return {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === "document") {
|
||||
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
|
||||
return {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Invalid variant provided: ${variant}`);
|
||||
};
|
||||
|
||||
export const getAllDocumentFormatsFromBinaryData = (
|
||||
description: Uint8Array
|
||||
): {
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Server } from "@hocuspocus/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// editor types
|
||||
import { TUserDetails } from "@plane/editor";
|
||||
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
|
||||
// extensions
|
||||
import { getExtensions } from "@/core/extensions/index.js";
|
||||
// lib
|
||||
import { handleAuthentication } from "@/core/lib/authentication.js";
|
||||
// types
|
||||
import { type HocusPocusServerContext } from "@/core/types/common.js";
|
||||
|
||||
export const getHocusPocusServer = async () => {
|
||||
const extensions = await getExtensions();
|
||||
const serverName = process.env.HOSTNAME || uuidv4();
|
||||
return Server.configure({
|
||||
name: serverName,
|
||||
onAuthenticate: async ({
|
||||
requestHeaders,
|
||||
context,
|
||||
// user id used as token for authentication
|
||||
token,
|
||||
}) => {
|
||||
let cookie: string | undefined = undefined;
|
||||
let userId: string | undefined = undefined;
|
||||
|
||||
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
|
||||
// the cookies are not passed in the request headers)
|
||||
try {
|
||||
const parsedToken = JSON.parse(token) as TUserDetails;
|
||||
userId = parsedToken.id;
|
||||
cookie = parsedToken.cookie;
|
||||
} catch (error) {
|
||||
// If token parsing fails, fallback to request headers
|
||||
console.error("Token parsing failed, using request headers:", error);
|
||||
} finally {
|
||||
// If cookie is still not found, fallback to request headers
|
||||
if (!cookie) {
|
||||
cookie = requestHeaders.cookie?.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (!cookie || !userId) {
|
||||
throw new Error("Credentials not provided");
|
||||
}
|
||||
|
||||
// set cookie in context, so it can be used throughout the ws connection
|
||||
(context as HocusPocusServerContext).cookie = cookie;
|
||||
|
||||
try {
|
||||
await handleAuthentication({
|
||||
cookie,
|
||||
userId,
|
||||
});
|
||||
} catch (_error) {
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
},
|
||||
async onStateless({ payload, document }) {
|
||||
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
|
||||
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||
if (response) {
|
||||
document.broadcastStateless(response);
|
||||
}
|
||||
},
|
||||
extensions,
|
||||
debounce: 10000,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
// core helpers
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
// services
|
||||
import { UserService } from "@/core/services/user.service.js";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
type Props = {
|
||||
cookie: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export const handleAuthentication = async (props: Props) => {
|
||||
const { cookie, userId } = props;
|
||||
// fetch current user info
|
||||
let response;
|
||||
try {
|
||||
response = await userService.currentUser(cookie);
|
||||
} catch (error) {
|
||||
manualLogger.error("Failed to fetch current user:", error);
|
||||
throw error;
|
||||
}
|
||||
if (response.id !== userId) {
|
||||
throw Error("Authentication failed: Token doesn't match the current user.");
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: response.id,
|
||||
name: response.display_name,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
import { logger } from "@plane/logger";
|
||||
// helpers
|
||||
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page.js";
|
||||
// services
|
||||
import { PageService } from "@/services/page.service";
|
||||
// utils
|
||||
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/utils";
|
||||
|
||||
import { PageService } from "@/core/services/page.service.js";
|
||||
import { manualLogger } from "../helpers/logger.js";
|
||||
const pageService = new PageService();
|
||||
|
||||
export const updatePageDescription = async (
|
||||
@@ -30,7 +29,7 @@ export const updatePageDescription = async (
|
||||
|
||||
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
|
||||
} catch (error) {
|
||||
logger.error("Update error:", error);
|
||||
manualLogger.error("Update error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -48,7 +47,7 @@ const fetchDescriptionHTMLAndTransform = async (
|
||||
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
|
||||
return contentBinary;
|
||||
} catch (error) {
|
||||
logger.error("Error while transforming from HTML to Uint8Array", error);
|
||||
manualLogger.error("Error while transforming from HTML to Uint8Array", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -75,7 +74,7 @@ export const fetchPageDescriptionBinary = async (
|
||||
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
logger.error("Fetch error:", error);
|
||||
manualLogger.error("Fetch error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export function getRedisUrl() {
|
||||
const redisUrl = process.env.REDIS_URL?.trim();
|
||||
const redisHost = process.env.REDIS_HOST?.trim();
|
||||
const redisPort = process.env.REDIS_PORT?.trim();
|
||||
|
||||
if (redisUrl) {
|
||||
return redisUrl;
|
||||
}
|
||||
|
||||
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
|
||||
return `redis://${redisHost}:${redisPort}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// types
|
||||
import { TPage } from "@plane/types";
|
||||
// services
|
||||
import { API_BASE_URL, APIService } from "@/services/api.service";
|
||||
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
|
||||
|
||||
export class PageService extends APIService {
|
||||
constructor() {
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// types
|
||||
import type { IUser } from "@plane/types";
|
||||
// services
|
||||
import { API_BASE_URL, APIService } from "@/services/api.service";
|
||||
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
|
||||
|
||||
export class UserService extends APIService {
|
||||
constructor() {
|
||||
@@ -1,4 +1,7 @@
|
||||
export type TDocumentTypes = "project_page";
|
||||
// types
|
||||
import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js";
|
||||
|
||||
export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
|
||||
|
||||
export type HocusPocusServerContext = {
|
||||
cookie: string;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../../ce/lib/fetch-document.js";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../../ce/lib/update-document.js";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export * from "../../ce/types/common.js";
|
||||
@@ -1,191 +0,0 @@
|
||||
import { Database } from "@hocuspocus/extension-database";
|
||||
import { Logger } from "@hocuspocus/extension-logger";
|
||||
import { Redis } from "@hocuspocus/extension-redis";
|
||||
import { Server, Hocuspocus } from "@hocuspocus/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// plane imports
|
||||
import type { TUserDetails } from "@plane/editor";
|
||||
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
|
||||
import { logger } from "@plane/logger";
|
||||
// lib
|
||||
import { fetchPageDescriptionBinary, updatePageDescription } from "@/lib/page";
|
||||
// redis
|
||||
import { redisManager } from "@/redis";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
// types
|
||||
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
|
||||
|
||||
export class HocusPocusServerManager {
|
||||
private static instance: HocusPocusServerManager | null = null;
|
||||
private server: Hocuspocus | null = null;
|
||||
private isInitialized: boolean = false;
|
||||
// server options
|
||||
private serverName = process.env.HOSTNAME || uuidv4();
|
||||
|
||||
private constructor() {
|
||||
// Private constructor to prevent direct instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of HocusPocusServerManager
|
||||
*/
|
||||
public static getInstance(): HocusPocusServerManager {
|
||||
if (!HocusPocusServerManager.instance) {
|
||||
HocusPocusServerManager.instance = new HocusPocusServerManager();
|
||||
}
|
||||
return HocusPocusServerManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate the user
|
||||
* @param requestHeaders - The request headers
|
||||
* @param context - The context
|
||||
* @param token - The token
|
||||
* @returns The authenticated user
|
||||
*/
|
||||
private onAuthenticate = async ({ requestHeaders, context, token }: any) => {
|
||||
let cookie: string | undefined = undefined;
|
||||
let userId: string | undefined = undefined;
|
||||
|
||||
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
|
||||
// the cookies are not passed in the request headers)
|
||||
try {
|
||||
const parsedToken = JSON.parse(token) as TUserDetails;
|
||||
userId = parsedToken.id;
|
||||
cookie = parsedToken.cookie;
|
||||
} catch (error) {
|
||||
// If token parsing fails, fallback to request headers
|
||||
logger.error("Token parsing failed, using request headers:", error);
|
||||
} finally {
|
||||
// If cookie is still not found, fallback to request headers
|
||||
if (!cookie) {
|
||||
cookie = requestHeaders.cookie?.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (!cookie || !userId) {
|
||||
throw new Error("Credentials not provided");
|
||||
}
|
||||
|
||||
// set cookie in context, so it can be used throughout the ws connection
|
||||
(context as HocusPocusServerContext).cookie = cookie;
|
||||
|
||||
try {
|
||||
const userService = new UserService();
|
||||
const user = await userService.currentUser(cookie);
|
||||
if (user.id !== userId) {
|
||||
throw new Error("Authentication unsuccessful!");
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.display_name,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
};
|
||||
|
||||
private onStateless = async ({ payload, document }: any) => {
|
||||
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
|
||||
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||
if (response) {
|
||||
document.broadcastStateless(response);
|
||||
}
|
||||
};
|
||||
|
||||
private onDatabaseFetch = async ({ context, documentName: pageId, requestParameters }: any) => {
|
||||
try {
|
||||
const cookie = (context as HocusPocusServerContext).cookie;
|
||||
// query params
|
||||
const params = requestParameters;
|
||||
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
|
||||
// fetch document
|
||||
if (documentType === "project_page") {
|
||||
const data = await fetchPageDescriptionBinary(params, pageId, cookie);
|
||||
return data;
|
||||
}
|
||||
throw new Error(`Invalid document type ${documentType} provided.`);
|
||||
} catch (error) {
|
||||
logger.error("Error in fetching document", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
private onDatabaseStore = async ({ context, state, documentName: pageId, requestParameters }: any) => {
|
||||
const cookie = (context as HocusPocusServerContext).cookie;
|
||||
try {
|
||||
// query params
|
||||
const params = requestParameters;
|
||||
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
|
||||
|
||||
if (documentType === "project_page") {
|
||||
await updatePageDescription(params, pageId, state, cookie);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error in updating document:", error);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Initialize and configure the HocusPocus server
|
||||
*/
|
||||
public async initialize(): Promise<Hocuspocus> {
|
||||
if (this.isInitialized && this.server) {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
const redisClient = redisManager.getClient();
|
||||
if (!redisClient) {
|
||||
throw new Error("Redis client not initialized");
|
||||
}
|
||||
|
||||
this.server = Server.configure({
|
||||
name: this.serverName,
|
||||
onAuthenticate: this.onAuthenticate,
|
||||
onStateless: this.onStateless,
|
||||
extensions: [
|
||||
new Logger({
|
||||
onChange: false,
|
||||
log: (message) => {
|
||||
logger.info(message);
|
||||
},
|
||||
}),
|
||||
new Database({
|
||||
fetch: this.onDatabaseFetch,
|
||||
store: this.onDatabaseStore,
|
||||
}),
|
||||
new Redis({
|
||||
redis: redisClient,
|
||||
}),
|
||||
],
|
||||
debounce: 10000,
|
||||
});
|
||||
|
||||
this.isInitialized = true;
|
||||
return this.server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured server instance
|
||||
*/
|
||||
public getServer(): Hocuspocus | null {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server has been initialized
|
||||
*/
|
||||
public isServerInitialized(): boolean {
|
||||
return this.isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
HocusPocusServerManager.instance = null;
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import Redis from "ioredis";
|
||||
import { logger } from "@plane/logger";
|
||||
|
||||
export class RedisManager {
|
||||
private static instance: RedisManager;
|
||||
private redisClient: Redis | null = null;
|
||||
private isConnected: boolean = false;
|
||||
private connectionPromise: Promise<void> | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): RedisManager {
|
||||
if (!RedisManager.instance) {
|
||||
RedisManager.instance = new RedisManager();
|
||||
}
|
||||
return RedisManager.instance;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.redisClient && this.isConnected) {
|
||||
logger.info("Redis client already initialized and connected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connectionPromise) {
|
||||
logger.info("Redis connection already in progress, waiting...");
|
||||
await this.connectionPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.connectionPromise = this.connect();
|
||||
await this.connectionPromise;
|
||||
}
|
||||
|
||||
private getRedisUrl(): string {
|
||||
const redisUrl = process.env.REDIS_URL?.trim();
|
||||
const redisHost = process.env.REDIS_HOST?.trim();
|
||||
const redisPort = process.env.REDIS_PORT?.trim();
|
||||
|
||||
if (redisUrl) {
|
||||
return redisUrl;
|
||||
}
|
||||
|
||||
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
|
||||
return `redis://${redisHost}:${redisPort}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
try {
|
||||
const redisUrl = this.getRedisUrl();
|
||||
|
||||
if (!redisUrl) {
|
||||
logger.warn("No Redis URL provided, Redis functionality will be disabled");
|
||||
this.isConnected = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.redisClient = new Redis(redisUrl, {
|
||||
lazyConnect: true,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: 10000,
|
||||
commandTimeout: 5000,
|
||||
enableOfflineQueue: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
// Set up event listeners
|
||||
this.redisClient.on("connect", () => {
|
||||
logger.info("Redis client connected");
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.redisClient.on("ready", () => {
|
||||
logger.info("Redis client ready");
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.redisClient.on("error", (error) => {
|
||||
logger.error("Redis client error:", error);
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.redisClient.on("close", () => {
|
||||
logger.warn("Redis client connection closed");
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.redisClient.on("reconnecting", () => {
|
||||
logger.info("Redis client reconnecting...");
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
// Connect to Redis
|
||||
await this.redisClient.connect();
|
||||
|
||||
// Test the connection
|
||||
await this.redisClient.ping();
|
||||
logger.info("Redis connection test successful");
|
||||
} catch (error) {
|
||||
logger.error("Failed to initialize Redis client:", error);
|
||||
this.isConnected = false;
|
||||
throw error;
|
||||
} finally {
|
||||
this.connectionPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
public getClient(): Redis | null {
|
||||
if (!this.redisClient || !this.isConnected) {
|
||||
logger.warn("Redis client not available or not connected");
|
||||
return null;
|
||||
}
|
||||
return this.redisClient;
|
||||
}
|
||||
|
||||
public isClientConnected(): boolean {
|
||||
return this.isConnected && this.redisClient !== null;
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this.redisClient) {
|
||||
try {
|
||||
await this.redisClient.quit();
|
||||
logger.info("Redis client disconnected gracefully");
|
||||
} catch (error) {
|
||||
logger.error("Error disconnecting Redis client:", error);
|
||||
// Force disconnect if quit fails
|
||||
this.redisClient.disconnect();
|
||||
} finally {
|
||||
this.redisClient = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods for common Redis operations
|
||||
public async set(key: string, value: string, ttl?: number): Promise<boolean> {
|
||||
const client = this.getClient();
|
||||
if (!client) return false;
|
||||
|
||||
try {
|
||||
if (ttl) {
|
||||
await client.setex(key, ttl, value);
|
||||
} else {
|
||||
await client.set(key, value);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Error setting Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string | null> {
|
||||
const client = this.getClient();
|
||||
if (!client) return null;
|
||||
|
||||
try {
|
||||
return await client.get(key);
|
||||
} catch (error) {
|
||||
logger.error(`Error getting Redis key ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async del(key: string): Promise<boolean> {
|
||||
const client = this.getClient();
|
||||
if (!client) return false;
|
||||
|
||||
try {
|
||||
await client.del(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async exists(key: string): Promise<boolean> {
|
||||
const client = this.getClient();
|
||||
if (!client) return false;
|
||||
|
||||
try {
|
||||
const result = await client.exists(key);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logger.error(`Error checking Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async expire(key: string, ttl: number): Promise<boolean> {
|
||||
const client = this.getClient();
|
||||
if (!client) return false;
|
||||
|
||||
try {
|
||||
const result = await client.expire(key, ttl);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logger.error(`Error setting expiry for Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a default instance for convenience
|
||||
export const redisManager = RedisManager.getInstance();
|
||||
+80
-39
@@ -3,15 +3,13 @@ import cors from "cors";
|
||||
import express, { Request, Response } from "express";
|
||||
import expressWs from "express-ws";
|
||||
import helmet from "helmet";
|
||||
// plane imports
|
||||
import { registerControllers } from "@plane/decorators";
|
||||
import { logger, loggerMiddleware } from "@plane/logger";
|
||||
// controllers
|
||||
import { CONTROLLERS } from "@/controllers";
|
||||
// hocuspocus server
|
||||
import { HocusPocusServerManager } from "@/hocuspocus";
|
||||
// redis
|
||||
import { redisManager } from "@/redis";
|
||||
// helpers
|
||||
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js";
|
||||
import { logger, manualLogger } from "@/core/helpers/logger.js";
|
||||
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
|
||||
// types
|
||||
import { TConvertDocumentRequestBody } from "@/core/types/common.js";
|
||||
|
||||
export class Server {
|
||||
private app: any;
|
||||
@@ -22,38 +20,74 @@ export class Server {
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.router = express.Router();
|
||||
this.app.set("port", process.env.PORT || 3000);
|
||||
this.app.use(process.env.LIVE_BASE_PATH || "/live", this.router);
|
||||
expressWs(this.app);
|
||||
this.app.set("port", process.env.PORT || 3000);
|
||||
this.setupMiddleware();
|
||||
this.setupHocusPocus();
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
try {
|
||||
redisManager.initialize();
|
||||
logger.info("Redis setup completed");
|
||||
const manager = HocusPocusServerManager.getInstance();
|
||||
this.hocuspocusServer = await manager.initialize();
|
||||
logger.info("HocusPocus setup completed");
|
||||
} catch (error) {
|
||||
logger.error("Failed to setup Redis:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setupMiddleware() {
|
||||
// Security middleware
|
||||
this.app.use(helmet());
|
||||
// Middleware for response compression
|
||||
this.app.use(compression({ level: 6, threshold: 5 * 1000 }));
|
||||
// Logging middleware
|
||||
this.app.use(loggerMiddleware);
|
||||
this.app.use(logger);
|
||||
// Body parsing middleware
|
||||
this.app.use(express.json());
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
// cors middleware
|
||||
this.app.use(cors());
|
||||
this.app.use(process.env.LIVE_BASE_PATH || "/live", this.router);
|
||||
}
|
||||
|
||||
private async setupHocusPocus() {
|
||||
this.hocuspocusServer = await getHocusPocusServer().catch((err) => {
|
||||
manualLogger.error("Failed to initialize HocusPocusServer:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
private setupRoutes() {
|
||||
this.router.get("/health", (_req: Request, res: Response) => {
|
||||
res.status(200).json({ status: "OK" });
|
||||
});
|
||||
|
||||
this.router.ws("/collaboration", (ws: any, req: Request) => {
|
||||
try {
|
||||
this.hocuspocusServer.handleConnection(ws, req);
|
||||
} catch (err) {
|
||||
manualLogger.error("WebSocket connection error:", err);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
this.router.post("/convert-document", (req: Request, res: Response) => {
|
||||
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
|
||||
try {
|
||||
if (description_html === undefined || variant === undefined) {
|
||||
res.status(400).send({
|
||||
message: "Missing required fields",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { description, description_binary } = convertHTMLDocumentToAllFormats({
|
||||
document_html: description_html,
|
||||
variant,
|
||||
});
|
||||
res.status(200).json({
|
||||
description,
|
||||
description_binary,
|
||||
});
|
||||
} catch (error) {
|
||||
manualLogger.error("Error in /convert-document endpoint:", error);
|
||||
res.status(500).json({
|
||||
message: `Internal server error.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.app.use((_req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
message: "Not Found",
|
||||
@@ -61,30 +95,37 @@ export class Server {
|
||||
});
|
||||
}
|
||||
|
||||
private setupRoutes() {
|
||||
CONTROLLERS.forEach((controller) => registerControllers(this.router, controller as any)); // TODO: fix this
|
||||
}
|
||||
|
||||
public listen() {
|
||||
this.serverInstance = this.app.listen(this.app.get("port"), () => {
|
||||
logger.info(`Plane Live server has started at port ${this.app.get("port")}`);
|
||||
manualLogger.info(`Plane Live server has started at port ${this.app.get("port")}`);
|
||||
});
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
// Close the HocusPocus server WebSocket connections
|
||||
if (this.hocuspocusServer) {
|
||||
await this.hocuspocusServer.destroy();
|
||||
logger.info("HocusPocus server WebSocket connections closed gracefully.");
|
||||
}
|
||||
|
||||
// Disconnect Redis
|
||||
await redisManager.disconnect();
|
||||
logger.info("Redis connection closed gracefully.");
|
||||
|
||||
await this.hocuspocusServer.destroy();
|
||||
manualLogger.info("HocusPocus server WebSocket connections closed gracefully.");
|
||||
// Close the Express server
|
||||
this.serverInstance.close(() => {
|
||||
logger.info("Express server closed gracefully.");
|
||||
manualLogger.info("Express server closed gracefully.");
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const server = new Server();
|
||||
server.listen();
|
||||
|
||||
// Graceful shutdown on unhandled rejection
|
||||
process.on("unhandledRejection", async (err: any) => {
|
||||
manualLogger.info("Unhandled Rejection: ", err);
|
||||
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
|
||||
await server.destroy();
|
||||
});
|
||||
|
||||
// Graceful shutdown on uncaught exception
|
||||
process.on("uncaughtException", async (err: any) => {
|
||||
manualLogger.info("Uncaught Exception: ", err);
|
||||
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
|
||||
await server.destroy();
|
||||
});
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { logger } from "@plane/logger";
|
||||
import { Server } from "./server";
|
||||
|
||||
let server: Server;
|
||||
|
||||
async function startServer() {
|
||||
server = new Server();
|
||||
try {
|
||||
await server.initialize();
|
||||
server.listen();
|
||||
} catch (error) {
|
||||
logger.error("Failed to start server:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
|
||||
// Graceful shutdown on unhandled rejection
|
||||
process.on("unhandledRejection", async (err: any) => {
|
||||
logger.error(`UNHANDLED REJECTION! 💥 Shutting down...`, err);
|
||||
try {
|
||||
if (server) {
|
||||
await server.destroy();
|
||||
}
|
||||
} finally {
|
||||
logger.info("Exiting process...");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown on uncaught exception
|
||||
process.on("uncaughtException", async (err: any) => {
|
||||
logger.error(`UNCAUGHT EXCEPTION! 💥 Shutting down...`, err);
|
||||
try {
|
||||
if (server) {
|
||||
await server.destroy();
|
||||
}
|
||||
} finally {
|
||||
logger.info("Exiting process...");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./document";
|
||||
@@ -13,6 +13,11 @@ export async function generateMetadata({ params }: Props) {
|
||||
const { anchor } = params;
|
||||
const DEFAULT_TITLE = "Plane";
|
||||
const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities.";
|
||||
// Validate anchor before using in request (only allow alphanumeric, -, _)
|
||||
const ANCHOR_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
if (!ANCHOR_REGEX.test(anchor)) {
|
||||
return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION };
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/anchor/${anchor}/meta/`);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { PlaneLockup } from "@plane/ui";
|
||||
import { PlaneLockup } from "@plane/propel/icons";
|
||||
// components
|
||||
import { PoweredBy } from "@/components/common/powered-by";
|
||||
import { UserAvatar } from "@/components/issues/navbar/user-avatar";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { FC } from "react";
|
||||
import { WEBSITE_URL } from "@plane/constants";
|
||||
// assets
|
||||
import { PlaneLogo } from "@plane/ui";
|
||||
import { PlaneLogo } from "@plane/propel/icons";
|
||||
|
||||
type TPoweredBy = {
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { PriorityIcon, type TIssuePriorities } from "@plane/ui";
|
||||
import { PriorityIcon, type TIssuePriorities } from "@plane/propel/icons";
|
||||
|
||||
type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ISSUE_PRIORITY_FILTERS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PriorityIcon } from "@plane/ui";
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
// local imports
|
||||
import { FilterHeader } from "./helpers/filter-header";
|
||||
import { FilterOption } from "./helpers/filter-option";
|
||||
|
||||
@@ -4,7 +4,8 @@ import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { Loader, StateGroupIcon } from "@plane/ui";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { Loader } from "@plane/ui";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
// local imports
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane ui
|
||||
import { ContrastIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { ContrastIcon } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
//hooks
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane ui
|
||||
import { DiceIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { DiceIcon } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { SignalHigh } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { TIssuePriorities } from "@plane/types";
|
||||
import { PriorityIcon } from "@plane/ui";
|
||||
// constants
|
||||
import { cn, getIssuePriorityFilters } from "@plane/utils";
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane ui
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
//hooks
|
||||
|
||||
@@ -4,6 +4,7 @@ import isNil from "lodash/isNil";
|
||||
import { ContrastIcon } from "lucide-react";
|
||||
// types
|
||||
import { EIconSize, ISSUE_PRIORITIES } from "@plane/constants";
|
||||
import { CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
TGroupedIssues,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui";
|
||||
import { Avatar } from "@plane/ui";
|
||||
// components
|
||||
// constants
|
||||
// stores
|
||||
|
||||
@@ -5,7 +5,8 @@ import { observer } from "mobx-react";
|
||||
import { Link2, MoveRight } from "lucide-react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { CenterPanelIcon, FullScreenPanelIcon, setToast, SidePanelIcon, TOAST_TYPE } from "@plane/ui";
|
||||
import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons";
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useParams } from "next/navigation";
|
||||
import { CalendarCheck2, Signal } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { DoubleCircleIcon, StateGroupIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { cn, getIssuePriorityFilters } from "@plane/utils";
|
||||
// components
|
||||
import { Icon } from "@/components/ui";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { PlaneLockup } from "@plane/ui";
|
||||
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">
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Breadcrumbs, ContrastIcon, Header } from "@plane/ui";
|
||||
import { ContrastIcon } from "@plane/propel/icons";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
// plane web components
|
||||
|
||||
@@ -8,8 +8,9 @@ import { useParams } from "next/navigation";
|
||||
import { ChevronDown, PanelRight } from "lucide-react";
|
||||
import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { UserActivityIcon } from "@plane/propel/icons";
|
||||
import { IUserProfileProjectSegregation } from "@plane/types";
|
||||
import { Breadcrumbs, Header, CustomMenu, UserActivityIcon } from "@plane/ui";
|
||||
import { Breadcrumbs, Header, CustomMenu } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
|
||||
+2
-1
@@ -3,10 +3,11 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArchiveIcon, ContrastIcon, DiceIcon, LayersIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// ui
|
||||
import { ArchiveIcon, Breadcrumbs, Header, ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
// hooks
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { ArchiveIcon, Breadcrumbs, LayersIcon, Header } from "@plane/ui";
|
||||
import { ArchiveIcon, LayersIcon } from "@plane/propel/icons";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@ import {
|
||||
} from "@plane/constants";
|
||||
import { usePlatformOS } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ContrastIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { Breadcrumbs, Button, ContrastIcon, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui";
|
||||
import { cn, isIssueFilterActive } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ import {
|
||||
EProjectFeatureKey,
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { DiceIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { Breadcrumbs, Button, DiceIcon, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
import { cn, isIssueFilterActive } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { Breadcrumbs, Button, Header, RecentStickyIcon } from "@plane/ui";
|
||||
import { RecentStickyIcon } from "@plane/propel/icons";
|
||||
import { Breadcrumbs, Button, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { StickySearch } from "@/components/stickies/modal/search";
|
||||
|
||||
@@ -191,7 +191,7 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
|
||||
<Header.RightItem>
|
||||
<Header.RightItem className="items-center">
|
||||
{!isLocked ? (
|
||||
<>
|
||||
<GlobalViewLayoutSelection
|
||||
|
||||
@@ -6,8 +6,9 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PlaneLogo } from "@plane/propel/icons";
|
||||
import { IWorkspace } from "@plane/types";
|
||||
import { Button, getButtonStyling, PlaneLogo } from "@plane/ui";
|
||||
import { Button, getButtonStyling } from "@plane/ui";
|
||||
// components
|
||||
import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form";
|
||||
// hooks
|
||||
|
||||
@@ -10,9 +10,10 @@ import { CheckCircle2 } from "lucide-react";
|
||||
import { ROLE, MEMBER_TRACKER_EVENTS, MEMBER_TRACKER_ELEMENTS, GROUP_WORKSPACE_TRACKER_EVENT } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { PlaneLogo } from "@plane/propel/icons";
|
||||
import type { IWorkspaceMemberInvitation } from "@plane/types";
|
||||
// ui
|
||||
import { Button, TOAST_TYPE, setToast, PlaneLogo } from "@plane/ui";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { truncateText } from "@plane/utils";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PreloadResources } from "./layout.preload";
|
||||
// styles
|
||||
import "@/styles/command-pallette.css";
|
||||
import "@/styles/emoji.css";
|
||||
import "@/styles/react-day-picker.css";
|
||||
import "@plane/propel/styles/react-day-picker";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: {
|
||||
|
||||
+63
-49
@@ -1,69 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// layouts
|
||||
import { Button } from "@plane/ui";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import DefaultLayout from "@/layouts/default-layout";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
// images
|
||||
import maintenanceModeDarkModeImage from "@/public/instance/maintenance-mode-dark.svg";
|
||||
import maintenanceModeLightModeImage from "@/public/instance/maintenance-mode-light.svg";
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
const linkMap = [
|
||||
{
|
||||
key: "mail_to",
|
||||
label: "Contact Support",
|
||||
value: "mailto:support@plane.so",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status Page",
|
||||
value: "https://status.plane.so/",
|
||||
},
|
||||
{
|
||||
key: "twitter_handle",
|
||||
label: "@planepowers",
|
||||
value: "https://x.com/planepowers",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CustomErrorComponent() {
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
const router = useAppRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authService
|
||||
.signOut(API_BASE_URL)
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to sign out. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => router.push("/"));
|
||||
};
|
||||
// derived values
|
||||
const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage;
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>
|
||||
<div className="grid h-full place-items-center p-4">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="space-y-2 relative flex flex-col justify-center items-center">
|
||||
<h3 className="text-lg font-semibold">Yikes! That doesn{"'"}t look good.</h3>
|
||||
<p className="mx-auto md:w-1/2 text-sm text-custom-text-200">
|
||||
That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more
|
||||
details, please write to{" "}
|
||||
<a href="mailto:support@plane.so" className="text-custom-primary">
|
||||
support@plane.so
|
||||
</a>{" "}
|
||||
or on our{" "}
|
||||
<div className="relative container mx-auto h-full w-full max-w-xl flex flex-col gap-2 items-center justify-center gap-y-6 bg-custom-background-100 text-center px-6">
|
||||
<div className="relative w-full">
|
||||
<Image
|
||||
src={maintenanceModeImage}
|
||||
height="176"
|
||||
width="288"
|
||||
alt="ProjectSettingImg"
|
||||
className="w-full h-full object-fill object-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full relative flex flex-col gap-4 mt-4">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h1 className="text-xl font-semibold text-custom-text-100 text-left">
|
||||
🚧 Looks like something went wrong!
|
||||
</h1>
|
||||
<span className="text-base font-medium text-custom-text-200 text-left">
|
||||
We track these errors automatically and working on getting things back up and running. If the problem
|
||||
persists feel free to contact us. In the meantime, try refreshing.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start gap-6 mt-1">
|
||||
{linkMap.map((link) => (
|
||||
<div key={link.key}>
|
||||
<a
|
||||
href="https://discord.com/invite/A92xrEGCge"
|
||||
href={link.value}
|
||||
target="_blank"
|
||||
className="text-custom-primary"
|
||||
rel="noopener noreferrer"
|
||||
className="text-custom-primary-100 hover:underline text-sm"
|
||||
>
|
||||
Discord
|
||||
{link.label}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Link href="/" className={cn(getButtonStyling("primary", "md"))}>
|
||||
Go to home
|
||||
</Link>
|
||||
<Button variant="neutral-primary" size="md" onClick={handleSignOut}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start gap-6">
|
||||
<Button variant="primary" size="md" onClick={() => router.push("/")}>
|
||||
Go to home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
import { BreadcrumbNavigationDropdown, Breadcrumbs, ISvgIcons } from "@plane/ui";
|
||||
import { ISvgIcons } from "@plane/propel/icons";
|
||||
import { BreadcrumbNavigationDropdown, Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation";
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@ import { observer } from "mobx-react";
|
||||
import { Check } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { Spinner, StateGroupIcon } from "@plane/ui";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// store hooks
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
// types
|
||||
import { Briefcase, FileText, Layers, LayoutGrid } from "lucide-react";
|
||||
import { ContrastIcon, DiceIcon } from "@plane/propel/icons";
|
||||
import {
|
||||
IWorkspaceDefaultSearchResult,
|
||||
IWorkspaceIssueSearchResult,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
IWorkspaceSearchResult,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { ContrastIcon, DiceIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { generateWorkItemLink } from "@plane/utils";
|
||||
// plane web components
|
||||
|
||||
@@ -2,9 +2,8 @@ import { FC, ReactNode, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { TIssueComment } from "@plane/types";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { EIssueCommentAccessSpecifier, TIssueComment } from "@plane/types";
|
||||
import { Avatar, Tooltip } from "@plane/ui";
|
||||
import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
@@ -27,7 +26,13 @@ export const CommentBlock: FC<TCommentBlock> = observer((props) => {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!comment || !userDetails) return null;
|
||||
const displayName = comment?.actor_detail?.is_bot
|
||||
? comment?.actor_detail?.first_name + ` ${t("bot")}`
|
||||
: (userDetails?.display_name ?? comment?.actor_detail?.display_name);
|
||||
|
||||
const avatarUrl = userDetails?.avatar_url ?? comment?.actor_detail?.avatar_url;
|
||||
|
||||
if (!comment) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -43,20 +48,15 @@ export const CommentBlock: FC<TCommentBlock> = observer((props) => {
|
||||
"flex-shrink-0 relative w-7 h-6 rounded-full transition-border duration-1000 flex justify-center items-center z-[3] uppercase font-medium"
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
size="base"
|
||||
name={userDetails?.display_name}
|
||||
src={getFileURL(userDetails?.avatar_url)}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<Avatar size="base" name={displayName} src={getFileURL(avatarUrl)} className="flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 truncate flex-grow">
|
||||
<div className="flex w-full gap-2">
|
||||
<div className="flex-1 flex flex-wrap items-center gap-1">
|
||||
<div className="text-xs font-medium">
|
||||
{comment?.actor_detail?.is_bot
|
||||
? comment?.actor_detail?.first_name + ` ${t("bot")}`
|
||||
: comment?.actor_detail?.display_name || userDetails.display_name}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium">
|
||||
{`${displayName}${comment.access === EIssueCommentAccessSpecifier.EXTERNAL ? " (External User)" : ""}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-custom-text-300">
|
||||
commented{" "}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PlaneLogo } from "@plane/ui";
|
||||
import { PlaneLogo } from "@plane/propel/icons";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
// package.json
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
|
||||
export const MaintenanceMessage = observer(() => {
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
export const MaintenanceMessage = () => {
|
||||
const linkMap = [
|
||||
{
|
||||
key: "mail_to",
|
||||
label: "Contact Support",
|
||||
value: "mailto:support@plane.so",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<h1 className="text-xl font-medium text-custom-text-100 text-center md:text-left">
|
||||
{t(
|
||||
"self_hosted_maintenance_message.plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start"
|
||||
)}
|
||||
<br />
|
||||
{t("self_hosted_maintenance_message.choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure")}
|
||||
</h1>
|
||||
<>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h1 className="text-xl font-semibold text-custom-text-100 text-left">
|
||||
🚧 Looks like Plane didn't start up correctly!
|
||||
</h1>
|
||||
<span className="text-base font-medium text-custom-text-200 text-left">
|
||||
Some services might have failed to start. Please check your container logs to identify and resolve the issue.
|
||||
If you're stuck, reach out to our support team for more help.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-6 mt-1">
|
||||
{linkMap.map((link) => (
|
||||
<div key={link.key}>
|
||||
<a
|
||||
href={link.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-custom-primary-100 hover:underline text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
// types
|
||||
import { DiceIcon, DoubleCircleIcon, ISvgIcons } from "@plane/propel/icons";
|
||||
import { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types";
|
||||
import { DiceIcon, DoubleCircleIcon, ISvgIcons } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
SpreadsheetAssigneeColumn,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
export const PageNavigationPaneAssetsTabEmptyState = () => {
|
||||
// asset resolved path
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/pages/navigation-pane/assets" });
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/wiki/navigation-pane/assets" });
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
export const PageNavigationPaneOutlineTabEmptyState = () => {
|
||||
// asset resolved path
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/pages/navigation-pane/outline" });
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/wiki/navigation-pane/outline" });
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FileText, Layers } from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EProjectFeatureKey } from "@plane/constants";
|
||||
import { ContrastIcon, DiceIcon, Intake, LayersIcon } from "@plane/ui";
|
||||
import { ContrastIcon, DiceIcon, LayersIcon, Intake } from "@plane/propel/icons";
|
||||
// components
|
||||
import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
|
||||
import { RelatedIcon } from "@plane/ui";
|
||||
import { RelatedIcon } from "@plane/propel/icons";
|
||||
import type { TRelationObject } from "@/components/issues/issue-detail-widgets/relations";
|
||||
import type { TIssueRelationTypes } from "../../types";
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { EIssueLayoutTypes } from "@plane/types";
|
||||
import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssueLayoutTypes, IProjectView } from "@plane/types";
|
||||
import { TContextMenuItem } from "@plane/ui";
|
||||
import { TWorkspaceLayoutProps } from "@/components/views/helper";
|
||||
|
||||
export type TLayoutSelectionProps = {
|
||||
@@ -10,3 +13,68 @@ export type TLayoutSelectionProps = {
|
||||
export const GlobalViewLayoutSelection = (props: TLayoutSelectionProps) => <></>;
|
||||
|
||||
export const WorkspaceAdditionalLayouts = (props: TWorkspaceLayoutProps) => <></>;
|
||||
|
||||
export type TMenuItemsFactoryProps = {
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
setDeleteViewModal: (open: boolean) => void;
|
||||
setCreateUpdateViewModal: (open: boolean) => void;
|
||||
handleOpenInNewTab: () => void;
|
||||
handleCopyText: () => void;
|
||||
isLocked: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId?: string;
|
||||
viewId: string;
|
||||
};
|
||||
|
||||
export const useMenuItemsFactory = (props: TMenuItemsFactoryProps) => {
|
||||
const { isOwner, isAdmin, setDeleteViewModal, setCreateUpdateViewModal, handleOpenInNewTab, handleCopyText } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const editMenuItem = () => ({
|
||||
key: "edit",
|
||||
action: () => setCreateUpdateViewModal(true),
|
||||
title: t("edit"),
|
||||
icon: Pencil,
|
||||
shouldRender: isOwner,
|
||||
});
|
||||
|
||||
const openInNewTabMenuItem = () => ({
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: t("open_in_new_tab"),
|
||||
icon: ExternalLink,
|
||||
});
|
||||
|
||||
const copyLinkMenuItem = () => ({
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: t("copy_link"),
|
||||
icon: Link,
|
||||
});
|
||||
|
||||
const deleteMenuItem = () => ({
|
||||
key: "delete",
|
||||
action: () => setDeleteViewModal(true),
|
||||
title: t("delete"),
|
||||
icon: Trash2,
|
||||
shouldRender: isOwner || isAdmin,
|
||||
});
|
||||
|
||||
return {
|
||||
editMenuItem,
|
||||
openInNewTabMenuItem,
|
||||
copyLinkMenuItem,
|
||||
deleteMenuItem,
|
||||
};
|
||||
};
|
||||
|
||||
export const useViewMenuItems = (props: TMenuItemsFactoryProps): TContextMenuItem[] => {
|
||||
const factory = useMenuItemsFactory(props);
|
||||
|
||||
return [factory.editMenuItem(), factory.openInNewTabMenuItem(), factory.copyLinkMenuItem(), factory.deleteMenuItem()];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const AdditionalHeaderItems = (view: IProjectView) => <></>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BarChart2, Briefcase, Home, Inbox, Layers, PenSquare } from "lucide-react";
|
||||
import { ArchiveIcon, ContrastIcon, UserActivityIcon } from "@plane/ui";
|
||||
import { ArchiveIcon, ContrastIcon, UserActivityIcon } from "@plane/propel/icons";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export const getSidebarNavigationItemIcon = (key: string, className: string = "") => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ReactNode } from "react";
|
||||
import { FileText, Layers, Timer } from "lucide-react";
|
||||
// plane imports
|
||||
import { ContrastIcon, DiceIcon, Intake } from "@plane/propel/icons";
|
||||
import { IProject } from "@plane/types";
|
||||
import { ContrastIcon, DiceIcon, Intake } from "@plane/ui";
|
||||
|
||||
export type TProperties = {
|
||||
key: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Briefcase, FileText, Layers, LucideIcon } from "lucide-react";
|
||||
// plane imports
|
||||
import { ContrastIcon, DiceIcon, FavoriteFolderIcon, ISvgIcons } from "@plane/propel/icons";
|
||||
import { IFavorite } from "@plane/types";
|
||||
import { ContrastIcon, DiceIcon, FavoriteFolderIcon, ISvgIcons } from "@plane/ui";
|
||||
|
||||
export const FAVORITE_ITEM_ICONS: Record<string, React.FC<ISvgIcons> | LucideIcon> = {
|
||||
page: FileText,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./project";
|
||||
export * from "./workspace.service";
|
||||
export * from "@/services/workspace.service";
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./estimate.service";
|
||||
export * from "./view.service";
|
||||
export * from "@/services/view.service";
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { EViewAccess, TPublishViewSettings } from "@plane/types";
|
||||
import { ViewService as CoreViewService } from "@/services/view.service";
|
||||
|
||||
export class ViewService extends CoreViewService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async updateViewAccess(workspaceSlug: string, projectId: string, viewId: string, access: EViewAccess) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async lockView(workspaceSlug: string, projectId: string, viewId: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async unLockView(workspaceSlug: string, projectId: string, viewId: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async getPublishDetails(workspaceSlug: string, projectId: string, viewId: string): Promise<any> {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
async publishView(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
workspaceSlug: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
projectId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
viewId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
data: TPublishViewSettings
|
||||
): Promise<any> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async updatePublishedView(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
workspaceSlug: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
projectId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
viewId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
data: Partial<TPublishViewSettings>
|
||||
): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async unPublishView(workspaceSlug: string, projectId: string, viewId: string): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { EViewAccess } from "@plane/types";
|
||||
import { WorkspaceService as CoreWorkspaceService } from "@/services/workspace.service";
|
||||
|
||||
export class WorkspaceService extends CoreWorkspaceService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async updateViewAccess(workspaceSlug: string, viewId: string, access: EViewAccess) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async lockView(workspaceSlug: string, viewId: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async unLockView(workspaceSlug: string, viewId: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@/store/global-view.store";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@/store/project-view.store";
|
||||
@@ -1,28 +1,17 @@
|
||||
// plane package imports
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import { IAnalyticsResponseFields } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import TrendPiece from "./trend-piece";
|
||||
|
||||
export type InsightCardProps = {
|
||||
data?: IAnalyticsResponseFields;
|
||||
label: string;
|
||||
isLoading?: boolean;
|
||||
versus?: string | null;
|
||||
};
|
||||
|
||||
const InsightCard = (props: InsightCardProps) => {
|
||||
const { data, label, isLoading, versus } = props;
|
||||
const { count, filter_count } = data || {};
|
||||
const percentage = useMemo(() => {
|
||||
if (count != null && filter_count != null) {
|
||||
const result = ((count - filter_count) / count) * 100;
|
||||
const isFiniteAndNotNaNOrZero = Number.isFinite(result) && !Number.isNaN(result) && result !== 0;
|
||||
return isFiniteAndNotNaNOrZero ? result : null;
|
||||
}
|
||||
return null;
|
||||
}, [count, filter_count]);
|
||||
const { data, label, isLoading = false } = props;
|
||||
const count = data?.count ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -30,12 +19,6 @@ const InsightCard = (props: InsightCardProps) => {
|
||||
{!isLoading ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-2xl font-bold text-custom-text-100">{count}</div>
|
||||
{/* {percentage && (
|
||||
<div className="flex gap-1 text-xs text-custom-text-300">
|
||||
<TrendPiece percentage={percentage} size="xs" />
|
||||
{versus && <div>vs {versus}</div>}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
) : (
|
||||
<Loader.Item height="50px" width="100%" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user