Compare commits

..
Author SHA1 Message Date
Jayash Tripathy 5cb98904bd fix: merge conflicts 2025-09-16 18:19:20 +05:30
Jayash Tripathy d719b787fa Merge branch 'preview' of https://github.com/makeplane/plane into feat-propel-tabs-implementation 2025-09-16 18:19:10 +05:30
Anmol Singh BhatiaandGitHub bf45635a7b [WEB-4898] fix: extended sidebar toggle #7797 2025-09-16 15:35:31 +05:30
NikhilandGitHub 56d3a9e049 [WEB-4900] refactor: remove base_host retrieval from authentication views (#7804)
* refactor: remove base_host retrieval from authentication views

* Removed unnecessary base_host retrieval from GitHub, GitLab, and Google callback endpoints.
* Updated MagicSignUpEndpoint to use get_safe_redirect_url for URL construction.
* Refactored MagicSignInSpaceEndpoint to streamline URL redirection logic.

* refactor: streamline URL redirection in MagicSignInSpaceEndpoint

* Removed redundant base_url retrieval from the exception handling in MagicSignInSpaceEndpoint.
* Enhanced the clarity of URL construction by directly using get_safe_redirect_url.
2025-09-16 10:57:20 +05:30
sriram veeraghanta 1f7eef5f81 chore: django import error 2025-09-16 01:24:48 +05:30
sriram veeraghanta bd2272a7da chore (deps): pnpm lockfile overrides update 2025-09-16 00:55:35 +05:30
sriram veeraghanta b9c6bb07bf chore(deps): axios version upgrade to 1.12.0 2025-09-16 00:14:18 +05:30
345dfce25d [WEB-4900]: validated authentication redirection paths (#7798)
* refactor: replace validate_next_path with get_safe_redirect_url for safer URL redirection across authentication views

* refactor: use get_safe_redirect_url for improved URL redirection in SignInAuthSpaceEndpoint and SignUpAuthSpaceEndpoint

* fix: redirect paths

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-09-16 00:01:06 +05:30
116c8118ab [WIKI-659] chore: added issue relation and page sort order (#7784)
* chore: added issue relation and page sort order

* feat: add ProjectWebhook model to manage webhooks associated with projects

* chore: updated the migration file

* chore: added migration

* chore: reverted the page base code

* chore: added a variable for sort order in pages

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-09-15 18:36:00 +05:30
c3e7cfd16b [WEB-4723] fix: disable project features on project create (#7625)
* fix: disbale project features on project create

* Implement migration 0105 to alter project cycle view fields to Boolean with default values

* Add project view settings in workspace seed task

* Add is_current_version_deprecated field to Instance model

Index user_id field in Session model

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-09-12 13:01:03 +05:30
Jayash TripathyandGitHub 9ffc30f7b1 [WEB-4889] refactor: add fill in bar chart bar stroke (#7776)
* ♻️ refactor: add fill in barchart bar stroke

* ♻️ refactor: added fill in the circle
2025-09-12 00:04:16 +05:30
Dheeraj Kumar KetireddyandGitHub b60f12a88e [WEB-4861] fix: update redirection path in MagicSignInEndpoint to home page (#7774)
* fix: update redirection path in MagicSignInEndpoint to home page

* Use / for clarity
2025-09-11 18:56:32 +05:30
Vamsi KrishnaandGitHub 76a0b38dd1 [WEB-4890]fix: dropdown width #7778 2025-09-11 18:56:04 +05:30
SangeethaandGitHub 8ee665f491 [WEB-4875] fix: unsubscribed work items on workspace subscribed work item filter #7775 2025-09-11 17:52:51 +05:30
Vamsi KrishnaandGitHub 85f23b450d [WEB-4852] chore: views refactor (#7729)
* chore: refactored view store and services

* chore: removed unused import

* chore: refactored update view component

* fix: lint errors
2025-09-11 17:09:56 +05:30
Vamsi KrishnaandGitHub 8bf059535a [WEB-4858]chore: updated content for error page (#7766)
* chore: updated content for error page

* chore: updated btn url
2025-09-11 14:22:46 +05:30
Vamsi KrishnaandGitHub 4cfea87108 [WEB-4857] fix: applied filters root update #7750 2025-09-11 14:21:30 +05:30
Vamsi KrishnaandGitHub 4fe2ef706b [WEB-4441]fix: members account type dropdown position #7759 2025-09-11 14:20:10 +05:30
sriram veeraghantaGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
8d354b3eb2 Potential fix for code scanning alert no. 636: URL redirection from remote source (#7760)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-11 14:18:13 +05:30
SangeethaandGitHub ec541c2557 [WEB-4854] chore: project admin accesss to workspace admins (#7749)
* chore: project admin accesss to workspace admins

* chore: frontend changes

* chore: remove console.log

* chore: refactor permission decorator

* chore: role enum

* chore: rearrange role_choices
2025-09-11 14:16:36 +05:30
11cd8d11e4 [WIKI-632] chore: accept additional props for document collaborative editor (#7718)
* chore: add collaborative document editor extended props

* fix: additional rich text extension props

* fix: formatting

* chore: add types to the trailing node extension

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2025-09-11 14:15:39 +05:30
Jayash TripathyandGitHub 0f7bfdde91 [WEB-4877] fix: webapp crash because of bar chart (#7763)
* 🔧 fix: dynamic bar color handling and refactored color retrieval logic.

* ♻️  refactor: updated any to Record in getBarColor
2025-09-10 17:44:52 +05:30
Vamsi KrishnaandGitHub ac835bf287 [WEB-4874]fix: calendar picker build errors and styles override (#7762)
* fix: calender picker build errors

* fix: styles override in the picker implementation
2025-09-10 17:10:23 +05:30
sriram veeraghanta db18c3555c Merge branch 'canary' of github.com:makeplane/plane into preview 2025-09-10 14:45:14 +05:30
sriram veeraghantaandCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> b696ae91ed Potential fix for code scanning alert no. 646: Server-side request forgery (#7758)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-10 14:44:34 +05:30
sriramveeraghanta 61e91bd09c chore (deps): django and vite version bump 2025-09-10 14:44:34 +05:30
sriram veeraghantaGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
20d773042b Potential fix for code scanning alert no. 646: Server-side request forgery (#7758)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-10 14:42:46 +05:30
sriramveeraghanta 30b175108b chore (deps): django and vite version bump 2025-09-10 13:38:30 +05:30
6d116beea3 [WEB-4320] dev: propel emoji reaction component (#7741)
* dev: animated counter added to propel

* chore: animated counter story added

* chore: propel config updated

* chore: code refactor

* dev: emoji reaction and renderer component added to propel

* dev: emoji reaction story added

* chore: propel config updated

* chore: code refactor

* fix: format error

* chore: lint error resolved

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-09-10 00:54:47 +05:30
b0db4fcf10 [WEB-4736] dev: propel button (#7746)
* dev: button added to propel

* dev: button story added

* chore: propel config updated

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-09-10 00:34:17 +05:30
Anmol Singh BhatiaandGitHub 7e03264758 [WEB-4730] dev: propel context menu component (#7745)
* dev: context menu component added

* dev: context menu story added

* chore: propel config updated
2025-09-10 00:15:55 +05:30
Anmol Singh BhatiaandGitHub 1c8ac3d247 [WEB-4737] dev: propel pill component (#7743)
* dev: pill component added to propel

* dev: pill story added

* chore: propel config updated

* chore: code refactor
2025-09-10 00:15:37 +05:30
3b8bb1effc [WEB-4733] dev: propel toolbar component (#7742)
* dev: toolbar component added to propel

* dev: toolbar story added

* chore: propel config updated

* chore: code refactor

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-09-10 00:15:10 +05:30
Anmol Singh BhatiaandGitHub 5a63e6dad2 [WEB-4858] chore: error page ui revamp #7747 2025-09-10 00:03:39 +05:30
Anmol Singh BhatiaandGitHub 45688bdc72 [WEB-4860] dev: propel animated counter component (#7740)
* dev: animated counter added to propel

* chore: animated counter story added

* chore: propel config updated

* chore: code refactor

* chore: code refactor

* fix: format error
2025-09-09 23:51:45 +05:30
Jayash TripathyandGitHub 43b7a6ad0a [WEB-4855] refactor: chart tick improvements (#7732)
* 🚧 WIP: Introduced customTicks prop in BarChart for flexible tick rendering.

*  feat: added customTicks to axis charts for flexible tick rendering

* 🔧 fix: update default bar fill color to black and ensure consistent color usage in BarChart

*  feat: add customTooltipContent prop to LineChart for enhanced tooltip flexibility

* 🔧 fix: update bar fill color handling to support dynamic colors based on data and removed DEFAULT_BAR_FILL_COLOR

* 🔧 fix: correct bar fill color handling in BarChart to ensure proper color assignment for tooltips

* 🔧 fix: update customTicks prop types in TAxisChartProps to use unknown type for better type safety

* 📝 chore: updated translations and cleaned up insight card

* 🚨 fix: lint

* 🔧 fix: remove unused translation key "no_of" from Russian translations
2025-09-09 23:51:13 +05:30
Anmol Singh BhatiaandGitHub 498613284e [WEB-4841] chore: calendar component migration UI to propel (#7730)
* chore: move calendar components and dependencies

* chore: update package configurations

* chore: calendar import updated

* chore: propel config updated

* chore: propel calendar code refactor

* chore: code refactor

* fix: build error
2025-09-09 23:50:51 +05:30
Anmol Singh BhatiaandGitHub 9ab3143a73 [WEB-4816] chore: add label flow (#7716)
* chore: remove create label modal

* fix: label spinner

* chore: add label flow improvements

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-09-09 23:50:39 +05:30
56cd0fc445 [WEB-4025] fix: external user comment and reaction (#7692)
* chore: reactions types updated

* fix: external user comments

* fix: external user reactions

* chore: added display name for actor

* chore: merge conflicts

* chore: updated the created_by and updated_by

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-09-09 23:50:11 +05:30
Vamsi KrishnaandGitHub 260d9a053d [WEB-4802] fix: module sort order (#7674)
* fix: module sort order

* chore: removed secondary sort for progress

* chore: sort refactor
2025-09-09 23:49:30 +05:30
Vipin ChaudharyandGitHub 34bdc2ad76 [WIKI-636] fix: replace project loader with page loader #7752 2025-09-09 19:27:00 +05:30
99bc4262c5 [WIKI-654] fix: version history to load from html (#7754)
* fix: versions history to load from html

* chore: added json and stripped to store in version history (#7755)

---------

Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
2025-09-09 19:26:19 +05:30
Aaryan KhandelwalandGitHub 49127ebeea [WIKI-653] fix: page content loader #7753 2025-09-09 18:20:31 +05:30
Vamsi KrishnaandGitHub a8a6536379 [WEB-4688]feat: added collapsible to propel (#7643)
* chore: added collapsibl to propel

* fix: export path

* chore: made collapsible a compound component

* fix: lint and format errors

* chore: updated propel exports order and added collapsible to tsdown config
2025-09-09 13:59:00 +05:30
Vamsi KrishnaandGitHub 11b83cf4f2 [WEB-4692]feat: added toast to propel package (#7640)
* * chore: migrated toast to base ui toast
* chore: added toast to propel package

* fix: lint errors

* fix: export path

* fix: lint errors

* chore: imporoved props handling

* fix: lint errors

* chore: updated import paths

* chore: replaced clsx with cn

* * chore: updated tsdown config
* chore: updated exports order
2025-09-09 13:58:11 +05:30
Anmol Singh BhatiaandGitHub fbd48c33f5 [WEB-4729] dev: propel scrollarea (#7748)
* dev: scrollarea added to propel

* dev: scrollarea story added

* chore: propel config updated

* fix: format error
2025-09-09 13:56:28 +05:30
Vamsi KrishnaandGitHub 37ce8a9fe6 [WEB-4800]chore: settings header component refactor #7669 2025-09-09 13:42:40 +05:30
Bavisetti NarayanandGitHub 291101a8e5 chore: added attributes in ol tag (#7736) 2025-09-08 17:17:38 +05:30
Vipin ChaudharyandGitHub 0cb4976e38 [WIKI-647] chore: update image path (#7734)
* chore: update image path

* chore: update asset path
2025-09-08 15:44:56 +05:30
Anmol Singh BhatiaandGitHub 20d139cc9e [WEB-4839] chore: maintenance mode empty state updated (#7728)
* chore: maintenance mode empty state updated

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-09-05 20:16:39 +05:30
ff181e566f [WEB-4738] feat: add Skeleton component with respective stories in propel (#7704)
*  feat: add Skeleton component with respective stories in propel

* 🚨 fix: lint issues

* ♻️ refactor : enhance Skeleton component with improved props and structure

*  feat: add Skeleton component entry to package.json

* 🚨 fix: lint

* ♻️ refactor: rename Skeleton to skeleton

* fix: rename fixes

* ♻️ refactor: rename skeleton

* 🚨 fix: lint

* ♻️ refactor: add skeleton entry to tsdown config and package.json

* ♻️  refactor: sorted propel exports

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-09-05 15:51:05 +05:30
Jayash TripathyandGitHub a696b6039c [WEB-4835] feat: storybook enhancements (#7702)
*  feat: integrate Storybook addons and enhance Tabs component

- Added `@storybook/addon-designs` and `@storybook/addon-docs` to the project dependencies.
- Enhanced the Tabs component stories to support dynamic tab options.
- Introduced a custom theme for Storybook with a new manager configuration.
- Added a new SVG logo for branding in Storybook.
- Pinned the storybook and related packages version

* ♻️  refactor: update Storybook manager configuration by removing unused import

* ♻️  refactor: enhance Tabs story with size labels and layout adjustments

* 🚨 fix: lint issues

* 🚨 fix: lock file

* ✏️  fix: update brand image path in Storybook manager configuration

* ♻️ refactor: improve Tabs story by ensuring safe default value and enhancing content rendering
2025-09-05 14:55:33 +05:30
Vipin ChaudharyandGitHub af1dcd335e [WIKI-640] chore: refactor link view #7727 2025-09-05 14:14:30 +05:30
Aaryan KhandelwalandGitHub a1500c2206 [WIKI-644] fix: remove unnecessary props from the editor package #7726 2025-09-05 13:50:44 +05:30
Aaryan KhandelwalandGitHub 88f194ca8e [WIKI-637] fix: work item description version history (#7725)
* fix: work item description version history

* chore: add key to the editor
2025-09-04 18:57:50 +05:30
Aaryan KhandelwalandGitHub b68d2ca921 [WIKI-642] fix: editor bubble menu overflow issues #7724 2025-09-04 18:16:03 +05:30
Aaryan KhandelwalandGitHub 78c6aede52 [WIKI-641] fix: overwrite tailwind typography plugin styles #7723 2025-09-04 18:14:48 +05:30
sriramveeraghanta c6c46f9aab Merge branch 'canary' of github.com:makeplane/plane into preview 2025-09-04 14:48:27 +05:30
Anmol Singh BhatiaandGitHub 7d7e37439d [WEB-4840] chore: icon migration from @plane/ui to @plane/propel/icons (#7721)
* chore: move icons from ui package to propel package

* chore: package and tsdown config updated

* chore: migrate all icon imports from @plane/ui to @plane/propel/icons

* chore: remove icon components from @plane/ui package (migrated to
  @plane/propel/icons)

* chore: code refactoring

* chore: migrate remaining icon components from @Plane/ui to @Plane/propel/icons

* fix: lint error

* chore: code refactor
2025-09-04 14:44:53 +05:30
Akshat JainandGitHub d258080ee7 [INFRA-235] fix: docker compose file restart_policy changed to any (#7689)
* chore: update docker-compose.yml to change restart policy condition from 'on-failure' to 'any' and remove SSL variable from variables.env

* fix: update docker-compose.yml to change restart policy condition from 'any' to 'on-failure'
2025-09-04 14:18:45 +05:30
Bavisetti Narayanandsriramveeraghanta c40c7804e7 [WEB-4806] chore: updated html attribute validation #7719 2025-09-04 13:53:21 +05:30
Bavisetti NarayanandGitHub 36c735bede [WEB-4806] chore: updated html attribute validation #7719 2025-09-04 13:52:43 +05:30
Bavisetti Narayanandsriramveeraghanta cab5d20217 [WEB-4806] chore: improved the html validation (#7676)
* chore: improved the html validation

* chore: removed the models changes

* chore: removed extra filters

* chore: changed the protocols
2025-09-03 19:57:37 +05:30
Jayash Tripathy 8bf02b2a7f refactor: cleanup 2025-08-21 16:35:38 +05:30
Jayash Tripathy 35f91c3d9b refactor: enhance type safety for tab change handlers across components 2025-08-21 16:34:41 +05:30
Jayash Tripathy cf7a35ab73 refactor: expose Tabs components for better modularity 2025-08-21 16:34:36 +05:30
Jayash Tripathy 5de96eee02 refactor: update image upload instructions for clarity 2025-08-21 16:34:29 +05:30
Jayash Tripathy 0c645a77f9 refactor: cleanup 2025-08-21 16:34:01 +05:30
Jayash Tripathy 4f061399a5 refactor: migrate components to use propel/tabs for improved tab functionality and type safety 2025-08-21 16:33:59 +05:30
Jayash Tripathy c0fb81271e refacator: improved types of tabs and migrated some components to use propel/tabs 2025-08-21 16:33:27 +05:30
Jayash Tripathy 3e571d4e4b refactor: improve type definitions for TabListItem and TabItem 2025-08-21 16:32:14 +05:30
Jayash Tripathy 813679d21f refactor: type safety in tabs 2025-08-21 16:32:12 +05:30
517 changed files with 12710 additions and 3559 deletions
@@ -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 -1
View File
@@ -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">
+20 -2
View File
@@ -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
+22 -13
View File
@@ -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()
+20 -3
View File
@@ -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,
)
+39 -13
View File
@@ -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()
):
@@ -1,6 +1,3 @@
# Python imports
from urllib.parse import urlencode, urljoin
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
@@ -19,7 +16,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class SignInAuthEndpoint(View):
@@ -34,11 +31,11 @@ class SignInAuthEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
# Base URL join
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -58,10 +55,10 @@ class SignInAuthEndpoint(View):
)
params = exc.get_error_dict()
# Next path
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -76,10 +73,10 @@ class SignInAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -92,10 +89,10 @@ class SignInAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -112,19 +109,23 @@ class SignInAuthEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(validate_next_path(next_path))
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host(request=request, is_app=True), path)
# Get the safe redirect URL
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -141,10 +142,10 @@ class SignUpAuthEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -161,10 +162,10 @@ class SignUpAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
# Validate the email
@@ -179,10 +180,10 @@ class SignUpAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -197,10 +198,10 @@ class SignUpAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -217,17 +218,21 @@ class SignUpAuthEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(validate_next_path(next_path))
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host(request=request, is_app=True), path)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -1,5 +1,5 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
@@ -16,8 +16,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class GitHubOauthInitiateEndpoint(View):
def get(self, request):
@@ -35,10 +34,10 @@ class GitHubOauthInitiateEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -49,10 +48,10 @@ class GitHubOauthInitiateEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -61,7 +60,6 @@ class GitHubCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
@@ -70,9 +68,11 @@ class GitHubCallbackEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
@@ -81,9 +81,11 @@ class GitHubCallbackEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -93,17 +95,23 @@ class GitHubCallbackEndpoint(View):
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(validate_next_path(next_path))
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, path)
# Get the safe redirect URL
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={}
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,5 +1,5 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
@@ -16,7 +16,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class GitLabOauthInitiateEndpoint(View):
@@ -25,7 +25,7 @@ class GitLabOauthInitiateEndpoint(View):
request.session["host"] = base_host(request=request, is_app=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(validate_next_path(next_path))
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
@@ -35,10 +35,10 @@ class GitLabOauthInitiateEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -49,10 +49,10 @@ class GitLabOauthInitiateEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -61,7 +61,6 @@ class GitLabCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
@@ -70,9 +69,11 @@ class GitLabCallbackEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
@@ -81,9 +82,11 @@ class GitLabCallbackEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -94,16 +97,23 @@ class GitLabCallbackEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(validate_next_path(next_path))
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, path)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={}
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,6 +1,5 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
@@ -18,7 +17,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class GoogleOauthInitiateEndpoint(View):
@@ -36,10 +35,10 @@ class GoogleOauthInitiateEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -51,10 +50,10 @@ class GoogleOauthInitiateEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -63,7 +62,6 @@ class GoogleCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
@@ -72,9 +70,11 @@ class GoogleCallbackEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
@@ -82,9 +82,11 @@ class GoogleCallbackEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
provider = GoogleOAuthProvider(
@@ -94,15 +96,21 @@ class GoogleCallbackEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(
base_host, str(validate_next_path(next_path)) if next_path else path
if next_path:
path = next_path
else:
path = get_redirection_path(user=user)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={}
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,6 +1,3 @@
# Python imports
from urllib.parse import urlencode, urljoin
# Django imports
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
@@ -26,7 +23,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
)
from plane.authentication.rate_limit import AuthenticationThrottle
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class MagicGenerateEndpoint(APIView):
@@ -72,10 +69,10 @@ class MagicSignInEndpoint(View):
error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -88,10 +85,10 @@ class MagicSignInEndpoint(View):
error_message="USER_DOES_NOT_EXIST",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -107,7 +104,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 = (
@@ -116,15 +114,19 @@ class MagicSignInEndpoint(View):
else str(get_redirection_path(user=user))
)
# redirect to referer path
url = urljoin(base_host(request=request, is_app=True), path)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -144,10 +146,10 @@ class MagicSignUpEndpoint(View):
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
# Existing user
@@ -158,10 +160,10 @@ class MagicSignUpEndpoint(View):
error_message="USER_ALREADY_EXIST",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -177,18 +179,22 @@ class MagicSignUpEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(validate_next_path(next_path))
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host(request=request, is_app=True), path)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -1,6 +1,3 @@
# Python imports
from urllib.parse import urlencode
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
@@ -17,7 +14,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class SignInAuthSpaceEndpoint(View):
@@ -32,9 +29,11 @@ class SignInAuthSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
# set the referer as session to redirect after login
@@ -51,9 +50,11 @@ class SignInAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
# Validate email
@@ -67,9 +68,11 @@ class SignInAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
# Existing User
@@ -82,9 +85,11 @@ class SignInAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -95,13 +100,19 @@ class SignInAuthSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to next path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params={}
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -117,9 +128,11 @@ class SignUpAuthSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
email = request.POST.get("email", False)
@@ -135,9 +148,11 @@ class SignUpAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
# Validate the email
email = email.strip().lower()
@@ -151,9 +166,11 @@ class SignUpAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
# Existing User
@@ -166,9 +183,11 @@ class SignUpAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -179,11 +198,17 @@ class SignUpAuthSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params={}
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,6 +1,5 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
@@ -15,7 +14,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class GitHubOauthInitiateSpaceEndpoint(View):
@@ -23,9 +22,6 @@ class GitHubOauthInitiateSpaceEndpoint(View):
# Get host and next path
request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
@@ -34,9 +30,11 @@ class GitHubOauthInitiateSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -47,9 +45,11 @@ class GitHubOauthInitiateSpaceEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -66,9 +66,11 @@ class GitHubCallbackSpaceEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
@@ -77,9 +79,11 @@ class GitHubCallbackSpaceEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -89,11 +93,17 @@ class GitHubCallbackSpaceEndpoint(View):
user_login(request=request, user=user, is_space=True)
# Process workspace and project invitations
# redirect to referer path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,6 +1,5 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
@@ -15,7 +14,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class GitLabOauthInitiateSpaceEndpoint(View):
@@ -23,8 +22,6 @@ class GitLabOauthInitiateSpaceEndpoint(View):
# Get host and next path
request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
@@ -34,9 +31,11 @@ class GitLabOauthInitiateSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -47,9 +46,11 @@ class GitLabOauthInitiateSpaceEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -66,9 +67,11 @@ class GitLabCallbackSpaceEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
@@ -77,9 +80,11 @@ class GitLabCallbackSpaceEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -89,11 +94,17 @@ class GitLabCallbackSpaceEndpoint(View):
user_login(request=request, user=user, is_space=True)
# Process workspace and project invitations
# redirect to referer path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,6 +1,5 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
@@ -15,15 +14,13 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class GoogleOauthInitiateSpaceEndpoint(View):
def get(self, request):
request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
@@ -33,9 +30,11 @@ class GoogleOauthInitiateSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -46,9 +45,11 @@ class GoogleOauthInitiateSpaceEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -65,9 +66,11 @@ class GoogleCallbackSpaceEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
@@ -75,9 +78,11 @@ class GoogleCallbackSpaceEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
provider = GoogleOAuthProvider(request=request, code=code)
@@ -85,11 +90,17 @@ class GoogleCallbackSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,6 +1,3 @@
# Python imports
from urllib.parse import urlencode
# Django imports
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
@@ -23,7 +20,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class MagicGenerateSpaceEndpoint(APIView):
@@ -66,9 +63,11 @@ class MagicSignInSpaceEndpoint(View):
error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
existing_user = User.objects.filter(email=email).first()
@@ -79,9 +78,11 @@ class MagicSignInSpaceEndpoint(View):
error_message="USER_DOES_NOT_EXIST",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
# Active User
@@ -93,15 +94,18 @@ class MagicSignInSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
path = str(next_path) if next_path else ""
url = f"{base_host(request=request, is_space=True)}{path}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True), next_path=next_path
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -120,9 +124,11 @@ class MagicSignUpSpaceEndpoint(View):
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
# Existing User
existing_user = User.objects.filter(email=email).first()
@@ -133,9 +139,11 @@ class MagicSignUpSpaceEndpoint(View):
error_message="USER_ALREADY_EXIST",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
try:
@@ -146,12 +154,16 @@ class MagicSignUpSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True), next_path=next_path
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -7,7 +7,7 @@ from django.utils import timezone
# Module imports
from plane.authentication.utils.host import base_host, user_ip
from plane.db.models import User
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class SignOutAuthSpaceEndpoint(View):
@@ -22,8 +22,14 @@ class SignOutAuthSpaceEndpoint(View):
user.save()
# Log the user out
logout(request)
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path
)
return HttpResponseRedirect(url)
except Exception:
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path
)
return HttpResponseRedirect(url)
@@ -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),
),
]
@@ -0,0 +1,152 @@
# Generated by Django 4.2.22 on 2025-09-12 08:45
import uuid
import django
from django.conf import settings
from django.db import migrations, models
def set_page_sort_order(apps, schema_editor):
Page = apps.get_model("db", "Page")
batch_size = 3000
sort_order = 100
# Get page IDs ordered by name using the historical model
# This should include all pages regardless of soft-delete status
page_ids = list(Page.objects.all().order_by("name").values_list("id", flat=True))
updated_pages = []
for page_id in page_ids:
# Create page instance with minimal data
updated_pages.append(Page(id=page_id, sort_order=sort_order))
sort_order += 100
# Bulk update when batch is full
if len(updated_pages) >= batch_size:
Page.objects.bulk_update(
updated_pages, ["sort_order"], batch_size=batch_size
)
updated_pages = []
# Update remaining pages
if updated_pages:
Page.objects.bulk_update(updated_pages, ["sort_order"], batch_size=batch_size)
def reverse_set_page_sort_order(apps, schema_editor):
Page = apps.get_model("db", "Page")
Page.objects.update(sort_order=Page.DEFAULT_SORT_ORDER)
class Migration(migrations.Migration):
dependencies = [
("db", "0105_alter_project_cycle_view_and_more"),
]
operations = [
migrations.CreateModel(
name="ProjectWebhook",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"webhook",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_webhooks",
to="db.webhook",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Project Webhook",
"verbose_name_plural": "Project Webhooks",
"db_table": "project_webhooks",
"ordering": ("-created_at",),
},
),
migrations.AddConstraint(
model_name="projectwebhook",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("project", "webhook"),
name="project_webhook_unique_project_webhook_when_deleted_at_null",
),
),
migrations.AlterUniqueTogether(
name="projectwebhook",
unique_together={("project", "webhook", "deleted_at")},
),
migrations.AlterField(
model_name="issuerelation",
name="relation_type",
field=models.CharField(
default="blocked_by", max_length=20, verbose_name="Issue Relation Type"
),
),
migrations.RunPython(
set_page_sort_order, reverse_code=reverse_set_page_sort_order
),
]
+1 -1
View File
@@ -284,6 +284,7 @@ class IssueRelationChoices(models.TextChoices):
BLOCKED_BY = "blocked_by", "Blocked By"
START_BEFORE = "start_before", "Start Before"
FINISH_BEFORE = "finish_before", "Finish Before"
IMPLEMENTED_BY = "implemented_by", "Implemented By"
class IssueRelation(ProjectBaseModel):
@@ -295,7 +296,6 @@ class IssueRelation(ProjectBaseModel):
)
relation_type = models.CharField(
max_length=20,
choices=IssueRelationChoices.choices,
verbose_name="Issue Relation Type",
default=IssueRelationChoices.BLOCKED_BY,
)
+2 -1
View File
@@ -19,6 +19,7 @@ def get_view_props():
class Page(BaseModel):
PRIVATE_ACCESS = 1
PUBLIC_ACCESS = 0
DEFAULT_SORT_ORDER = 65535
ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public"))
@@ -57,7 +58,7 @@ class Page(BaseModel):
)
moved_to_page = models.UUIDField(null=True, blank=True)
moved_to_project = models.UUIDField(null=True, blank=True)
sort_order = models.FloatField(default=65535)
sort_order = models.FloatField(default=DEFAULT_SORT_ORDER)
external_id = models.CharField(max_length=255, null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
+9 -3
View File
@@ -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)
+1 -1
View File
@@ -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):
+22 -1
View File
@@ -7,7 +7,7 @@ from django.db import models
from django.core.exceptions import ValidationError
# Module imports
from plane.db.models import BaseModel
from plane.db.models import BaseModel, ProjectBaseModel
def generate_token():
@@ -90,3 +90,24 @@ class WebhookLog(BaseModel):
def __str__(self):
return f"{self.event_type} {str(self.webhook)}"
class ProjectWebhook(ProjectBaseModel):
webhook = models.ForeignKey(
"db.Webhook", on_delete=models.CASCADE, related_name="project_webhooks"
)
class Meta:
unique_together = ["project", "webhook", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "webhook"],
condition=models.Q(deleted_at__isnull=True),
name="project_webhook_unique_project_webhook_when_deleted_at_null",
)
]
verbose_name = "Project Webhook"
verbose_name_plural = "Project Webhooks"
db_table = "project_webhooks"
ordering = ("-created_at",)
+10 -2
View File
@@ -34,6 +34,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
)
from plane.utils.ip_address import get_client_ip
from plane.utils.path_validator import get_safe_redirect_url
class InstanceAdminEndpoint(BaseAPIView):
@@ -392,7 +393,14 @@ class InstanceAdminSignOutEndpoint(View):
user.save()
# Log the user out
logout(request)
url = urljoin(base_host(request=request, is_admin=True))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_admin=True),
next_path=""
)
return HttpResponseRedirect(url)
except Exception:
return HttpResponseRedirect(base_host(request=request, is_admin=True))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_admin=True),
next_path=""
)
return HttpResponseRedirect(url)
@@ -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"
+46 -40
View File
@@ -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"}
+2
View File
@@ -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
@@ -6,12 +6,14 @@ def get_inverse_relation(relation_type):
"blocking": "blocked_by",
"start_before": "start_after",
"finish_before": "finish_after",
"implemented_by": "implements",
"implements": "implemented_by",
}
return relation_mapping.get(relation_type, relation_type)
def get_actual_relation(relation_type):
# This function is used to get the actual relation type which is store in database
# This function is used to get the actual relation type which is stored in database
actual_relation = {
"start_after": "start_before",
"finish_after": "finish_before",
@@ -19,6 +21,8 @@ def get_actual_relation(relation_type):
"blocked_by": "blocked_by",
"start_before": "start_before",
"finish_before": "finish_before",
"implemented_by": "implemented_by",
"implements": "implemented_by",
}
return actual_relation.get(relation_type, relation_type)
+82 -5
View File
@@ -2,20 +2,97 @@
from urllib.parse import urlparse
def _contains_suspicious_patterns(path: str) -> bool:
"""
Check for suspicious patterns that might indicate malicious intent.
Args:
path (str): The path to check
Returns:
bool: True if suspicious patterns found, False otherwise
"""
suspicious_patterns = [
r'javascript:', # JavaScript injection
r'data:', # Data URLs
r'vbscript:', # VBScript injection
r'file:', # File protocol
r'ftp:', # FTP protocol
r'%2e%2e', # URL encoded path traversal
r'%2f%2f', # URL encoded double slash
r'%5c%5c', # URL encoded backslashes
r'<script', # Script tags
r'<iframe', # Iframe tags
r'<object', # Object tags
r'<embed', # Embed tags
r'<form', # Form tags
r'onload=', # Event handlers
r'onerror=', # Event handlers
r'onclick=', # Event handlers
]
path_lower = path.lower()
for pattern in suspicious_patterns:
if pattern in path_lower:
return True
return False
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.
if not next_path or not isinstance(next_path, str):
return ""
# Limit input length to prevent DoS attacks
if len(next_path) > 500:
return ""
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 ""
# Additional security checks
if _contains_suspicious_patterns(next_path):
return ""
return next_path
def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) -> str:
"""
Safely construct a redirect URL with validated next_path.
Args:
base_url (str): The base URL to redirect to
next_path (str): The next path to append
params (dict): The parameters to append
Returns:
str: The safe redirect URL
"""
from urllib.parse import urlencode
# Validate the next path
validated_path = validate_next_path(next_path)
# Add the next path to the parameters
if validated_path:
params["next_path"] = validated_path
# Return the safe redirect URL
return f"{base_url.rstrip('/')}?{urlencode(params)}"
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.22
Django==4.2.24
# rest framework
djangorestframework==3.15.2
# postgres
+1 -1
View File
@@ -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"]
-2
View File
@@ -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",
+14
View File
@@ -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.`);
};
+15
View File
@@ -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.`);
};
+1
View File
@@ -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",
});
}
}
-5
View File
@@ -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];
+117
View File
@@ -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 }),
},
});
};
+39
View File
@@ -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
): {
+69
View File
@@ -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,
});
};
+33
View File
@@ -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;
}
};
+15
View File
@@ -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,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,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;
+1
View File
@@ -0,0 +1 @@
export * from "../../ce/lib/fetch-document.js";
+1
View File
@@ -0,0 +1 @@
export * from "../../ce/lib/update-document.js";
+1
View File
@@ -0,0 +1 @@
export * from "../../ce/types/common.js";
-191
View File
@@ -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;
}
}
-210
View File
@@ -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
View File
@@ -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();
});
-43
View File
@@ -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
View File
@@ -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";
+1 -1
View File
@@ -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
@@ -6,7 +6,8 @@ import { useRouter } from "next/navigation";
// plane package imports
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { type TabItem, Tabs } from "@plane/ui";
import { TAnalyticsTabsBase } from "@plane/types";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@plane/propel/tabs";
// components
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
import { PageHead } from "@/components/core/page-title";
@@ -59,20 +60,11 @@ const AnalyticsPage = observer((props: Props) => {
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
: undefined;
const ANALYTICS_TABS = useMemo(() => getAnalyticsTabs(t), [t]);
const tabs: TabItem[] = useMemo(
() =>
ANALYTICS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
content: <tab.content />,
onClick: () => {
router.push(`/${currentWorkspace?.slug}/analytics/${tab.key}`);
},
disabled: tab.isDisabled,
})),
[ANALYTICS_TABS, router, currentWorkspace?.slug]
);
const defaultTab = tabId || ANALYTICS_TABS[0].key;
const defaultTab = (tabId as TAnalyticsTabsBase) || ANALYTICS_TABS[0].key;
const handleTabChange = (value: TAnalyticsTabsBase) => {
router.push(`/${currentWorkspace?.slug}/analytics/${value}`);
};
return (
<>
@@ -80,19 +72,25 @@ const AnalyticsPage = observer((props: Props) => {
{workspaceProjectIds && (
<>
{workspaceProjectIds.length > 0 || loader === "init-loader" ? (
<div className="flex h-full overflow-hidden bg-custom-background-100 justify-between items-center ">
<Tabs
tabs={tabs}
storageKey={`analytics-page-${currentWorkspace?.id}`}
defaultTab={defaultTab}
size="md"
tabListContainerClassName="px-6 py-2 border-b border-custom-border-200 flex items-center justify-between"
tabListClassName="my-2 w-auto"
tabClassName="px-3"
tabPanelClassName="h-full overflow-hidden overflow-y-auto px-2"
storeInLocalStorage={false}
actions={<AnalyticsFilterActions />}
/>
<div className="flex h-full overflow-hidden bg-custom-background-100 justify-between items-center">
<Tabs value={defaultTab} onValueChange={handleTabChange} className="flex flex-col w-full h-full">
<div className="px-6 py-2 border-b border-custom-border-200 flex items-center justify-between">
<TabsList className="my-2 w-auto">
{ANALYTICS_TABS.map((tab) => (
<TabsTrigger key={tab.key} value={tab.key} size="md" className="px-3" disabled={tab.isDisabled}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<AnalyticsFilterActions />
</div>
{ANALYTICS_TABS.map((tab) => (
<TabsContent key={tab.key} value={tab.key} className="h-full overflow-hidden overflow-y-auto px-2">
<tab.content />
</TabsContent>
))}
</Tabs>
</div>
) : (
<DetailedEmptyState
@@ -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";
@@ -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
@@ -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";
@@ -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";
@@ -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
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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";
+1 -1
View File
@@ -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
View File
@@ -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">
&#x1F6A7; 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";
@@ -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">
&#x1F6A7; Looks like Plane didn&apos;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&apos;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>
</>
);
});
};

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