diff --git a/.gitignore b/.gitignore index f19497acce..f0093a0e68 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,4 @@ build/ .react-router/ AGENTS.md temp/ +scripts/ diff --git a/apps/admin/package.json b/apps/admin/package.json index 7c551b91e4..635eac0ee0 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -48,6 +48,7 @@ "uuid": "catalog:" }, "devDependencies": { + "@dotenvx/dotenvx": "catalog:", "@plane/eslint-config": "workspace:*", "@plane/tailwind-config": "workspace:*", "@plane/typescript-config": "workspace:*", @@ -57,7 +58,6 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "dotenv": "^16.4.5", "typescript": "catalog:", "vite": "catalog:", "vite-tsconfig-paths": "^5.1.4" diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 97993fcb72..c9d97157f4 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -1,6 +1,6 @@ import path from "node:path"; +import * as dotenv from "@dotenvx/dotenvx"; import { reactRouter } from "@react-router/dev/vite"; -import dotenv from "dotenv"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { joinUrlPath } from "@plane/utils"; diff --git a/apps/api/.env.example b/apps/api/.env.example index f158e3d7cc..4c84bd6837 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -32,6 +32,9 @@ AWS_S3_ENDPOINT_URL="http://localhost:9000" AWS_S3_BUCKET_NAME="uploads" # Maximum file upload limit FILE_SIZE_LIMIT=5242880 +# Signed URL expiration time in seconds (default: 3600 = 1 hour) +# Set to 30 for 30 seconds, 300 for 5 minutes, etc. +SIGNED_URL_EXPIRATION=3600 # Settings related to Docker DOCKERIZED=1 # deprecated diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index b58b9fdcb7..6525ddce63 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -54,4 +54,5 @@ from .asset import ( FileAssetSerializer, ) from .invite import WorkspaceInviteSerializer -from .member import ProjectMemberSerializer \ No newline at end of file +from .member import ProjectMemberSerializer +from .sticky import StickySerializer \ No newline at end of file diff --git a/apps/api/plane/api/serializers/cycle.py b/apps/api/plane/api/serializers/cycle.py index 6b7bfa442f..f2724231a2 100644 --- a/apps/api/plane/api/serializers/cycle.py +++ b/apps/api/plane/api/serializers/cycle.py @@ -4,7 +4,7 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from plane.db.models import Cycle, CycleIssue, User +from plane.db.models import Cycle, CycleIssue, User, Project from plane.utils.timezone_converter import convert_to_utc @@ -55,6 +55,18 @@ class CycleCreateSerializer(BaseSerializer): ] def validate(self, data): + project_id = self.initial_data.get("project_id") or ( + self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None + ) + + if not project_id: + raise serializers.ValidationError("Project ID is required") + + project = Project.objects.filter(id=project_id).first() + if not project: + raise serializers.ValidationError("Project not found") + if not project.cycle_view: + raise serializers.ValidationError("Cycles are not enabled for this project") if ( data.get("start_date", None) is not None and data.get("end_date", None) is not None @@ -63,13 +75,6 @@ class CycleCreateSerializer(BaseSerializer): raise serializers.ValidationError("Start date cannot exceed end date") if data.get("start_date", None) is not None and data.get("end_date", None) is not None: - project_id = self.initial_data.get("project_id") or ( - self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None - ) - - if not project_id: - raise serializers.ValidationError("Project ID is required") - data["start_date"] = convert_to_utc( date=str(data.get("start_date").date()), project_id=project_id, diff --git a/apps/api/plane/api/serializers/module.py b/apps/api/plane/api/serializers/module.py index 77be453c88..d1e3b0d81a 100644 --- a/apps/api/plane/api/serializers/module.py +++ b/apps/api/plane/api/serializers/module.py @@ -10,6 +10,7 @@ from plane.db.models import ( ModuleMember, ModuleIssue, ProjectMember, + Project, ) @@ -53,6 +54,14 @@ class ModuleCreateSerializer(BaseSerializer): ] def validate(self, data): + project_id = self.context.get("project_id") + if not project_id: + raise serializers.ValidationError("Project ID is required") + project = Project.objects.get(id=project_id) + if not project: + raise serializers.ValidationError("Project not found") + if not project.module_view: + raise serializers.ValidationError("Modules are not enabled for this project") if ( data.get("start_date", None) is not None and data.get("target_date", None) is not None diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py index 5b30703611..770957e08c 100644 --- a/apps/api/plane/api/serializers/project.py +++ b/apps/api/plane/api/serializers/project.py @@ -17,7 +17,7 @@ from plane.utils.content_validator import ( from .base import BaseSerializer -class ProjectCreateSerializer(BaseSerializer): +class ProjectCreateSerializer(BaseSerializer): """ Serializer for creating projects with workspace validation. @@ -171,7 +171,7 @@ class ProjectUpdateSerializer(ProjectCreateSerializer): if ( validated_data.get("estimate", None) is not None - and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate")).exists() + and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate").id).exists() ): # Check if the estimate is a estimate in the project raise serializers.ValidationError("Estimate should be a estimate in the project") diff --git a/apps/api/plane/api/serializers/sticky.py b/apps/api/plane/api/serializers/sticky.py new file mode 100644 index 0000000000..067fc1b899 --- /dev/null +++ b/apps/api/plane/api/serializers/sticky.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from .base import BaseSerializer +from plane.db.models import Sticky +from plane.utils.content_validator import validate_html_content, validate_binary_data + + +class StickySerializer(BaseSerializer): + class Meta: + model = Sticky + fields = "__all__" + read_only_fields = ["workspace", "owner"] + extra_kwargs = {"name": {"required": False}} + + def validate(self, data): + # Validate description content for security + if "description_html" in data and data["description_html"]: + is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"]) + if not is_valid: + raise serializers.ValidationError({"error": "html content is not valid"}) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html + + if "description_binary" in data and data["description_binary"]: + is_valid, error_msg = validate_binary_data(data["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) + + return data diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index d239b67887..593501939c 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -9,6 +9,7 @@ from .state import urlpatterns as state_patterns from .user import urlpatterns as user_patterns from .work_item import urlpatterns as work_item_patterns from .invite import urlpatterns as invite_patterns +from .sticky import urlpatterns as sticky_patterns urlpatterns = [ *asset_patterns, @@ -22,4 +23,5 @@ urlpatterns = [ *user_patterns, *work_item_patterns, *invite_patterns, + *sticky_patterns, ] diff --git a/apps/api/plane/api/urls/sticky.py b/apps/api/plane/api/urls/sticky.py new file mode 100644 index 0000000000..0066e77ea4 --- /dev/null +++ b/apps/api/plane/api/urls/sticky.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from plane.api.views import StickyViewSet + + +router = DefaultRouter() +router.register(r"stickies", StickyViewSet, basename="workspace-stickies") + +urlpatterns = [ + path("workspaces//", include(router.urls)), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 280c23bc23..75b1b17c40 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -54,4 +54,6 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpo from .user import UserEndpoint -from .invite import WorkspaceInvitationsViewset \ No newline at end of file +from .invite import WorkspaceInvitationsViewset + +from .sticky import StickyViewSet diff --git a/apps/api/plane/api/views/sticky.py b/apps/api/plane/api/views/sticky.py new file mode 100644 index 0000000000..a5173edc73 --- /dev/null +++ b/apps/api/plane/api/views/sticky.py @@ -0,0 +1,109 @@ +from rest_framework.response import Response +from rest_framework import status + +from plane.api.views.base import BaseViewSet +from plane.app.permissions import WorkspaceUserPermission +from plane.db.models import Sticky, Workspace +from plane.api.serializers import StickySerializer + +# OpenAPI imports +from plane.utils.openapi.decorators import sticky_docs + +from drf_spectacular.utils import OpenApiRequest, OpenApiResponse +from plane.utils.openapi import ( + STICKY_EXAMPLE, + create_paginated_response, + DELETED_RESPONSE, +) + + +class StickyViewSet(BaseViewSet): + serializer_class = StickySerializer + model = Sticky + use_read_replica = True + permission_classes = [WorkspaceUserPermission] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(owner_id=self.request.user.id) + .distinct() + ) + + @sticky_docs( + operation_id="create_sticky", + summary="Create a new sticky", + description="Create a new sticky in the workspace", + request=OpenApiRequest(request=StickySerializer), + responses={ + 201: OpenApiResponse(description="Sticky created", response=StickySerializer, examples=[STICKY_EXAMPLE]) + }, + ) + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + serializer = StickySerializer(data=request.data) + if serializer.is_valid(): + serializer.save(workspace_id=workspace.id, owner_id=request.user.id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @sticky_docs( + operation_id="list_stickies", + summary="List stickies", + description="List all stickies in the workspace", + responses={ + 200: create_paginated_response( + StickySerializer, "Sticky", "List of stickies", example_name="List of stickies" + ) + }, + ) + def list(self, request, slug): + query = request.query_params.get("query", False) + stickies = self.get_queryset().order_by("-created_at") + if query: + stickies = stickies.filter(description_stripped__icontains=query) + + return self.paginate( + request=request, + queryset=(stickies), + on_results=lambda stickies: StickySerializer(stickies, many=True).data, + default_per_page=20, + ) + + @sticky_docs( + operation_id="retrieve_sticky", + summary="Retrieve a sticky", + description="Retrieve a sticky by its ID", + responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])}, + ) + def retrieve(self, request, slug, pk): + sticky = self.get_object() + return Response(StickySerializer(sticky).data) + + @sticky_docs( + operation_id="update_sticky", + summary="Update a sticky", + description="Update a sticky by its ID", + request=OpenApiRequest(request=StickySerializer), + responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])}, + ) + def partial_update(self, request, slug, pk): + sticky = self.get_object() + serializer = StickySerializer(sticky, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @sticky_docs( + operation_id="delete_sticky", + summary="Delete a sticky", + description="Delete a sticky by its ID", + responses={204: DELETED_RESPONSE}, + ) + def destroy(self, request, slug, pk): + sticky = self.get_object() + sticky.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/urls/project.py b/apps/api/plane/app/urls/project.py index 701e8933c7..a6dd8d8a8a 100644 --- a/apps/api/plane/app/urls/project.py +++ b/apps/api/plane/app/urls/project.py @@ -11,7 +11,6 @@ from plane.app.views import ( ProjectIdentifierEndpoint, ProjectFavoritesViewSet, UserProjectInvitationsViewset, - ProjectPublicCoverImagesEndpoint, UserProjectRolesEndpoint, ProjectArchiveUnarchiveEndpoint, ProjectMemberPreferenceEndpoint, @@ -106,11 +105,6 @@ urlpatterns = [ ProjectFavoritesViewSet.as_view({"delete": "destroy"}), name="project-favorite", ), - path( - "project-covers/", - ProjectPublicCoverImagesEndpoint.as_view(), - name="project-covers", - ), path( "workspaces//projects//project-deploy-boards/", DeployBoardViewSet.as_view({"get": "list", "post": "create"}), diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 5f848a5ba0..7a0e5cb3a2 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -3,7 +3,6 @@ from .project.base import ( ProjectIdentifierEndpoint, ProjectUserViewsEndpoint, ProjectFavoritesViewSet, - ProjectPublicCoverImagesEndpoint, DeployBoardViewSet, ProjectArchiveUnarchiveEndpoint, ) diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index 3aa3564916..3dd1e3db42 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -1,43 +1,44 @@ # Python imports -import boto3 -from django.conf import settings -from django.utils import timezone import json +import boto3 + # Django imports -from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery +from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery +from django.utils import timezone # Third Party imports -from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import AllowAny +from rest_framework.response import Response # Module imports -from plane.app.views.base import BaseViewSet, BaseAPIView +from plane.app.permissions import ROLE, ProjectMemberPermission, allow_permission from plane.app.serializers import ( - ProjectSerializer, - ProjectListSerializer, DeployBoardSerializer, + ProjectListSerializer, + ProjectSerializer, ) - -from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE +from plane.app.views.base import BaseAPIView, BaseViewSet +from plane.bgtasks.recent_visited_task import recent_visited_task +from plane.bgtasks.webhook_task import model_activity, webhook_activity from plane.db.models import ( UserFavorite, - Intake, DeployBoard, + Intake, IssueUserProperty, Project, ProjectIdentifier, ProjectMember, + ProjectNetwork, State, DEFAULT_STATES, Workspace, WorkspaceMember, ) from plane.utils.cache import cache_response -from plane.bgtasks.webhook_task import model_activity, webhook_activity -from plane.bgtasks.recent_visited_task import recent_visited_task from plane.utils.exception_logger import log_exception from plane.utils.host import base_host @@ -210,19 +211,25 @@ class ProjectViewSet(BaseViewSet): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def retrieve(self, request, slug, pk): - project = ( - self.get_queryset() - .filter( - project_projectmember__member=self.request.user, - project_projectmember__is_active=True, - ) - .filter(archived_at__isnull=True) - .filter(pk=pk) - ).first() + project = self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk).first() if project is None: return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND) + member_ids = [str(project_member.member_id) for project_member in project.members_list] + + if str(request.user.id) not in member_ids: + if project.network == ProjectNetwork.SECRET.value: + return Response( + {"error": "You do not have permission"}, + status=status.HTTP_403_FORBIDDEN, + ) + else: + return Response( + {"error": "You are not a member of this project"}, + status=status.HTTP_409_CONFLICT, + ) + recent_visited_task.delay( slug=slug, project_id=pk, @@ -519,49 +526,6 @@ class ProjectFavoritesViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class ProjectPublicCoverImagesEndpoint(BaseAPIView): - permission_classes = [AllowAny] - - # Cache the below api for 24 hours - @cache_response(60 * 60 * 24, user=False) - def get(self, request): - files = [] - if settings.USE_MINIO: - s3 = boto3.client( - "s3", - endpoint_url=settings.AWS_S3_ENDPOINT_URL, - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - ) - else: - s3 = boto3.client( - "s3", - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - ) - params = { - "Bucket": settings.AWS_STORAGE_BUCKET_NAME, - "Prefix": "static/project-cover/", - } - - try: - response = s3.list_objects_v2(**params) - # Extracting file keys from the response - if "Contents" in response: - for content in response["Contents"]: - if not content["Key"].endswith( - "/" - ): # This line ensures we're only getting files, not "sub-folders" - files.append( - f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" - ) - - return Response(files, status=status.HTTP_200_OK) - except Exception as e: - log_exception(e) - return Response([], status=status.HTTP_200_OK) - - class DeployBoardViewSet(BaseViewSet): permission_classes = [ProjectMemberPermission] serializer_class = DeployBoardSerializer diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py index 69d45226ce..3ab7061e15 100644 --- a/apps/api/plane/app/views/project/member.py +++ b/apps/api/plane/app/views/project/member.py @@ -164,6 +164,40 @@ class ProjectMemberViewSet(BaseViewSet): serializer = ProjectMemberRoleSerializer(project_members, fields=("id", "member", "role"), many=True) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def retrieve(self, request, slug, project_id, pk): + requesting_project_member = ProjectMember.objects.get( + project_id=project_id, + workspace__slug=slug, + member=request.user, + is_active=True, + ) + + project_member = ( + ProjectMember.objects.filter( + pk=pk, + project_id=project_id, + workspace__slug=slug, + member__is_bot=False, + is_active=True, + ) + .select_related("project", "member", "workspace") + .first() + ) + + if not project_member: + return Response( + {"error": "Project member not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if requesting_project_member.role > ROLE.GUEST.value: + serializer = ProjectMemberAdminSerializer(project_member) + else: + serializer = ProjectMemberRoleSerializer(project_member, fields=("id", "member", "role")) + + return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def partial_update(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, is_active=True) diff --git a/apps/api/plane/app/views/user/base.py b/apps/api/plane/app/views/user/base.py index e9d6b04ee7..30b0391838 100644 --- a/apps/api/plane/app/views/user/base.py +++ b/apps/api/plane/app/views/user/base.py @@ -2,8 +2,6 @@ import uuid import json import logging -import random -import string import secrets # Django imports @@ -151,13 +149,7 @@ class UserEndpoint(BaseViewSet): # Include user ID to bind the code to the specific user cache_key = f"magic_email_update_{user.id}_{new_email}" ## Generate a random token - token = ( - "".join(secrets.choice(string.ascii_lowercase) for _ in range(4)) - + "-" - + "".join(secrets.choice(string.ascii_lowercase) for _ in range(4)) - + "-" - + "".join(secrets.choice(string.ascii_lowercase) for _ in range(4)) - ) + token = str(secrets.randbelow(900000) + 100000) # Store in cache with 10 minute expiration cache_data = json.dumps({"token": token}) cache.set(cache_key, cache_data, timeout=600) diff --git a/apps/api/plane/app/views/workspace/member.py b/apps/api/plane/app/views/workspace/member.py index d81a647f69..3394cb253f 100644 --- a/apps/api/plane/app/views/workspace/member.py +++ b/apps/api/plane/app/views/workspace/member.py @@ -50,6 +50,25 @@ class WorkSpaceMemberViewSet(BaseViewSet): serializer = WorkSpaceMemberSerializer(workspace_members, fields=("id", "member", "role"), many=True) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def retrieve(self, request, slug, pk): + workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True) + + try: + # Get the specific workspace member by pk + member = self.get_queryset().get(pk=pk) + except WorkspaceMember.DoesNotExist: + return Response( + {"error": "Workspace member not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if workspace_member.role > ROLE.GUEST.value: + serializer = WorkspaceMemberAdminSerializer(member, fields=("id", "member", "role")) + else: + serializer = WorkSpaceMemberSerializer(member, fields=("id", "member", "role")) + return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def partial_update(self, request, slug, pk): workspace_member = WorkspaceMember.objects.get( diff --git a/apps/api/plane/authentication/provider/credentials/magic_code.py b/apps/api/plane/authentication/provider/credentials/magic_code.py index 3f03572a47..e7c5cfff95 100644 --- a/apps/api/plane/authentication/provider/credentials/magic_code.py +++ b/apps/api/plane/authentication/provider/credentials/magic_code.py @@ -1,8 +1,7 @@ # Python imports import json import os -import random -import string +import secrets # Module imports @@ -50,13 +49,7 @@ class MagicCodeProvider(CredentialAdapter): def initiate(self): ## Generate a random token - token = ( - "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - ) + token = str(secrets.randbelow(900000) + 100000) ri = redis_instance() diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py index d24a145644..cf58632ac4 100644 --- a/apps/api/plane/db/models/__init__.py +++ b/apps/api/plane/db/models/__init__.py @@ -52,6 +52,7 @@ from .project import ( ProjectIdentifier, ProjectMember, ProjectMemberInvite, + ProjectNetwork, ProjectPublicMember, ) from .session import Session diff --git a/apps/api/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py index 23eeebec1c..fed0c5e17e 100644 --- a/apps/api/plane/license/api/views/instance.py +++ b/apps/api/plane/license/api/views/instance.py @@ -175,6 +175,7 @@ class InstanceEndpoint(BaseAPIView): data["app_base_url"] = settings.APP_BASE_URL data["instance_changelog_url"] = settings.INSTANCE_CHANGELOG_URL + data["is_self_managed"] = settings.IS_SELF_MANAGED instance_data = serializer.data instance_data["workspaces_exist"] = Workspace.objects.count() >= 1 diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 4178052166..a9e9925c28 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -25,6 +25,9 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key()) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = int(os.environ.get("DEBUG", "0")) +# Self-hosted mode +IS_SELF_MANAGED = True + # Allowed Hosts ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",") @@ -69,9 +72,7 @@ MIDDLEWARE = [ # Rest Framework settings REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework.authentication.SessionAuthentication", - ), + "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",), "DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",), "DEFAULT_THROTTLE_RATES": { "anon": "30/minute", diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py index 0a07200863..7c048f679a 100644 --- a/apps/api/plane/settings/storage.py +++ b/apps/api/plane/settings/storage.py @@ -29,6 +29,8 @@ class S3Storage(S3Boto3Storage): self.aws_region = os.environ.get("AWS_REGION") # Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL self.aws_s3_endpoint_url = os.environ.get("AWS_S3_ENDPOINT_URL") or os.environ.get("MINIO_ENDPOINT_URL") + # Use the SIGNED_URL_EXPIRATION environment variable for the expiration time (default: 3600 seconds) + self.signed_url_expiration = int(os.environ.get("SIGNED_URL_EXPIRATION", "3600")) if os.environ.get("USE_MINIO") == "1": # Determine protocol based on environment variable @@ -56,8 +58,10 @@ class S3Storage(S3Boto3Storage): config=boto3.session.Config(signature_version="s3v4"), ) - def generate_presigned_post(self, object_name, file_type, file_size, expiration=3600): + def generate_presigned_post(self, object_name, file_type, file_size, expiration=None): """Generate a presigned URL to upload an S3 object""" + if expiration is None: + expiration = self.signed_url_expiration fields = {"Content-Type": file_type} conditions = [ @@ -104,13 +108,15 @@ class S3Storage(S3Boto3Storage): def generate_presigned_url( self, object_name, - expiration=3600, + expiration=None, http_method="GET", disposition="inline", filename=None, ): - content_disposition = self._get_content_disposition(disposition, filename) """Generate a presigned URL to share an S3 object""" + if expiration is None: + expiration = self.signed_url_expiration + content_disposition = self._get_content_disposition(disposition, filename) try: response = self.s3_client.generate_presigned_url( "get_object", diff --git a/apps/api/plane/tests/unit/settings/__init__.py b/apps/api/plane/tests/unit/settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/api/plane/tests/unit/settings/test_storage.py b/apps/api/plane/tests/unit/settings/test_storage.py new file mode 100644 index 0000000000..fe8cf43f8b --- /dev/null +++ b/apps/api/plane/tests/unit/settings/test_storage.py @@ -0,0 +1,202 @@ +import os +from unittest.mock import Mock, patch +import pytest +from plane.settings.storage import S3Storage + + +@pytest.mark.unit +class TestS3StorageSignedURLExpiration: + """Test the configurable signed URL expiration in S3Storage""" + + @patch.dict(os.environ, {}, clear=True) + @patch("plane.settings.storage.boto3") + def test_default_expiration_without_env_variable(self, mock_boto3): + """Test that default expiration is 3600 seconds when env variable is not set""" + # Mock the boto3 client + mock_boto3.client.return_value = Mock() + + # Create S3Storage instance without SIGNED_URL_EXPIRATION env variable + storage = S3Storage() + + # Assert default expiration is 3600 + assert storage.signed_url_expiration == 3600 + + @patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "30"}, clear=True) + @patch("plane.settings.storage.boto3") + def test_custom_expiration_with_env_variable(self, mock_boto3): + """Test that expiration is read from SIGNED_URL_EXPIRATION env variable""" + # Mock the boto3 client + mock_boto3.client.return_value = Mock() + + # Create S3Storage instance with SIGNED_URL_EXPIRATION=30 + storage = S3Storage() + + # Assert expiration is 30 + assert storage.signed_url_expiration == 30 + + @patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "300"}, clear=True) + @patch("plane.settings.storage.boto3") + def test_custom_expiration_multiple_values(self, mock_boto3): + """Test that expiration works with different custom values""" + # Mock the boto3 client + mock_boto3.client.return_value = Mock() + + # Create S3Storage instance with SIGNED_URL_EXPIRATION=300 + storage = S3Storage() + + # Assert expiration is 300 + assert storage.signed_url_expiration == 300 + + @patch.dict( + os.environ, + { + "AWS_ACCESS_KEY_ID": "test-key", + "AWS_SECRET_ACCESS_KEY": "test-secret", + "AWS_S3_BUCKET_NAME": "test-bucket", + "AWS_REGION": "us-east-1", + }, + clear=True, + ) + @patch("plane.settings.storage.boto3") + def test_generate_presigned_post_uses_default_expiration(self, mock_boto3): + """Test that generate_presigned_post uses the configured default expiration""" + # Mock the boto3 client and its response + mock_s3_client = Mock() + mock_s3_client.generate_presigned_post.return_value = { + "url": "https://test-url.com", + "fields": {}, + } + mock_boto3.client.return_value = mock_s3_client + + # Create S3Storage instance + storage = S3Storage() + + # Call generate_presigned_post without explicit expiration + storage.generate_presigned_post("test-object", "image/png", 1024) + + # Assert that the boto3 method was called with the default expiration (3600) + mock_s3_client.generate_presigned_post.assert_called_once() + call_kwargs = mock_s3_client.generate_presigned_post.call_args[1] + assert call_kwargs["ExpiresIn"] == 3600 + + @patch.dict( + os.environ, + { + "AWS_ACCESS_KEY_ID": "test-key", + "AWS_SECRET_ACCESS_KEY": "test-secret", + "AWS_S3_BUCKET_NAME": "test-bucket", + "AWS_REGION": "us-east-1", + "SIGNED_URL_EXPIRATION": "60", + }, + clear=True, + ) + @patch("plane.settings.storage.boto3") + def test_generate_presigned_post_uses_custom_expiration(self, mock_boto3): + """Test that generate_presigned_post uses custom expiration from env variable""" + # Mock the boto3 client and its response + mock_s3_client = Mock() + mock_s3_client.generate_presigned_post.return_value = { + "url": "https://test-url.com", + "fields": {}, + } + mock_boto3.client.return_value = mock_s3_client + + # Create S3Storage instance with SIGNED_URL_EXPIRATION=60 + storage = S3Storage() + + # Call generate_presigned_post without explicit expiration + storage.generate_presigned_post("test-object", "image/png", 1024) + + # Assert that the boto3 method was called with custom expiration (60) + mock_s3_client.generate_presigned_post.assert_called_once() + call_kwargs = mock_s3_client.generate_presigned_post.call_args[1] + assert call_kwargs["ExpiresIn"] == 60 + + @patch.dict( + os.environ, + { + "AWS_ACCESS_KEY_ID": "test-key", + "AWS_SECRET_ACCESS_KEY": "test-secret", + "AWS_S3_BUCKET_NAME": "test-bucket", + "AWS_REGION": "us-east-1", + }, + clear=True, + ) + @patch("plane.settings.storage.boto3") + def test_generate_presigned_url_uses_default_expiration(self, mock_boto3): + """Test that generate_presigned_url uses the configured default expiration""" + # Mock the boto3 client and its response + mock_s3_client = Mock() + mock_s3_client.generate_presigned_url.return_value = "https://test-url.com" + mock_boto3.client.return_value = mock_s3_client + + # Create S3Storage instance + storage = S3Storage() + + # Call generate_presigned_url without explicit expiration + storage.generate_presigned_url("test-object") + + # Assert that the boto3 method was called with the default expiration (3600) + mock_s3_client.generate_presigned_url.assert_called_once() + call_kwargs = mock_s3_client.generate_presigned_url.call_args[1] + assert call_kwargs["ExpiresIn"] == 3600 + + @patch.dict( + os.environ, + { + "AWS_ACCESS_KEY_ID": "test-key", + "AWS_SECRET_ACCESS_KEY": "test-secret", + "AWS_S3_BUCKET_NAME": "test-bucket", + "AWS_REGION": "us-east-1", + "SIGNED_URL_EXPIRATION": "30", + }, + clear=True, + ) + @patch("plane.settings.storage.boto3") + def test_generate_presigned_url_uses_custom_expiration(self, mock_boto3): + """Test that generate_presigned_url uses custom expiration from env variable""" + # Mock the boto3 client and its response + mock_s3_client = Mock() + mock_s3_client.generate_presigned_url.return_value = "https://test-url.com" + mock_boto3.client.return_value = mock_s3_client + + # Create S3Storage instance with SIGNED_URL_EXPIRATION=30 + storage = S3Storage() + + # Call generate_presigned_url without explicit expiration + storage.generate_presigned_url("test-object") + + # Assert that the boto3 method was called with custom expiration (30) + mock_s3_client.generate_presigned_url.assert_called_once() + call_kwargs = mock_s3_client.generate_presigned_url.call_args[1] + assert call_kwargs["ExpiresIn"] == 30 + + @patch.dict( + os.environ, + { + "AWS_ACCESS_KEY_ID": "test-key", + "AWS_SECRET_ACCESS_KEY": "test-secret", + "AWS_S3_BUCKET_NAME": "test-bucket", + "AWS_REGION": "us-east-1", + "SIGNED_URL_EXPIRATION": "30", + }, + clear=True, + ) + @patch("plane.settings.storage.boto3") + def test_explicit_expiration_overrides_default(self, mock_boto3): + """Test that explicit expiration parameter overrides the default""" + # Mock the boto3 client and its response + mock_s3_client = Mock() + mock_s3_client.generate_presigned_url.return_value = "https://test-url.com" + mock_boto3.client.return_value = mock_s3_client + + # Create S3Storage instance with SIGNED_URL_EXPIRATION=30 + storage = S3Storage() + + # Call generate_presigned_url with explicit expiration=120 + storage.generate_presigned_url("test-object", expiration=120) + + # Assert that the boto3 method was called with explicit expiration (120) + mock_s3_client.generate_presigned_url.assert_called_once() + call_kwargs = mock_s3_client.generate_presigned_url.call_args[1] + assert call_kwargs["ExpiresIn"] == 120 diff --git a/apps/api/plane/utils/openapi/__init__.py b/apps/api/plane/utils/openapi/__init__.py index bf68212588..b2c9ba6b0c 100644 --- a/apps/api/plane/utils/openapi/__init__.py +++ b/apps/api/plane/utils/openapi/__init__.py @@ -140,6 +140,7 @@ from .examples import ( WORKSPACE_MEMBER_EXAMPLE, PROJECT_MEMBER_EXAMPLE, CYCLE_ISSUE_EXAMPLE, + STICKY_EXAMPLE, ) # Helper decorators @@ -292,6 +293,7 @@ __all__ = [ "WORKSPACE_MEMBER_EXAMPLE", "PROJECT_MEMBER_EXAMPLE", "CYCLE_ISSUE_EXAMPLE", + "STICKY_EXAMPLE", # Decorators "workspace_docs", "project_docs", diff --git a/apps/api/plane/utils/openapi/decorators.py b/apps/api/plane/utils/openapi/decorators.py index e4a86839f6..c1ba9612e5 100644 --- a/apps/api/plane/utils/openapi/decorators.py +++ b/apps/api/plane/utils/openapi/decorators.py @@ -262,3 +262,18 @@ def state_docs(**kwargs): } return extend_schema(**_merge_schema_options(defaults, kwargs)) + +def sticky_docs(**kwargs): + """Decorator for sticky management endpoints""" + defaults = { + "tags": ["Stickies"], + "summary": "Endpoints for sticky create/update/delete and fetch sticky details", + "parameters": [WORKSPACE_SLUG_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) \ No newline at end of file diff --git a/apps/api/plane/utils/openapi/examples.py b/apps/api/plane/utils/openapi/examples.py index db7ee50c44..f41bdddbcb 100644 --- a/apps/api/plane/utils/openapi/examples.py +++ b/apps/api/plane/utils/openapi/examples.py @@ -672,6 +672,15 @@ CYCLE_ISSUE_EXAMPLE = OpenApiExample( }, ) +STICKY_EXAMPLE = OpenApiExample( + name="Sticky", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Sticky 1", + "description_html": "

Sticky 1 description

", + "created_at": "2024-01-01T10:30:00Z", + }, +) # Sample data for different entity types SAMPLE_ISSUE = { @@ -781,6 +790,13 @@ SAMPLE_CYCLE_ISSUE = { "created_at": "2024-01-01T10:30:00Z", } +SAMPLE_STICKY = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Sticky 1", + "description_html": "

Sticky 1 description

", + "created_at": "2024-01-01T10:30:00Z", +} + # Mapping of schema types to sample data SCHEMA_EXAMPLES = { "Issue": SAMPLE_ISSUE, @@ -795,6 +811,7 @@ SCHEMA_EXAMPLES = { "Activity": SAMPLE_ACTIVITY, "Intake": SAMPLE_INTAKE, "CycleIssue": SAMPLE_CYCLE_ISSUE, + "Sticky": SAMPLE_STICKY, } diff --git a/apps/live/package.json b/apps/live/package.json index 8cfe975d99..f68b650f4c 100644 --- a/apps/live/package.json +++ b/apps/live/package.json @@ -24,7 +24,7 @@ }, "author": "Plane Software Inc.", "dependencies": { - "@dotenvx/dotenvx": "^1.49.0", + "@dotenvx/dotenvx": "catalog:", "@hocuspocus/extension-database": "2.15.2", "@hocuspocus/extension-logger": "2.15.2", "@hocuspocus/extension-redis": "2.15.2", @@ -41,8 +41,7 @@ "axios": "catalog:", "compression": "1.8.1", "cors": "^2.8.5", - "dotenv": "^16.4.5", - "express": "^4.21.2", + "express": "catalog:", "express-ws": "^5.0.2", "helmet": "^7.1.0", "ioredis": "5.7.0", diff --git a/apps/space/core/components/account/auth-forms/unique-code.tsx b/apps/space/core/components/account/auth-forms/unique-code.tsx index 20a7ecacb7..c8a524b04d 100644 --- a/apps/space/core/components/account/auth-forms/unique-code.tsx +++ b/apps/space/core/components/account/auth-forms/unique-code.tsx @@ -111,7 +111,7 @@ export function AuthUniqueCodeForm(props: TAuthUniqueCodeForm) { name="code" value={uniqueCodeFormData.code} onChange={(e) => handleFormChange("code", e.target.value)} - placeholder="gets-sets-flys" + placeholder="123456" className="disable-autofill-style h-10 w-full border border-subtle !bg-surface-1 pr-12 placeholder:text-placeholder" autoFocus /> diff --git a/apps/space/package.json b/apps/space/package.json index 61e098e469..18522166c0 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -52,6 +52,7 @@ "uuid": "catalog:" }, "devDependencies": { + "@dotenvx/dotenvx": "catalog:", "@plane/eslint-config": "workspace:*", "@plane/tailwind-config": "workspace:*", "@plane/typescript-config": "workspace:*", @@ -61,7 +62,6 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "dotenv": "^16.4.5", "typescript": "catalog:", "vite": "catalog:", "vite-tsconfig-paths": "^5.1.4" diff --git a/apps/space/vite.config.ts b/apps/space/vite.config.ts index 5374a4eee2..58e0facb01 100644 --- a/apps/space/vite.config.ts +++ b/apps/space/vite.config.ts @@ -1,6 +1,6 @@ import path from "node:path"; +import * as dotenv from "@dotenvx/dotenvx"; import { reactRouter } from "@react-router/dev/vite"; -import dotenv from "dotenv"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { joinUrlPath } from "@plane/utils"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx index 22a7c82d2d..1aba8eae83 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -82,7 +82,7 @@ function IssueDetailsPage({ params }: Route.ComponentProps) { return ( <> - {error ? ( + {error && !issueLoader ? ( )} - + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx deleted file mode 100644 index 38d0ac946f..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Outlet } from "react-router"; -// plane web layouts -import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; -import type { Route } from "./+types/layout"; - -export default function ProjectDetailLayout({ params }: Route.ComponentProps) { - // router - const { workspaceSlug, projectId } = params; - return ( - - - - ); -} diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx index 7c6d22c99c..7e52686778 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx @@ -1,7 +1,6 @@ import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; // components -import { LogoSpinner } from "@/components/common/logo-spinner"; import { PageHead } from "@/components/core/page-title"; import { ProfileForm } from "@/components/profile/form"; // hooks @@ -12,13 +11,7 @@ function ProfileSettingsPage() { // store hooks const { data: currentUser, userProfile } = useUser(); - if (!currentUser) - return ( -
- -
- ); - + if (!currentUser) return <>; return ( <> diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx index 397b466037..1df1df643c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx @@ -2,7 +2,6 @@ import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; // components -import { LogoSpinner } from "@/components/common/logo-spinner"; import { PageHead } from "@/components/core/page-title"; import { PreferencesList } from "@/components/preferences/list"; import { LanguageTimezone } from "@/components/profile/preferences/language-timezone"; @@ -16,30 +15,23 @@ function ProfileAppearancePage() { // hooks const { data: userProfile } = useUserProfile(); + if (!userProfile) return <>; return ( <> - {userProfile ? ( - <> -
-
- - -
-
- - -
-
- - ) : ( -
- +
+
+ +
- )} +
+ + +
+
); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx new file mode 100644 index 0000000000..8e8c09064d --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx @@ -0,0 +1,33 @@ +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +import { Outlet } from "react-router"; +// components +import { getProjectActivePath } from "@/components/settings/helper"; +import { SettingsMobileNav } from "@/components/settings/mobile"; +import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar"; +// plane web imports +import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; +// types +import type { Route } from "./+types/layout"; + +function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // router + const pathname = usePathname(); + + return ( + <> + +
+
{projectId && }
+ +
+ +
+
+
+ + ); +} + +export default observer(ProjectDetailSettingsLayout); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx index 667a33cb0f..ea0486fa37 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx @@ -1,6 +1,5 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import useSWR from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; // components @@ -24,12 +23,8 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) { // router const { workspaceSlug, projectId } = params; // store hooks - const { currentProjectDetails, fetchProjectDetails } = useProject(); + const { currentProjectDetails } = useProject(); const { allowPermissions } = useUserPermissions(); - - // api call to fetch project details - // TODO: removed this API if not necessary - const { isLoading } = useSWR(`PROJECT_DETAILS_${projectId}`, () => fetchProjectDetails(workspaceSlug, projectId)); // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId); @@ -56,7 +51,7 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) { )}
- {currentProjectDetails && !isLoading ? ( + {currentProjectDetails ? ( { @@ -25,19 +21,7 @@ function ProjectSettingsLayout({ params }: Route.ComponentProps) { } }, [joinedProjectIds, router, workspaceSlug, projectId]); - return ( - <> - - -
-
{projectId && }
-
- -
-
-
- - ); + return ; } export default observer(ProjectSettingsLayout); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx index cf82a23d97..d57fe582ec 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx @@ -1,8 +1,14 @@ +import { observer } from "mobx-react"; import Link from "next/link"; import { useTheme } from "next-themes"; +// plane imports import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; import { Button, getButtonStyling } from "@plane/propel/button"; import { cn } from "@plane/utils"; +// assets +import ProjectDarkEmptyState from "@/app/assets/empty-state/project-settings/no-projects-dark.png?url"; +import ProjectLightEmptyState from "@/app/assets/empty-state/project-settings/no-projects-light.png?url"; +// hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; function ProjectSettingsPage() { @@ -10,13 +16,10 @@ function ProjectSettingsPage() { const { resolvedTheme } = useTheme(); const { toggleCreateProjectModal } = useCommandPalette(); // derived values - const resolvedPath = - resolvedTheme === "dark" - ? "/empty-state/project-settings/no-projects-dark.png" - : "/empty-state/project-settings/no-projects-light.png"; + const resolvedPath = resolvedTheme === "dark" ? ProjectDarkEmptyState : ProjectLightEmptyState; return (
- No projects yet + No projects yet
No projects yet
Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you @@ -38,4 +41,4 @@ function ProjectSettingsPage() { ); } -export default ProjectSettingsPage; +export default observer(ProjectSettingsPage); diff --git a/apps/web/app/assets/cover-images/image_1.jpg b/apps/web/app/assets/cover-images/image_1.jpg new file mode 100644 index 0000000000..3565e8890c Binary files /dev/null and b/apps/web/app/assets/cover-images/image_1.jpg differ diff --git a/apps/web/app/assets/cover-images/image_10.jpg b/apps/web/app/assets/cover-images/image_10.jpg new file mode 100644 index 0000000000..cecad95d48 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_10.jpg differ diff --git a/apps/web/app/assets/cover-images/image_11.jpg b/apps/web/app/assets/cover-images/image_11.jpg new file mode 100644 index 0000000000..ebe34f24de Binary files /dev/null and b/apps/web/app/assets/cover-images/image_11.jpg differ diff --git a/apps/web/app/assets/cover-images/image_12.jpg b/apps/web/app/assets/cover-images/image_12.jpg new file mode 100644 index 0000000000..cae254d468 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_12.jpg differ diff --git a/apps/web/app/assets/cover-images/image_13.jpg b/apps/web/app/assets/cover-images/image_13.jpg new file mode 100644 index 0000000000..a3f4d408bf Binary files /dev/null and b/apps/web/app/assets/cover-images/image_13.jpg differ diff --git a/apps/web/app/assets/cover-images/image_14.jpg b/apps/web/app/assets/cover-images/image_14.jpg new file mode 100644 index 0000000000..3b3da8801e Binary files /dev/null and b/apps/web/app/assets/cover-images/image_14.jpg differ diff --git a/apps/web/app/assets/cover-images/image_15.jpg b/apps/web/app/assets/cover-images/image_15.jpg new file mode 100644 index 0000000000..3e44b7f8d1 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_15.jpg differ diff --git a/apps/web/app/assets/cover-images/image_16.jpg b/apps/web/app/assets/cover-images/image_16.jpg new file mode 100644 index 0000000000..f31335bf28 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_16.jpg differ diff --git a/apps/web/app/assets/cover-images/image_17.jpg b/apps/web/app/assets/cover-images/image_17.jpg new file mode 100644 index 0000000000..d2a50342d2 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_17.jpg differ diff --git a/apps/web/app/assets/cover-images/image_18.jpg b/apps/web/app/assets/cover-images/image_18.jpg new file mode 100644 index 0000000000..10cf37c307 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_18.jpg differ diff --git a/apps/web/app/assets/cover-images/image_19.jpg b/apps/web/app/assets/cover-images/image_19.jpg new file mode 100644 index 0000000000..dca5619435 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_19.jpg differ diff --git a/apps/web/app/assets/cover-images/image_2.jpg b/apps/web/app/assets/cover-images/image_2.jpg new file mode 100644 index 0000000000..a1adad5263 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_2.jpg differ diff --git a/apps/web/app/assets/cover-images/image_20.jpg b/apps/web/app/assets/cover-images/image_20.jpg new file mode 100644 index 0000000000..a8daf9772d Binary files /dev/null and b/apps/web/app/assets/cover-images/image_20.jpg differ diff --git a/apps/web/app/assets/cover-images/image_21.jpg b/apps/web/app/assets/cover-images/image_21.jpg new file mode 100644 index 0000000000..57c094ebd7 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_21.jpg differ diff --git a/apps/web/app/assets/cover-images/image_22.jpg b/apps/web/app/assets/cover-images/image_22.jpg new file mode 100644 index 0000000000..9efc564b9b Binary files /dev/null and b/apps/web/app/assets/cover-images/image_22.jpg differ diff --git a/apps/web/app/assets/cover-images/image_23.jpg b/apps/web/app/assets/cover-images/image_23.jpg new file mode 100644 index 0000000000..fec33d99d7 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_23.jpg differ diff --git a/apps/web/app/assets/cover-images/image_24.jpg b/apps/web/app/assets/cover-images/image_24.jpg new file mode 100644 index 0000000000..54c74a6920 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_24.jpg differ diff --git a/apps/web/app/assets/cover-images/image_25.jpg b/apps/web/app/assets/cover-images/image_25.jpg new file mode 100644 index 0000000000..66841c06bc Binary files /dev/null and b/apps/web/app/assets/cover-images/image_25.jpg differ diff --git a/apps/web/app/assets/cover-images/image_26.jpg b/apps/web/app/assets/cover-images/image_26.jpg new file mode 100644 index 0000000000..d0c2766024 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_26.jpg differ diff --git a/apps/web/app/assets/cover-images/image_27.jpg b/apps/web/app/assets/cover-images/image_27.jpg new file mode 100644 index 0000000000..84abce2ef3 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_27.jpg differ diff --git a/apps/web/app/assets/cover-images/image_28.jpg b/apps/web/app/assets/cover-images/image_28.jpg new file mode 100644 index 0000000000..0ce78e34f9 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_28.jpg differ diff --git a/apps/web/app/assets/cover-images/image_29.jpg b/apps/web/app/assets/cover-images/image_29.jpg new file mode 100644 index 0000000000..9df7aa0e0f Binary files /dev/null and b/apps/web/app/assets/cover-images/image_29.jpg differ diff --git a/apps/web/app/assets/cover-images/image_3.jpg b/apps/web/app/assets/cover-images/image_3.jpg new file mode 100644 index 0000000000..451849288a Binary files /dev/null and b/apps/web/app/assets/cover-images/image_3.jpg differ diff --git a/apps/web/app/assets/cover-images/image_4.jpg b/apps/web/app/assets/cover-images/image_4.jpg new file mode 100644 index 0000000000..04d1109314 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_4.jpg differ diff --git a/apps/web/app/assets/cover-images/image_5.jpg b/apps/web/app/assets/cover-images/image_5.jpg new file mode 100644 index 0000000000..6dcdb25a3c Binary files /dev/null and b/apps/web/app/assets/cover-images/image_5.jpg differ diff --git a/apps/web/app/assets/cover-images/image_6.jpg b/apps/web/app/assets/cover-images/image_6.jpg new file mode 100644 index 0000000000..f1cb9bf018 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_6.jpg differ diff --git a/apps/web/app/assets/cover-images/image_7.jpg b/apps/web/app/assets/cover-images/image_7.jpg new file mode 100644 index 0000000000..c70602ca9c Binary files /dev/null and b/apps/web/app/assets/cover-images/image_7.jpg differ diff --git a/apps/web/app/assets/cover-images/image_8.jpg b/apps/web/app/assets/cover-images/image_8.jpg new file mode 100644 index 0000000000..508bd55785 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_8.jpg differ diff --git a/apps/web/app/assets/cover-images/image_9.jpg b/apps/web/app/assets/cover-images/image_9.jpg new file mode 100644 index 0000000000..d5267f9616 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_9.jpg differ diff --git a/apps/web/app/provider.tsx b/apps/web/app/provider.tsx index 233b4250d6..7d6811052c 100644 --- a/apps/web/app/provider.tsx +++ b/apps/web/app/provider.tsx @@ -25,8 +25,8 @@ const PostHogProvider = lazy(function PostHogProvider() { return import("@/lib/posthog-provider"); }); -const IntercomProvider = lazy(function IntercomProvider() { - return import("@/lib/intercom-provider"); +const ChatSupportModal = lazy(function ChatSupportModal() { + return import("@/components/global/chat-support-modal"); }); export interface IAppProvider { @@ -50,11 +50,10 @@ export function AppProvider(props: IAppProvider) { - - - {children} - - + + + {children} + diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts index 17d1fa7bbf..ccb9d78d37 100644 --- a/apps/web/app/routes/core.ts +++ b/apps/web/app/routes/core.ts @@ -108,9 +108,13 @@ export const coreRoutes: RouteConfigEntry[] = [ ), ]), - // ==================================================================== - // PROJECT LEVEL ROUTES - // ==================================================================== + // Archived Projects + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [ + route( + ":workspaceSlug/projects/archives", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx" + ), + ]), // -------------------------------------------------------------------- // PROJECT LEVEL ROUTES @@ -122,136 +126,123 @@ export const coreRoutes: RouteConfigEntry[] = [ ]), // Project Detail - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx", [ - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [ - // Project Issues List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/issues", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx" - ), - ]), - // Issue Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [ + // Project Issues List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [ route( - ":workspaceSlug/projects/:projectId/issues/:issueId", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx" - ), - - // Cycle Detail - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/cycles/:cycleId", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx" - ), - ]), - - // Cycles List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/cycles", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx" - ), - ]), - - // Module Detail - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/modules/:moduleId", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx" - ), - ]), - - // Modules List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/modules", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx" - ), - ]), - - // View Detail - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/views/:viewId", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx" - ), - ]), - - // Views List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/views", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx" - ), - ]), - - // Page Detail - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/pages/:pageId", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx" - ), - ]), - - // Pages List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/pages", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx" - ), - ]), - // Intake list - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/intake", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx" - ), - ]), - ]), - - // Archived Projects - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [ - route( - ":workspaceSlug/projects/archives", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx" + ":workspaceSlug/projects/:projectId/issues", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx" ), ]), - - // Project Archives - Issues, Cycles, Modules - // Project Archives - Issues - List - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [ - route( - ":workspaceSlug/projects/:projectId/archives/issues", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx" - ), - ]), - - // Project Archives - Issues - Detail - layout( - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx", - [ - route( - ":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx" - ), - ] + // Issue Detail + route( + ":workspaceSlug/projects/:projectId/issues/:issueId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx" ), - // Project Archives - Cycles - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [ + // Cycle Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [ route( - ":workspaceSlug/projects/:projectId/archives/cycles", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx" + ":workspaceSlug/projects/:projectId/cycles/:cycleId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx" ), ]), - // Project Archives - Modules - layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [ + // Cycles List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [ route( - ":workspaceSlug/projects/:projectId/archives/modules", - "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx" + ":workspaceSlug/projects/:projectId/cycles", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx" ), ]), + + // Module Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/modules/:moduleId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx" + ), + ]), + + // Modules List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/modules", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx" + ), + ]), + + // View Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/views/:viewId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx" + ), + ]), + + // Views List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/views", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx" + ), + ]), + + // Page Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/pages/:pageId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx" + ), + ]), + + // Pages List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/pages", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx" + ), + ]), + // Intake list + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/intake", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx" + ), + ]), + ]), + + // Project Archives - Issues, Cycles, Modules + // Project Archives - Issues - List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/archives/issues", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx" + ), + ]), + + // Project Archives - Issues - Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx" + ), + ]), + + // Project Archives - Cycles + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/archives/cycles", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx" + ), + ]), + + // Project Archives - Modules + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/archives/modules", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx" + ), ]), ]), @@ -320,44 +311,46 @@ export const coreRoutes: RouteConfigEntry[] = [ // -------------------------------------------------------------------- layout("./(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx", [ - // CORE Routes - // Project Settings + // No Projects available page route(":workspaceSlug/settings/projects", "./(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx"), - route( - ":workspaceSlug/settings/projects/:projectId", - "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx" - ), - // Project Members - route( - ":workspaceSlug/settings/projects/:projectId/members", - "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx" - ), - // Project Features - route( - ":workspaceSlug/settings/projects/:projectId/features", - "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx" - ), - // Project States - route( - ":workspaceSlug/settings/projects/:projectId/states", - "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx" - ), - // Project Labels - route( - ":workspaceSlug/settings/projects/:projectId/labels", - "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx" - ), - // Project Estimates - route( - ":workspaceSlug/settings/projects/:projectId/estimates", - "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx" - ), - // Project Automations - layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [ + layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx", [ + // Project Settings route( - ":workspaceSlug/settings/projects/:projectId/automations", - "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx" + ":workspaceSlug/settings/projects/:projectId", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx" ), + // Project Members + route( + ":workspaceSlug/settings/projects/:projectId/members", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx" + ), + // Project Features + route( + ":workspaceSlug/settings/projects/:projectId/features", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx" + ), + // Project States + route( + ":workspaceSlug/settings/projects/:projectId/states", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx" + ), + // Project Labels + route( + ":workspaceSlug/settings/projects/:projectId/labels", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx" + ), + // Project Estimates + route( + ":workspaceSlug/settings/projects/:projectId/estimates", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx" + ), + // Project Automations + layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [ + route( + ":workspaceSlug/settings/projects/:projectId/automations", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx" + ), + ]), ]), ]), ]), diff --git a/apps/web/ce/components/global/index.ts b/apps/web/ce/components/global/index.ts index c87c8ae027..08b85c764c 100644 --- a/apps/web/ce/components/global/index.ts +++ b/apps/web/ce/components/global/index.ts @@ -1,2 +1 @@ export * from "./version-number"; -export * from "./product-updates-header"; diff --git a/apps/web/ce/components/global/product-updates/changelog.tsx b/apps/web/ce/components/global/product-updates/changelog.tsx new file mode 100644 index 0000000000..672b7490bc --- /dev/null +++ b/apps/web/ce/components/global/product-updates/changelog.tsx @@ -0,0 +1,83 @@ +import { useState, useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +// hooks +import { Loader } from "@plane/ui"; +import { ProductUpdatesFallback } from "@/components/global/product-updates/fallback"; +import { useInstance } from "@/hooks/store/use-instance"; + +export const ProductUpdatesChangelog = observer(function ProductUpdatesChangelog() { + // refs + const isLoadingRef = useRef(true); + // states + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + // store hooks + const { config } = useInstance(); + // derived values + const changeLogUrl = config?.instance_changelog_url; + const shouldShowFallback = !changeLogUrl || changeLogUrl === "" || hasError; + + // timeout fallback - if iframe doesn't load within 15 seconds, show error + useEffect(() => { + if (!changeLogUrl || changeLogUrl === "") { + setIsLoading(false); + isLoadingRef.current = false; + return; + } + + setIsLoading(true); + setHasError(false); + isLoadingRef.current = true; + + const timeoutId = setTimeout(() => { + if (isLoadingRef.current) { + setHasError(true); + setIsLoading(false); + isLoadingRef.current = false; + } + }, 15000); // 15 second timeout + + return () => { + clearTimeout(timeoutId); + }; + }, [changeLogUrl]); + + const handleIframeLoad = () => { + setTimeout(() => { + isLoadingRef.current = false; + setIsLoading(false); + }, 1000); + }; + + const handleIframeError = () => { + isLoadingRef.current = false; + setHasError(true); + setIsLoading(false); + }; + + // Show fallback if URL is missing, empty, or iframe failed to load + if (shouldShowFallback) { + return ( + + ); + } + + return ( +
+ {isLoading && ( + + + + )} +