From b8768d72749e965d61ddd710a96348c276647bae Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Thu, 9 May 2024 19:06:39 +0530 Subject: [PATCH 01/37] fix: spreadsheet layout sticky column (#4416) * fix: spreadsheet layout sticky column * fix: spreadsheet layout sticky column --- .../issues/issue-layouts/spreadsheet/issue-row.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 00f2b2138..1bd8a8808 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -202,14 +202,13 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { return ( <> - + handleIssuePeekOverview(issueDetail)} className={cn( - "group clickable cursor-pointer sticky left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] z-10 border-custom-border-200", + "group clickable cursor-pointer h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] z-10 border-custom-border-200", { "border-b-[0.5px]": !getIsIssuePeeked(issueDetail.id), "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issueDetail.id), From 45bb1153ee562991d9c2afc415e913262c31674d Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Thu, 9 May 2024 21:05:51 +0530 Subject: [PATCH 02/37] fix: removing deploy with nginx env --- .github/workflows/feature-deployment.yml | 28 ++++++++++++++---------- admin/Dockerfile.admin | 10 ++++----- admin/Dockerfile.dev | 7 +++++- admin/next.config.js | 2 +- space/Dockerfile.dev | 6 +++-- space/Dockerfile.space | 8 +++---- space/next.config.js | 10 ++++----- space/pages/onboarding/index.tsx | 2 +- turbo.json | 4 ++-- 9 files changed, 45 insertions(+), 32 deletions(-) diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml index a0a9dc7f1..e848dc36d 100644 --- a/.github/workflows/feature-deployment.yml +++ b/.github/workflows/feature-deployment.yml @@ -5,17 +5,17 @@ on: inputs: web-build: required: false - description: 'Build Web' + description: "Build Web" type: boolean default: true space-build: required: false - description: 'Build Space' + description: "Build Space" type: boolean default: false admin-build: required: false - description: 'Build Admin' + description: "Build Admin" type: boolean default: false @@ -35,7 +35,7 @@ jobs: echo "BUILD_SPACE=$BUILD_SPACE" echo "BUILD_ADMIN=$BUILD_ADMIN" outputs: - web-build: ${{ env.BUILD_WEB}} + web-build: ${{ env.BUILD_WEB}} space-build: ${{env.BUILD_SPACE}} admin-build: ${{env.BUILD_ADMIN}} @@ -53,7 +53,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '18' + node-version: "18" - name: Install AWS cli run: | sudo apt-get update @@ -79,7 +79,7 @@ jobs: FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY - + feature-build-space: if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }} needs: setup-feature-build @@ -89,7 +89,7 @@ jobs: AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} - NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1 + NEXT_PUBLIC_SPACE_BASE_PATH: "/spaces" NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} outputs: do-build: ${{ needs.setup-feature-build.outputs.space-build }} @@ -98,7 +98,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '18' + node-version: "18" - name: Install AWS cli run: | sudo apt-get update @@ -134,7 +134,7 @@ jobs: AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} - NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1 + NEXT_PUBLIC_ADMIN_BASE_PATH: "/god-mode" NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} outputs: do-build: ${{ needs.setup-feature-build.outputs.admin-build }} @@ -143,7 +143,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '18' + node-version: "18" - name: Install AWS cli run: | sudo apt-get update @@ -172,7 +172,13 @@ jobs: feature-deploy: if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true' || needs.setup-feature-build.outputs.admin-build == 'true') }} - needs: [setup-feature-build, feature-build-web, feature-build-space, feature-build-admin] + needs: + [ + setup-feature-build, + feature-build-web, + feature-build-space, + feature-build-admin, + ] name: Feature Deploy runs-on: ubuntu-latest env: diff --git a/admin/Dockerfile.admin b/admin/Dockerfile.admin index 9abc5daef..f752df6d9 100644 --- a/admin/Dockerfile.admin +++ b/admin/Dockerfile.admin @@ -21,10 +21,10 @@ COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json ARG NEXT_PUBLIC_API_BASE_URL="" -ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH RUN yarn turbo run build --filter=admin @@ -40,10 +40,10 @@ COPY --from=installer /app/admin/public ./admin/public ARG NEXT_PUBLIC_API_BASE_URL="" -ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH ENV NEXT_TELEMETRY_DISABLED 1 ENV TURBO_TELEMETRY_DISABLED 1 diff --git a/admin/Dockerfile.dev b/admin/Dockerfile.dev index 0cbbdc8af..1ed84e78e 100644 --- a/admin/Dockerfile.dev +++ b/admin/Dockerfile.dev @@ -3,10 +3,15 @@ RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app - COPY . . + RUN yarn global add turbo RUN yarn install + +ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" + EXPOSE 3000 + VOLUME [ "/app/node_modules", "/app/admin/node_modules" ] + CMD ["yarn", "dev", "--filter=admin"] diff --git a/admin/next.config.js b/admin/next.config.js index 26ff4b870..07f6664af 100644 --- a/admin/next.config.js +++ b/admin/next.config.js @@ -7,7 +7,7 @@ const nextConfig = { images: { unoptimized: true, }, - basePath: "/god-mode", + basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "", }; module.exports = nextConfig; diff --git a/space/Dockerfile.dev b/space/Dockerfile.dev index 862210c33..213f3fb3c 100644 --- a/space/Dockerfile.dev +++ b/space/Dockerfile.dev @@ -3,12 +3,14 @@ RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app - COPY . . + RUN yarn global add turbo RUN yarn install + EXPOSE 4000 -ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 + +ENV NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" VOLUME [ "/app/node_modules", "/app/space/node_modules"] CMD ["yarn","dev", "--filter=space"] diff --git a/space/Dockerfile.space b/space/Dockerfile.space index a10690642..095f06722 100644 --- a/space/Dockerfile.space +++ b/space/Dockerfile.space @@ -21,10 +21,10 @@ COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json ARG NEXT_PUBLIC_API_BASE_URL="" -ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH RUN yarn turbo run build --filter=space @@ -40,10 +40,10 @@ COPY --from=installer /app/space/.next ./space/.next COPY --from=installer /app/space/public ./space/public ARG NEXT_PUBLIC_API_BASE_URL="" -ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH COPY start.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/start.sh diff --git a/space/next.config.js b/space/next.config.js index c3153b694..eb9dde88a 100644 --- a/space/next.config.js +++ b/space/next.config.js @@ -5,17 +5,18 @@ const { withSentryConfig } = require("@sentry/nextjs"); const nextConfig = { trailingSlash: true, + output: "standalone", + basePath: process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "", + reactStrictMode: false, + swcMinify: true, async headers() { return [ { source: "/", - headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }], + headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }], // clickjacking protection }, ]; }, - basePath: "/spaces", - reactStrictMode: false, - swcMinify: true, images: { remotePatterns: [ { @@ -25,7 +26,6 @@ const nextConfig = { ], unoptimized: true, }, - output: "standalone", }; if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0", 10)) { diff --git a/space/pages/onboarding/index.tsx b/space/pages/onboarding/index.tsx index 6ba95126a..8318f0346 100644 --- a/space/pages/onboarding/index.tsx +++ b/space/pages/onboarding/index.tsx @@ -17,7 +17,7 @@ import { AuthWrapper } from "@/lib/wrappers"; import ProfileSetupDark from "public/onboarding/profile-setup-dark.svg"; import ProfileSetup from "public/onboarding/profile-setup.svg"; -const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : ""; +const imagePrefix = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || ""; const OnBoardingPage = observer(() => { // router diff --git a/turbo.json b/turbo.json index d9a63f30e..211d83048 100644 --- a/turbo.json +++ b/turbo.json @@ -15,11 +15,11 @@ "NEXT_PUBLIC_ENABLE_SESSION_RECORDER", "NEXT_PUBLIC_SESSION_RECORDER_KEY", "NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS", - "NEXT_PUBLIC_DEPLOY_WITH_NGINX", "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_DEBUG", - "SENTRY_AUTH_TOKEN" + "SENTRY_AUTH_TOKEN", + "NEXT_PUBLIC_SPACE_BASE_PATH" ], "pipeline": { "build": { From 547a76ae55f02b4ae10220a9e80811649babacd0 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Fri, 10 May 2024 02:32:42 +0530 Subject: [PATCH 03/37] fix: admin and space redirections (#4419) * dev: add admin and space base url * fix: formatting * dev: add app,space and admin base url to the api env * fix: updated app base urls redirection * dev: add change password endpoint * dev: add none as default for base url * dev: space password management endpoints * fix: docker env update * fix: docker and env settings * fix: docker changes * fix: next config update --------- Co-authored-by: pablohashescobar Co-authored-by: guru_sainath --- admin/.env.example | 7 +- admin/Dockerfile.admin | 33 ++- .../components/admin-sidebar/help-section.tsx | 13 +- admin/components/new-user-popup.tsx | 6 +- apiserver/.env.example | 5 + apiserver/plane/api/views/base.py | 2 - apiserver/plane/authentication/urls.py | 18 ++ apiserver/plane/authentication/utils/host.py | 13 +- .../plane/authentication/views/__init__.py | 11 +- .../plane/authentication/views/app/github.py | 1 - .../plane/authentication/views/app/google.py | 5 +- .../views/app/password_management.py | 202 ++++++++++++++++++ .../plane/authentication/views/common.py | 191 +---------------- .../plane/authentication/views/space/email.py | 24 +-- .../authentication/views/space/github.py | 5 +- .../authentication/views/space/google.py | 6 +- .../plane/authentication/views/space/magic.py | 19 +- .../views/space/password_management.py | 202 ++++++++++++++++++ .../authentication/views/space/signout.py | 4 +- apiserver/plane/license/api/views/admin.py | 36 ++-- apiserver/plane/license/api/views/instance.py | 11 +- apiserver/plane/settings/common.py | 5 + deploy/selfhost/docker-compose.yml | 12 +- docker-compose.yml | 5 +- packages/types/src/instance/base.d.ts | 3 + space/.env.example | 5 +- space/Dockerfile.space | 26 ++- space/pages/404.tsx | 55 +++-- space/pages/project-not-published/index.tsx | 60 +++--- turbo.json | 11 +- web/.env.example | 9 +- web/Dockerfile.web | 49 +++-- web/components/headers/project-issues.tsx | 8 +- web/components/instance/not-ready-view.tsx | 17 +- .../project/publish-project/modal.tsx | 8 +- web/helpers/common.helper.ts | 10 +- web/next.config.js | 16 +- 37 files changed, 746 insertions(+), 367 deletions(-) create mode 100644 apiserver/plane/authentication/views/app/password_management.py create mode 100644 apiserver/plane/authentication/views/space/password_management.py diff --git a/admin/.env.example b/admin/.env.example index fbd4ad4f9..a86a8b4fb 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -1,2 +1,5 @@ -NEXT_PUBLIC_APP_URL= -NEXT_PUBLIC_API_BASE_URL= \ No newline at end of file +NEXT_PUBLIC_API_BASE_URL="" +NEXT_PUBLIC_ADMIN_BASE_URL="" +NEXT_PUBLIC_SPACE_BASE_URL="" +NEXT_PUBLIC_WEB_BASE_URL="" +NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" \ No newline at end of file diff --git a/admin/Dockerfile.admin b/admin/Dockerfile.admin index f752df6d9..901c39e27 100644 --- a/admin/Dockerfile.admin +++ b/admin/Dockerfile.admin @@ -1,3 +1,6 @@ +# ***************************************************************************** +# STAGE 1: Build the project +# ***************************************************************************** FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat WORKDIR /app @@ -7,6 +10,9 @@ COPY . . RUN turbo prune --scope=admin --docker +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** FROM node:18-alpine AS installer RUN apk add --no-cache libc6-compat @@ -21,13 +27,25 @@ COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json ARG NEXT_PUBLIC_API_BASE_URL="" -ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" - ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH +ENV NEXT_TELEMETRY_DISABLED 1 +ENV TURBO_TELEMETRY_DISABLED 1 + RUN yarn turbo run build --filter=admin +# ***************************************************************************** +# STAGE 3: Copy the project and start it +# ***************************************************************************** FROM node:18-alpine AS runner WORKDIR /app @@ -38,11 +56,16 @@ COPY --from=installer /app/admin/.next/standalone ./ COPY --from=installer /app/admin/.next/static ./admin/.next/static COPY --from=installer /app/admin/public ./admin/public - ARG NEXT_PUBLIC_API_BASE_URL="" -ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" - ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH ENV NEXT_TELEMETRY_DISABLED 1 diff --git a/admin/components/admin-sidebar/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx index c1da25b28..ba8f2cba5 100644 --- a/admin/components/admin-sidebar/help-section.tsx +++ b/admin/components/admin-sidebar/help-section.tsx @@ -1,12 +1,13 @@ "use client"; import { FC, useState, useRef } from "react"; -import { Transition } from "@headlessui/react"; import Link from "next/link"; +import { observer } from "mobx-react-lite"; +import { Transition } from "@headlessui/react"; import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; // hooks -import { useTheme } from "@/hooks"; +import { useInstance, useTheme } from "@/hooks"; // assets import packageJson from "package.json"; @@ -28,7 +29,9 @@ const helpOptions = [ }, ]; -export const HelpSection: FC = () => { +export const HelpSection: FC = observer(() => { + // hooks + const { instance } = useInstance(); // states const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); // store @@ -36,7 +39,7 @@ export const HelpSection: FC = () => { // refs const helpOptionsRef = useRef(null); - const redirectionLink = `${process.env.NEXT_PUBLIC_APP_URL ? `${process.env.NEXT_PUBLIC_APP_URL}/create-workspace` : `/god-mode/`}`; + const redirectionLink = `${instance?.config?.app_base_url ? `${instance?.config?.app_base_url}/create-workspace` : `/god-mode/`}`; return (
{
); -}; +}); diff --git a/admin/components/new-user-popup.tsx b/admin/components/new-user-popup.tsx index fa7c01abf..d17e99d5e 100644 --- a/admin/components/new-user-popup.tsx +++ b/admin/components/new-user-popup.tsx @@ -8,18 +8,20 @@ import { useTheme as nextUseTheme } from "next-themes"; import { Button, getButtonStyling } from "@plane/ui"; // helpers import { resolveGeneralTheme } from "helpers/common.helper"; +// hooks +import { useInstance, useTheme } from "@/hooks"; // icons import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg"; import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg"; -import { useTheme } from "@/hooks"; export const NewUserPopup: React.FC = observer(() => { // hooks const { isNewUserPopup, toggleNewUserPopup } = useTheme(); + const { instance } = useInstance(); // theme const { resolvedTheme } = nextUseTheme(); - const redirectionLink = `${process.env.NEXT_PUBLIC_APP_URL ? `${process.env.NEXT_PUBLIC_APP_URL}/create-workspace` : `/god-mode/`}`; + const redirectionLink = `${instance?.config?.app_base_url ? `${instance?.config?.app_base_url}/create-workspace` : `/god-mode/`}`; if (!isNewUserPopup) return <>; return ( diff --git a/apiserver/.env.example b/apiserver/.env.example index 52d8d1c50..e6590f831 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -44,3 +44,8 @@ WEB_URL="http://localhost" # Gunicorn Workers GUNICORN_WORKERS=2 + +# Base URLs +ADMIN_BASE_URL= +SPACE_BASE_URL= +APP_BASE_URL= diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 1f6bd70af..fee508a30 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -1,6 +1,4 @@ # Python imports -from urllib.parse import urlparse - import zoneinfo # Django imports diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py index 451b17e4e..4a6f8c3f4 100644 --- a/apiserver/plane/authentication/urls.py +++ b/apiserver/plane/authentication/urls.py @@ -7,6 +7,7 @@ from .views import ( ForgotPasswordEndpoint, SetUserPasswordEndpoint, ResetPasswordEndpoint, + ChangePasswordEndpoint, # App GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint, @@ -18,6 +19,8 @@ from .views import ( SignInAuthEndpoint, SignOutAuthEndpoint, SignUpAuthEndpoint, + ForgotPasswordSpaceEndpoint, + ResetPasswordSpaceEndpoint, # Space EmailCheckEndpoint, GitHubCallbackSpaceEndpoint, @@ -176,6 +179,21 @@ urlpatterns = [ ResetPasswordEndpoint.as_view(), name="forgot-password", ), + path( + "spaces/forgot-password/", + ForgotPasswordSpaceEndpoint.as_view(), + name="forgot-password", + ), + path( + "spaces/reset-password///", + ResetPasswordSpaceEndpoint.as_view(), + name="forgot-password", + ), + path( + "change-password/", + ChangePasswordEndpoint.as_view(), + name="forgot-password", + ), path( "set-password/", SetUserPasswordEndpoint.as_view(), diff --git a/apiserver/plane/authentication/utils/host.py b/apiserver/plane/authentication/utils/host.py index b9dc7189b..b670eed41 100644 --- a/apiserver/plane/authentication/utils/host.py +++ b/apiserver/plane/authentication/utils/host.py @@ -1,8 +1,19 @@ +# Python imports from urllib.parse import urlsplit +# Django imports +from django.conf import settings -def base_host(request): + +def base_host(request, is_admin=False, is_space=False): """Utility function to return host / origin from the request""" + + if is_admin and settings.ADMIN_BASE_URL: + return settings.ADMIN_BASE_URL + + if is_space and settings.SPACE_BASE_URL: + return settings.SPACE_BASE_URL + return ( request.META.get("HTTP_ORIGIN") or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}" diff --git a/apiserver/plane/authentication/views/__init__.py b/apiserver/plane/authentication/views/__init__.py index 4bd920e29..a5aadf728 100644 --- a/apiserver/plane/authentication/views/__init__.py +++ b/apiserver/plane/authentication/views/__init__.py @@ -1,8 +1,6 @@ from .common import ( ChangePasswordEndpoint, CSRFTokenEndpoint, - ForgotPasswordEndpoint, - ResetPasswordEndpoint, SetUserPasswordEndpoint, ) @@ -50,3 +48,12 @@ from .space.magic import ( from .space.signout import SignOutAuthSpaceEndpoint from .space.check import EmailCheckEndpoint + +from .space.password_management import ( + ForgotPasswordSpaceEndpoint, + ResetPasswordSpaceEndpoint, +) +from .app.password_management import ( + ForgotPasswordEndpoint, + ResetPasswordEndpoint, +) diff --git a/apiserver/plane/authentication/views/app/github.py b/apiserver/plane/authentication/views/app/github.py index e7184b16e..48b7e09d9 100644 --- a/apiserver/plane/authentication/views/app/github.py +++ b/apiserver/plane/authentication/views/app/github.py @@ -2,7 +2,6 @@ import uuid from urllib.parse import urlencode, urljoin # Django import -from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponseRedirect from django.views import View diff --git a/apiserver/plane/authentication/views/app/google.py b/apiserver/plane/authentication/views/app/google.py index 19c59691c..690a9778b 100644 --- a/apiserver/plane/authentication/views/app/google.py +++ b/apiserver/plane/authentication/views/app/google.py @@ -3,18 +3,17 @@ import uuid from urllib.parse import urlencode, urljoin # Django import -from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponseRedirect from django.views import View + +# Module imports from plane.authentication.provider.oauth.google import GoogleOAuthProvider from plane.authentication.utils.login import user_login from plane.authentication.utils.redirection_path import get_redirection_path from plane.authentication.utils.workspace_project_join import ( process_workspace_project_invitations, ) - -# Module imports from plane.license.models import Instance from plane.authentication.utils.host import base_host from plane.authentication.adapter.error import ( diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py new file mode 100644 index 000000000..80803cd25 --- /dev/null +++ b/apiserver/plane/authentication/views/app/password_management.py @@ -0,0 +1,202 @@ +# Python imports +import os +from urllib.parse import urlencode, urljoin + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView +from zxcvbn import zxcvbn + +# Django imports +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.http import HttpResponseRedirect +from django.utils.encoding import ( + DjangoUnicodeDecodeError, + smart_bytes, + smart_str, +) +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.views import View + +# Module imports +from plane.bgtasks.forgot_password_task import forgot_password +from plane.license.models import Instance +from plane.db.models import User +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +def generate_password_token(user): + uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) + token = PasswordResetTokenGenerator().make_token(user) + + return uidb64, token + + +class ForgotPasswordEndpoint(APIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + email = request.data.get("email") + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = ( + get_configuration_value( + [ + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST"), + }, + { + "key": "EMAIL_HOST_USER", + "default": os.environ.get("EMAIL_HOST_USER"), + }, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD"), + }, + ] + ) + ) + + if not (EMAIL_HOST): + exc = AuthenticationException( + error_message="SMTP_NOT_CONFIGURED", + error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"], + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the user + user = User.objects.filter(email=email).first() + if user: + # Get the reset token for user + uidb64, token = generate_password_token(user=user) + current_site = request.META.get("HTTP_ORIGIN") + # send the forgot password email + forgot_password.delay( + user.first_name, user.email, uidb64, token, current_site + ) + return Response( + {"message": "Check your email to reset your password"}, + status=status.HTTP_200_OK, + ) + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + +class ResetPasswordEndpoint(View): + + def post(self, request, uidb64, token): + try: + # Decode the id from the uidb64 + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(id=id) + + # check if the token is valid for the user + if not PasswordResetTokenGenerator().check_token(user, token): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INVALID_PASSWORD_TOKEN" + ], + error_message="INVALID_PASSWORD_TOKEN", + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request), + "accounts/reset-password?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + password = request.POST.get("password", False) + + if not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + url = urljoin( + base_host(request=request), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Check the password complexity + results = zxcvbn(password) + if results["score"] < 3: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + url = urljoin( + base_host(request=request), + "accounts/reset-password?" + + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # set_password also hashes the password that the user will get + user.set_password(password) + user.is_password_autoset = False + user.save() + + url = urljoin( + base_host(request=request), + "accounts/sign-in?" + urlencode({"success": True}), + ) + return HttpResponseRedirect(url) + except DjangoUnicodeDecodeError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "EXPIRED_PASSWORD_TOKEN" + ], + error_message="EXPIRED_PASSWORD_TOKEN", + ) + url = urljoin( + base_host(request=request), + "accounts/reset-password?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py index a66326b1a..4b93010de 100644 --- a/apiserver/plane/authentication/views/common.py +++ b/apiserver/plane/authentication/views/common.py @@ -1,21 +1,3 @@ -# Python imports -import os -from urllib.parse import urlencode, urljoin - -# Django imports -from django.contrib.auth.tokens import PasswordResetTokenGenerator -from django.core.exceptions import ValidationError -from django.core.validators import validate_email -from django.http import HttpResponseRedirect -from django.middleware.csrf import get_token -from django.utils.encoding import ( - DjangoUnicodeDecodeError, - smart_bytes, - smart_str, -) -from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode -from django.views import View - # Third party imports from rest_framework import status from rest_framework.permissions import AllowAny @@ -29,15 +11,12 @@ from plane.app.serializers import ( UserSerializer, ) from plane.authentication.utils.login import user_login -from plane.bgtasks.forgot_password_task import forgot_password from plane.db.models import User -from plane.license.models import Instance -from plane.license.utils.instance_value import get_configuration_value -from plane.authentication.utils.host import base_host from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) +from django.middleware.csrf import get_token class CSRFTokenEndpoint(APIView): @@ -55,174 +34,6 @@ class CSRFTokenEndpoint(APIView): ) -def generate_password_token(user): - uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) - token = PasswordResetTokenGenerator().make_token(user) - - return uidb64, token - - -class ForgotPasswordEndpoint(APIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - email = request.data.get("email") - - # Check instance configuration - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INSTANCE_NOT_CONFIGURED" - ], - error_message="INSTANCE_NOT_CONFIGURED", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = ( - get_configuration_value( - [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST"), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER"), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD"), - }, - ] - ) - ) - - if not (EMAIL_HOST): - exc = AuthenticationException( - error_message="SMTP_NOT_CONFIGURED", - error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"], - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - validate_email(email) - except ValidationError: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], - error_message="INVALID_EMAIL", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get the user - user = User.objects.filter(email=email).first() - if user: - # Get the reset token for user - uidb64, token = generate_password_token(user=user) - current_site = request.META.get("HTTP_ORIGIN") - # send the forgot password email - forgot_password.delay( - user.first_name, user.email, uidb64, token, current_site - ) - return Response( - {"message": "Check your email to reset your password"}, - status=status.HTTP_200_OK, - ) - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], - error_message="USER_DOES_NOT_EXIST", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - -class ResetPasswordEndpoint(View): - - def post(self, request, uidb64, token): - try: - # Decode the id from the uidb64 - id = smart_str(urlsafe_base64_decode(uidb64)) - user = User.objects.get(id=id) - - # check if the token is valid for the user - if not PasswordResetTokenGenerator().check_token(user, token): - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INVALID_PASSWORD_TOKEN" - ], - error_message="INVALID_PASSWORD_TOKEN", - ) - params = exc.get_error_dict() - url = urljoin( - base_host(request=request), - "accounts/reset-password?" + urlencode(params), - ) - return HttpResponseRedirect(url) - - password = request.POST.get("password", False) - - if not password: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", - ) - url = urljoin( - base_host(request=request), - "?" + urlencode(exc.get_error_dict()), - ) - return HttpResponseRedirect(url) - - # Check the password complexity - results = zxcvbn(password) - if results["score"] < 3: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", - ) - url = urljoin( - base_host(request=request), - "accounts/reset-password?" - + urlencode(exc.get_error_dict()), - ) - return HttpResponseRedirect(url) - - # set_password also hashes the password that the user will get - user.set_password(password) - user.is_password_autoset = False - user.save() - - url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode({"success": True}), - ) - return HttpResponseRedirect(url) - except DjangoUnicodeDecodeError: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "EXPIRED_PASSWORD_TOKEN" - ], - error_message="EXPIRED_PASSWORD_TOKEN", - ) - url = urljoin( - base_host(request=request), - "accounts/reset-password?" + urlencode(exc.get_error_dict()), - ) - return HttpResponseRedirect(url) - - class ChangePasswordEndpoint(APIView): def post(self, request): serializer = ChangePasswordSerializer(data=request.data) diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py index b0a82fe6f..4505332eb 100644 --- a/apiserver/plane/authentication/views/space/email.py +++ b/apiserver/plane/authentication/views/space/email.py @@ -37,7 +37,7 @@ class SignInAuthSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "accounts/sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -59,7 +59,7 @@ class SignInAuthSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces/accounts/sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -78,7 +78,7 @@ class SignInAuthSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces/accounts/sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -93,7 +93,7 @@ class SignInAuthSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces/accounts/sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -107,7 +107,7 @@ class SignInAuthSpaceEndpoint(View): user_login(request=request, user=user) # redirect to next path url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), str(next_path) if next_path else "/", ) return HttpResponseRedirect(url) @@ -116,7 +116,7 @@ class SignInAuthSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces/accounts/sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -140,7 +140,7 @@ class SignUpAuthSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -161,7 +161,7 @@ class SignUpAuthSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -180,7 +180,7 @@ class SignUpAuthSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -195,7 +195,7 @@ class SignUpAuthSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -209,7 +209,7 @@ class SignUpAuthSpaceEndpoint(View): user_login(request=request, user=user) # redirect to referer path url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), str(next_path) if next_path else "spaces", ) return HttpResponseRedirect(url) @@ -218,7 +218,7 @@ class SignUpAuthSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces?" + urlencode(params), ) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/github.py b/apiserver/plane/authentication/views/space/github.py index 192f0d039..4a0f23098 100644 --- a/apiserver/plane/authentication/views/space/github.py +++ b/apiserver/plane/authentication/views/space/github.py @@ -3,7 +3,6 @@ import uuid from urllib.parse import urlencode, urljoin # Django import -from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponseRedirect from django.views import View @@ -22,7 +21,7 @@ class GitHubOauthInitiateSpaceEndpoint(View): def get(self, request): # Get host and next path - request.session["host"] = base_host(request=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) @@ -40,7 +39,7 @@ class GitHubOauthInitiateSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/google.py b/apiserver/plane/authentication/views/space/google.py index 86632ebb4..2f6b57699 100644 --- a/apiserver/plane/authentication/views/space/google.py +++ b/apiserver/plane/authentication/views/space/google.py @@ -19,7 +19,7 @@ from plane.authentication.adapter.error import ( class GoogleOauthInitiateSpaceEndpoint(View): def get(self, request): - request.session["host"] = base_host(request=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) @@ -37,7 +37,7 @@ class GoogleOauthInitiateSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -53,7 +53,7 @@ class GoogleOauthInitiateSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py index 390f6021d..52771f71b 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -2,7 +2,6 @@ from urllib.parse import urlencode, urljoin # Django imports -from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import validate_email from django.http import HttpResponseRedirect from django.views import View @@ -48,7 +47,7 @@ class MagicGenerateSpaceEndpoint(APIView): exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST ) - origin = base_host(request=request) + origin = base_host(request=request, is_space=True) email = request.data.get("email", False) try: # Clean up the email @@ -86,7 +85,7 @@ class MagicSignInSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces/accounts/sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -99,7 +98,7 @@ class MagicSignInSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "accounts/sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -118,7 +117,7 @@ class MagicSignInSpaceEndpoint(View): else: # Get the redirection path path = str(next_path) if next_path else "spaces" - url = urljoin(base_host(request=request), path) + url = urljoin(base_host(request=request, is_space=True), path) return HttpResponseRedirect(url) except AuthenticationException as e: @@ -126,7 +125,7 @@ class MagicSignInSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces/accounts/sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -152,7 +151,7 @@ class MagicSignUpSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces/accounts/sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -166,7 +165,7 @@ class MagicSignUpSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -180,7 +179,7 @@ class MagicSignUpSpaceEndpoint(View): user_login(request=request, user=user) # redirect to referer path url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), str(next_path) if next_path else "spaces", ) return HttpResponseRedirect(url) @@ -190,7 +189,7 @@ class MagicSignUpSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "spaces/accounts/sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/password_management.py b/apiserver/plane/authentication/views/space/password_management.py new file mode 100644 index 000000000..aeac9776d --- /dev/null +++ b/apiserver/plane/authentication/views/space/password_management.py @@ -0,0 +1,202 @@ +# Python imports +import os +from urllib.parse import urlencode, urljoin + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView +from zxcvbn import zxcvbn + +# Django imports +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.http import HttpResponseRedirect +from django.utils.encoding import ( + DjangoUnicodeDecodeError, + smart_bytes, + smart_str, +) +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.views import View + +# Module imports +from plane.bgtasks.forgot_password_task import forgot_password +from plane.license.models import Instance +from plane.db.models import User +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +def generate_password_token(user): + uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) + token = PasswordResetTokenGenerator().make_token(user) + + return uidb64, token + + +class ForgotPasswordSpaceEndpoint(APIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + email = request.data.get("email") + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = ( + get_configuration_value( + [ + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST"), + }, + { + "key": "EMAIL_HOST_USER", + "default": os.environ.get("EMAIL_HOST_USER"), + }, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD"), + }, + ] + ) + ) + + if not (EMAIL_HOST): + exc = AuthenticationException( + error_message="SMTP_NOT_CONFIGURED", + error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"], + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + validate_email(email) + except ValidationError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the user + user = User.objects.filter(email=email).first() + if user: + # Get the reset token for user + uidb64, token = generate_password_token(user=user) + current_site = request.META.get("HTTP_ORIGIN") + # send the forgot password email + forgot_password.delay( + user.first_name, user.email, uidb64, token, current_site + ) + return Response( + {"message": "Check your email to reset your password"}, + status=status.HTTP_200_OK, + ) + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + +class ResetPasswordSpaceEndpoint(View): + + def post(self, request, uidb64, token): + try: + # Decode the id from the uidb64 + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(id=id) + + # check if the token is valid for the user + if not PasswordResetTokenGenerator().check_token(user, token): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INVALID_PASSWORD_TOKEN" + ], + error_message="INVALID_PASSWORD_TOKEN", + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_space=True), + "accounts/reset-password?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + password = request.POST.get("password", False) + + if not password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + url = urljoin( + base_host(request=request, is_space=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # Check the password complexity + results = zxcvbn(password) + if results["score"] < 3: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + ) + url = urljoin( + base_host(request=request, is_space=True), + "accounts/reset-password?" + + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + + # set_password also hashes the password that the user will get + user.set_password(password) + user.is_password_autoset = False + user.save() + + url = urljoin( + base_host(request=request, is_space=True), + "accounts/sign-in?" + urlencode({"success": True}), + ) + return HttpResponseRedirect(url) + except DjangoUnicodeDecodeError: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "EXPIRED_PASSWORD_TOKEN" + ], + error_message="EXPIRED_PASSWORD_TOKEN", + ) + url = urljoin( + base_host(request=request, is_space=True), + "accounts/reset-password?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/signout.py b/apiserver/plane/authentication/views/space/signout.py index b993fb78c..3cfd6d471 100644 --- a/apiserver/plane/authentication/views/space/signout.py +++ b/apiserver/plane/authentication/views/space/signout.py @@ -24,11 +24,11 @@ class SignOutAuthSpaceEndpoint(View): # Log the user out logout(request) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "accounts/sign-in?" + urlencode({"success": "true"}), ) return HttpResponseRedirect(url) except Exception: return HttpResponseRedirect( - base_host(request=request), "accounts/sign-in" + base_host(request=request, is_space=True), "accounts/sign-in" ) diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py index 8833c7a7c..ed3c00f17 100644 --- a/apiserver/plane/license/api/views/admin.py +++ b/apiserver/plane/license/api/views/admin.py @@ -106,7 +106,7 @@ class InstanceAdminSignUpEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -118,7 +118,7 @@ class InstanceAdminSignUpEndpoint(View): error_message="ADMIN_ALREADY_EXIST", ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -147,7 +147,7 @@ class InstanceAdminSignUpEndpoint(View): }, ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -169,7 +169,7 @@ class InstanceAdminSignUpEndpoint(View): }, ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -191,7 +191,7 @@ class InstanceAdminSignUpEndpoint(View): }, ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -213,7 +213,7 @@ class InstanceAdminSignUpEndpoint(View): }, ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -248,7 +248,9 @@ class InstanceAdminSignUpEndpoint(View): # get tokens for user user_login(request=request, user=user) - url = urljoin(base_host(request=request), "god-mode/general") + url = urljoin( + base_host(request=request, is_admin=True), "god-mode/general" + ) return HttpResponseRedirect(url) @@ -269,7 +271,7 @@ class InstanceAdminSignInEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/login?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -290,7 +292,7 @@ class InstanceAdminSignInEndpoint(View): }, ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/login?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -308,7 +310,7 @@ class InstanceAdminSignInEndpoint(View): }, ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/login?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -328,7 +330,7 @@ class InstanceAdminSignInEndpoint(View): }, ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/login?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -345,7 +347,7 @@ class InstanceAdminSignInEndpoint(View): }, ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/login?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -362,7 +364,7 @@ class InstanceAdminSignInEndpoint(View): }, ) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "god-mode/login?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -377,7 +379,9 @@ class InstanceAdminSignInEndpoint(View): # get tokens for user user_login(request=request, user=user) - url = urljoin(base_host(request=request), "god-mode/general") + url = urljoin( + base_host(request=request, is_admin=True), "god-mode/general" + ) return HttpResponseRedirect(url) @@ -411,11 +415,11 @@ class InstanceAdminSignOutEndpoint(View): # Log the user out logout(request) url = urljoin( - base_host(request=request), + base_host(request=request, is_admin=True), "accounts/sign-in?" + urlencode({"success": "true"}), ) return HttpResponseRedirect(url) except Exception: return HttpResponseRedirect( - base_host(request=request), "accounts/sign-in" + base_host(request=request, is_admin=True), "accounts/sign-in" ) diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 2247bbeb1..45c1f872d 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -2,6 +2,7 @@ import os # Django imports +from django.conf import settings # Third party imports from rest_framework import status @@ -148,9 +149,13 @@ class InstanceEndpoint(BaseAPIView): ) # is smtp configured - data["is_smtp_configured"] = ( - bool(EMAIL_HOST) - ) + data["is_smtp_configured"] = bool(EMAIL_HOST) + + # Base URL + data["admin_base_url"] = settings.ADMIN_BASE_URL + data["space_base_url"] = settings.SPACE_BASE_URL + data["app_base_url"] = settings.APP_BASE_URL + instance_data = serializer.data instance_data["workspaces_exist"] = Workspace.objects.count() > 1 diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 7ee1bdf55..4f5e6d4ee 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -342,3 +342,8 @@ CSRF_COOKIE_SECURE = secure_origins CSRF_COOKIE_HTTPONLY = True CSRF_TRUSTED_ORIGINS = cors_allowed_origins CSRF_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) + +# Base URLs +ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) +SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) +APP_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 6f12abd61..67f61d0ef 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -33,6 +33,10 @@ x-app-env: &app-env - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"} - BUCKET_NAME=${BUCKET_NAME:-uploads} - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + # Admin and Space URLs + - ADMIN_BASE_URL=${ADMIN_BASE_URL} + - SPACE_BASE_URL=${SPACE_BASE_URL} + - APP_BASE_URL=${APP_BASE_URL} services: web: @@ -40,7 +44,7 @@ services: image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped - command: /usr/local/bin/start.sh web/server.js web + command: node web/server.js web deploy: replicas: ${WEB_REPLICAS:-1} depends_on: @@ -52,20 +56,20 @@ services: image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped - command: /usr/local/bin/start.sh space/server.js space + command: node space/server.js space deploy: replicas: ${SPACE_REPLICAS:-1} depends_on: - api - worker - web - + admin: <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped - command: node admin/server.js admin + command: node admin/server.js admin deploy: replicas: ${ADMIN_REPLICAS:-1} depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index bf8066055..be1008193 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: args: DOCKER_BUILDKIT: 1 restart: always - command: /usr/local/bin/start.sh web/server.js web + command: node web/server.js web depends_on: - api @@ -32,7 +32,7 @@ services: args: DOCKER_BUILDKIT: 1 restart: always - command: /usr/local/bin/start.sh space/server.js space + command: node space/server.js space depends_on: - api - web @@ -134,7 +134,6 @@ services: MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} - # Comment this if you already have a reverse proxy running proxy: container_name: proxy diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index 87f03c68f..efc47b15d 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -43,6 +43,9 @@ export interface IInstance { has_openai_configured: boolean; file_size_limit: number | undefined; is_smtp_configured: boolean; + app_base_url: string | undefined; + space_base_url: string | undefined; + admin_base_url: string | undefined; }; } diff --git a/space/.env.example b/space/.env.example index fbd4ad4f9..33939c7e2 100644 --- a/space/.env.example +++ b/space/.env.example @@ -1,2 +1,3 @@ -NEXT_PUBLIC_APP_URL= -NEXT_PUBLIC_API_BASE_URL= \ No newline at end of file +NEXT_PUBLIC_API_BASE_URL="" +NEXT_PUBLIC_WEB_BASE_URL="" +NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" \ No newline at end of file diff --git a/space/Dockerfile.space b/space/Dockerfile.space index 095f06722..229585818 100644 --- a/space/Dockerfile.space +++ b/space/Dockerfile.space @@ -1,3 +1,6 @@ +# ***************************************************************************** +# STAGE 1: Build the project +# ***************************************************************************** FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat WORKDIR /app @@ -7,6 +10,9 @@ COPY . . RUN turbo prune --scope=space --docker +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** FROM node:18-alpine AS installer RUN apk add --no-cache libc6-compat @@ -21,13 +27,19 @@ COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json ARG NEXT_PUBLIC_API_BASE_URL="" -ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH RUN yarn turbo run build --filter=space +# ***************************************************************************** +# STAGE 3: Copy the project and start it +# ***************************************************************************** FROM node:18-alpine AS runner WORKDIR /app @@ -40,14 +52,14 @@ COPY --from=installer /app/space/.next ./space/.next COPY --from=installer /app/space/public ./space/public ARG NEXT_PUBLIC_API_BASE_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" - -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH -COPY start.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/start.sh - ENV NEXT_TELEMETRY_DISABLED 1 ENV TURBO_TELEMETRY_DISABLED 1 diff --git a/space/pages/404.tsx b/space/pages/404.tsx index 07e415a19..4591f71f8 100644 --- a/space/pages/404.tsx +++ b/space/pages/404.tsx @@ -1,31 +1,42 @@ // next imports +import { observer } from "mobx-react-lite"; import Image from "next/image"; +// hooks +import { useInstance } from "@/hooks/store"; +// images import notFoundImage from "public/404.svg"; -const Custom404Error = () => ( -
-
-
-
- 404- Page not found -
-
Oops! Something went wrong.
-
- Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is - temporarily unavailable. -
-
+const Custom404Error = observer(() => { + // hooks + const { instance } = useInstance(); -
- - Go to your Workspace - + const redirectionUrl = instance?.config?.app_base_url || "/"; + + return ( +
+
+
+
+ 404- Page not found +
+
Oops! Something went wrong.
+
+ Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is + temporarily unavailable. +
+
+ +
-
-); + ); +}); export default Custom404Error; diff --git a/space/pages/project-not-published/index.tsx b/space/pages/project-not-published/index.tsx index 803ed3d03..0bd25dd6e 100644 --- a/space/pages/project-not-published/index.tsx +++ b/space/pages/project-not-published/index.tsx @@ -1,39 +1,49 @@ // next imports +import { observer } from "mobx-react-lite"; import Image from "next/image"; // helpers import { EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { useInstance } from "@/hooks/store"; // wrappers import { AuthWrapper } from "@/lib/wrappers"; // images import projectNotPublishedImage from "@/public/project-not-published.svg"; -const CustomProjectNotPublishedError = () => ( - -
-
-
-
- 404- Page not found -
-
- Oops! The page you{`'`}re looking for isn{`'`}t live at the moment. -
-
- If this is your project, login to your workspace to adjust its visibility settings and make it public. -
-
+const CustomProjectNotPublishedError = observer(() => { + // hooks + const { instance } = useInstance(); -
- - Go to your Workspace - + const redirectionUrl = instance?.config?.app_base_url || "/"; + + return ( + +
+
+
+
+ 404- Page not found +
+
+ Oops! The page you{`'`}re looking for isn{`'`}t live at the moment. +
+
+ If this is your project, login to your workspace to adjust its visibility settings and make it public. +
+
+ +
-
- -); + + ); +}); export default CustomProjectNotPublishedError; diff --git a/turbo.json b/turbo.json index 211d83048..e980747df 100644 --- a/turbo.json +++ b/turbo.json @@ -3,9 +3,11 @@ "globalEnv": [ "NODE_ENV", "NEXT_PUBLIC_API_BASE_URL", - "NEXT_PUBLIC_APP_URL", - "NEXT_PUBLIC_DEPLOY_URL", - "NEXT_PUBLIC_GOD_MODE_URL", + "NEXT_PUBLIC_ADMIN_BASE_URL", + "NEXT_PUBLIC_ADMIN_BASE_PATH", + "NEXT_PUBLIC_SPACE_BASE_URL", + "NEXT_PUBLIC_SPACE_BASE_PATH", + "NEXT_PUBLIC_WEB_BASE_URL", "NEXT_PUBLIC_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_ENVIRONMENT", "NEXT_PUBLIC_ENABLE_SENTRY", @@ -18,8 +20,7 @@ "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_DEBUG", - "SENTRY_AUTH_TOKEN", - "NEXT_PUBLIC_SPACE_BASE_PATH" + "SENTRY_AUTH_TOKEN" ], "pipeline": { "build": { diff --git a/web/.env.example b/web/.env.example index 74d175cf8..8e5b0f482 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,2 +1,7 @@ -# Public boards deploy URL -NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces" \ No newline at end of file +NEXT_PUBLIC_API_BASE_URL="" + +NEXT_PUBLIC_ADMIN_BASE_URL="" +NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" + +NEXT_PUBLIC_SPACE_BASE_URL="" +NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" diff --git a/web/Dockerfile.web b/web/Dockerfile.web index bed1c09ce..3326fc751 100644 --- a/web/Dockerfile.web +++ b/web/Dockerfile.web @@ -1,6 +1,6 @@ -# ****************************************** +# ***************************************************************************** # STAGE 1: Build the project -# ****************************************** +# ***************************************************************************** FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat # Set working directory @@ -11,17 +11,14 @@ COPY . . RUN turbo prune --scope=web --docker - -# ****************************************** +# ***************************************************************************** # STAGE 2: Install dependencies & build the project -# ****************************************** +# ***************************************************************************** # Add lockfile and package.json's of isolated subworkspace FROM node:18-alpine AS installer RUN apk add --no-cache libc6-compat WORKDIR /app -ARG NEXT_PUBLIC_API_BASE_URL="" -ARG NEXT_PUBLIC_DEPLOY_URL="" # First install the dependencies (as they change less often) COPY .gitignore .gitignore @@ -33,16 +30,29 @@ RUN yarn install --network-timeout 500000 COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json +ARG NEXT_PUBLIC_API_BASE_URL="" ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_URL="" +ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="" +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH + +ENV NEXT_TELEMETRY_DISABLED 1 +ENV TURBO_TELEMETRY_DISABLED 1 RUN yarn turbo run build --filter=web - -# ****************************************** +# ***************************************************************************** # STAGE 3: Copy the project and start it -# ****************************************** - +# ***************************************************************************** FROM node:18-alpine AS runner WORKDIR /app @@ -56,12 +66,19 @@ COPY --from=installer /app/web/.next ./web/.next COPY --from=installer /app/web/public ./web/public ARG NEXT_PUBLIC_API_BASE_URL="" -ARG NEXT_PUBLIC_DEPLOY_URL="" ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL -COPY start.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/start.sh +ARG NEXT_PUBLIC_ADMIN_BASE_URL="" +ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="" +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH ENV NEXT_TELEMETRY_DISABLED 1 ENV TURBO_TELEMETRY_DISABLED 1 diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 69fe434e3..9f64c0d3a 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -16,6 +16,7 @@ import { ProjectLogo } from "@/components/project"; import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers +import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@/helpers/common.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { @@ -99,7 +100,8 @@ export const ProjectIssuesHeader: React.FC = observer(() => { [workspaceSlug, projectId, updateFilters] ); - const deployUrl = process.env.NEXT_PUBLIC_DEPLOY_URL; + const DEPLOY_URL = SPACE_BASE_URL + SPACE_BASE_PATH; + const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); @@ -163,9 +165,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => { ) : null}
- {currentProjectDetails?.is_deployed && deployUrl && ( + {currentProjectDetails?.is_deployed && DEPLOY_URL && ( { +export const InstanceNotReady: FC = observer(() => { + // hooks + // const { instance } = useInstance(); - const planeGodModeUrl = `${process.env.NEXT_PUBLIC_GOD_MODE_URL}/god-mode/setup/?auth_enabled=0`; + const GOD_MODE_URL = encodeURI(ADMIN_BASE_URL + ADMIN_BASE_PATH + "setup/?auth_enabled=0"); return (
); -}; +}); diff --git a/web/components/project/publish-project/modal.tsx b/web/components/project/publish-project/modal.tsx index ea1d38041..41fd9f368 100644 --- a/web/components/project/publish-project/modal.tsx +++ b/web/components/project/publish-project/modal.tsx @@ -10,7 +10,7 @@ import { IProject } from "@plane/types"; // ui import { Button, Loader, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; // hooks -import { useProjectPublish } from "@/hooks/store"; +import { useInstance, useProjectPublish } from "@/hooks/store"; // store import { IProjectPublishSettings, TProjectPublishViews } from "@/store/project/project-publish.store"; // types @@ -54,14 +54,14 @@ const viewOptions: { export const PublishProjectModal: React.FC = observer((props) => { const { isOpen, project, onClose } = props; + // hooks + const { instance } = useInstance(); // states const [isUnPublishing, setIsUnPublishing] = useState(false); const [isUpdateRequired, setIsUpdateRequired] = useState(false); - let plane_deploy_url = process.env.NEXT_PUBLIC_DEPLOY_URL; + const plane_deploy_url = instance?.config?.space_base_url || ""; - if (typeof window !== "undefined" && !plane_deploy_url) - plane_deploy_url = window.location.protocol + "//" + window.location.host + "/spaces"; // router const router = useRouter(); const { workspaceSlug } = router.query; diff --git a/web/helpers/common.helper.ts b/web/helpers/common.helper.ts index a98ab0d56..2f4814194 100644 --- a/web/helpers/common.helper.ts +++ b/web/helpers/common.helper.ts @@ -1,6 +1,14 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; + +export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || ""; +export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || ""; + +export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || ""; +export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || ""; + export const debounce = (func: any, wait: number, immediate: boolean = false) => { let timeout: any; @@ -21,5 +29,3 @@ export const debounce = (func: any, wait: number, immediate: boolean = false) => }; export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); - -export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ? process.env.NEXT_PUBLIC_API_BASE_URL : ""; diff --git a/web/next.config.js b/web/next.config.js index 7d86c85b0..8af0d42e3 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -31,7 +31,7 @@ const nextConfig = { unoptimized: true, }, async rewrites() { - return [ + const rewrites = [ { source: "/ingest/static/:path*", destination: "https://us-assets.i.posthog.com/static/:path*", @@ -40,11 +40,17 @@ const nextConfig = { source: "/ingest/:path*", destination: "https://us.i.posthog.com/:path*", }, - { - source: "/god-mode/:path*", - destination: `${process.env.NEXT_PUBLIC_GOD_MODE_URL || ""}/:path*`, - }, ]; + if (process.env.NEXT_PUBLIC_ADMIN_BASE_URL || process.env.NEXT_PUBLIC_ADMIN_BASE_PATH) { + const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "" + const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "" + const GOD_MODE_BASE_URL = ADMIN_BASE_URL + ADMIN_BASE_PATH + rewrites.push({ + source: "/god-mode/:path*", + destination: `${GOD_MODE_BASE_URL}/:path*`, + }) + } + return rewrites; }, }; From 243680132e81ed0b722e83d93b2fd8ca4947c444 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Fri, 10 May 2024 03:46:45 +0530 Subject: [PATCH 04/37] fix: space re-directions --- web/components/project/publish-project/modal.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/web/components/project/publish-project/modal.tsx b/web/components/project/publish-project/modal.tsx index 41fd9f368..0b94c56f1 100644 --- a/web/components/project/publish-project/modal.tsx +++ b/web/components/project/publish-project/modal.tsx @@ -9,11 +9,12 @@ import { Dialog, Transition } from "@headlessui/react"; import { IProject } from "@plane/types"; // ui import { Button, Loader, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; +// helpers +import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@/helpers/common.helper"; // hooks -import { useInstance, useProjectPublish } from "@/hooks/store"; +import { useProjectPublish } from "@/hooks/store"; // store import { IProjectPublishSettings, TProjectPublishViews } from "@/store/project/project-publish.store"; -// types // local components import { CustomPopover } from "./popover"; @@ -55,12 +56,13 @@ const viewOptions: { export const PublishProjectModal: React.FC = observer((props) => { const { isOpen, project, onClose } = props; // hooks - const { instance } = useInstance(); + // const { instance } = useInstance(); // states const [isUnPublishing, setIsUnPublishing] = useState(false); const [isUpdateRequired, setIsUpdateRequired] = useState(false); - const plane_deploy_url = instance?.config?.space_base_url || ""; + // const plane_deploy_url = instance?.config?.space_base_url || ""; + const SPACE_URL = SPACE_BASE_URL + SPACE_BASE_PATH; // router const router = useRouter(); @@ -320,10 +322,10 @@ export const PublishProjectModal: React.FC = observer((props) => { <>
- {`${plane_deploy_url}/${workspaceSlug}/${project.id}`} + {`${SPACE_URL}/${workspaceSlug}/${project.id}`}
- +
From e396424db793bbb71041dd23eff2ae7d3b94cc2c Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 10 May 2024 15:19:05 +0530 Subject: [PATCH 05/37] [WEB-1251] chore: view list enhancement (#4427) * chore: moved search query to mobx store * chore: moved view sub-header to app header * chore: created by avatar added in view item list --- web/components/headers/project-views.tsx | 2 + web/components/views/index.ts | 1 + web/components/views/view-list-header.tsx | 84 +++++++++++++++++++ .../views/view-list-item-action.tsx | 10 ++- web/components/views/views-list.tsx | 82 +----------------- web/store/project-view.store.ts | 12 +++ 6 files changed, 109 insertions(+), 82 deletions(-) create mode 100644 web/components/views/view-list-header.tsx diff --git a/web/components/headers/project-views.tsx b/web/components/headers/project-views.tsx index b733e6784..c79934aec 100644 --- a/web/components/headers/project-views.tsx +++ b/web/components/headers/project-views.tsx @@ -7,6 +7,7 @@ import { Breadcrumbs, PhotoFilterIcon, Button } from "@plane/ui"; import { BreadcrumbLink } from "@/components/common"; // helpers import { ProjectLogo } from "@/components/project"; +import { ViewListHeader } from "@/components/views"; import { EUserProjectRoles } from "@/constants/project"; // constants import { useCommandPalette, useProject, useUser } from "@/hooks/store"; @@ -58,6 +59,7 @@ export const ProjectViewsHeader: React.FC = observer(() => {
{canUserCreateIssue && (
+
+ )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+
+
+ ); +}); diff --git a/web/components/views/view-list-item-action.tsx b/web/components/views/view-list-item-action.tsx index 563d1da41..80ba5ba0c 100644 --- a/web/components/views/view-list-item-action.tsx +++ b/web/components/views/view-list-item-action.tsx @@ -11,7 +11,8 @@ import { EUserProjectRoles } from "@/constants/project"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks -import { useProjectView, useUser } from "@/hooks/store"; +import { useMember, useProjectView, useUser } from "@/hooks/store"; +import { ButtonAvatars } from "../dropdowns/member/avatar"; type Props = { parentRef: React.RefObject; @@ -31,6 +32,7 @@ export const ViewListItemAction: FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { addViewToFavorites, removeViewFromFavorites } = useProjectView(); + const { getUserDetails } = useMember(); // derived values const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -50,6 +52,8 @@ export const ViewListItemAction: FC = observer((props) => { removeViewFromFavorites(workspaceSlug.toString(), projectId.toString(), view.id); }; + const createdByDetails = view.created_by ? getUserDetails(view.created_by) : undefined; + return ( <> {workspaceSlug && projectId && view && ( @@ -65,6 +69,10 @@ export const ViewListItemAction: FC = observer((props) => {

{totalFilters} {totalFilters === 1 ? "filter" : "filters"}

+ + {/* created by */} + {createdByDetails && } + {isEditingAllowed && ( { diff --git a/web/components/views/views-list.tsx b/web/components/views/views-list.tsx index 967469c79..ea300678a 100644 --- a/web/components/views/views-list.tsx +++ b/web/components/views/views-list.tsx @@ -1,7 +1,4 @@ -import { useRef, useState } from "react"; import { observer } from "mobx-react-lite"; -// ui -import { Search, X } from "lucide-react"; // components import { ListLayout } from "@/components/core/list"; import { EmptyState } from "@/components/empty-state"; @@ -9,28 +6,13 @@ import { ViewListLoader } from "@/components/ui"; import { ProjectViewListItem } from "@/components/views"; // constants import { EmptyStateType } from "@/constants/empty-state"; -// helper -import { cn } from "@/helpers/common.helper"; // hooks import { useCommandPalette, useProjectView } from "@/hooks/store"; -import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; export const ProjectViewsList = observer(() => { - // states - const [searchQuery, setSearchQuery] = useState(""); - const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false); - - // refs - const inputRef = useRef(null); - // store hooks const { toggleCreateViewModal } = useCommandPalette(); - const { projectViewIds, getViewById, loader } = useProjectView(); - - // outside click detector hook - useOutsideClickDetector(inputRef, () => { - if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); - }); + const { projectViewIds, getViewById, loader, searchQuery } = useProjectView(); if (loader || !projectViewIds) return ; @@ -39,72 +21,10 @@ export const ProjectViewsList = observer(() => { const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(searchQuery.toLowerCase())); - // handlers - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - if (searchQuery && searchQuery.trim() !== "") setSearchQuery(""); - else { - setIsSearchOpen(false); - inputRef.current?.blur(); - } - } - }; - return ( <> {viewsList.length > 0 ? (
-
-
- Project Views -
-
-
- {!isSearchOpen && ( - - )} -
- - setSearchQuery(e.target.value)} - onKeyDown={handleInputKeyDown} - /> - {isSearchOpen && ( - - )} -
-
-
-
{filteredViewsList.length > 0 ? ( filteredViewsList.map((view) => ) diff --git a/web/store/project-view.store.ts b/web/store/project-view.store.ts index 95ecece58..92b8719c1 100644 --- a/web/store/project-view.store.ts +++ b/web/store/project-view.store.ts @@ -12,11 +12,13 @@ export interface IProjectViewStore { loader: boolean; fetchedMap: Record; // observables + searchQuery: string; viewMap: Record; // computed projectViewIds: string[] | null; // computed actions getViewById: (viewId: string) => IProjectView; + updateSearchQuery: (query: string) => void; // fetch actions fetchViews: (workspaceSlug: string, projectId: string) => Promise; fetchViewDetails: (workspaceSlug: string, projectId: string, viewId: string) => Promise; @@ -38,6 +40,7 @@ export class ProjectViewStore implements IProjectViewStore { // observables loader: boolean = false; viewMap: Record = {}; + searchQuery: string = ""; //loaders fetchedMap: Record = {}; // root store @@ -51,6 +54,7 @@ export class ProjectViewStore implements IProjectViewStore { loader: observable.ref, viewMap: observable, fetchedMap: observable, + searchQuery: observable.ref, // computed projectViewIds: computed, // fetch actions @@ -60,6 +64,8 @@ export class ProjectViewStore implements IProjectViewStore { createView: action, updateView: action, deleteView: action, + // actions + updateSearchQuery: action, // favorites actions addViewToFavorites: action, removeViewFromFavorites: action, @@ -85,6 +91,12 @@ export class ProjectViewStore implements IProjectViewStore { */ getViewById = computedFn((viewId: string) => this.viewMap?.[viewId] ?? null); + /** + * @description update search query + * @param {string} query + */ + updateSearchQuery = (query: string) => (this.searchQuery = query); + /** * Fetches views for current project * @param workspaceSlug From 57eda3408221553f83ac79d8f7a78809941e8e40 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 10 May 2024 15:19:59 +0530 Subject: [PATCH 06/37] chore: notification action item enhancement (#4426) --- web/components/notifications/notification-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/notifications/notification-card.tsx b/web/components/notifications/notification-card.tsx index c98772f9b..cfae8a7c1 100644 --- a/web/components/notifications/notification-card.tsx +++ b/web/components/notifications/notification-card.tsx @@ -173,7 +173,7 @@ export const NotificationCard: React.FC = (props) => {
{!notification.message ? ( -
+
{notificationTriggeredBy.is_bot ? notificationTriggeredBy.first_name From 0af55e7bbbc8ffd583bc1f460ec019e548e11081 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 10 May 2024 15:21:05 +0530 Subject: [PATCH 07/37] [WEB-1250] chore: module list enhancement (#4425) * chore: move module sub-header to app header * chore: gantt header improvement, remove title * chore: progress indicator size reduced * chore: replace members with lead and updated start and end date ui --- web/components/gantt-chart/chart/header.tsx | 4 +- web/components/gantt-chart/chart/root.tsx | 1 - web/components/headers/modules-list.tsx | 2 + web/components/modules/module-card-item.tsx | 40 ++++++++------- .../modules/module-list-item-action.tsx | 50 +++++++++---------- web/components/modules/module-list-item.tsx | 6 +-- .../projects/[projectId]/modules/index.tsx | 17 +++---- 7 files changed, 59 insertions(+), 61 deletions(-) diff --git a/web/components/gantt-chart/chart/header.tsx b/web/components/gantt-chart/chart/header.tsx index e2722b22a..fe4d0c885 100644 --- a/web/components/gantt-chart/chart/header.tsx +++ b/web/components/gantt-chart/chart/header.tsx @@ -15,18 +15,16 @@ type Props = { handleChartView: (view: TGanttViews) => void; handleToday: () => void; loaderTitle: string; - title: string; toggleFullScreenMode: () => void; }; export const GanttChartHeader: React.FC = observer((props) => { - const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, title, toggleFullScreenMode } = props; + const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode } = props; // chart hook const { currentView } = useGanttChart(); return (
-
{title}
{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}
diff --git a/web/components/gantt-chart/chart/root.tsx b/web/components/gantt-chart/chart/root.tsx index a4ea8cbf2..395e0771c 100644 --- a/web/components/gantt-chart/chart/root.tsx +++ b/web/components/gantt-chart/chart/root.tsx @@ -172,7 +172,6 @@ export const ChartViewRoot: FC = observer((props) => { handleChartView={(key) => updateCurrentViewRenderPayload(null, key)} handleToday={handleToday} loaderTitle={loaderTitle} - title={title} /> {
+ {canUserCreateModule && (
@@ -217,11 +219,13 @@ export const ModuleCardItem: React.FC = observer((props) => {
{isDateValid ? ( - <> - - {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} - - +
+ + {renderFormattedDate(startDate)} + + + {renderFormattedDate(endDate)} +
) : ( No due date )} @@ -229,7 +233,7 @@ export const ModuleCardItem: React.FC = observer((props) => {
-
+
{isEditingAllowed && ( { diff --git a/web/components/modules/module-list-item-action.tsx b/web/components/modules/module-list-item-action.tsx index b34dc7555..fa7d71577 100644 --- a/web/components/modules/module-list-item-action.tsx +++ b/web/components/modules/module-list-item-action.tsx @@ -2,11 +2,11 @@ import React, { FC } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // icons -import { User2 } from "lucide-react"; +import { CalendarCheck2, CalendarClock, MoveRight, User2 } from "lucide-react"; // types import { IModule } from "@plane/types"; // ui -import { Avatar, AvatarGroup, Tooltip, setPromiseToast } from "@plane/ui"; +import { Tooltip, setPromiseToast } from "@plane/ui"; // components import { FavoriteStar } from "@/components/core"; import { ModuleQuickActions } from "@/components/modules"; @@ -18,7 +18,7 @@ import { EUserProjectRoles } from "@/constants/project"; import { getDate, renderFormattedDate } from "@/helpers/date-time.helper"; // hooks import { useEventTracker, useMember, useModule, useUser } from "@/hooks/store"; -import { usePlatformOS } from "@/hooks/use-platform-os"; +import { ButtonAvatars } from "../dropdowns/member/avatar"; type Props = { moduleId: string; @@ -38,7 +38,6 @@ export const ModuleListItemAction: FC = observer((props) => { const { addModuleToFavorites, removeModuleFromFavorites } = useModule(); const { getUserDetails } = useMember(); const { captureEvent } = useEventTracker(); - const { isMobile } = usePlatformOS(); // derived values const endDate = getDate(moduleDetails.target_date); @@ -109,11 +108,23 @@ export const ModuleListItemAction: FC = observer((props) => { }); }; + const moduleLeadDetails = moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined; + return ( <> + {renderDate && ( +
+ + {renderFormattedDate(startDate)} + + + {renderFormattedDate(endDate)} +
+ )} + {moduleStatus && ( = observer((props) => { )} - {renderDate && ( - - {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} + {moduleLeadDetails ? ( + + + ) : ( + + + + + )} - -
- {moduleDetails.member_ids.length > 0 ? ( - - {moduleDetails.member_ids.map((member_id) => { - const member = getUserDetails(member_id); - return ; - })} - - ) : ( - - - - )} -
-
- {isEditingAllowed && !moduleDetails.archived_at && ( { diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index 9ad7d2225..b74592112 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -77,7 +77,7 @@ export const ModuleListItem: React.FC = observer((props) => { ) : progress === 100 ? ( ) : ( - {`${progress}%`} + {`${progress}%`} )} } @@ -89,9 +89,7 @@ export const ModuleListItem: React.FC = observer((props) => { } - actionableItems={ - - } + actionableItems={} isMobile={isMobile} parentRef={parentRef} /> diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx index 85be0c140..08e621d0f 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx @@ -1,20 +1,23 @@ import { ReactElement, useCallback } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; +// types import { TModuleFilters } from "@plane/types"; -// layouts // components import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; import { ModulesListHeader } from "@/components/headers"; -import { ModuleViewHeader, ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; -// types -// hooks +import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; import ModulesListMobileHeader from "@/components/modules/moduels-list-mobile-header"; +// constants import { EmptyStateType } from "@/constants/empty-state"; +// helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; +// hooks import { useModuleFilter, useProject } from "@/hooks/store"; +// layouts import { AppLayout } from "@/layouts/app-layout"; +// types import { NextPageWithLayout } from "@/lib/types"; const ProjectModulesPage: NextPageWithLayout = observer(() => { @@ -58,12 +61,6 @@ const ProjectModulesPage: NextPageWithLayout = observer(() => { <>
-
-
- Module name -
- -
{(calculateTotalFilters(currentProjectFilters ?? {}) !== 0 || currentProjectDisplayFilters?.favorites) && (
Date: Fri, 10 May 2024 15:22:01 +0530 Subject: [PATCH 08/37] chore: project card enhancement (#4424) --- web/components/project/card.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/components/project/card.tsx b/web/components/project/card.tsx index 5e94b1fb5..8f29b245b 100644 --- a/web/components/project/card.tsx +++ b/web/components/project/card.tsx @@ -2,7 +2,7 @@ import React, { useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; -import { ArchiveRestoreIcon, Check, ExternalLink, LinkIcon, Lock, Pencil, Trash2, UserPlus } from "lucide-react"; +import { ArchiveRestoreIcon, Check, ExternalLink, LinkIcon, Lock, Settings, Trash2, UserPlus } from "lucide-react"; // types import type { IProject } from "@plane/types"; // ui @@ -105,10 +105,10 @@ export const ProjectCard: React.FC = observer((props) => { const MENU_ITEMS: TContextMenuItem[] = [ { - key: "edit", + key: "settings", action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`), - title: "Edit", - icon: Pencil, + title: "Settings", + icon: Settings, shouldRender: !isArchived && (isOwner || isMember), }, { @@ -322,7 +322,7 @@ export const ProjectCard: React.FC = observer((props) => { }} href={`/${workspaceSlug}/projects/${project.id}/settings`} > - + ) : ( From 40560109b58ebc8d37c0ce6165871f8714547538 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Fri, 10 May 2024 15:23:51 +0530 Subject: [PATCH 09/37] fix: admin app redirections --- .eslintrc.js | 2 +- admin/.env.example | 6 +- admin/app/ai/components/ai-config-form.tsx | 2 +- admin/app/ai/page.tsx | 2 +- .../components/email-config-switch.tsx | 2 +- .../components/password-config-switch.tsx | 2 +- .../github/components/github-config-form.tsx | 2 +- .../authentication/github/components/root.tsx | 2 +- admin/app/authentication/github/page.tsx | 2 +- .../google/components/google-config-form.tsx | 2 +- .../authentication/google/components/root.tsx | 2 +- admin/app/authentication/google/page.tsx | 2 +- admin/app/authentication/page.tsx | 2 +- .../email/components/email-config-form.tsx | 2 +- admin/app/email/page.tsx | 2 +- .../components/general-config-form.tsx | 2 +- admin/app/general/page.tsx | 2 +- .../image/components/image-config-form.tsx | 2 +- admin/app/image/page.tsx | 2 +- admin/app/layout.tsx | 58 +++++++++---------- .../components/admin-sidebar/help-section.tsx | 2 +- admin/components/admin-sidebar/root.tsx | 2 +- .../admin-sidebar/sidebar-dropdown.tsx | 2 +- .../sidebar-menu-hamburger-toogle.tsx | 2 +- .../components/admin-sidebar/sidebar-menu.tsx | 2 +- admin/components/new-user-popup.tsx | 2 +- admin/helpers/common.helper.ts | 11 +++- admin/hooks/index.ts | 6 -- admin/hooks/store/index.ts | 3 + admin/lib/wrappers/app-wrapper.tsx | 4 +- admin/lib/wrappers/auth-wrapper.tsx | 2 +- admin/lib/wrappers/instance-wrapper.tsx | 9 ++- admin/package.json | 2 +- admin/services/instance.service.ts | 4 +- space/helpers/common.helper.ts | 8 ++- space/package.json | 4 +- space/pages/onboarding/index.tsx | 5 +- web/Dockerfile.web | 8 +-- web/components/instance/not-ready-view.tsx | 12 +--- 39 files changed, 99 insertions(+), 91 deletions(-) delete mode 100644 admin/hooks/index.ts create mode 100644 admin/hooks/store/index.ts diff --git a/.eslintrc.js b/.eslintrc.js index c229c0952..b1a019e35 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,7 +4,7 @@ module.exports = { extends: ["custom"], settings: { next: { - rootDir: ["web/", "space/"], + rootDir: ["web/", "space/", "admin/"], }, }, }; diff --git a/admin/.env.example b/admin/.env.example index a86a8b4fb..fdeb05c4d 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -1,5 +1,3 @@ NEXT_PUBLIC_API_BASE_URL="" -NEXT_PUBLIC_ADMIN_BASE_URL="" -NEXT_PUBLIC_SPACE_BASE_URL="" -NEXT_PUBLIC_WEB_BASE_URL="" -NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" \ No newline at end of file +NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +NEXT_PUBLIC_WEB_BASE_URL="" \ No newline at end of file diff --git a/admin/app/ai/components/ai-config-form.tsx b/admin/app/ai/components/ai-config-form.tsx index d61eb9ed9..fda70611c 100644 --- a/admin/app/ai/components/ai-config-form.tsx +++ b/admin/app/ai/components/ai-config-form.tsx @@ -6,7 +6,7 @@ import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@ // components import { ControllerInput, TControllerInputFormField } from "components/common"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; type IInstanceAIForm = { config: IFormattedInstanceConfiguration; diff --git a/admin/app/ai/page.tsx b/admin/app/ai/page.tsx index 71af4a5ba..5d002ca55 100644 --- a/admin/app/ai/page.tsx +++ b/admin/app/ai/page.tsx @@ -7,7 +7,7 @@ import { Loader } from "@plane/ui"; import { PageHeader } from "@/components/core"; import { InstanceAIForm } from "./components"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; const InstanceAIPage = observer(() => { // store diff --git a/admin/app/authentication/components/email-config-switch.tsx b/admin/app/authentication/components/email-config-switch.tsx index 0958b3c42..9c23901fe 100644 --- a/admin/app/authentication/components/email-config-switch.tsx +++ b/admin/app/authentication/components/email-config-switch.tsx @@ -3,7 +3,7 @@ import React from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; // ui import { ToggleSwitch } from "@plane/ui"; // types diff --git a/admin/app/authentication/components/password-config-switch.tsx b/admin/app/authentication/components/password-config-switch.tsx index 92428e494..ce33cd329 100644 --- a/admin/app/authentication/components/password-config-switch.tsx +++ b/admin/app/authentication/components/password-config-switch.tsx @@ -3,7 +3,7 @@ import React from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; // ui import { ToggleSwitch } from "@plane/ui"; // types diff --git a/admin/app/authentication/github/components/github-config-form.tsx b/admin/app/authentication/github/components/github-config-form.tsx index 22eb11ff4..43d220575 100644 --- a/admin/app/authentication/github/components/github-config-form.tsx +++ b/admin/app/authentication/github/components/github-config-form.tsx @@ -2,7 +2,7 @@ import { FC, useState } from "react"; import { useForm } from "react-hook-form"; import Link from "next/link"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; // ui import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; // components diff --git a/admin/app/authentication/github/components/root.tsx b/admin/app/authentication/github/components/root.tsx index 742462c3b..d820bc8a2 100644 --- a/admin/app/authentication/github/components/root.tsx +++ b/admin/app/authentication/github/components/root.tsx @@ -4,7 +4,7 @@ import React from "react"; import Link from "next/link"; import { observer } from "mobx-react-lite"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; // ui import { ToggleSwitch, getButtonStyling } from "@plane/ui"; // icons diff --git a/admin/app/authentication/github/page.tsx b/admin/app/authentication/github/page.tsx index 6470f812a..893762d47 100644 --- a/admin/app/authentication/github/page.tsx +++ b/admin/app/authentication/github/page.tsx @@ -11,7 +11,7 @@ import { PageHeader } from "@/components/core"; import { AuthenticationMethodCard } from "../components"; import { InstanceGithubConfigForm } from "./components"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; // helpers import { resolveGeneralTheme } from "@/helpers/common.helper"; // icons diff --git a/admin/app/authentication/google/components/google-config-form.tsx b/admin/app/authentication/google/components/google-config-form.tsx index 42cea78fd..f07021694 100644 --- a/admin/app/authentication/google/components/google-config-form.tsx +++ b/admin/app/authentication/google/components/google-config-form.tsx @@ -2,7 +2,7 @@ import { FC, useState } from "react"; import { useForm } from "react-hook-form"; import Link from "next/link"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; // ui import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; // components diff --git a/admin/app/authentication/google/components/root.tsx b/admin/app/authentication/google/components/root.tsx index 6b287476d..5432c95bf 100644 --- a/admin/app/authentication/google/components/root.tsx +++ b/admin/app/authentication/google/components/root.tsx @@ -4,7 +4,7 @@ import React from "react"; import Link from "next/link"; import { observer } from "mobx-react-lite"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; // ui import { ToggleSwitch, getButtonStyling } from "@plane/ui"; // icons diff --git a/admin/app/authentication/google/page.tsx b/admin/app/authentication/google/page.tsx index f7fa6e643..9b02842af 100644 --- a/admin/app/authentication/google/page.tsx +++ b/admin/app/authentication/google/page.tsx @@ -10,7 +10,7 @@ import { PageHeader } from "@/components/core"; import { AuthenticationMethodCard } from "../components"; import { InstanceGoogleConfigForm } from "./components"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; // icons import GoogleLogo from "@/public/logos/google-logo.svg"; diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx index 59e405608..068592468 100644 --- a/admin/app/authentication/page.tsx +++ b/admin/app/authentication/page.tsx @@ -14,7 +14,7 @@ import { GoogleConfiguration } from "./google/components"; import { GithubConfiguration } from "./github/components"; import { PageHeader } from "@/components/core"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; // helpers import { resolveGeneralTheme } from "@/helpers/common.helper"; // images diff --git a/admin/app/email/components/email-config-form.tsx b/admin/app/email/components/email-config-form.tsx index 38b50d50f..50c867132 100644 --- a/admin/app/email/components/email-config-form.tsx +++ b/admin/app/email/components/email-config-form.tsx @@ -1,7 +1,7 @@ import React, { FC, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; // ui import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; // components diff --git a/admin/app/email/page.tsx b/admin/app/email/page.tsx index a3b0bed59..6ffebc904 100644 --- a/admin/app/email/page.tsx +++ b/admin/app/email/page.tsx @@ -7,7 +7,7 @@ import { Loader } from "@plane/ui"; import { PageHeader } from "@/components/core"; import { InstanceEmailForm } from "./components"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; const InstanceEmailPage = observer(() => { // store diff --git a/admin/app/general/components/general-config-form.tsx b/admin/app/general/components/general-config-form.tsx index f45876419..5e360e048 100644 --- a/admin/app/general/components/general-config-form.tsx +++ b/admin/app/general/components/general-config-form.tsx @@ -6,7 +6,7 @@ import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; // components import { ControllerInput } from "components/common"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; export interface IGeneralConfigurationForm { instance: IInstance["instance"]; diff --git a/admin/app/general/page.tsx b/admin/app/general/page.tsx index 10429c1c9..accaf01d1 100644 --- a/admin/app/general/page.tsx +++ b/admin/app/general/page.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { PageHeader } from "@/components/core"; import { GeneralConfigurationForm } from "./components"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; const GeneralPage = observer(() => { const { instance, instanceAdmins } = useInstance(); diff --git a/admin/app/image/components/image-config-form.tsx b/admin/app/image/components/image-config-form.tsx index 722051878..1779468fa 100644 --- a/admin/app/image/components/image-config-form.tsx +++ b/admin/app/image/components/image-config-form.tsx @@ -5,7 +5,7 @@ import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from // components import { ControllerInput } from "components/common"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; type IInstanceImageConfigForm = { config: IFormattedInstanceConfiguration; diff --git a/admin/app/image/page.tsx b/admin/app/image/page.tsx index 68572c519..cbf4a8f4d 100644 --- a/admin/app/image/page.tsx +++ b/admin/app/image/page.tsx @@ -7,7 +7,7 @@ import { Loader } from "@plane/ui"; import { PageHeader } from "@/components/core"; import { InstanceImageConfigForm } from "./components"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; const InstanceImagePage = observer(() => { // store diff --git a/admin/app/layout.tsx b/admin/app/layout.tsx index d991f9d82..3352cbfae 100644 --- a/admin/app/layout.tsx +++ b/admin/app/layout.tsx @@ -7,6 +7,8 @@ import { StoreProvider } from "@/lib/store-context"; import { AppWrapper } from "@/lib/wrappers"; // constants import { SITE_NAME, SITE_DESCRIPTION, SITE_URL, TWITTER_USER_NAME, SITE_KEYWORDS, SITE_TITLE } from "@/constants/seo"; +// helpers +import { ASSET_PREFIX } from "@/helpers/common.helper"; // styles import "./globals.css"; @@ -14,35 +16,31 @@ interface RootLayoutProps { children: ReactNode; } -const RootLayout = ({ children, ...pageProps }: RootLayoutProps) => { - const prefix = "/god-mode/"; - - return ( - - - {SITE_TITLE} - - - - - - - - - - - - - - - - - {children} - - - - - ); -}; +const RootLayout = ({ children, ...pageProps }: RootLayoutProps) => ( + + + {SITE_TITLE} + + + + + + + + + + + + + + + + + {children} + + + + +); export default RootLayout; diff --git a/admin/components/admin-sidebar/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx index ba8f2cba5..8b3f5baeb 100644 --- a/admin/components/admin-sidebar/help-section.tsx +++ b/admin/components/admin-sidebar/help-section.tsx @@ -7,7 +7,7 @@ import { Transition } from "@headlessui/react"; import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; // hooks -import { useInstance, useTheme } from "@/hooks"; +import { useInstance, useTheme } from "@/hooks/store"; // assets import packageJson from "package.json"; diff --git a/admin/components/admin-sidebar/root.tsx b/admin/components/admin-sidebar/root.tsx index 3b754d8b2..654769924 100644 --- a/admin/components/admin-sidebar/root.tsx +++ b/admin/components/admin-sidebar/root.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useTheme } from "@/hooks"; +import { useTheme } from "@/hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar"; diff --git a/admin/components/admin-sidebar/sidebar-dropdown.tsx b/admin/components/admin-sidebar/sidebar-dropdown.tsx index 68212464e..f248f852f 100644 --- a/admin/components/admin-sidebar/sidebar-dropdown.tsx +++ b/admin/components/admin-sidebar/sidebar-dropdown.tsx @@ -7,7 +7,7 @@ import { LogOut, UserCog2, Palette } from "lucide-react"; import { Menu, Transition } from "@headlessui/react"; import { Avatar } from "@plane/ui"; // hooks -import { useTheme, useUser } from "@/hooks"; +import { useTheme, useUser } from "@/hooks/store"; // helpers import { API_BASE_URL, cn } from "@/helpers/common.helper"; // services diff --git a/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx b/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx index ba00afa7f..d6ed65541 100644 --- a/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx +++ b/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx @@ -3,7 +3,7 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useTheme } from "@/hooks"; +import { useTheme } from "@/hooks/store"; // icons import { Menu } from "lucide-react"; diff --git a/admin/components/admin-sidebar/sidebar-menu.tsx b/admin/components/admin-sidebar/sidebar-menu.tsx index e7111aea2..dfb410051 100644 --- a/admin/components/admin-sidebar/sidebar-menu.tsx +++ b/admin/components/admin-sidebar/sidebar-menu.tsx @@ -6,7 +6,7 @@ import { observer } from "mobx-react-lite"; import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; import { Tooltip } from "@plane/ui"; // hooks -import { useTheme } from "@/hooks"; +import { useTheme } from "@/hooks/store"; // helpers import { cn } from "@/helpers/common.helper"; diff --git a/admin/components/new-user-popup.tsx b/admin/components/new-user-popup.tsx index d17e99d5e..6b4cea340 100644 --- a/admin/components/new-user-popup.tsx +++ b/admin/components/new-user-popup.tsx @@ -9,7 +9,7 @@ import { Button, getButtonStyling } from "@plane/ui"; // helpers import { resolveGeneralTheme } from "helpers/common.helper"; // hooks -import { useInstance, useTheme } from "@/hooks"; +import { useInstance, useTheme } from "@/hooks/store"; // icons import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg"; import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg"; diff --git a/admin/helpers/common.helper.ts b/admin/helpers/common.helper.ts index 3bf03024b..e7aae0698 100644 --- a/admin/helpers/common.helper.ts +++ b/admin/helpers/common.helper.ts @@ -1,7 +1,16 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; -export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ? process.env.NEXT_PUBLIC_API_BASE_URL : ""; +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; + +export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || ""; + +export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || ""; +export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || ""; + +export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || ""; + +export const ASSET_PREFIX = ADMIN_BASE_PATH; export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); diff --git a/admin/hooks/index.ts b/admin/hooks/index.ts deleted file mode 100644 index 273970eda..000000000 --- a/admin/hooks/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./use-outside-click-detector"; - -// store-hooks -export * from "./store/use-theme"; -export * from "./store/use-instance"; -export * from "./store/use-user"; diff --git a/admin/hooks/store/index.ts b/admin/hooks/store/index.ts new file mode 100644 index 000000000..7447064da --- /dev/null +++ b/admin/hooks/store/index.ts @@ -0,0 +1,3 @@ +export * from "./use-theme"; +export * from "./use-instance"; +export * from "./use-user"; diff --git a/admin/lib/wrappers/app-wrapper.tsx b/admin/lib/wrappers/app-wrapper.tsx index 6be1cec24..aa6e26330 100644 --- a/admin/lib/wrappers/app-wrapper.tsx +++ b/admin/lib/wrappers/app-wrapper.tsx @@ -4,11 +4,11 @@ import { FC, ReactNode, useEffect, Suspense } from "react"; import { observer } from "mobx-react-lite"; import { SWRConfig } from "swr"; // hooks -import { useTheme, useUser } from "@/hooks"; +import { useTheme, useUser } from "@/hooks/store"; // ui import { Toast } from "@plane/ui"; // constants -import { SWR_CONFIG } from "constants/swr-config"; +import { SWR_CONFIG } from "@/constants/swr-config"; // helpers import { resolveGeneralTheme } from "helpers/common.helper"; diff --git a/admin/lib/wrappers/auth-wrapper.tsx b/admin/lib/wrappers/auth-wrapper.tsx index 75e7c2acc..00f947047 100644 --- a/admin/lib/wrappers/auth-wrapper.tsx +++ b/admin/lib/wrappers/auth-wrapper.tsx @@ -6,7 +6,7 @@ import { observer } from "mobx-react-lite"; import useSWR from "swr"; import { Spinner } from "@plane/ui"; // hooks -import { useInstance, useUser } from "@/hooks"; +import { useInstance, useUser } from "@/hooks/store"; // helpers import { EAuthenticationPageType } from "@/helpers"; diff --git a/admin/lib/wrappers/instance-wrapper.tsx b/admin/lib/wrappers/instance-wrapper.tsx index da02992aa..6ee1dc247 100644 --- a/admin/lib/wrappers/instance-wrapper.tsx +++ b/admin/lib/wrappers/instance-wrapper.tsx @@ -10,7 +10,7 @@ import { DefaultLayout } from "@/layouts"; // components import { InstanceNotReady } from "@/components/instance"; // hooks -import { useInstance } from "@/hooks"; +import { useInstance } from "@/hooks/store"; // helpers import { EInstancePageType } from "@/helpers"; @@ -28,6 +28,9 @@ export const InstanceWrapper: FC = observer((props) => { const { isLoading: isSWRLoading } = useSWR("INSTANCE_INFORMATION", () => fetchInstanceInfo(), { revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnReconnect: false, + errorRetryCount: 0, }); if (isSWRLoading || isLoading) @@ -37,6 +40,10 @@ export const InstanceWrapper: FC = observer((props) => {
); + if (!instance) { + return <>Something went wrong; + } + if (instance?.instance?.is_setup_done === false && authEnabled === "1") return ( diff --git a/admin/package.json b/admin/package.json index 6a63ea937..936c612bb 100644 --- a/admin/package.json +++ b/admin/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "turbo run develop", - "develop": "next dev --port 3333", + "develop": "next dev --port 3001", "build": "next build", "preview": "next build && next start", "start": "next start", diff --git a/admin/services/instance.service.ts b/admin/services/instance.service.ts index 519adc9f2..109b52e44 100644 --- a/admin/services/instance.service.ts +++ b/admin/services/instance.service.ts @@ -1,8 +1,8 @@ -import { APIService } from "services/api.service"; // types import type { IFormattedInstanceConfiguration, IInstance, IInstanceAdmin, IInstanceConfiguration } from "@plane/types"; // helpers -import { API_BASE_URL } from "helpers/common.helper"; +import { API_BASE_URL } from "@/helpers/common.helper"; +import { APIService } from "@/services/api.service"; export class InstanceService extends APIService { constructor() { diff --git a/space/helpers/common.helper.ts b/space/helpers/common.helper.ts index 085b34dc2..f39cddc0e 100644 --- a/space/helpers/common.helper.ts +++ b/space/helpers/common.helper.ts @@ -1,6 +1,12 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; -export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; + +export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || ""; + +export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || ""; + +export const ASSET_PREFIX = SPACE_BASE_PATH; export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); diff --git a/space/package.json b/space/package.json index d27a23109..a10d190d2 100644 --- a/space/package.json +++ b/space/package.json @@ -4,9 +4,9 @@ "private": true, "scripts": { "dev": "turbo run develop", - "develop": "next dev -p 4000", + "develop": "next dev -p 3002", "build": "next build", - "start": "next start -p 4000", + "start": "next start", "lint": "next lint", "export": "next export" }, diff --git a/space/pages/onboarding/index.tsx b/space/pages/onboarding/index.tsx index 8318f0346..5c0b45e24 100644 --- a/space/pages/onboarding/index.tsx +++ b/space/pages/onboarding/index.tsx @@ -9,6 +9,7 @@ import { Avatar } from "@plane/ui"; import { OnBoardingForm } from "@/components/accounts/onboarding-form"; // helpers import { EPageTypes } from "@/helpers/authentication.helper"; +import { ASSET_PREFIX } from "@/helpers/common.helper"; // hooks import { useUser, useUserProfile } from "@/hooks/store"; // wrappers @@ -17,8 +18,6 @@ import { AuthWrapper } from "@/lib/wrappers"; import ProfileSetupDark from "public/onboarding/profile-setup-dark.svg"; import ProfileSetup from "public/onboarding/profile-setup.svg"; -const imagePrefix = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || ""; - const OnBoardingPage = observer(() => { // router const router = useRouter(); @@ -60,7 +59,7 @@ const OnBoardingPage = observer(() => {
Plane Logo { - // hooks - // const { instance } = useInstance(); - - const GOD_MODE_URL = encodeURI(ADMIN_BASE_URL + ADMIN_BASE_PATH + "setup/?auth_enabled=0"); +export const InstanceNotReady: FC = () => { + const GOD_MODE_URL = encodeURI(ADMIN_BASE_URL + ADMIN_BASE_PATH + "/setup/?auth_enabled=0"); return (
@@ -48,4 +42,4 @@ export const InstanceNotReady: FC = observer(() => {
); -}); +}; From da78933c614d45a52532deee57a6ffefc184aaa6 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 10 May 2024 15:24:18 +0530 Subject: [PATCH 10/37] [WEB-1274] chore: issue spreadsheet enhancement (#4423) * chore: border and background remove from cycle and module select * choe: indentation improvement --- web/components/dropdowns/module/index.tsx | 2 +- .../spreadsheet/columns/cycle-column.tsx | 2 +- .../spreadsheet/columns/module-column.tsx | 2 +- .../issues/issue-layouts/spreadsheet/index.ts | 1 + .../issue-layouts/spreadsheet/issue-row.tsx | 30 +++++++++++-------- .../spreadsheet/spreadsheet-header.tsx | 14 +++------ 6 files changed, 25 insertions(+), 26 deletions(-) diff --git a/web/components/dropdowns/module/index.tsx b/web/components/dropdowns/module/index.tsx index e1441efe9..6883332e1 100644 --- a/web/components/dropdowns/module/index.tsx +++ b/web/components/dropdowns/module/index.tsx @@ -73,7 +73,7 @@ const ButtonContent: React.FC = (props) => { return ( <> {showCount ? ( -
+
{!hideIcon && } {(value.length > 0 || !!placeholder) && (
diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx index 342a9208b..a412c604f 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx @@ -57,7 +57,7 @@ export const SpreadsheetCycleColumn: React.FC = observer((props) => { placeholder="Select cycle" buttonVariant="transparent-with-text" buttonContainerClassName="w-full relative flex items-center p-2" - buttonClassName="relative border-[0.5px] border-custom-border-400 h-4.5" + buttonClassName="relative leading-4 h-4.5 bg-transparent" onClose={onClose} />
diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx index 75b9ac065..fb742f703 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx @@ -68,7 +68,7 @@ export const SpreadsheetModuleColumn: React.FC = observer((props) => { placeholder="Select modules" buttonVariant="transparent-with-text" buttonContainerClassName="w-full relative flex items-center p-2" - buttonClassName="relative border-[0.5px] border-custom-border-400 h-4.5" + buttonClassName="relative leading-4 h-4.5 bg-transparent" onClose={onClose} multiple showCount diff --git a/web/components/issues/issue-layouts/spreadsheet/index.ts b/web/components/issues/issue-layouts/spreadsheet/index.ts index 8f7c4a7fd..8fa49e851 100644 --- a/web/components/issues/issue-layouts/spreadsheet/index.ts +++ b/web/components/issues/issue-layouts/spreadsheet/index.ts @@ -2,3 +2,4 @@ export * from "./columns"; export * from "./roots"; export * from "./spreadsheet-view"; export * from "./quick-add-issue-form"; +export * from "./spreadsheet-header-column"; diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 1bd8a8808..1d1ea1e08 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -50,7 +50,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => { containerRef, issueIds, spreadsheetColumnsList, - spacingLeft = 14, + spacingLeft = 6, } = props; const [isExpanded, setExpanded] = useState(false); @@ -96,7 +96,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => { quickActions={quickActions} canEditProperties={canEditProperties} nestingLevel={nestingLevel + 1} - spacingLeft={spacingLeft + (displayProperties.key ? 16 : 28)} + spacingLeft={spacingLeft + (displayProperties.key ? 12 : 28)} isEstimateEnabled={isEstimateEnabled} updateIssue={updateIssue} portalElement={portalElement} @@ -140,7 +140,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { isExpanded, setExpanded, spreadsheetColumnsList, - spacingLeft = 14, + spacingLeft = 6, } = props; // states const [isMenuActive, setIsMenuActive] = useState(false); @@ -218,18 +218,22 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { disabled={!!issueDetail?.tempId} >
-
- {issueDetail.sub_issues_count > 0 && ( - - )} +
+ {/* bulk ops */} + +
+ {issueDetail.sub_issues_count > 0 && ( + + )} +
diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx index 968aade3e..63017f0e7 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx @@ -1,10 +1,9 @@ // ui import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +// types import { LayersIcon } from "@plane/ui"; -// constants // components -import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; -import { SpreadsheetHeaderColumn } from "./spreadsheet-header-column"; +import { SpreadsheetHeaderColumn } from "@/components/issues/issue-layouts"; interface Props { displayProperties: IIssueDisplayProperties; @@ -25,13 +24,8 @@ export const SpreadsheetHeader = (props: Props) => { className="sticky left-0 z-[15] h-11 w-[28rem] flex items-center bg-custom-background-90 text-sm font-medium before:absolute before:h-full before:right-0 before:border-[0.5px] before:border-custom-border-100" tabIndex={-1} > - - - #ID - - - - + + Issue From dc77e4afdb86574bf02a204908b66f185f43a1fa Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 10 May 2024 15:25:16 +0530 Subject: [PATCH 11/37] chore: project publish modal improvement (#4422) --- web/components/project/publish-project/modal.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/web/components/project/publish-project/modal.tsx b/web/components/project/publish-project/modal.tsx index 0b94c56f1..6f2f68fad 100644 --- a/web/components/project/publish-project/modal.tsx +++ b/web/components/project/publish-project/modal.tsx @@ -139,13 +139,7 @@ export const PublishProjectModal: React.FC = observer((props) => { const handlePublishProject = async (payload: IProjectPublishSettings) => { if (!workspaceSlug) return; - return publishProject(workspaceSlug.toString(), project.id, payload) - .then((res) => { - handleClose(); - // window.open(`${plane_deploy_url}/${workspaceSlug}/${project.id}`, "_blank"); - return res; - }) - .catch((err) => err); + return publishProject(workspaceSlug.toString(), project.id, payload); }; const handleUpdatePublishSettings = async (payload: IProjectPublishSettings) => { @@ -174,10 +168,6 @@ export const PublishProjectModal: React.FC = observer((props) => { setIsUnPublishing(true); await unPublishProject(workspaceSlug.toString(), project.id, publishId) - .then((res) => { - handleClose(); - return res; - }) .catch(() => setToast({ type: TOAST_TYPE.ERROR, From 74eb50aa1a911a3ca8ee996ea643c62c6dad97f2 Mon Sep 17 00:00:00 2001 From: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Date: Fri, 10 May 2024 16:08:04 +0530 Subject: [PATCH 12/37] selfhosting fixes for custom branch and platform (#4431) --- deploy/selfhost/docker-compose.yml | 13 +++++++++++-- deploy/selfhost/install.sh | 10 ++++++---- deploy/selfhost/variables.env | 3 +++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 67f61d0ef..6f58113be 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -42,6 +42,7 @@ services: web: <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable} + platform: ${DOCKER_PLATFORM:-} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: node web/server.js web @@ -54,6 +55,7 @@ services: space: <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable} + platform: ${DOCKER_PLATFORM:-} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: node space/server.js space @@ -66,7 +68,8 @@ services: admin: <<: *app-env - image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable} + image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable} + platform: ${DOCKER_PLATFORM:-} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: node admin/server.js admin @@ -79,6 +82,7 @@ services: api: <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} + platform: ${DOCKER_PLATFORM:-} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: ./bin/takeoff @@ -93,6 +97,7 @@ services: worker: <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} + platform: ${DOCKER_PLATFORM:-} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: ./bin/worker @@ -106,6 +111,7 @@ services: beat-worker: <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} + platform: ${DOCKER_PLATFORM:-} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: ./bin/beat @@ -119,6 +125,7 @@ services: migrator: <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} + platform: ${DOCKER_PLATFORM:-} pull_policy: ${PULL_POLICY:-always} restart: no command: > @@ -138,6 +145,7 @@ services: command: postgres -c 'max_connections=1000' volumes: - pgdata:/var/lib/postgresql/data + plane-redis: <<: *app-env image: redis:7.2.4-alpine @@ -148,7 +156,7 @@ services: plane-minio: <<: *app-env - image: minio/minio + image: minio/minio:latest pull_policy: if_not_present restart: unless-stopped command: server /export --console-address ":9090" @@ -159,6 +167,7 @@ services: proxy: <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable} + platform: ${DOCKER_PLATFORM:-} pull_policy: ${PULL_POLICY:-always} ports: - ${NGINX_PORT}:80 diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index aaf129524..b36d3b6b2 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -2,7 +2,8 @@ BRANCH=master SCRIPT_DIR=$PWD -PLANE_INSTALL_DIR=$PWD/plane-app +SERVICE_FOLDER=plane-app +PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER export APP_RELEASE=$BRANCH export DOCKERHUB_USER=makeplane export PULL_POLICY=always @@ -140,7 +141,7 @@ function download() { function startServices() { /bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull" - local migrator_container_id=$(docker container ls -aq -f "name=plane-app-migrator") + local migrator_container_id=$(docker container ls -aq -f "name=$SERVICE_FOLDER-migrator") if [ -n "$migrator_container_id" ]; then local idx=0 while docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do @@ -168,7 +169,7 @@ function startServices() { fi fi - local api_container_id=$(docker container ls -q -f "name=plane-app-api") + local api_container_id=$(docker container ls -q -f "name=$SERVICE_FOLDER-api") local idx2=0 while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q "."; do @@ -408,7 +409,8 @@ fi # REMOVE SPECIAL CHARACTERS FROM BRANCH NAME if [ "$BRANCH" != "master" ]; then - PLANE_INSTALL_DIR=$PWD/plane-app-$(echo $BRANCH | sed -r 's@(\/|" "|\.)@-@g') + SERVICE_FOLDER=plane-app-$(echo $BRANCH | sed -r 's@(\/|" "|\.)@-@g') + PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER fi mkdir -p $PLANE_INSTALL_DIR/archive diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index 91e206bb4..62c4bc164 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -43,3 +43,6 @@ FILE_SIZE_LIMIT=5242880 # Gunicorn Workers GUNICORN_WORKERS=1 + +# UNCOMMENT `DOCKER_PLATFORM` IF YOU ARE ON `ARM64` AND DOCKER IMAGE IS NOT AVAILABLE FOR RESPECTIVE `APP_RELEASE` +# DOCKER_PLATFORM=linux/amd64 \ No newline at end of file From b725c69882102436b382eca9d6e19e89e8e44a6f Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Fri, 10 May 2024 16:14:15 +0530 Subject: [PATCH 13/37] list and spreadsheet sub issues mutation issue (#4415) --- web/components/issues/issue-detail/parent-select.tsx | 6 ++++-- web/components/issues/issue-layouts/list/block.tsx | 7 +++++-- .../issues/issue-layouts/spreadsheet/issue-row.tsx | 7 +++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx index 6994dd5bd..d8399fc02 100644 --- a/web/components/issues/issue-detail/parent-select.tsx +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -34,7 +34,7 @@ export const IssueParentSelect: React.FC = observer((props) isParentIssueModalOpen, toggleParentIssueModal, removeSubIssue, - subIssues: { setSubIssueHelpers }, + subIssues: { setSubIssueHelpers, fetchSubIssues }, } = useIssueDetail(); // derived values @@ -47,7 +47,8 @@ export const IssueParentSelect: React.FC = observer((props) try { await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }); await issueOperations.fetch(workspaceSlug, projectId, issueId); - toggleParentIssueModal(issueId); + _issueId && (await fetchSubIssues(workspaceSlug, projectId, _issueId)); + toggleParentIssueModal(null); } catch (error) { console.error("something went wrong while fetching the issue"); } @@ -62,6 +63,7 @@ export const IssueParentSelect: React.FC = observer((props) try { setSubIssueHelpers(parentIssueId, "issue_loader", issueId); await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId); + await fetchSubIssues(workspaceSlug, projectId, parentIssueId); setSubIssueHelpers(parentIssueId, "issue_loader", issueId); } catch (error) { setToast({ diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index c68abac14..46499020e 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -57,11 +57,14 @@ export const IssueBlock: React.FC = observer((props: IssueBlock setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); const issue = issuesMap[issueId]; + const subIssues = subIssuesStore.subIssuesByIssueId(issueId); const { isMobile } = usePlatformOS(); if (!issue) return null; const canEditIssueProperties = canEditProperties(issue.project_id); const projectIdentifier = getProjectIdentifierById(issue.project_id); + // if sub issues have been fetched for the issue, use that for count or use issue's sub_issues_count + const subIssuesCount = subIssues ? subIssues.length : issue.sub_issues_count; const paddingLeft = `${spacingLeft}px`; @@ -90,11 +93,11 @@ export const IssueBlock: React.FC = observer((props: IssueBlock } )} > -
+
- {issue.sub_issues_count > 0 && ( + {subIssuesCount > 0 && ( + )} + {secondaryButton} +
+
+
+); diff --git a/admin/components/common/index.ts b/admin/components/common/index.ts index 97248b999..77f0e9327 100644 --- a/admin/components/common/index.ts +++ b/admin/components/common/index.ts @@ -4,3 +4,4 @@ export * from "./controller-input"; export * from "./copy-field"; export * from "./password-strength-meter"; export * from "./banner"; +export * from "./empty-state"; diff --git a/admin/lib/wrappers/instance-wrapper.tsx b/admin/lib/wrappers/instance-wrapper.tsx index 6ee1dc247..f86adfdce 100644 --- a/admin/lib/wrappers/instance-wrapper.tsx +++ b/admin/lib/wrappers/instance-wrapper.tsx @@ -13,6 +13,7 @@ import { InstanceNotReady } from "@/components/instance"; import { useInstance } from "@/hooks/store"; // helpers import { EInstancePageType } from "@/helpers"; +import { EmptyState } from "@/components/common"; type TInstanceWrapper = { children: ReactNode; @@ -41,7 +42,12 @@ export const InstanceWrapper: FC = observer((props) => { ); if (!instance) { - return <>Something went wrong; + return ( + + ); } if (instance?.instance?.is_setup_done === false && authEnabled === "1") diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 4b975939d..73809b9ad 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -1,51 +1,52 @@ AUTHENTICATION_ERROR_CODES = { # Global "INSTANCE_NOT_CONFIGURED": 5000, - "INVALID_EMAIL": 5012, - "EMAIL_REQUIRED": 5013, - "SIGNUP_DISABLED": 5001, + "INVALID_EMAIL": 5005, + "EMAIL_REQUIRED": 5010, + "SIGNUP_DISABLED": 5015, # Password strength - "INVALID_PASSWORD": 5002, - "SMTP_NOT_CONFIGURED": 5007, + "INVALID_PASSWORD": 5020, + "SMTP_NOT_CONFIGURED": 5025, # Sign Up - "USER_ALREADY_EXIST": 5003, - "AUTHENTICATION_FAILED_SIGN_UP": 5006, - "REQUIRED_EMAIL_PASSWORD_SIGN_UP": 5015, - "INVALID_EMAIL_SIGN_UP": 5017, - "INVALID_EMAIL_MAGIC_SIGN_UP": 5019, - "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5023, + "USER_ALREADY_EXIST": 5030, + "AUTHENTICATION_FAILED_SIGN_UP": 5035, + "REQUIRED_EMAIL_PASSWORD_SIGN_UP": 5040, + "INVALID_EMAIL_SIGN_UP": 5045, + "INVALID_EMAIL_MAGIC_SIGN_UP": 5050, + "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5055, # Sign In - "USER_DOES_NOT_EXIST": 5004, - "AUTHENTICATION_FAILED_SIGN_IN": 5005, - "REQUIRED_EMAIL_PASSWORD_SIGN_IN": 5014, - "INVALID_EMAIL_SIGN_IN": 5016, - "INVALID_EMAIL_MAGIC_SIGN_IN": 5018, - "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED": 5022, - # Both Sign in and Sign up - "INVALID_MAGIC_CODE": 5008, - "EXPIRED_MAGIC_CODE": 5009, + "USER_DOES_NOT_EXIST": 5060, + "AUTHENTICATION_FAILED_SIGN_IN": 5065, + "REQUIRED_EMAIL_PASSWORD_SIGN_IN": 5070, + "INVALID_EMAIL_SIGN_IN": 5075, + "INVALID_EMAIL_MAGIC_SIGN_IN": 5080, + "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED": 5085, + # Both Sign in and Sign up for magic + "INVALID_MAGIC_CODE": 5090, + "EXPIRED_MAGIC_CODE": 5095, + "EMAIL_CODE_ATTEMPT_EXHAUSTED": 5100, # Oauth - "GOOGLE_NOT_CONFIGURED": 5010, - "GITHUB_NOT_CONFIGURED": 5011, - "GOOGLE_OAUTH_PROVIDER_ERROR": 5021, - "GITHUB_OAUTH_PROVIDER_ERROR": 5020, + "GOOGLE_NOT_CONFIGURED": 5105, + "GITHUB_NOT_CONFIGURED": 5110, + "GOOGLE_OAUTH_PROVIDER_ERROR": 5115, + "GITHUB_OAUTH_PROVIDER_ERROR": 5120, # Reset Password - "INVALID_PASSWORD_TOKEN": 5024, - "EXPIRED_PASSWORD_TOKEN": 5025, + "INVALID_PASSWORD_TOKEN": 5125, + "EXPIRED_PASSWORD_TOKEN": 5130, # Change password - "INCORRECT_OLD_PASSWORD": 5026, - "INVALID_NEW_PASSWORD": 5027, + "INCORRECT_OLD_PASSWORD": 5135, + "INVALID_NEW_PASSWORD": 5140, # set passowrd - "PASSWORD_ALREADY_SET": 5028, + "PASSWORD_ALREADY_SET": 5145, # Admin - "ADMIN_ALREADY_EXIST": 5029, - "REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME": 5030, - "INVALID_ADMIN_EMAIL": 5031, - "INVALID_ADMIN_PASSWORD": 5032, - "REQUIRED_ADMIN_EMAIL_PASSWORD": 5033, - "ADMIN_AUTHENTICATION_FAILED": 5034, - "ADMIN_USER_ALREADY_EXIST": 5035, - "ADMIN_USER_DOES_NOT_EXIST": 5036, + "ADMIN_ALREADY_EXIST": 5150, + "REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME": 5155, + "INVALID_ADMIN_EMAIL": 5160, + "INVALID_ADMIN_PASSWORD": 5165, + "REQUIRED_ADMIN_EMAIL_PASSWORD": 5170, + "ADMIN_AUTHENTICATION_FAILED": 5175, + "ADMIN_USER_ALREADY_EXIST": 5180, + "ADMIN_USER_DOES_NOT_EXIST": 5185, } diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py index 71451ef0d..c1207d14d 100644 --- a/apiserver/plane/authentication/provider/credentials/magic_code.py +++ b/apiserver/plane/authentication/provider/credentials/magic_code.py @@ -77,7 +77,13 @@ class MagicCodeProvider(CredentialAdapter): current_attempt = data["current_attempt"] + 1 if data["current_attempt"] > 2: - return key, "" + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "EMAIL_CODE_ATTEMPT_EXHAUSTED" + ], + error_message="EMAIL_CODE_ATTEMPT_EXHAUSTED", + payload={"email": self.key}, + ) value = { "current_attempt": current_attempt, diff --git a/apiserver/plane/authentication/utils/host.py b/apiserver/plane/authentication/utils/host.py index b670eed41..4046c1e20 100644 --- a/apiserver/plane/authentication/utils/host.py +++ b/apiserver/plane/authentication/utils/host.py @@ -5,21 +5,38 @@ from urllib.parse import urlsplit from django.conf import settings -def base_host(request, is_admin=False, is_space=False): +def base_host(request, is_admin=False, is_space=False, is_app=False): """Utility function to return host / origin from the request""" - - if is_admin and settings.ADMIN_BASE_URL: - return settings.ADMIN_BASE_URL - - if is_space and settings.SPACE_BASE_URL: - return settings.SPACE_BASE_URL - - return ( + # Calculate the base origin from request + base_origin = str( request.META.get("HTTP_ORIGIN") or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}" or f"""{"https" if request.is_secure() else "http"}://{request.get_host()}""" ) + # Admin redirections + if is_admin: + if settings.ADMIN_BASE_URL: + return settings.ADMIN_BASE_URL + else: + return base_origin + "/god-mode/" + + # Space redirections + if is_space: + if settings.SPACE_BASE_URL: + return settings.SPACE_BASE_URL + else: + return base_origin + "/spaces/" + + # App Redirection + if is_app: + if settings.APP_BASE_URL: + return settings.APP_BASE_URL + else: + return base_origin + + return base_origin + def user_ip(request): return str(request.META.get("REMOTE_ADDR")) diff --git a/apiserver/plane/authentication/utils/login.py b/apiserver/plane/authentication/utils/login.py index 88a988c8f..45dbdc249 100644 --- a/apiserver/plane/authentication/utils/login.py +++ b/apiserver/plane/authentication/utils/login.py @@ -5,12 +5,17 @@ from django.contrib.auth import login from plane.authentication.utils.host import base_host -def user_login(request, user): +def user_login(request, user, is_app=False, is_admin=False, is_space=False): login(request=request, user=user) device_info = { "user_agent": request.META.get("HTTP_USER_AGENT", ""), "ip_address": request.META.get("REMOTE_ADDR", ""), - "domain": base_host(request=request), + "domain": base_host( + request=request, + is_app=is_app, + is_admin=is_admin, + is_space=is_space, + ), } request.session["device_info"] = device_info request.session.save() diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index 7ef6ac9f4..4093be108 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -42,8 +42,8 @@ class SignInAuthEndpoint(View): params["next_path"] = str(next_path) # Base URL join url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -66,8 +66,8 @@ class SignInAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -85,8 +85,8 @@ class SignInAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -100,8 +100,8 @@ class SignInAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -111,7 +111,7 @@ class SignInAuthEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_app=True) # Process workspace and project invitations process_workspace_project_invitations(user=user) # Get the redirection path @@ -121,15 +121,15 @@ class SignInAuthEndpoint(View): path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host(request=request), path) + url = urljoin(base_host(request=request, is_app=True), path) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -152,7 +152,7 @@ class SignUpAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -173,7 +173,7 @@ class SignUpAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -192,7 +192,7 @@ class SignUpAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -207,7 +207,7 @@ class SignUpAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -218,7 +218,7 @@ class SignUpAuthEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_app=True) # Process workspace and project invitations process_workspace_project_invitations(user=user) # Get the redirection path @@ -227,14 +227,14 @@ class SignUpAuthEndpoint(View): else: path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host(request=request), path) + url = urljoin(base_host(request=request, is_app=True), path) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/github.py b/apiserver/plane/authentication/views/app/github.py index 48b7e09d9..73afa674b 100644 --- a/apiserver/plane/authentication/views/app/github.py +++ b/apiserver/plane/authentication/views/app/github.py @@ -24,7 +24,7 @@ class GitHubOauthInitiateEndpoint(View): def get(self, request): # Get host and next path - request.session["host"] = base_host(request=request) + 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(next_path) @@ -42,7 +42,7 @@ class GitHubOauthInitiateEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -57,7 +57,7 @@ class GitHubOauthInitiateEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -110,7 +110,7 @@ class GitHubCallbackEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_app=True) # Process workspace and project invitations process_workspace_project_invitations(user=user) # Get the redirection path diff --git a/apiserver/plane/authentication/views/app/google.py b/apiserver/plane/authentication/views/app/google.py index 690a9778b..ea3afed89 100644 --- a/apiserver/plane/authentication/views/app/google.py +++ b/apiserver/plane/authentication/views/app/google.py @@ -24,7 +24,7 @@ from plane.authentication.adapter.error import ( class GoogleOauthInitiateEndpoint(View): def get(self, request): - request.session["host"] = base_host(request=request) + 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(next_path) @@ -42,7 +42,7 @@ class GoogleOauthInitiateEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -58,7 +58,7 @@ class GoogleOauthInitiateEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -108,7 +108,7 @@ class GoogleCallbackEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_app=True) # Process workspace and project invitations process_workspace_project_invitations(user=user) # Get the redirection path diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py index 8f9f3633b..0fa529674 100644 --- a/apiserver/plane/authentication/views/app/magic.py +++ b/apiserver/plane/authentication/views/app/magic.py @@ -90,8 +90,8 @@ class MagicSignInEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -104,8 +104,8 @@ class MagicSignInEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -116,7 +116,7 @@ class MagicSignInEndpoint(View): user = provider.authenticate() profile = Profile.objects.get(user=user) # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_app=True) # Process workspace and project invitations process_workspace_project_invitations(user=user) if user.is_password_autoset and profile.is_onboarded: @@ -129,7 +129,7 @@ class MagicSignInEndpoint(View): else str(process_workspace_project_invitations(user=user)) ) # redirect to referer path - url = urljoin(base_host(request=request), path) + url = urljoin(base_host(request=request, is_app=True), path) return HttpResponseRedirect(url) except AuthenticationException as e: @@ -137,8 +137,8 @@ class MagicSignInEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode(params), + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -163,7 +163,7 @@ class MagicSignUpEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -177,7 +177,7 @@ class MagicSignUpEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -188,7 +188,7 @@ class MagicSignUpEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_app=True) # Process workspace and project invitations process_workspace_project_invitations(user=user) # Get the redirection path @@ -197,7 +197,7 @@ class MagicSignUpEndpoint(View): else: path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host(request=request), path) + url = urljoin(base_host(request=request, is_app=True), path) return HttpResponseRedirect(url) except AuthenticationException as e: @@ -205,7 +205,7 @@ class MagicSignUpEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py index 80803cd25..b26b57760 100644 --- a/apiserver/plane/authentication/views/app/password_management.py +++ b/apiserver/plane/authentication/views/app/password_management.py @@ -146,7 +146,7 @@ class ResetPasswordEndpoint(View): ) params = exc.get_error_dict() url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "accounts/reset-password?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -159,8 +159,9 @@ class ResetPasswordEndpoint(View): error_message="INVALID_PASSWORD", ) url = urljoin( - base_host(request=request), - "?" + urlencode(exc.get_error_dict()), + base_host(request=request, is_app=True), + "accounts/reset-password?" + + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -172,7 +173,7 @@ class ResetPasswordEndpoint(View): error_message="INVALID_PASSWORD", ) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "accounts/reset-password?" + urlencode(exc.get_error_dict()), ) @@ -184,8 +185,8 @@ class ResetPasswordEndpoint(View): user.save() url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode({"success": True}), + base_host(request=request, is_app=True), + "sign-in?" + urlencode({"success": True}), ) return HttpResponseRedirect(url) except DjangoUnicodeDecodeError: @@ -196,7 +197,7 @@ class ResetPasswordEndpoint(View): error_message="EXPIRED_PASSWORD_TOKEN", ) url = urljoin( - base_host(request=request), + base_host(request=request, is_app=True), "accounts/reset-password?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/signout.py b/apiserver/plane/authentication/views/app/signout.py index 967f398eb..10461f240 100644 --- a/apiserver/plane/authentication/views/app/signout.py +++ b/apiserver/plane/authentication/views/app/signout.py @@ -1,5 +1,5 @@ # Python imports -from urllib.parse import urlencode, urljoin +from urllib.parse import urljoin # Django imports from django.views import View @@ -23,12 +23,9 @@ class SignOutAuthEndpoint(View): user.save() # Log the user out logout(request) - url = urljoin( - base_host(request=request), - "accounts/sign-in?" + urlencode({"success": "true"}), - ) + url = urljoin(base_host(request=request, is_app=True), "sign-in") return HttpResponseRedirect(url) except Exception: return HttpResponseRedirect( - base_host(request=request), "accounts/sign-in" + base_host(request=request, is_app=True), "sign-in" ) diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py index 4b93010de..16ac058b0 100644 --- a/apiserver/plane/authentication/views/common.py +++ b/apiserver/plane/authentication/views/common.py @@ -70,7 +70,7 @@ class ChangePasswordEndpoint(APIView): user.set_password(serializer.data.get("new_password")) user.is_password_autoset = False user.save() - user_login(user=user, request=request) + user_login(user=user, request=request, is_app=True) return Response( {"message": "Password updated successfully"}, status=status.HTTP_200_OK, @@ -131,7 +131,7 @@ class SetUserPasswordEndpoint(APIView): user.is_password_autoset = False user.save() # Login the user as the session is invalidated - user_login(user=user, request=request) + user_login(user=user, request=request, is_app=True) # Return the user serializer = UserSerializer(user) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py index 4505332eb..e11ab29b5 100644 --- a/apiserver/plane/authentication/views/space/email.py +++ b/apiserver/plane/authentication/views/space/email.py @@ -38,7 +38,7 @@ class SignInAuthSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "accounts/sign-in?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -60,7 +60,7 @@ class SignInAuthSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces/accounts/sign-in?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -79,7 +79,7 @@ class SignInAuthSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces/accounts/sign-in?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -94,7 +94,7 @@ class SignInAuthSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces/accounts/sign-in?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -104,11 +104,11 @@ class SignInAuthSpaceEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_space=True) # redirect to next path url = urljoin( base_host(request=request, is_space=True), - str(next_path) if next_path else "/", + str(next_path) if next_path else "", ) return HttpResponseRedirect(url) except AuthenticationException as e: @@ -117,7 +117,7 @@ class SignInAuthSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces/accounts/sign-in?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -141,7 +141,7 @@ class SignUpAuthSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -162,7 +162,7 @@ class SignUpAuthSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) # Validate the email @@ -181,7 +181,7 @@ class SignUpAuthSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -196,7 +196,7 @@ class SignUpAuthSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -206,11 +206,11 @@ class SignUpAuthSpaceEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_space=True) # redirect to referer path url = urljoin( base_host(request=request, is_space=True), - str(next_path) if next_path else "spaces", + str(next_path) if next_path else "", ) return HttpResponseRedirect(url) except AuthenticationException as e: @@ -219,6 +219,6 @@ class SignUpAuthSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/github.py b/apiserver/plane/authentication/views/space/github.py index 4a0f23098..8430cbdfb 100644 --- a/apiserver/plane/authentication/views/space/github.py +++ b/apiserver/plane/authentication/views/space/github.py @@ -55,7 +55,7 @@ class GitHubOauthInitiateSpaceEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request), + base_host(request=request, is_space=True), "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -108,10 +108,10 @@ class GitHubCallbackSpaceEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_space=True) # Process workspace and project invitations # redirect to referer path - url = urljoin(base_host, str(next_path) if next_path else "/") + url = urljoin(base_host, str(next_path) if next_path else "") return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() diff --git a/apiserver/plane/authentication/views/space/google.py b/apiserver/plane/authentication/views/space/google.py index 2f6b57699..502f146c3 100644 --- a/apiserver/plane/authentication/views/space/google.py +++ b/apiserver/plane/authentication/views/space/google.py @@ -103,7 +103,7 @@ class GoogleCallbackSpaceEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_space=True) # redirect to referer path url = urljoin( base_host, str(next_path) if next_path else "/spaces" diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py index 52771f71b..45a8e3755 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -86,7 +86,7 @@ class MagicSignInSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces/accounts/sign-in?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -99,7 +99,7 @@ class MagicSignInSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "accounts/sign-in?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -109,14 +109,14 @@ class MagicSignInSpaceEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_space=True) # redirect to referer path profile = Profile.objects.get(user=user) if user.is_password_autoset and profile.is_onboarded: - path = "spaces/accounts/set-password" + path = "accounts/set-password" else: # Get the redirection path - path = str(next_path) if next_path else "spaces" + path = str(next_path) if next_path else "" url = urljoin(base_host(request=request, is_space=True), path) return HttpResponseRedirect(url) @@ -126,7 +126,7 @@ class MagicSignInSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces/accounts/sign-in?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -152,7 +152,7 @@ class MagicSignUpSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces/accounts/sign-in?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) @@ -176,7 +176,7 @@ class MagicSignUpSpaceEndpoint(View): ) user = provider.authenticate() # Login the user and record his device info - user_login(request=request, user=user) + user_login(request=request, user=user, is_space=True) # redirect to referer path url = urljoin( base_host(request=request, is_space=True), @@ -190,6 +190,6 @@ class MagicSignUpSpaceEndpoint(View): params["next_path"] = str(next_path) url = urljoin( base_host(request=request, is_space=True), - "spaces/accounts/sign-in?" + urlencode(params), + "?" + urlencode(params), ) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/password_management.py b/apiserver/plane/authentication/views/space/password_management.py index aeac9776d..5263c8956 100644 --- a/apiserver/plane/authentication/views/space/password_management.py +++ b/apiserver/plane/authentication/views/space/password_management.py @@ -183,11 +183,9 @@ class ResetPasswordSpaceEndpoint(View): user.is_password_autoset = False user.save() - url = urljoin( - base_host(request=request, is_space=True), - "accounts/sign-in?" + urlencode({"success": True}), + return HttpResponseRedirect( + base_host(request=request, is_space=True) ) - return HttpResponseRedirect(url) except DjangoUnicodeDecodeError: exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES[ diff --git a/apiserver/plane/authentication/views/space/signout.py b/apiserver/plane/authentication/views/space/signout.py index 3cfd6d471..655d8b1c8 100644 --- a/apiserver/plane/authentication/views/space/signout.py +++ b/apiserver/plane/authentication/views/space/signout.py @@ -23,12 +23,10 @@ class SignOutAuthSpaceEndpoint(View): user.save() # Log the user out logout(request) - url = urljoin( - base_host(request=request, is_space=True), - "accounts/sign-in?" + urlencode({"success": "true"}), + return HttpResponseRedirect( + base_host(request=request, is_space=True) ) - return HttpResponseRedirect(url) except Exception: return HttpResponseRedirect( - base_host(request=request, is_space=True), "accounts/sign-in" + base_host(request=request, is_space=True) ) diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py index ed3c00f17..945f4b1b1 100644 --- a/apiserver/plane/license/api/views/admin.py +++ b/apiserver/plane/license/api/views/admin.py @@ -107,7 +107,7 @@ class InstanceAdminSignUpEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/setup?" + urlencode(exc.get_error_dict()), + "setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -119,7 +119,7 @@ class InstanceAdminSignUpEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/setup?" + urlencode(exc.get_error_dict()), + "setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -148,7 +148,7 @@ class InstanceAdminSignUpEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/setup?" + urlencode(exc.get_error_dict()), + "setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -170,7 +170,7 @@ class InstanceAdminSignUpEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/setup?" + urlencode(exc.get_error_dict()), + "setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -192,7 +192,7 @@ class InstanceAdminSignUpEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/setup?" + urlencode(exc.get_error_dict()), + "setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) else: @@ -214,7 +214,7 @@ class InstanceAdminSignUpEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/setup?" + urlencode(exc.get_error_dict()), + "setup?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -247,10 +247,8 @@ class InstanceAdminSignUpEndpoint(View): instance.save() # get tokens for user - user_login(request=request, user=user) - url = urljoin( - base_host(request=request, is_admin=True), "god-mode/general" - ) + user_login(request=request, user=user, is_admin=True) + url = urljoin(base_host(request=request, is_admin=True), "general") return HttpResponseRedirect(url) @@ -272,7 +270,7 @@ class InstanceAdminSignInEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/login?" + urlencode(exc.get_error_dict()), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -293,7 +291,7 @@ class InstanceAdminSignInEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/login?" + urlencode(exc.get_error_dict()), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -311,7 +309,7 @@ class InstanceAdminSignInEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/login?" + urlencode(exc.get_error_dict()), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -331,7 +329,7 @@ class InstanceAdminSignInEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/login?" + urlencode(exc.get_error_dict()), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -348,7 +346,7 @@ class InstanceAdminSignInEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/login?" + urlencode(exc.get_error_dict()), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -365,7 +363,7 @@ class InstanceAdminSignInEndpoint(View): ) url = urljoin( base_host(request=request, is_admin=True), - "god-mode/login?" + urlencode(exc.get_error_dict()), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) # settings last active for the user @@ -378,10 +376,8 @@ class InstanceAdminSignInEndpoint(View): user.save() # get tokens for user - user_login(request=request, user=user) - url = urljoin( - base_host(request=request, is_admin=True), "god-mode/general" - ) + user_login(request=request, user=user, is_admin=True) + url = urljoin(base_host(request=request, is_admin=True), "general") return HttpResponseRedirect(url) @@ -414,12 +410,9 @@ class InstanceAdminSignOutEndpoint(View): user.save() # Log the user out logout(request) - url = urljoin( - base_host(request=request, is_admin=True), - "accounts/sign-in?" + urlencode({"success": "true"}), - ) + url = urljoin(base_host(request=request, is_admin=True)) return HttpResponseRedirect(url) except Exception: return HttpResponseRedirect( - base_host(request=request, is_admin=True), "accounts/sign-in" + base_host(request=request, is_admin=True) ) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 4f5e6d4ee..f043340a2 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -346,4 +346,4 @@ CSRF_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) # Base URLs ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) -APP_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) +APP_BASE_URL = os.environ.get("APP_BASE_URL") or os.environ.get("WEB_URL") diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 2290262ae..b96d1ca31 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -35,10 +35,10 @@ CORS_ALLOWED_ORIGINS = [ "http://127.0.0.1", "http://localhost:3000", "http://127.0.0.1:3000", - "http://localhost:4000", - "http://127.0.0.1:4000", - "http://localhost:3333", - "http://127.0.0.1:3333", + "http://localhost:3001", + "http://127.0.0.1:3001", + "http://localhost:3002", + "http://127.0.0.1:3002", ] CSRF_TRUSTED_ORIGINS = CORS_ALLOWED_ORIGINS CORS_ALLOW_ALL_ORIGINS = True diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index c56222c67..806f83aca 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -12,8 +12,6 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") INSTALLED_APPS += ("scout_apm.django",) # noqa -# Honor the 'X-Forwarded-Proto' header for request.is_secure() -SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # Scout Settings SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt index cd0aac542..8cf46af5f 100644 --- a/apiserver/runtime.txt +++ b/apiserver/runtime.txt @@ -1 +1 @@ -python-3.11.9 \ No newline at end of file +python-3.12.3 \ No newline at end of file diff --git a/space/components/accounts/onboarding-form.tsx b/space/components/accounts/onboarding-form.tsx index 7a719d938..768d5160f 100644 --- a/space/components/accounts/onboarding-form.tsx +++ b/space/components/accounts/onboarding-form.tsx @@ -140,8 +140,11 @@ export const OnBoardingForm: React.FC = observer((props) => {
-
-
-
@@ -125,24 +130,29 @@ export const AuthRoot: FC = observer((props) => { {authStep === EAuthSteps.EMAIL && } {authStep === EAuthSteps.UNIQUE_CODE && ( { setEmail(""); setAuthStep(EAuthSteps.EMAIL); }} - submitButtonText="Continue" - mode={authMode} + generateEmailUniqueCode={generateEmailUniqueCode} /> )} {authStep === EAuthSteps.PASSWORD && ( { setEmail(""); setAuthStep(EAuthSteps.EMAIL); }} - handleStepChange={(step) => setAuthStep(step)} - mode={authMode} + handleAuthStep={(step: EAuthSteps) => { + if (step === EAuthSteps.UNIQUE_CODE) generateEmailUniqueCode(email); + setAuthStep(step); + }} /> )} {isOAuthEnabled && } diff --git a/web/components/account/auth-forms/email.tsx b/web/components/account/auth-forms/email.tsx index 573dc59e6..923bc5fcf 100644 --- a/web/components/account/auth-forms/email.tsx +++ b/web/components/account/auth-forms/email.tsx @@ -7,6 +7,7 @@ import { IEmailCheckData } from "@plane/types"; // ui import { Button, Input, Spinner } from "@plane/ui"; // helpers +import { cn } from "@/helpers/common.helper"; import { checkEmailValidity } from "@/helpers/string.helper"; type TAuthEmailForm = { @@ -19,6 +20,7 @@ export const AuthEmailForm: FC = observer((props) => { // states const [isSubmitting, setIsSubmitting] = useState(false); const [email, setEmail] = useState(defaultEmail); + const [isFocused, setFocused] = useState(false); const emailError = useMemo( () => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined), @@ -38,31 +40,36 @@ export const AuthEmailForm: FC = observer((props) => { const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting; return ( -
+
-
+
setEmail(e.target.value)} - hasError={Boolean(emailError?.email)} placeholder="name@company.com" - className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} autoFocus /> {email.length > 0 && ( - setEmail("")} - /> +
+ setEmail("")} /> +
)}
- {emailError?.email && ( + {emailError?.email && !isFocused && (

{emailError.email} diff --git a/web/components/account/auth-forms/password.tsx b/web/components/account/auth-forms/password.tsx index 37c6d6506..35385ee71 100644 --- a/web/components/account/auth-forms/password.tsx +++ b/web/components/account/auth-forms/password.tsx @@ -14,15 +14,17 @@ import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; import { API_BASE_URL } from "@/helpers/common.helper"; import { getPasswordStrength } from "@/helpers/password.helper"; // hooks -import { useEventTracker, useInstance } from "@/hooks/store"; +import { useEventTracker } from "@/hooks/store"; // services import { AuthService } from "@/services/auth.service"; type Props = { email: string; + isPasswordAutoset: boolean; + isSMTPConfigured: boolean; mode: EAuthModes; - handleStepChange: (step: EAuthSteps) => void; handleEmailClear: () => void; + handleAuthStep: (step: EAuthSteps) => void; }; type TPasswordFormValues = { @@ -39,9 +41,8 @@ const defaultValues: TPasswordFormValues = { const authService = new AuthService(); export const AuthPasswordForm: React.FC = observer((props: Props) => { - const { email, handleStepChange, handleEmailClear, mode } = props; + const { email, isSMTPConfigured, handleAuthStep, handleEmailClear, mode } = props; // hooks - const { instance } = useInstance(); const { captureEvent } = useEventTracker(); // states const [csrfToken, setCsrfToken] = useState(undefined); @@ -56,9 +57,6 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { const handleShowPassword = (key: keyof typeof showPassword) => setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); - // derived values - const isSmtpConfigured = instance?.config?.is_smtp_configured; - const handleFormChange = (key: keyof TPasswordFormValues, value: string) => setPasswordFormData((prev) => ({ ...prev, [key]: value })); @@ -68,13 +66,13 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { }, [csrfToken]); const redirectToUniqueCodeSignIn = async () => { - handleStepChange(EAuthSteps.UNIQUE_CODE); + handleAuthStep(EAuthSteps.UNIQUE_CODE); }; const passwordSupport = mode === EAuthModes.SIGN_IN ? ( -

- {isSmtpConfigured ? ( +
+ {isSMTPConfigured ? ( captureEvent(FORGOT_PASSWORD)} href={`/accounts/forgot-password?email=${email}`} @@ -87,7 +85,10 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { )}
) : ( - isPasswordInputFocused && + passwordFormData.password.length > 0 && + (getPasswordStrength(passwordFormData.password) < 3 || isPasswordInputFocused) && ( + + ) ); const isButtonDisabled = useMemo( @@ -112,11 +113,14 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { onError={() => setIsSubmitting(false)} > +
-
+ {mode === EAuthModes.SIGN_UP && (
)} +
{mode === EAuthModes.SIGN_IN ? ( <> - {instance && isSmtpConfigured && ( + {isSMTPConfigured && (
- + +
+ +
); }; diff --git a/web/components/account/password-strength-meter.tsx b/web/components/account/password-strength-meter.tsx index 86ee814c8..ebfdad7bb 100644 --- a/web/components/account/password-strength-meter.tsx +++ b/web/components/account/password-strength-meter.tsx @@ -24,9 +24,9 @@ export const PasswordStrengthMeter: React.FC = (props: Props) => { text = "Password is too short"; textColor = `text-[#DC3E42]`; } else if (strength < 3) { - bars = [`bg-[#FFBA18]`, `bg-[#FFBA18]`, `bg-[#F0F0F3]`]; + bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; text = "Password is weak"; - textColor = `text-[#FFBA18]`; + textColor = `text-[#F0F0F3]`; } else { bars = [`bg-[#3E9B4F]`, `bg-[#3E9B4F]`, `bg-[#3E9B4F]`]; text = "Password is strong"; diff --git a/web/components/onboarding/create-workspace.tsx b/web/components/onboarding/create-workspace.tsx index 9550043c0..eaedf229c 100644 --- a/web/components/onboarding/create-workspace.tsx +++ b/web/components/onboarding/create-workspace.tsx @@ -148,7 +148,10 @@ export const CreateWorkspace: React.FC = (props) => {
-
-

-
-
-
-
+
diff --git a/web/lib/wrappers/authentication-wrapper.tsx b/web/lib/wrappers/authentication-wrapper.tsx index 4993846f3..b71335bf2 100644 --- a/web/lib/wrappers/authentication-wrapper.tsx +++ b/web/lib/wrappers/authentication-wrapper.tsx @@ -84,7 +84,7 @@ export const AuthenticationWrapper: FC = observer((props if (pageType === EPageTypes.ONBOARDING) { if (!currentUser?.id) { - router.push("/accounts/sign-in"); + router.push("/sign-in"); return <>; } else { if (currentUser && currentUserProfile?.id && currentUserProfile?.is_onboarded) { @@ -97,7 +97,7 @@ export const AuthenticationWrapper: FC = observer((props if (pageType === EPageTypes.SET_PASSWORD) { if (!currentUser?.id) { - router.push("/accounts/sign-in"); + router.push("/sign-in"); return <>; } else { if ( @@ -121,7 +121,7 @@ export const AuthenticationWrapper: FC = observer((props return <>; } } else { - router.push("/accounts/sign-in"); + router.push("/sign-in"); return <>; } } diff --git a/web/pages/accounts/forgot-password.tsx b/web/pages/accounts/forgot-password.tsx index cb1df8504..5334bc15e 100644 --- a/web/pages/accounts/forgot-password.tsx +++ b/web/pages/accounts/forgot-password.tsx @@ -176,7 +176,7 @@ const ForgotPasswordPage: NextPageWithLayout = () => { > {resendTimerCode > 0 ? `Resend in ${resendTimerCode} seconds` : "Send reset link"} - + Back to sign in diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 6b085ffd3..5d497abab 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -48,7 +48,7 @@ const HomePage: NextPageWithLayout = observer(() => {
Already have an account?{" "} captureEvent(NAVIGATE_TO_SIGNIN, {})} className="font-semibold text-custom-primary-100 hover:underline" > diff --git a/web/pages/accounts/sign-in.tsx b/web/pages/sign-in.tsx similarity index 100% rename from web/pages/accounts/sign-in.tsx rename to web/pages/sign-in.tsx diff --git a/web/styles/globals.css b/web/styles/globals.css index b27d3ef45..09e3b9c08 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -640,4 +640,13 @@ div.web-view-spinner div.bar12 { .highlight-with-line { border-left: 5px solid rgb(var(--color-primary-100)) !important; background: rgb(var(--color-background-80)); -} \ No newline at end of file +} + +/* By applying below class, the autofilled text in form fields will not have the default autofill background color and styles applied by WebKit browsers */ + +.disable-autofill-style:-webkit-autofill, +.disable-autofill-style:-webkit-autofill:hover, +.disable-autofill-style:-webkit-autofill:focus, +.disable-autofill-style:-webkit-autofill:active { + -webkit-background-clip: text; +} From 0ad8bf7664f5404b36e93dd8cda814cb6bc2b1b1 Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Fri, 10 May 2024 17:32:23 +0530 Subject: [PATCH 16/37] [WEB-1118] fix: table selections using drag handle fixed (#4429) * fix: table selections in using drag handle fixed * fix: not show drag handles for empty p tags --- .../editor/core/src/lib/editor-commands.ts | 3 + .../src/ui/extensions/table/table/table.ts | 20 ++-- packages/editor/core/src/ui/props.tsx | 1 - .../extensions/src/extensions/drag-drop.tsx | 105 +++++++++++------- 4 files changed, 82 insertions(+), 47 deletions(-) diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index ce2cf3ad6..b82b1f354 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -97,6 +97,9 @@ const replaceCodeBlockWithContent = (editor: Editor) => { const startPos = pos; const endPos = pos + node.nodeSize; const textContent = node.textContent; + if (textContent.length === 0) { + editor.chain().focus().toggleCodeBlock().run(); + } replaceCodeBlock(startPos, endPos, textContent); return false; } diff --git a/packages/editor/core/src/ui/extensions/table/table/table.ts b/packages/editor/core/src/ui/extensions/table/table/table.ts index 5fd06caf6..c1f65feec 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table.ts +++ b/packages/editor/core/src/ui/extensions/table/table/table.ts @@ -218,15 +218,21 @@ export const Table = Node.create({ addKeyboardShortcuts() { return { Tab: () => { - if (this.editor.commands.goToNextCell()) { - return true; - } + if (this.editor.isActive("table")) { + if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) { + return false; + } + if (this.editor.commands.goToNextCell()) { + return true; + } - if (!this.editor.can().addRowAfter()) { - return false; - } + if (!this.editor.can().addRowAfter()) { + return false; + } - return this.editor.chain().addRowAfter().goToNextCell().run(); + return this.editor.chain().addRowAfter().goToNextCell().run(); + } + return false; }, "Shift-Tab": () => this.editor.commands.goToPreviousCell(), Backspace: deleteTableWhenAllCellsSelected, diff --git a/packages/editor/core/src/ui/props.tsx b/packages/editor/core/src/ui/props.tsx index 3d46b5840..32d1510c7 100644 --- a/packages/editor/core/src/ui/props.tsx +++ b/packages/editor/core/src/ui/props.tsx @@ -15,7 +15,6 @@ export function CoreEditorProps(editorClassName: string): EditorProps { if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { const slashCommand = document.querySelector("#slash-command"); if (slashCommand) { - console.log("registered"); return true; } } diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index ab2df31ad..32867a5f1 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -14,6 +14,21 @@ export interface DragHandleOptions { }; } +export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void) => + Extension.create({ + name: "dragAndDrop", + + addProseMirrorPlugins() { + return [ + DragHandle({ + dragHandleWidth: 24, + scrollThreshold: { up: 300, down: 100 }, + setHideDragHandle, + }), + ]; + }, + }); + function createDragHandleElement(): HTMLElement { const dragHandleElement = document.createElement("div"); dragHandleElement.draggable = true; @@ -49,23 +64,31 @@ function absoluteRect(node: Element) { } function nodeDOMAtCoords(coords: { x: number; y: number }) { - return document - .elementsFromPoint(coords.x, coords.y) - .find( - (elem: Element) => - elem.parentElement?.matches?.(".ProseMirror") || - elem.matches( - [ - "li", - "p:not(:first-child)", - ".code-block", - "blockquote", - "h1, h2, h3", - "table", - "[data-type=horizontalRule]", - ].join(", ") - ) - ); + const elements = document.elementsFromPoint(coords.x, coords.y); + const generalSelectors = [ + "li", + "p:not(:first-child)", + ".code-block", + "blockquote", + "h1, h2, h3", + ".table-wrapper", + "[data-type=horizontalRule]", + ].join(", "); + + for (const elem of elements) { + // if the element is a

tag that is the first child of a td or th + if ( + (elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) && + elem?.textContent?.trim() !== "" + ) { + return elem; // Return only if p tag is not empty + } + // apply general selector + if (elem.matches(generalSelectors)) { + return elem; + } + } + return null; } function nodePosAtDOM(node: Element, view: EditorView, options: DragHandleOptions) { @@ -86,15 +109,19 @@ function nodePosAtDOMForBlockquotes(node: Element, view: EditorView) { })?.inside; } -function calcNodePos(pos: number, view: EditorView) { +function calcNodePos(pos: number, view: EditorView, node: Element) { const maxPos = view.state.doc.content.size; const safePos = Math.max(0, Math.min(pos, maxPos)); const $pos = view.state.doc.resolve(safePos); if ($pos.depth > 1) { - const newPos = $pos.before($pos.depth); - return Math.max(0, Math.min(newPos, maxPos)); + if (node.matches("ul:not([data-type=taskList]) li, ol li")) { + // only for nested lists + const newPos = $pos.before($pos.depth); + return Math.max(0, Math.min(newPos, maxPos)); + } } + return safePos; } @@ -114,12 +141,12 @@ function DragHandle(options: DragHandleOptions) { let draggedNodePos = nodePosAtDOM(node, view, options); if (draggedNodePos == null || draggedNodePos < 0) return; - draggedNodePos = calcNodePos(draggedNodePos, view); + draggedNodePos = calcNodePos(draggedNodePos, view, node); const { from, to } = view.state.selection; const diff = from - to; - const fromSelectionPos = calcNodePos(from, view); + const fromSelectionPos = calcNodePos(from, view, node); let differentNodeSelected = false; const nodePos = view.state.doc.resolve(fromSelectionPos); @@ -148,6 +175,19 @@ function DragHandle(options: DragHandleOptions) { listType = node.parentElement!.tagName; } + if (node.matches("blockquote")) { + let nodePosForBlockquotes = nodePosAtDOMForBlockquotes(node, view); + if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return; + + const docSize = view.state.doc.content.size; + nodePosForBlockquotes = Math.max(0, Math.min(nodePosForBlockquotes, docSize)); + + if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) { + const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockquotes); + view.dispatch(view.state.tr.setSelection(nodeSelection)); + } + } + const slice = view.state.selection.content(); const { dom, text } = __serializeForClipboard(view, slice); @@ -190,7 +230,7 @@ function DragHandle(options: DragHandleOptions) { if (nodePos === null || nodePos === undefined) return; // Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied - nodePos = calcNodePos(nodePos, view); + nodePos = calcNodePos(nodePos, view, node); // Use NodeSelection to select the node at the calculated position const nodeSelection = NodeSelection.create(view.state.doc, nodePos); @@ -279,9 +319,11 @@ function DragHandle(options: DragHandleOptions) { // Li markers if (node.matches("ul:not([data-type=taskList]) li, ol li")) { - rect.top += 4; rect.left -= 18; } + if (node.matches(".table-wrapper")) { + rect.top += 8; + } rect.width = options.dragHandleWidth; @@ -352,18 +394,3 @@ function DragHandle(options: DragHandleOptions) { }, }); } - -export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void) => - Extension.create({ - name: "dragAndDrop", - - addProseMirrorPlugins() { - return [ - DragHandle({ - dragHandleWidth: 24, - scrollThreshold: { up: 300, down: 100 }, - setHideDragHandle, - }), - ]; - }, - }); From 2ef3c06da0322f3a1848f4c951423ddc128f4539 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Fri, 10 May 2024 19:34:40 +0530 Subject: [PATCH 17/37] fix: redirection issues and instance validation changes --- .../components/admin-sidebar/help-section.tsx | 7 ++- apiserver/plane/license/api/views/instance.py | 2 + apiserver/plane/settings/local.py | 13 ----- apiserver/plane/space/views/base.py | 9 ++++ space/components/instance/index.ts | 3 +- .../instance/instance-failure-view.tsx | 38 +++++++++++++ space/components/instance/not-ready-view.tsx | 51 ++++++++---------- space/helpers/common.helper.ts | 3 ++ space/layouts/project-layout.tsx | 8 ++- space/lib/wrappers/auth-wrapper.tsx | 1 + space/lib/wrappers/instance-wrapper.tsx | 9 +++- .../public/instance/instance-failure-dark.svg | 40 ++++++++++++++ space/public/instance/instance-failure.svg | 40 ++++++++++++++ space/public/instance/plane-takeoff.png | Bin 0 -> 47818 bytes 14 files changed, 170 insertions(+), 54 deletions(-) create mode 100644 space/components/instance/instance-failure-view.tsx create mode 100644 space/public/instance/instance-failure-dark.svg create mode 100644 space/public/instance/instance-failure.svg create mode 100644 space/public/instance/plane-takeoff.png diff --git a/admin/components/admin-sidebar/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx index 8b3f5baeb..84e28c67a 100644 --- a/admin/components/admin-sidebar/help-section.tsx +++ b/admin/components/admin-sidebar/help-section.tsx @@ -7,9 +7,10 @@ import { Transition } from "@headlessui/react"; import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; // hooks -import { useInstance, useTheme } from "@/hooks/store"; +import { useTheme } from "@/hooks/store"; // assets import packageJson from "package.json"; +import { WEB_BASE_URL } from "@/helpers/common.helper"; const helpOptions = [ { @@ -30,8 +31,6 @@ const helpOptions = [ ]; export const HelpSection: FC = observer(() => { - // hooks - const { instance } = useInstance(); // states const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); // store @@ -39,7 +38,7 @@ export const HelpSection: FC = observer(() => { // refs const helpOptionsRef = useRef(null); - const redirectionLink = `${instance?.config?.app_base_url ? `${instance?.config?.app_base_url}/create-workspace` : `/god-mode/`}`; + const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace"); return (

void; +}; + +export const InstanceFailureView: FC = (props) => { + const { mutate } = props; + const { resolvedTheme } = useTheme(); + + const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; + + return ( +
+
+
+ Plane Logo +

Unable to fetch instance details.

+

+ We were unable to fetch the details of the instance.
+ Fret not, it might just be a connectivity issue. +

+
+
+ +
+
+
+ ); +}; diff --git a/space/components/instance/not-ready-view.tsx b/space/components/instance/not-ready-view.tsx index a28fcf3e7..815e0d1fe 100644 --- a/space/components/instance/not-ready-view.tsx +++ b/space/components/instance/not-ready-view.tsx @@ -1,40 +1,33 @@ import { FC } from "react"; import Image from "next/image"; -import { useTheme } from "next-themes"; -// icons -import { UserCog2 } from "lucide-react"; +import Link from "next/link"; // ui -import { getButtonStyling } from "@plane/ui"; +import { Button } from "@plane/ui"; +// helpers +import { ADMIN_BASE_URL, ADMIN_BASE_PATH } from "@/helpers/common.helper"; // images -import instanceNotReady from "public/instance/plane-instance-not-ready.webp"; -import PlaneBlackLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg"; -import PlaneWhiteLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg"; +import PlaneTakeOffImage from "@/public/instance/plane-takeoff.png"; export const InstanceNotReady: FC = () => { - const { resolvedTheme } = useTheme(); - - const planeLogo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo; + const GOD_MODE_URL = encodeURI(ADMIN_BASE_URL + ADMIN_BASE_PATH + "/setup/?auth_enabled=0"); return ( -
-
-
-
-
- Plane logo -
-
- Instance not ready -
-
-

Your Plane instance isn{"'"}t ready yet

-

Ask your Instance Admin to complete set-up first.

- - - Get started - -
-
+
+
+
+

Welcome aboard Plane!

+ Plane Logo +

+ Get started by setting up your instance and workspace +

+
+ +
+ + +
diff --git a/space/helpers/common.helper.ts b/space/helpers/common.helper.ts index f39cddc0e..99e04e559 100644 --- a/space/helpers/common.helper.ts +++ b/space/helpers/common.helper.ts @@ -3,6 +3,9 @@ import { twMerge } from "tailwind-merge"; export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; +export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || ""; +export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || ""; + export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || ""; export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || ""; diff --git a/space/layouts/project-layout.tsx b/space/layouts/project-layout.tsx index c5946277f..0411bcbcc 100644 --- a/space/layouts/project-layout.tsx +++ b/space/layouts/project-layout.tsx @@ -1,10 +1,9 @@ -import Image from "next/image"; - -// mobx import { observer } from "mobx-react-lite"; -import planeLogo from "public/plane-logo.svg"; +import Image from "next/image"; // components import IssueNavbar from "@/components/issues/navbar"; +// logo +import planeLogo from "public/plane-logo.svg"; const ProjectLayout = ({ children }: { children: React.ReactNode }) => (
@@ -12,7 +11,6 @@ const ProjectLayout = ({ children }: { children: React.ReactNode }) => (
{children}
- = observer((props) => {
); + if (pageType === EPageTypes.PUBLIC) return <>{children}; if (pageType === EPageTypes.INIT) { diff --git a/space/lib/wrappers/instance-wrapper.tsx b/space/lib/wrappers/instance-wrapper.tsx index 05390fad8..3be92ed05 100644 --- a/space/lib/wrappers/instance-wrapper.tsx +++ b/space/lib/wrappers/instance-wrapper.tsx @@ -4,7 +4,7 @@ import useSWR from "swr"; // ui import { Spinner } from "@plane/ui"; // components -import { InstanceNotReady } from "@/components/instance"; +import { InstanceNotReady, InstanceFailureView } from "@/components/instance"; // hooks import { useInstance } from "@/hooks/store"; @@ -17,8 +17,11 @@ export const InstanceWrapper: FC = observer((props) => { // hooks const { isLoading, instance, fetchInstanceInfo } = useInstance(); - const { isLoading: isSWRLoading } = useSWR("INSTANCE_INFORMATION", () => fetchInstanceInfo(), { + const { isLoading: isSWRLoading, mutate } = useSWR("INSTANCE_INFORMATION", () => fetchInstanceInfo(), { revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnReconnect: false, + errorRetryCount: 0, }); if (isSWRLoading || isLoading) @@ -28,6 +31,8 @@ export const InstanceWrapper: FC = observer((props) => {
); + if (!instance) return ; + if (instance?.instance?.is_setup_done === false) return ; return <>{children}; diff --git a/space/public/instance/instance-failure-dark.svg b/space/public/instance/instance-failure-dark.svg new file mode 100644 index 000000000..58d691705 --- /dev/null +++ b/space/public/instance/instance-failure-dark.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/space/public/instance/instance-failure.svg b/space/public/instance/instance-failure.svg new file mode 100644 index 000000000..a59862283 --- /dev/null +++ b/space/public/instance/instance-failure.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/space/public/instance/plane-takeoff.png b/space/public/instance/plane-takeoff.png new file mode 100644 index 0000000000000000000000000000000000000000..417ff82999890f25a8d61da4e174375b58f457a3 GIT binary patch literal 47818 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

;B*sK>V_Ek<3UIhOnk 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 Date: Sat, 11 May 2024 14:47:56 +0530 Subject: [PATCH 18/37] [WEB-1254] chore: list layout indentation enhancement and cycle list page ui improvement (#4435) * chore: list layout indentation improvement * chore: cycle list layout spacing and date ui updated * chore: platform ui improvement --- .../cycles/board/cycles-board-card.tsx | 16 +++++---- .../cycles/list/cycle-list-item-action.tsx | 14 +++++--- .../cycles/list/cycles-list-item.tsx | 2 +- web/components/headers/cycle-issues.tsx | 1 - web/components/headers/cycles.tsx | 1 - web/components/headers/global-issues.tsx | 4 +-- web/components/headers/module-issues.tsx | 1 - web/components/headers/modules-list.tsx | 1 - web/components/headers/page-details.tsx | 4 +-- web/components/headers/pages.tsx | 3 +- web/components/headers/project-inbox.tsx | 2 +- web/components/headers/project-issues.tsx | 1 - .../headers/project-view-issues.tsx | 1 - web/components/headers/project-views.tsx | 9 ++--- web/components/headers/projects.tsx | 1 - .../issues/issue-layouts/list/block-root.tsx | 2 +- .../issues/issue-layouts/list/block.tsx | 33 ++++++++++--------- .../issues/issue-layouts/list/default.tsx | 2 +- .../issues/issue-layouts/save-filter-view.tsx | 2 +- .../pages/list/block-item-action.tsx | 26 +++++---------- 20 files changed, 58 insertions(+), 68 deletions(-) diff --git a/web/components/cycles/board/cycles-board-card.tsx b/web/components/cycles/board/cycles-board-card.tsx index ec6d80921..641007798 100644 --- a/web/components/cycles/board/cycles-board-card.tsx +++ b/web/components/cycles/board/cycles-board-card.tsx @@ -2,7 +2,7 @@ import { FC, MouseEvent, useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; -import { Info } from "lucide-react"; +import { CalendarCheck2, CalendarClock, Info, MoveRight } from "lucide-react"; // types import type { TCycleGroups } from "@plane/types"; // ui @@ -226,12 +226,14 @@ export const CyclesBoardCard: FC = observer((props) => {

diff --git a/web/components/cycles/list/cycle-list-item-action.tsx b/web/components/cycles/list/cycle-list-item-action.tsx index 1f3d2ef65..05db2e2fa 100644 --- a/web/components/cycles/list/cycle-list-item-action.tsx +++ b/web/components/cycles/list/cycle-list-item-action.tsx @@ -1,6 +1,6 @@ import React, { FC, MouseEvent } from "react"; import { observer } from "mobx-react"; -import { User2 } from "lucide-react"; +import { CalendarCheck2, CalendarClock, MoveRight, User2 } from "lucide-react"; // types import { ICycle, TCycleGroups } from "@plane/types"; // ui @@ -106,9 +106,15 @@ export const CycleListItemAction: FC = observer((props) => { return ( <> -
- {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} -
+ {renderDate && ( +
+ + {renderFormattedDate(startDate)} + + + {renderFormattedDate(endDate)} +
+ )} {currentCycle && (
= observer((props) => { ) : progress === 100 ? ( ) : ( - {`${progress}%`} + {`${progress}%`} )} } diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index e9e89256b..602e32235 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -281,7 +281,6 @@ export const CycleIssuesHeader: React.FC = observer(() => { toggleCreateIssueModal(true, EIssuesStoreType.CYCLE); }} size="sm" - prependIcon={} > Add Issue diff --git a/web/components/headers/cycles.tsx b/web/components/headers/cycles.tsx index e5c88d3f5..d58bcb31b 100644 --- a/web/components/headers/cycles.tsx +++ b/web/components/headers/cycles.tsx @@ -61,7 +61,6 @@ export const CyclesHeader: FC = observer(() => { )}
diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index 5bbaa83e1..d28cdb988 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -288,7 +288,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => { toggleCreateIssueModal(true, EIssuesStoreType.MODULE); }} size="sm" - prependIcon={} > Add Issue diff --git a/web/components/headers/modules-list.tsx b/web/components/headers/modules-list.tsx index 881af67aa..e1b237809 100644 --- a/web/components/headers/modules-list.tsx +++ b/web/components/headers/modules-list.tsx @@ -63,7 +63,6 @@ export const ModulesListHeader: React.FC = observer(() => {
{showButton && (
-
)} diff --git a/web/components/headers/pages.tsx b/web/components/headers/pages.tsx index aed050848..707048af4 100644 --- a/web/components/headers/pages.tsx +++ b/web/components/headers/pages.tsx @@ -59,14 +59,13 @@ export const PagesHeader = observer(() => {
)} diff --git a/web/components/headers/project-inbox.tsx b/web/components/headers/project-inbox.tsx index 80a39862b..c5661fe91 100644 --- a/web/components/headers/project-inbox.tsx +++ b/web/components/headers/project-inbox.tsx @@ -70,7 +70,7 @@ export const ProjectInboxHeader: FC = observer(() => { issue={undefined} /> -
diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 9f64c0d3a..f3e6cd0e8 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -229,7 +229,6 @@ export const ProjectIssuesHeader: React.FC = observer(() => { toggleCreateIssueModal(true, EIssuesStoreType.PROJECT); }} size="sm" - prependIcon={} >
Add
Issue diff --git a/web/components/headers/project-view-issues.tsx b/web/components/headers/project-view-issues.tsx index 1e21d10c7..3d8d151d0 100644 --- a/web/components/headers/project-view-issues.tsx +++ b/web/components/headers/project-view-issues.tsx @@ -241,7 +241,6 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { toggleCreateIssueModal(true, EIssuesStoreType.PROJECT_VIEW); }} size="sm" - prependIcon={} > Add Issue diff --git a/web/components/headers/project-views.tsx b/web/components/headers/project-views.tsx index c79934aec..e3093e72a 100644 --- a/web/components/headers/project-views.tsx +++ b/web/components/headers/project-views.tsx @@ -61,13 +61,8 @@ export const ProjectViewsHeader: React.FC = observer(() => {
-
diff --git a/web/components/headers/projects.tsx b/web/components/headers/projects.tsx index ba75832e0..432603817 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -167,7 +167,6 @@ export const ProjectsHeader = observer(() => {
{isAuthorizedUser && ( - )} +
+
+ +
+ {issue.sub_issues_count > 0 && ( + + )} +
{displayProperties && displayProperties?.key && (
@@ -118,7 +121,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock
{issue?.is_draft ? ( - +

{issue.name}

) : ( @@ -132,7 +135,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock className="w-full truncate cursor-pointer text-sm text-custom-text-100" disabled={!!issue?.tempId} > - +

{issue.name}

@@ -151,7 +154,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock {!issue?.tempId ? ( <> = (props) => { (_list: IGroupByColumn) => validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
-
+
= (props) => { onClose={() => setViewModal(false)} /> -
diff --git a/web/components/pages/list/block-item-action.tsx b/web/components/pages/list/block-item-action.tsx index f9fb57f0f..dbe9269a8 100644 --- a/web/components/pages/list/block-item-action.tsx +++ b/web/components/pages/list/block-item-action.tsx @@ -50,24 +50,16 @@ export const BlockItemAction: FC = observer((props) => { return ( <> {/* page details */} -
- {/* Labels - */} -
- - - -
- - {/* 10m read - */} -
- - {access === 0 ? : } - -
+
+ + + +
+
+ + {access === 0 ? : } +
- {/* vertical divider */} From 16d8dfc86e6afd15adc8525959d5bc68800af343 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Sat, 11 May 2024 15:14:59 +0530 Subject: [PATCH 19/37] fix: build errors --- web/components/headers/cycle-issues.tsx | 2 +- web/components/headers/cycles.tsx | 2 -- web/components/headers/global-issues.tsx | 2 -- web/components/headers/module-issues.tsx | 2 +- web/components/headers/modules-list.tsx | 2 -- web/components/headers/page-details.tsx | 2 +- web/components/headers/pages.tsx | 2 +- web/components/headers/project-inbox.tsx | 2 +- web/components/headers/project-issues.tsx | 2 +- web/components/headers/project-view-issues.tsx | 2 -- web/components/headers/project-views.tsx | 1 - web/components/headers/projects.tsx | 2 +- web/components/issues/issue-layouts/list/block.tsx | 4 ++-- web/components/issues/issue-layouts/save-filter-view.tsx | 1 - web/components/pages/list/block-item-action.tsx | 2 +- 15 files changed, 10 insertions(+), 20 deletions(-) diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 602e32235..3b6d40534 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; // icons -import { ArrowRight, Plus, PanelRight } from "lucide-react"; +import { ArrowRight, PanelRight } from "lucide-react"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; // ui diff --git a/web/components/headers/cycles.tsx b/web/components/headers/cycles.tsx index d58bcb31b..c2be61d82 100644 --- a/web/components/headers/cycles.tsx +++ b/web/components/headers/cycles.tsx @@ -1,8 +1,6 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -// icons -import { Plus } from "lucide-react"; // ui import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; // components diff --git a/web/components/headers/global-issues.tsx b/web/components/headers/global-issues.tsx index dfacf30e0..1ec2a5d2c 100644 --- a/web/components/headers/global-issues.tsx +++ b/web/components/headers/global-issues.tsx @@ -1,8 +1,6 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -// icons -import { PlusIcon } from "lucide-react"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index d28cdb988..9a911103d 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; // icons -import { ArrowRight, PanelRight, Plus } from "lucide-react"; +import { ArrowRight, PanelRight } from "lucide-react"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; // ui diff --git a/web/components/headers/modules-list.tsx b/web/components/headers/modules-list.tsx index e1b237809..90866d73e 100644 --- a/web/components/headers/modules-list.tsx +++ b/web/components/headers/modules-list.tsx @@ -1,7 +1,5 @@ import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -// icons -import { Plus } from "lucide-react"; // ui import { Breadcrumbs, Button, DiceIcon } from "@plane/ui"; // components diff --git a/web/components/headers/page-details.tsx b/web/components/headers/page-details.tsx index 2dab6b92f..0a02c1528 100644 --- a/web/components/headers/page-details.tsx +++ b/web/components/headers/page-details.tsx @@ -1,7 +1,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -import { FileText, Plus } from "lucide-react"; +import { FileText } from "lucide-react"; // hooks // ui import { Breadcrumbs, Button } from "@plane/ui"; diff --git a/web/components/headers/pages.tsx b/web/components/headers/pages.tsx index 707048af4..7ab9cb75d 100644 --- a/web/components/headers/pages.tsx +++ b/web/components/headers/pages.tsx @@ -1,6 +1,6 @@ import { observer } from "mobx-react"; import { useRouter } from "next/router"; -import { FileText, Plus } from "lucide-react"; +import { FileText } from "lucide-react"; // hooks // ui import { Breadcrumbs, Button } from "@plane/ui"; diff --git a/web/components/headers/project-inbox.tsx b/web/components/headers/project-inbox.tsx index c5661fe91..d61e2492d 100644 --- a/web/components/headers/project-inbox.tsx +++ b/web/components/headers/project-inbox.tsx @@ -1,7 +1,7 @@ import { FC, useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -import { Plus, RefreshCcw } from "lucide-react"; +import { RefreshCcw } from "lucide-react"; // ui import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; // components diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index f3e6cd0e8..95983d85a 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // icons -import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; +import { Briefcase, Circle, ExternalLink } from "lucide-react"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; // ui diff --git a/web/components/headers/project-view-issues.tsx b/web/components/headers/project-view-issues.tsx index 3d8d151d0..297c976ee 100644 --- a/web/components/headers/project-view-issues.tsx +++ b/web/components/headers/project-view-issues.tsx @@ -2,8 +2,6 @@ import { useCallback } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; -// icons -import { Plus } from "lucide-react"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; // ui diff --git a/web/components/headers/project-views.tsx b/web/components/headers/project-views.tsx index e3093e72a..3cd578847 100644 --- a/web/components/headers/project-views.tsx +++ b/web/components/headers/project-views.tsx @@ -1,6 +1,5 @@ import { observer } from "mobx-react"; import { useRouter } from "next/router"; -import { Plus } from "lucide-react"; // hooks // components import { Breadcrumbs, PhotoFilterIcon, Button } from "@plane/ui"; diff --git a/web/components/headers/projects.tsx b/web/components/headers/projects.tsx index 432603817..7126b2697 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -1,6 +1,6 @@ import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; -import { Search, Plus, Briefcase, X, ListFilter } from "lucide-react"; +import { Search, Briefcase, X, ListFilter } from "lucide-react"; // types import { TProjectFilters } from "@plane/types"; // ui diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 1e90bf5bd..2403fcc53 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -57,14 +57,14 @@ export const IssueBlock: React.FC = observer((props: IssueBlock setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); const issue = issuesMap[issueId]; - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); + // const subIssues = subIssuesStore.subIssuesByIssueId(issueId); const { isMobile } = usePlatformOS(); if (!issue) return null; const canEditIssueProperties = canEditProperties(issue.project_id); const projectIdentifier = getProjectIdentifierById(issue.project_id); // if sub issues have been fetched for the issue, use that for count or use issue's sub_issues_count - const subIssuesCount = subIssues ? subIssues.length : issue.sub_issues_count; + // const subIssuesCount = subIssues ? subIssues.length : issue.sub_issues_count; const paddingLeft = `${spacingLeft}px`; diff --git a/web/components/issues/issue-layouts/save-filter-view.tsx b/web/components/issues/issue-layouts/save-filter-view.tsx index 14bd5e62d..036d537d8 100644 --- a/web/components/issues/issue-layouts/save-filter-view.tsx +++ b/web/components/issues/issue-layouts/save-filter-view.tsx @@ -1,5 +1,4 @@ import { FC, useState } from "react"; -import { Plus } from "lucide-react"; import { Button } from "@plane/ui"; // components import { CreateUpdateProjectViewModal } from "@/components/views"; diff --git a/web/components/pages/list/block-item-action.tsx b/web/components/pages/list/block-item-action.tsx index dbe9269a8..55d24dcdf 100644 --- a/web/components/pages/list/block-item-action.tsx +++ b/web/components/pages/list/block-item-action.tsx @@ -1,6 +1,6 @@ import React, { FC } from "react"; import { observer } from "mobx-react"; -import { Circle, Earth, Info, Lock, Minus } from "lucide-react"; +import { Earth, Info, Lock, Minus } from "lucide-react"; // ui import { Avatar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; // components From 4aed6e7aedf72ee06a54bedfa394302b3058b73f Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Sat, 11 May 2024 16:29:53 +0530 Subject: [PATCH 20/37] fix: issue layout application error (#4437) --- web/components/issues/issue-layouts/list/block.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 2403fcc53..7aa6d698b 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -93,7 +93,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock } )} > -
+
From 91a66a757a7186ec1f9b2f075570b1cf4cd4a04e Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Sat, 11 May 2024 17:47:00 +0530 Subject: [PATCH 21/37] fix: console warnings --- web/components/issues/sub-issues/issues-list.tsx | 6 +++--- web/components/ui/loader/layouts/list-layout-loader.tsx | 5 +++-- web/store/member/index.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/web/components/issues/sub-issues/issues-list.tsx b/web/components/issues/sub-issues/issues-list.tsx index 1c08a2b9e..2ca847624 100644 --- a/web/components/issues/sub-issues/issues-list.tsx +++ b/web/components/issues/sub-issues/issues-list.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, Fragment } from "react"; import { observer } from "mobx-react-lite"; import { TIssue } from "@plane/types"; // hooks @@ -45,7 +45,7 @@ export const IssueList: FC = observer((props) => { {subIssueIds && subIssueIds.length > 0 && subIssueIds.map((issueId) => ( - <> + = observer((props) => { handleIssueCrudState={handleIssueCrudState} subIssueOperations={subIssueOperations} /> - + ))}
( @@ -8,13 +9,13 @@ const ListItemRow = () => (
{[...Array(6)].map((_, index) => ( - <> + {getRandomInt(1, 2) % 2 === 0 ? ( ) : ( )} - + ))}
diff --git a/web/store/member/index.ts b/web/store/member/index.ts index 65b35f76a..958fb7ead 100644 --- a/web/store/member/index.ts +++ b/web/store/member/index.ts @@ -1,7 +1,7 @@ import { action, makeObservable, observable } from "mobx"; // types -import { RootStore } from "@/store/root.store"; import { IUserLite } from "@plane/types"; +import { RootStore } from "@/store/root.store"; import { IProjectMemberStore, ProjectMemberStore } from "./project-member.store"; import { IWorkspaceMemberStore, WorkspaceMemberStore } from "./workspace-member.store"; From 3723ece8d55e73ca19dff952e6472e9fa285f197 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Sat, 11 May 2024 18:55:47 +0530 Subject: [PATCH 22/37] fix: postcss upgrade to latest version --- admin/package.json | 2 +- package.json | 2 +- packages/editor/core/package.json | 2 +- packages/editor/document-editor/package.json | 2 +- packages/editor/extensions/package.json | 2 +- packages/editor/lite-text-editor/package.json | 2 +- packages/editor/rich-text-editor/package.json | 2 +- packages/tailwind-config-custom/package.json | 2 +- yarn.lock | 32 +++---------------- 9 files changed, 12 insertions(+), 36 deletions(-) diff --git a/admin/package.json b/admin/package.json index 936c612bb..713a83e57 100644 --- a/admin/package.json +++ b/admin/package.json @@ -25,7 +25,7 @@ "mobx-react-lite": "^4.0.5", "next": "^14.2.3", "next-themes": "^0.2.1", - "postcss": "8.4.23", + "postcss": "^8.4.38", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.51.0", diff --git a/package.json b/package.json index 0c846ea0d..05c1c7f24 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "devDependencies": { "autoprefixer": "^10.4.15", "eslint-config-custom": "*", - "postcss": "^8.4.29", + "postcss": "^8.4.38", "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", "tailwindcss": "^3.3.3", diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index 883610ebe..3df3e5892 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -61,7 +61,7 @@ "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", "eslint-config-custom": "*", - "postcss": "^8.4.29", + "postcss": "^8.4.38", "tailwind-config-custom": "*", "tsconfig": "*", "tsup": "^7.2.0", diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index ca2e501e6..83316b938 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -46,7 +46,7 @@ "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", "eslint-config-custom": "*", - "postcss": "^8.4.29", + "postcss": "^8.4.38", "tailwind-config-custom": "*", "tsconfig": "*", "tsup": "^7.2.0", diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json index 8c3608729..695c5e973 100644 --- a/packages/editor/extensions/package.json +++ b/packages/editor/extensions/package.json @@ -42,7 +42,7 @@ "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", "eslint-config-custom": "*", - "postcss": "^8.4.29", + "postcss": "^8.4.38", "tailwind-config-custom": "*", "tsconfig": "*", "tsup": "^7.2.0", diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index d0868e239..f252296d4 100644 --- a/packages/editor/lite-text-editor/package.json +++ b/packages/editor/lite-text-editor/package.json @@ -37,7 +37,7 @@ "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", "eslint-config-custom": "*", - "postcss": "^8.4.29", + "postcss": "^8.4.38", "tailwind-config-custom": "*", "tsconfig": "*", "tsup": "^7.2.0", diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index 6e596e925..fe48682ce 100644 --- a/packages/editor/rich-text-editor/package.json +++ b/packages/editor/rich-text-editor/package.json @@ -39,7 +39,7 @@ "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", "eslint-config-custom": "*", - "postcss": "^8.4.29", + "postcss": "^8.4.38", "react": "^18.2.0", "tailwind-config-custom": "*", "tsconfig": "*", diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index 076a9d352..11d518286 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -7,7 +7,7 @@ "devDependencies": { "@tailwindcss/typography": "^0.5.9", "autoprefixer": "^10.4.14", - "postcss": "^8.4.21", + "postcss": "^8.4.38", "prettier": "^2.8.8", "prettier-plugin-tailwindcss": "^0.3.0", "tailwindcss": "^3.2.7", diff --git a/yarn.lock b/yarn.lock index c25eddcda..053bb68d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6908,15 +6908,6 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.4.23: - version "8.4.23" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.23.tgz#df0aee9ac7c5e53e1075c24a3613496f9e6552ab" - integrity sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA== - dependencies: - nanoid "^3.3.6" - picocolors "^1.0.0" - source-map-js "^1.0.2" - postcss@8.4.31: version "8.4.31" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" @@ -6926,7 +6917,7 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.29: +postcss@^8.4.23, postcss@^8.4.38: version "8.4.38" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== @@ -7954,16 +7945,8 @@ streamx@^2.15.0, streamx@^2.16.1: optionalDependencies: bare-events "^2.2.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8043,14 +8026,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== From 198a2a63f24e46728dfd98175d54b9c33b71ee6b Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Mon, 13 May 2024 12:06:34 +0530 Subject: [PATCH 23/37] [WEB-1271] fix: show only joined projects in the filters list (#4417) --- .../issues/issue-layouts/filters/header/filters/project.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/issues/issue-layouts/filters/header/filters/project.tsx b/web/components/issues/issue-layouts/filters/header/filters/project.tsx index b3924cd34..26b0bb46b 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/project.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/project.tsx @@ -23,9 +23,9 @@ export const FilterProjects: React.FC = observer((props) => { const [itemsToRender, setItemsToRender] = useState(5); const [previewEnabled, setPreviewEnabled] = useState(true); // store - const { getProjectById, workspaceProjectIds } = useProject(); + const { getProjectById, joinedProjectIds } = useProject(); // derived values - const projects = workspaceProjectIds?.map((projectId) => getProjectById(projectId)!) ?? null; + const projects = joinedProjectIds?.map((projectId) => getProjectById(projectId)!) ?? null; const appliedFiltersCount = appliedFilters?.length ?? 0; const sortedOptions = useMemo(() => { From 18ba4009e0c4f965d09304c01fc0ef72a2e644b6 Mon Sep 17 00:00:00 2001 From: Satish Gandham Date: Mon, 13 May 2024 13:05:10 +0530 Subject: [PATCH 24/37] - Stop the default behavior on the custom menu button. (#4440) - Refactor menu click handler function --- packages/ui/src/dropdowns/custom-menu.tsx | 27 +++++++++-------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 549c83fe7..316cc6960 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -68,6 +68,13 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { if (closeOnSelect) closeDropdown(); }; + const handleMenuButtonClick = (e:React.MouseEvent)=>{ + e.stopPropagation(); + e.preventDefault() + isOpen ? closeDropdown() : openDropdown(); + if (menuButtonOnClick) menuButtonOnClick(); + } + useOutsideClickDetector(dropdownRef, closeDropdown); let menuItems = ( @@ -112,11 +119,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { +
+
+
+
+ ); +} diff --git a/space/app/layout.tsx b/space/app/layout.tsx new file mode 100644 index 000000000..b5501957e --- /dev/null +++ b/space/app/layout.tsx @@ -0,0 +1,35 @@ +import { Metadata } from "next"; +// styles +import "@/styles/globals.css"; +// helpers +import { ASSET_PREFIX } from "@/helpers/common.helper"; + +export const metadata: Metadata = { + title: "Plane Deploy | Make your Plane boards public with one-click", + description: "Plane Deploy is a customer feedback management tool built on top of plane.so", + openGraph: { + title: "Plane Deploy | Make your Plane boards public with one-click", + description: "Plane Deploy is a customer feedback management tool built on top of plane.so", + url: "https://sites.plane.so/", + }, + keywords: + "software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + }, +}; + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + ); +} diff --git a/space/app/not-found.tsx b/space/app/not-found.tsx new file mode 100644 index 000000000..98c4f95ca --- /dev/null +++ b/space/app/not-found.tsx @@ -0,0 +1,39 @@ +"use client"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import { Button } from "@plane/ui"; +// assets +import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg"; +import InstanceFailureImage from "@/public/instance/instance-failure.svg"; + +export default function InstanceNotFound() { + const { resolvedTheme } = useTheme(); + + const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; + + const handleRetry = () => { + window.location.reload(); + }; + + return ( +
+
+
+
+ Plane Logo +

Unable to fetch instance details.

+

+ We were unable to fetch the details of the instance.
+ Fret not, it might just be a connectivity issue. +

+
+
+ +
+
+
+
+ ); +} diff --git a/space/app/page.tsx b/space/app/page.tsx new file mode 100644 index 000000000..b2c032287 --- /dev/null +++ b/space/app/page.tsx @@ -0,0 +1,43 @@ +// components +import { UserLoggedIn } from "@/components/accounts"; +import { InstanceNotReady, InstanceFailureView } from "@/components/instance"; +import { AuthView } from "@/components/views"; +// helpers +// import { EPageTypes } from "@/helpers/authentication.helper"; +// import { useInstance, useUser } from "@/hooks/store"; +// wrapper +// import { AuthWrapper } from "@/lib/wrappers"; +// lib +import { AppProvider } from "@/lib/app-providers"; +// services +import { InstanceService } from "@/services/instance.service"; +import { UserService } from "@/services/user.service"; + +const userServices = new UserService(); +const instanceService = new InstanceService(); + +export default async function HomePage() { + const instanceDetails = await instanceService.getInstanceInfo().catch(() => undefined); + const user = await userServices + .currentUser() + .then((user) => ({ ...user, isAuthenticated: true })) + .catch(() => ({ isAuthenticated: false })); + + if (!instanceDetails) { + return ; + } + + if (!instanceDetails?.instance?.is_setup_done) { + ; + } + + if (user.isAuthenticated) { + return ; + } + + return ( + + + + ); +} diff --git a/space/components/accounts/auth-forms/email.tsx b/space/components/accounts/auth-forms/email.tsx index 550dea2bf..8f40b74d5 100644 --- a/space/components/accounts/auth-forms/email.tsx +++ b/space/components/accounts/auth-forms/email.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import { Controller, useForm } from "react-hook-form"; // icons diff --git a/space/components/accounts/auth-forms/forgot-password-popover.tsx b/space/components/accounts/auth-forms/forgot-password-popover.tsx index 31bafce26..8e0f2064c 100644 --- a/space/components/accounts/auth-forms/forgot-password-popover.tsx +++ b/space/components/accounts/auth-forms/forgot-password-popover.tsx @@ -1,3 +1,4 @@ +"use client"; import { Fragment, useState } from "react"; import { usePopper } from "react-popper"; import { X } from "lucide-react"; diff --git a/space/components/accounts/auth-forms/password.tsx b/space/components/accounts/auth-forms/password.tsx index 35d8703b6..b9d614155 100644 --- a/space/components/accounts/auth-forms/password.tsx +++ b/space/components/accounts/auth-forms/password.tsx @@ -1,7 +1,9 @@ +"use client"; + import React, { useEffect, useMemo, useState } from "react"; // icons import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import { Eye, EyeOff, XCircle } from "lucide-react"; // ui import { Button, Input, Spinner } from "@plane/ui"; @@ -12,7 +14,7 @@ import { API_BASE_URL } from "@/helpers/common.helper"; import { getPasswordStrength } from "@/helpers/password.helper"; // hooks import { useInstance } from "@/hooks/store"; -import { AuthService } from "@/services/authentication.service"; +import { AuthService } from "@/services/auth.service"; type Props = { email: string; @@ -43,12 +45,11 @@ export const PasswordForm: React.FC = (props) => { const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); // hooks - const { instance } = useInstance(); + const { data: instance, config: instanceConfig } = useInstance(); // router - const router = useRouter(); - const { next_path } = router.query; + const { next_path } = useParams(); // derived values - const isSmtpConfigured = instance?.config?.is_smtp_configured; + const isSmtpConfigured = instanceConfig?.is_smtp_configured; const handleFormChange = (key: keyof TPasswordFormValues, value: string) => setPasswordFormData((prev) => ({ ...prev, [key]: value })); diff --git a/space/components/accounts/auth-forms/root.tsx b/space/components/accounts/auth-forms/root.tsx index 1dac88655..273a438bf 100644 --- a/space/components/accounts/auth-forms/root.tsx +++ b/space/components/accounts/auth-forms/root.tsx @@ -1,3 +1,5 @@ +"use client"; + import React, { useState } from "react"; import { observer } from "mobx-react-lite"; // components @@ -7,7 +9,7 @@ import { EmailForm, UniqueCodeForm, PasswordForm, OAuthOptions, TermsAndConditio import { useInstance } from "@/hooks/store"; import useToast from "@/hooks/use-toast"; // services -import { AuthService } from "@/services/authentication.service"; +import { AuthService } from "@/services/auth.service"; export enum EAuthSteps { EMAIL = "EMAIL", @@ -60,9 +62,9 @@ export const AuthRoot = observer(() => { const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); const [email, setEmail] = useState(""); // hooks - const { instance } = useInstance(); + const { config: instanceConfig } = useInstance(); // derived values - const isSmtpConfigured = instance?.config?.is_smtp_configured; + const isSmtpConfigured = instanceConfig?.is_smtp_configured; const { header, subHeader } = getHeaderSubHeader(authMode); @@ -112,8 +114,8 @@ export const AuthRoot = observer(() => { ); }; - const isOAuthEnabled = - instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled); + const isOAuthEnabled = instanceConfig && (instanceConfig?.is_google_enabled || instanceConfig?.is_github_enabled); + return (
@@ -149,7 +151,7 @@ export const AuthRoot = observer(() => { )} )} - {isOAuthEnabled && } + {isOAuthEnabled !== undefined && }
); diff --git a/space/components/accounts/auth-forms/unique-code.tsx b/space/components/accounts/auth-forms/unique-code.tsx index bf76acdb9..fc04938bc 100644 --- a/space/components/accounts/auth-forms/unique-code.tsx +++ b/space/components/accounts/auth-forms/unique-code.tsx @@ -1,5 +1,7 @@ +"use client"; + import React, { useEffect, useState } from "react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // icons import { CircleCheck, XCircle } from "lucide-react"; // ui @@ -10,7 +12,7 @@ import { API_BASE_URL } from "@/helpers/common.helper"; import useTimer from "@/hooks/use-timer"; import useToast from "@/hooks/use-toast"; // services -import { AuthService } from "@/services/authentication.service"; +import { AuthService } from "@/services/auth.service"; // types import { IEmailCheckData } from "@/types/auth"; import { EAuthModes } from "./root"; @@ -43,8 +45,7 @@ export const UniqueCodeForm: React.FC = (props) => { const [csrfToken, setCsrfToken] = useState(undefined); const [isSubmitting, setIsSubmitting] = useState(false); // router - const router = useRouter(); - const { next_path } = router.query; + const { next_path } = useParams(); // toast alert const { setToastAlert } = useToast(); // timer diff --git a/space/components/accounts/oauth/github-button.tsx b/space/components/accounts/oauth/github-button.tsx index 740d59aaf..9a907f03a 100644 --- a/space/components/accounts/oauth/github-button.tsx +++ b/space/components/accounts/oauth/github-button.tsx @@ -1,3 +1,5 @@ +"use client"; + import { FC } from "react"; import Image from "next/image"; import { useTheme } from "next-themes"; diff --git a/space/components/accounts/oauth/google-button.tsx b/space/components/accounts/oauth/google-button.tsx index d31c6f59f..035a541a5 100644 --- a/space/components/accounts/oauth/google-button.tsx +++ b/space/components/accounts/oauth/google-button.tsx @@ -1,3 +1,5 @@ +"use client"; + import { FC } from "react"; import Image from "next/image"; import { useTheme } from "next-themes"; diff --git a/space/components/accounts/oauth/oauth-options.tsx b/space/components/accounts/oauth/oauth-options.tsx index b8e86c2ca..13b8c7d27 100644 --- a/space/components/accounts/oauth/oauth-options.tsx +++ b/space/components/accounts/oauth/oauth-options.tsx @@ -1,3 +1,5 @@ +"use client"; + import { observer } from "mobx-react-lite"; // components import { GithubOAuthButton, GoogleOAuthButton } from "@/components/accounts"; @@ -6,7 +8,7 @@ import { useInstance } from "@/hooks/store"; export const OAuthOptions: React.FC = observer(() => { // hooks - const { instance } = useInstance(); + const { config: instanceConfig } = useInstance(); return ( <> @@ -16,12 +18,12 @@ export const OAuthOptions: React.FC = observer(() => {
- {instance?.config?.is_google_enabled && ( + {instanceConfig?.is_google_enabled && (
)} - {instance?.config?.is_github_enabled && } + {instanceConfig?.is_github_enabled && }
); diff --git a/space/components/accounts/onboarding-form.tsx b/space/components/accounts/onboarding-form.tsx index 768d5160f..50e5f0f0d 100644 --- a/space/components/accounts/onboarding-form.tsx +++ b/space/components/accounts/onboarding-form.tsx @@ -1,3 +1,5 @@ +"use client"; + import React, { useMemo, useState } from "react"; import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; @@ -8,7 +10,7 @@ import { Button, Input, Spinner, TOAST_TYPE, setToast } from "@plane/ui"; // components import { UserImageUploadModal } from "@/components/accounts"; // hooks -import { useMobxStore } from "@/hooks/store"; +import { useUser } from "@/hooks/store"; // services import fileService from "@/services/file.service"; @@ -35,9 +37,7 @@ export const OnBoardingForm: React.FC = observer((props) => { const [isRemoving, setIsRemoving] = useState(false); const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); // store hooks - const { - user: { updateCurrentUser }, - } = useMobxStore(); + const { updateCurrentUser } = useUser(); // form info const { getValues, diff --git a/space/components/accounts/password-strength-meter.tsx b/space/components/accounts/password-strength-meter.tsx index 86ee814c8..c12d78421 100644 --- a/space/components/accounts/password-strength-meter.tsx +++ b/space/components/accounts/password-strength-meter.tsx @@ -1,3 +1,5 @@ +"use client"; + // icons import { CircleCheck } from "lucide-react"; // helpers diff --git a/space/components/accounts/terms-and-conditions.tsx b/space/components/accounts/terms-and-conditions.tsx index fbb16fd21..4bbde51f3 100644 --- a/space/components/accounts/terms-and-conditions.tsx +++ b/space/components/accounts/terms-and-conditions.tsx @@ -1,3 +1,5 @@ +"use client"; + import React, { FC } from "react"; import Link from "next/link"; import { EAuthModes } from "./auth-forms"; diff --git a/space/components/accounts/user-image-upload-modal.tsx b/space/components/accounts/user-image-upload-modal.tsx index fc0f4393a..802a6af62 100644 --- a/space/components/accounts/user-image-upload-modal.tsx +++ b/space/components/accounts/user-image-upload-modal.tsx @@ -1,3 +1,4 @@ +"use client"; import React, { useState } from "react"; import { observer } from "mobx-react-lite"; import { useDropzone } from "react-dropzone"; @@ -27,7 +28,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { const [image, setImage] = useState(null); const [isImageUploading, setIsImageUploading] = useState(false); // store hooks - const { instance } = useInstance(); + const { config: instanceConfig } = useInstance(); const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]); @@ -36,7 +37,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { accept: { "image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"], }, - maxSize: instance?.config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: (instanceConfig?.file_size_limit as number) ?? MAX_FILE_SIZE, multiple: false, }); diff --git a/space/components/accounts/user-logged-in.tsx b/space/components/accounts/user-logged-in.tsx index 7bf864431..773d934ad 100644 --- a/space/components/accounts/user-logged-in.tsx +++ b/space/components/accounts/user-logged-in.tsx @@ -1,3 +1,5 @@ +"use client"; + import Image from "next/image"; // hooks import { useUser } from "@/hooks/store"; diff --git a/space/components/instance/instance-failure-view.tsx b/space/components/instance/instance-failure-view.tsx index 0b875382a..1173a6894 100644 --- a/space/components/instance/instance-failure-view.tsx +++ b/space/components/instance/instance-failure-view.tsx @@ -1,3 +1,4 @@ +"use client"; import { FC } from "react"; import Image from "next/image"; import { useTheme } from "next-themes"; @@ -7,15 +8,18 @@ import InstanceFailureDarkImage from "public/instance/instance-failure-dark.svg" import InstanceFailureImage from "public/instance/instance-failure.svg"; type InstanceFailureViewProps = { - mutate: () => void; + // mutate: () => void; }; -export const InstanceFailureView: FC = (props) => { - const { mutate } = props; +export const InstanceFailureView: FC = () => { const { resolvedTheme } = useTheme(); const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; + const handleRetry = () => { + window.location.reload(); + }; + return (
@@ -28,7 +32,7 @@ export const InstanceFailureView: FC = (props) => {

-
diff --git a/space/components/instance/not-ready-view.tsx b/space/components/instance/not-ready-view.tsx index 815e0d1fe..5b94d92ed 100644 --- a/space/components/instance/not-ready-view.tsx +++ b/space/components/instance/not-ready-view.tsx @@ -1,18 +1,19 @@ +"use client"; + import { FC } from "react"; import Image from "next/image"; -import Link from "next/link"; // ui import { Button } from "@plane/ui"; -// helpers +// helper import { ADMIN_BASE_URL, ADMIN_BASE_PATH } from "@/helpers/common.helper"; // images import PlaneTakeOffImage from "@/public/instance/plane-takeoff.png"; export const InstanceNotReady: FC = () => { - const GOD_MODE_URL = encodeURI(ADMIN_BASE_URL + ADMIN_BASE_PATH + "/setup/?auth_enabled=0"); + const GOD_MODE_URL = encodeURI(ADMIN_BASE_URL + ADMIN_BASE_PATH); return ( -
+ diff --git a/space/components/issues/board-views/kanban/block.tsx b/space/components/issues/board-views/kanban/block.tsx index 6c2aa5279..4ecee992c 100644 --- a/space/components/issues/board-views/kanban/block.tsx +++ b/space/components/issues/board-views/kanban/block.tsx @@ -1,53 +1,48 @@ "use client"; -// mobx react lite +import { FC } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; +import { useRouter, useSearchParams } from "next/navigation"; +// components import { IssueBlockDueDate } from "@/components/issues/board-views/block-due-date"; import { IssueBlockPriority } from "@/components/issues/board-views/block-priority"; import { IssueBlockState } from "@/components/issues/board-views/block-state"; -import { useMobxStore } from "@/hooks/store"; - -// components +// hooks +import { useIssueDetails, useProject } from "@/hooks/store"; // interfaces -import { RootStore } from "@/store/root.store"; -import { IIssue } from "types/issue"; +import { IIssue } from "@/types/issue"; -export const IssueKanBanBlock = observer(({ issue }: { issue: IIssue }) => { - const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore(); +type IssueKanBanBlockProps = { + issue: IIssue; + workspaceSlug: string; + projectId: string; + params: any; +}; +export const IssueKanBanBlock: FC = observer((props) => { + const { workspaceSlug, projectId, params, issue } = props; + const { board, priorities, states, labels } = params; + // store + const { project } = useProject(); + const { setPeekId } = useIssueDetails(); // router const router = useRouter(); - const { workspace_slug, project_slug, board, priorities, states, labels } = router.query as { - workspace_slug: string; - project_slug: string; - board: string; - priorities: string; - states: string; - labels: string; - }; + const searchParams = useSearchParams(); const handleBlockClick = () => { - issueDetailStore.setPeekId(issue.id); + setPeekId(issue.id); const params: any = { board: board, peekId: issue.id }; if (states && states.length > 0) params.states = states; if (priorities && priorities.length > 0) params.priorities = priorities; if (labels && labels.length > 0) params.labels = labels; - router.push( - { - pathname: `/${workspace_slug}/${project_slug}`, - query: { ...params }, - }, - undefined, - { shallow: true } - ); + router.push(`/${workspaceSlug}/${projectId}?${searchParams}`); }; return (
{/* id */}
- {projectStore?.project?.identifier}-{issue?.sequence_id} + {project?.identifier}-{issue?.sequence_id}
{/* name */} diff --git a/space/components/issues/board-views/kanban/header.tsx b/space/components/issues/board-views/kanban/header.tsx index c08f89975..5a4d9a226 100644 --- a/space/components/issues/board-views/kanban/header.tsx +++ b/space/components/issues/board-views/kanban/header.tsx @@ -1,18 +1,16 @@ // mobx react lite import { observer } from "mobx-react-lite"; -// interfaces -// constants -import { StateGroupIcon } from "@plane/ui"; -import { issueGroupFilter } from "@/constants/data"; // ui +import { StateGroupIcon } from "@plane/ui"; +// constants +import { issueGroupFilter } from "@/constants/data"; // mobx hook -import { useMobxStore } from "@/hooks/store"; -import { RootStore } from "@/store/root.store"; -import { IIssueState } from "types/issue"; +import { useIssue } from "@/hooks/store"; +// interfaces +import { IIssueState } from "@/types/issue"; export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) => { - const store: RootStore = useMobxStore(); - + const { getCountOfIssuesByState } = useIssue(); const stateGroup = issueGroupFilter(state.group); if (stateGroup === null) return <>; @@ -23,9 +21,7 @@ export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) =>
{state?.name}
- - {store.issue.getCountOfIssuesByState(state.id)} - + {getCountOfIssuesByState(state.id)}
); }); diff --git a/space/components/issues/board-views/kanban/index.tsx b/space/components/issues/board-views/kanban/index.tsx index d1a9fe709..e2e4e9900 100644 --- a/space/components/issues/board-views/kanban/index.tsx +++ b/space/components/issues/board-views/kanban/index.tsx @@ -1,36 +1,47 @@ "use client"; -// mobx react lite +import { FC } from "react"; import { observer } from "mobx-react-lite"; // components import { IssueKanBanBlock } from "@/components/issues/board-views/kanban/block"; import { IssueKanBanHeader } from "@/components/issues/board-views/kanban/header"; // ui import { Icon } from "@/components/ui"; -// interfaces // mobx hook -import { useMobxStore } from "@/hooks/store"; -import { RootStore } from "@/store/root.store"; -import { IIssueState, IIssue } from "types/issue"; +import { useIssue } from "@/hooks/store"; +// interfaces +import { IIssueState, IIssue } from "@/types/issue"; -export const IssueKanbanView = observer(() => { - const store: RootStore = useMobxStore(); +type IssueKanbanViewProps = { + workspaceSlug: string; + projectId: string; +}; + +export const IssueKanbanView: FC = observer((props) => { + const { workspaceSlug, projectId } = props; + // store hooks + const { states, getFilteredIssuesByState } = useIssue(); return (
- {store?.issue?.states && - store?.issue?.states.length > 0 && - store?.issue?.states.map((_state: IIssueState) => ( + {states && + states.length > 0 && + states.map((_state: IIssueState) => (
- {store.issue.getFilteredIssuesByState(_state.id) && - store.issue.getFilteredIssuesByState(_state.id).length > 0 ? ( + {getFilteredIssuesByState(_state.id) && getFilteredIssuesByState(_state.id).length > 0 ? (
- {store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( - + {getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( + ))}
) : ( diff --git a/space/components/issues/board-views/list/block.tsx b/space/components/issues/board-views/list/block.tsx index 63b589066..e05ebcb2d 100644 --- a/space/components/issues/board-views/list/block.tsx +++ b/space/components/issues/board-views/list/block.tsx @@ -1,47 +1,40 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; // components import { IssueBlockDueDate } from "@/components/issues/board-views/block-due-date"; import { IssueBlockLabels } from "@/components/issues/board-views/block-labels"; import { IssueBlockPriority } from "@/components/issues/board-views/block-priority"; import { IssueBlockState } from "@/components/issues/board-views/block-state"; // mobx hook -import { useMobxStore } from "@/hooks/store"; +import { useIssueDetails, useProject } from "@/hooks/store"; // interfaces -import { RootStore } from "@/store/root.store"; -import { IIssue } from "types/issue"; +import { IIssue } from "@/types/issue"; // store -export const IssueListBlock: FC<{ issue: IIssue }> = observer((props) => { - const { issue } = props; +type IssueListBlockProps = { + issue: IIssue; + workspaceSlug: string; + projectId: string; +}; + +export const IssueListBlock: FC = observer((props) => { + const { workspaceSlug, projectId, issue } = props; + const { board, states, priorities, labels } = useParams(); + const searchParams = useSearchParams(); // store - const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore(); + const { project } = useProject(); + const { setPeekId } = useIssueDetails(); // router const router = useRouter(); - const { workspace_slug, project_slug, board, priorities, states, labels } = router.query as { - workspace_slug: string; - project_slug: string; - board: string; - priorities: string; - states: string; - labels: string; - }; const handleBlockClick = () => { - issueDetailStore.setPeekId(issue.id); + setPeekId(issue.id); const params: any = { board: board, peekId: issue.id }; if (states && states.length > 0) params.states = states; if (priorities && priorities.length > 0) params.priorities = priorities; if (labels && labels.length > 0) params.labels = labels; - router.push( - { - pathname: `/${workspace_slug}/${project_slug}`, - query: { ...params }, - }, - undefined, - { shallow: true } - ); + router.push(`/${workspaceSlug}/${projectId}?${searchParams}`); // router.push(`/${workspace_slug?.toString()}/${project_slug}?board=${board?.toString()}&peekId=${issue.id}`); }; @@ -50,7 +43,7 @@ export const IssueListBlock: FC<{ issue: IIssue }> = observer((props) => {
{/* id */}
- {projectStore?.project?.identifier}-{issue?.sequence_id} + {project?.identifier}-{issue?.sequence_id}
{/* name */}
diff --git a/space/components/issues/board-views/list/header.tsx b/space/components/issues/board-views/list/header.tsx index 6266c9cef..22a902474 100644 --- a/space/components/issues/board-views/list/header.tsx +++ b/space/components/issues/board-views/list/header.tsx @@ -1,18 +1,15 @@ -// mobx react lite import { observer } from "mobx-react-lite"; -// interfaces // ui import { StateGroupIcon } from "@plane/ui"; // constants import { issueGroupFilter } from "@/constants/data"; // mobx hook -import { useMobxStore } from "@/hooks/store"; -import { RootStore } from "@/store/root.store"; -import { IIssueState } from "types/issue"; +import { useIssue } from "@/hooks/store"; +// types +import { IIssueState } from "@/types/issue"; export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { - const store: RootStore = useMobxStore(); - + const { getCountOfIssuesByState } = useIssue(); const stateGroup = issueGroupFilter(state.group); if (stateGroup === null) return <>; @@ -23,7 +20,7 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
{state?.name}
-
{store.issue.getCountOfIssuesByState(state.id)}
+
{getCountOfIssuesByState(state.id)}
); }); diff --git a/space/components/issues/board-views/list/index.tsx b/space/components/issues/board-views/list/index.tsx index 03ca07998..7740bfd58 100644 --- a/space/components/issues/board-views/list/index.tsx +++ b/space/components/issues/board-views/list/index.tsx @@ -1,29 +1,34 @@ +import { FC } from "react"; import { observer } from "mobx-react-lite"; // components import { IssueListBlock } from "@/components/issues/board-views/list/block"; import { IssueListHeader } from "@/components/issues/board-views/list/header"; -// interfaces // mobx hook -import { useMobxStore } from "@/hooks/store"; -// store -import { RootStore } from "@/store/root.store"; -import { IIssueState, IIssue } from "types/issue"; +import { useIssue } from "@/hooks/store"; +// types +import { IIssueState, IIssue } from "@/types/issue"; -export const IssueListView = observer(() => { - const { issue: issueStore }: RootStore = useMobxStore(); +type IssueListViewProps = { + workspaceSlug: string; + projectId: string; +}; + +export const IssueListView: FC = observer((props) => { + const { workspaceSlug, projectId } = props; + // store hooks + const { states, getFilteredIssuesByState } = useIssue(); return ( <> - {issueStore?.states && - issueStore?.states.length > 0 && - issueStore?.states.map((_state: IIssueState) => ( + {states && + states.length > 0 && + states.map((_state: IIssueState) => (
- {issueStore.getFilteredIssuesByState(_state.id) && - issueStore.getFilteredIssuesByState(_state.id).length > 0 ? ( + {getFilteredIssuesByState(_state.id) && getFilteredIssuesByState(_state.id).length > 0 ? (
- {issueStore.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( - + {getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( + ))}
) : ( diff --git a/space/components/issues/filters/applied-filters/filters-list.tsx b/space/components/issues/filters/applied-filters/filters-list.tsx index e0d41341b..a0d703c2c 100644 --- a/space/components/issues/filters/applied-filters/filters-list.tsx +++ b/space/components/issues/filters/applied-filters/filters-list.tsx @@ -1,12 +1,10 @@ -// components // icons import { X } from "lucide-react"; -// helpers -import { IIssueFilterOptions } from "@/store/issues/types"; -import { IIssueLabel, IIssueState } from "types/issue"; +// types +import { IIssueLabel, IIssueState, IIssueFilterOptions } from "@/types/issue"; +// components import { AppliedPriorityFilters } from "./priority"; import { AppliedStateFilters } from "./state"; -// types type Props = { appliedFilters: IIssueFilterOptions; diff --git a/space/components/issues/filters/applied-filters/root.tsx b/space/components/issues/filters/applied-filters/root.tsx index 7362e8787..1e32ea363 100644 --- a/space/components/issues/filters/applied-filters/root.tsx +++ b/space/components/issues/filters/applied-filters/root.tsx @@ -1,33 +1,31 @@ +"use client"; + import { FC, useCallback } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; -// components +import { useRouter } from "next/navigation"; +// hooks +import { useIssue, useProject, useIssueFilter } from "@/hooks/store"; // store -import { useMobxStore } from "@/hooks/store"; -import { IIssueFilterOptions } from "@/store/issues/types"; -import { RootStore } from "@/store/root.store"; +import { IIssueFilterOptions } from "@/types/issue"; +// components import { AppliedFiltersList } from "./filters-list"; -export const IssueAppliedFilters: FC = observer(() => { +// TODO: fix component types +export const IssueAppliedFilters: FC = observer((props: any) => { const router = useRouter(); - const { workspace_slug: workspaceSlug, project_slug: projectId } = router.query as { - workspace_slug: string; - project_slug: string; - }; - - const { - issuesFilter: { issueFilters, updateFilters }, - issue: { states, labels }, - project: { activeBoard }, - }: RootStore = useMobxStore(); + const { workspaceSlug, projectId } = props; + const { states, labels } = useIssue(); + const { activeLayout } = useProject(); + const { issueFilters, updateFilters } = useIssueFilter(); const userFilters = issueFilters?.filters || {}; - const appliedFilters: IIssueFilterOptions = {}; + const appliedFilters: any = {}; + Object.entries(userFilters).forEach(([key, value]) => { if (!value) return; if (Array.isArray(value) && value.length === 0) return; - appliedFilters[key as keyof IIssueFilterOptions] = value; + appliedFilters[key] = value; }); const updateRouteParams = useCallback( @@ -36,16 +34,17 @@ export const IssueAppliedFilters: FC = observer(() => { const priority = key === "priority" ? value || [] : issueFilters?.filters?.priority ?? []; const labels = key === "labels" ? value || [] : issueFilters?.filters?.labels ?? []; - let params: any = { board: activeBoard || "list" }; + let params: any = { board: activeLayout || "list" }; if (!clearFields) { if (priority.length > 0) params = { ...params, priorities: priority.join(",") }; if (state.length > 0) params = { ...params, states: state.join(",") }; if (labels.length > 0) params = { ...params, labels: labels.join(",") }; } - - router.push({ pathname: `/${workspaceSlug}/${projectId}`, query: { ...params } }, undefined, { shallow: true }); + console.log("params", params); + // TODO: fix this redirection + // router.push({ pathname: `/${workspaceSlug}/${projectId}`, query: { ...params } }, undefined, { shallow: true }); }, - [workspaceSlug, projectId, activeBoard, issueFilters, router] + [workspaceSlug, projectId, activeLayout, issueFilters, router] ); const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => { @@ -80,7 +79,7 @@ export const IssueAppliedFilters: FC = observer(() => {
{ +type IssueFiltersDropdownProps = { + workspaceSlug: string; + projectId: string; +}; + +export const IssueFiltersDropdown: FC = observer((props) => { + const { workspaceSlug, projectId } = props; + const searchParams = useSearchParams(); const router = useRouter(); - const { workspace_slug: workspaceSlug, project_slug: projectId } = router.query as { - workspace_slug: string; - project_slug: string; - }; - - const { - project: { activeBoard }, - issue: { states, labels }, - issuesFilter: { issueFilters, updateFilters }, - }: RootStore = useMobxStore(); + // store hooks + const { activeLayout } = useProject(); + const { states, labels } = useIssue(); + const { issueFilters, updateFilters } = useIssueFilter(); const updateRouteParams = useCallback( (key: keyof IIssueFilterOptions, value: string[]) => { @@ -31,14 +31,14 @@ export const IssueFiltersDropdown: FC = observer(() => { const priority = key === "priority" ? value : issueFilters?.filters?.priority ?? []; const labels = key === "labels" ? value : issueFilters?.filters?.labels ?? []; - let params: any = { board: activeBoard || "list" }; + let params: any = { board: activeLayout || "list" }; if (priority.length > 0) params = { ...params, priorities: priority.join(",") }; if (state.length > 0) params = { ...params, states: state.join(",") }; if (labels.length > 0) params = { ...params, labels: labels.join(",") }; - - router.push({ pathname: `/${workspaceSlug}/${projectId}`, query: { ...params } }, undefined, { shallow: true }); + console.log("params", params); + router.push(`/${workspaceSlug}/${projectId}?${searchParams}`); }, - [workspaceSlug, projectId, activeBoard, issueFilters, router] + [workspaceSlug, projectId, activeLayout, issueFilters, router] ); const handleFilters = useCallback( @@ -66,8 +66,8 @@ export const IssueFiltersDropdown: FC = observer(() => { diff --git a/space/components/issues/filters/selection.tsx b/space/components/issues/filters/selection.tsx index 71f0c5f1b..9d7d89666 100644 --- a/space/components/issues/filters/selection.tsx +++ b/space/components/issues/filters/selection.tsx @@ -1,13 +1,10 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; import { Search, X } from "lucide-react"; -// components // types - -// filter helpers -import { ILayoutDisplayFiltersOptions } from "@/store/issues/helpers"; -import { IIssueFilterOptions } from "@/store/issues/types"; -import { IIssueState, IIssueLabel } from "types/issue"; +import { IIssueState, IIssueLabel, IIssueFilterOptions } from "@/types/issue"; +import { ILayoutDisplayFiltersOptions } from "@/types/issue-filters"; +// components import { FilterPriority, FilterState } from "./"; type Props = { diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx index de8633100..97cd0c500 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/index.tsx @@ -1,54 +1,52 @@ -import { useEffect } from "react"; +"use client"; + +import { useEffect, FC } from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; -import { useRouter } from "next/router"; -// components +import { useRouter, useParams, useSearchParams, usePathname } from "next/navigation"; import { Briefcase } from "lucide-react"; import { Avatar, Button } from "@plane/ui"; +// components import { ProjectLogo } from "@/components/common"; import { IssueFiltersDropdown } from "@/components/issues/filters"; // hooks -import { useMobxStore, useUser } from "@/hooks/store"; -// store -import { RootStore } from "@/store/root.store"; +import { useProject, useUser, useIssueFilter } from "@/hooks/store"; +// types import { TIssueBoardKeys } from "@/types/issue"; +// components import { NavbarIssueBoardView } from "./issue-board-view"; import { NavbarTheme } from "./theme"; -const IssueNavbar = observer(() => { - const { - project: projectStore, - issuesFilter: { updateFilters }, - }: RootStore = useMobxStore(); - const { data: user } = useUser(); - // router +type IssueNavbarProps = { + projectSettings: any; + workspaceSlug: string; + projectId: string; +}; + +const IssueNavbar: FC = observer((props) => { + const { projectSettings, workspaceSlug, projectId } = props; + const { project_details, views } = projectSettings; + const { board, labels, states, priorities, peekId } = useParams(); + const searchParams = useSearchParams(); + const pathName = usePathname(); + // hooks const router = useRouter(); - const { workspace_slug, project_slug, board, peekId, states, priorities, labels } = router.query as { - workspace_slug: string; - project_slug: string; - peekId: string; - board: string; - states: string; - priorities: string; - labels: string; - }; + // store + const { settings, activeLayout, hydrate, setActiveLayout } = useProject(); + const { data: user } = useUser(); + const { updateFilters } = useIssueFilter(); + hydrate(projectSettings); useEffect(() => { - if (workspace_slug && project_slug) { - projectStore.fetchProjectSettings(workspace_slug.toString(), project_slug.toString()); - } - }, [projectStore, workspace_slug, project_slug]); - - useEffect(() => { - if (workspace_slug && project_slug && projectStore?.deploySettings) { + if (workspaceSlug && projectId && settings) { const viewsAcceptable: string[] = []; let currentBoard: TIssueBoardKeys | null = null; - if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list"); - if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban"); - if (projectStore?.deploySettings?.views?.calendar) viewsAcceptable.push("calendar"); - if (projectStore?.deploySettings?.views?.gantt) viewsAcceptable.push("gantt"); - if (projectStore?.deploySettings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet"); + if (settings?.views?.list) viewsAcceptable.push("list"); + if (settings?.views?.kanban) viewsAcceptable.push("kanban"); + if (settings?.views?.calendar) viewsAcceptable.push("calendar"); + if (settings?.views?.gantt) viewsAcceptable.push("gantt"); + if (settings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet"); if (board) { if (viewsAcceptable.includes(board.toString())) { @@ -65,49 +63,47 @@ const IssueNavbar = observer(() => { } if (currentBoard) { - if (projectStore?.activeBoard === null || projectStore?.activeBoard !== currentBoard) { + if (activeLayout === null || activeLayout !== currentBoard) { let params: any = { board: currentBoard }; if (peekId && peekId.length > 0) params = { ...params, peekId: peekId }; if (priorities && priorities.length > 0) params = { ...params, priorities: priorities }; if (states && states.length > 0) params = { ...params, states: states }; if (labels && labels.length > 0) params = { ...params, labels: labels }; - + console.log("params", params); let storeParams: any = {}; if (priorities && priorities.length > 0) storeParams = { ...storeParams, priority: priorities.split(",") }; if (states && states.length > 0) storeParams = { ...storeParams, state: states.split(",") }; if (labels && labels.length > 0) storeParams = { ...storeParams, labels: labels.split(",") }; - if (storeParams) updateFilters(project_slug, storeParams); - - projectStore.setActiveBoard(currentBoard); - router.push({ - pathname: `/${workspace_slug}/${project_slug}`, - query: { ...params }, - }); + if (storeParams) updateFilters(projectId, storeParams); + setActiveLayout(currentBoard); + router.push(`/${workspaceSlug}/${projectId}?${searchParams}`); } } } }, [ board, - workspace_slug, - project_slug, + workspaceSlug, + projectId, router, - projectStore, - projectStore?.deploySettings, updateFilters, labels, states, priorities, peekId, + settings, + activeLayout, + setActiveLayout, + searchParams, ]); return (
{/* project detail */}
- {projectStore.project ? ( + {project_details ? ( - + ) : ( @@ -115,21 +111,18 @@ const IssueNavbar = observer(() => { )}
- {projectStore?.project?.name || `...`} + {project_details?.name || `...`}
- {/* issue search bar */} -
{/* */}
- {/* issue views */}
- +
{/* issue filters */}
- +
{/* theming */} @@ -137,14 +130,14 @@ const IssueNavbar = observer(() => {
- {user ? ( + {user?.id ? (
{user.display_name}
) : (
- +
diff --git a/space/components/issues/navbar/issue-board-view.tsx b/space/components/issues/navbar/issue-board-view.tsx index 12574bef8..d2eb53398 100644 --- a/space/components/issues/navbar/issue-board-view.tsx +++ b/space/components/issues/navbar/issue-board-view.tsx @@ -1,47 +1,49 @@ +"use client"; + +import { FC } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; // constants import { issueViews } from "@/constants/data"; +// hooks +import { useProject } from "@/hooks/store"; // mobx -import { useMobxStore } from "@/hooks/store"; -import { RootStore } from "@/store/root.store"; -import { TIssueBoardKeys } from "types/issue"; +import { TIssueBoardKeys } from "@/types/issue"; -export const NavbarIssueBoardView = observer(() => { - const { - project: { viewOptions, setActiveBoard, activeBoard }, - }: RootStore = useMobxStore(); - // router - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; +type NavbarIssueBoardViewProps = { + layouts: Record; +}; + +export const NavbarIssueBoardView: FC = observer((props) => { + const { layouts } = props; + + const { activeLayout, setActiveLayout } = useProject(); const handleCurrentBoardView = (boardView: string) => { - setActiveBoard(boardView as TIssueBoardKeys); - router.push(`/${workspace_slug}/${project_slug}?board=${boardView}`); + setActiveLayout(boardView as TIssueBoardKeys); }; return ( <> - {viewOptions && - Object.keys(viewOptions).map((viewKey: string) => { - if (viewOptions[viewKey]) { + {layouts && + Object.keys(layouts).map((layoutKey: string) => { + if (layouts[layoutKey as TIssueBoardKeys]) { return (
handleCurrentBoardView(viewKey)} - title={viewKey} + onClick={() => handleCurrentBoardView(layoutKey)} + title={layoutKey} > - {issueViews[viewKey]?.icon} + {issueViews[layoutKey]?.icon}
); diff --git a/space/components/issues/navbar/theme.tsx b/space/components/issues/navbar/theme.tsx index 1d45625c7..e09bdda60 100644 --- a/space/components/issues/navbar/theme.tsx +++ b/space/components/issues/navbar/theme.tsx @@ -1,3 +1,5 @@ +"use client"; + // next theme import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; @@ -16,7 +18,6 @@ export const NavbarTheme = observer(() => { useEffect(() => { if (!theme) return; - setAppTheme(theme); }, [theme]); diff --git a/space/components/issues/peek-overview/comment/add-comment.tsx b/space/components/issues/peek-overview/comment/add-comment.tsx index dadcc1747..96366eadd 100644 --- a/space/components/issues/peek-overview/comment/add-comment.tsx +++ b/space/components/issues/peek-overview/comment/add-comment.tsx @@ -1,12 +1,11 @@ import React, { useRef } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; import { useForm, Controller } from "react-hook-form"; // components import { EditorRefApi } from "@plane/lite-text-editor"; import { LiteTextEditor } from "@/components/editor/lite-text-editor"; // hooks -import { useMobxStore, useUser } from "@/hooks/store"; +import { useIssueDetails, useProject, useUser } from "@/hooks/store"; import useToast from "@/hooks/use-toast"; // types import { Comment } from "@/types/issue"; @@ -17,22 +16,21 @@ const defaultValues: Partial = { type Props = { disabled?: boolean; + workspaceSlug: string; + projectId: string; }; -export const AddComment: React.FC = observer(() => { +export const AddComment: React.FC = observer((props) => { // const { disabled = false } = props; + const { workspaceSlug, projectId } = props; // refs const editorRef = useRef(null); - // router - const router = useRouter(); - const { workspace_slug, project_slug } = router.query; // store hooks - const { project } = useMobxStore(); - const { issueDetails: issueDetailStore } = useMobxStore(); + const { workspace } = useProject(); + const { peekId: issueId, addIssueComment } = useIssueDetails(); const { data: currentUser } = useUser(); // derived values - const workspaceId = project.workspace?.id; - const issueId = issueDetailStore.peekId; + const workspaceId = workspace?.id; // form info const { handleSubmit, @@ -45,10 +43,9 @@ export const AddComment: React.FC = observer(() => { const { setToastAlert } = useToast(); const onSubmit = async (formData: Comment) => { - if (!workspace_slug || !project_slug || !issueId || isSubmitting || !formData.comment_html) return; + if (!workspaceSlug || !projectId || !issueId || isSubmitting || !formData.comment_html) return; - await issueDetailStore - .addIssueComment(workspace_slug.toString(), project_slug.toString(), issueId, formData) + await addIssueComment(workspaceSlug, projectId, issueId, formData) .then(() => { reset(defaultValues); editorRef.current?.clearEditor(); @@ -75,7 +72,7 @@ export const AddComment: React.FC = observer(() => { if (currentUser) handleSubmit(onSubmit)(e); }} workspaceId={workspaceId as string} - workspaceSlug={workspace_slug as string} + workspaceSlug={workspaceSlug} ref={editorRef} initialValue={ !value || value === "" || (typeof value === "object" && Object.keys(value).length === 0) diff --git a/space/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/components/issues/peek-overview/comment/comment-detail-card.tsx index e3962c5e4..449b7883c 100644 --- a/space/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -10,9 +10,7 @@ import { CommentReactions } from "@/components/issues/peek-overview"; // helpers import { timeAgo } from "@/helpers/date-time.helper"; // hooks -import { useMobxStore, useUser } from "@/hooks/store"; -// store -import { RootStore } from "@/store/root.store"; +import { useIssueDetails, useProject, useUser } from "@/hooks/store"; // types import { Comment } from "@/types/issue"; @@ -23,12 +21,13 @@ type Props = { export const CommentCard: React.FC = observer((props) => { const { comment, workspaceSlug } = props; - const { project }: RootStore = useMobxStore(); - const workspaceId = project.workspace?.id; - - // store - const { issueDetails: issueDetailStore } = useMobxStore(); + // store hooks + const { workspace } = useProject(); + const { peekId, deleteIssueComment, updateIssueComment } = useIssueDetails(); const { data: currentUser } = useUser(); + // derived values + const workspaceId = workspace?.id; + // states const [isEditing, setIsEditing] = useState(false); // refs @@ -44,15 +43,14 @@ export const CommentCard: React.FC = observer((props) => { }); const handleDelete = () => { - if (!workspaceSlug || !issueDetailStore.peekId) return; - issueDetailStore.deleteIssueComment(workspaceSlug, comment.project, issueDetailStore.peekId, comment.id); + if (!workspaceSlug || !peekId) return; + deleteIssueComment(workspaceSlug, comment.project, peekId, comment.id); }; const handleCommentUpdate = async (formData: Comment) => { - if (!workspaceSlug || !issueDetailStore.peekId) return; - issueDetailStore.updateIssueComment(workspaceSlug, comment.project, issueDetailStore.peekId, comment.id, formData); + if (!workspaceSlug || !peekId) return; + updateIssueComment(workspaceSlug, comment.project, peekId, comment.id, formData); setIsEditing(false); - editorRef.current?.setEditorValue(formData.comment_html); showEditorRef.current?.setEditorValue(formData.comment_html); }; @@ -135,7 +133,7 @@ export const CommentCard: React.FC = observer((props) => {
- +
diff --git a/space/components/issues/peek-overview/comment/comment-reactions.tsx b/space/components/issues/peek-overview/comment/comment-reactions.tsx index ca451f7b4..a19502e53 100644 --- a/space/components/issues/peek-overview/comment/comment-reactions.tsx +++ b/space/components/issues/peek-overview/comment/comment-reactions.tsx @@ -1,58 +1,38 @@ import React from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; import { Tooltip } from "@plane/ui"; // ui import { ReactionSelector } from "@/components/ui"; // helpers import { groupReactions, renderEmoji } from "@/helpers/emoji.helper"; // hooks -import { useMobxStore, useUser } from "@/hooks/store"; +import { useIssueDetails, useUser } from "@/hooks/store"; type Props = { commentId: string; projectId: string; + workspaceSlug: string; }; export const CommentReactions: React.FC = observer((props) => { - const { commentId, projectId } = props; - - const router = useRouter(); - const { workspace_slug } = router.query; + const { commentId, projectId, workspaceSlug } = props; // hooks - const { issueDetails: issueDetailsStore } = useMobxStore(); + const { addCommentReaction, removeCommentReaction, details, peekId } = useIssueDetails(); const { data: user } = useUser(); - const peekId = issueDetailsStore.peekId; - const commentReactions = peekId - ? issueDetailsStore.details[peekId].comments.find((c) => c.id === commentId)?.comment_reactions - : []; + const commentReactions = peekId ? details[peekId].comments.find((c) => c.id === commentId)?.comment_reactions : []; const groupedReactions = peekId ? groupReactions(commentReactions ?? [], "reaction") : {}; const userReactions = commentReactions?.filter((r) => r.actor_detail.id === user?.id); const handleAddReaction = (reactionHex: string) => { - if (!workspace_slug || !projectId || !peekId) return; - - issueDetailsStore.addCommentReaction( - workspace_slug.toString(), - projectId.toString(), - peekId, - commentId, - reactionHex - ); + if (!workspaceSlug || !projectId || !peekId) return; + addCommentReaction(workspaceSlug, projectId, peekId, commentId, reactionHex); }; const handleRemoveReaction = (reactionHex: string) => { - if (!workspace_slug || !projectId || !peekId) return; - - issueDetailsStore.removeCommentReaction( - workspace_slug.toString(), - projectId.toString(), - peekId, - commentId, - reactionHex - ); + if (!workspaceSlug || !projectId || !peekId) return; + removeCommentReaction(workspaceSlug, projectId, peekId, commentId, reactionHex); }; const handleReactionClick = (reactionHex: string) => { diff --git a/space/components/issues/peek-overview/full-screen-peek-view.tsx b/space/components/issues/peek-overview/full-screen-peek-view.tsx index 5ab4dffd7..f5918de43 100644 --- a/space/components/issues/peek-overview/full-screen-peek-view.tsx +++ b/space/components/issues/peek-overview/full-screen-peek-view.tsx @@ -13,11 +13,12 @@ import { IIssue } from "@/types/issue"; type Props = { handleClose: () => void; issueDetails: IIssue | undefined; - workspace_slug: string; + workspaceSlug: string; + projectId: string; }; export const FullScreenPeekView: React.FC = observer((props) => { - const { handleClose, issueDetails } = props; + const { handleClose, issueDetails, workspaceSlug, projectId } = props; return (
@@ -35,7 +36,11 @@ export const FullScreenPeekView: React.FC = observer((props) => {
{/* issue activity/comments */}
- +
) : ( diff --git a/space/components/issues/peek-overview/header.tsx b/space/components/issues/peek-overview/header.tsx index 7aba40305..3b8e2960f 100644 --- a/space/components/issues/peek-overview/header.tsx +++ b/space/components/issues/peek-overview/header.tsx @@ -2,19 +2,17 @@ import React from "react"; import { observer } from "mobx-react-lite"; import { MoveRight } from "lucide-react"; import { Listbox, Transition } from "@headlessui/react"; -// hooks // ui import { Icon } from "@/components/ui"; // helpers import { copyTextToClipboard } from "@/helpers/string.helper"; +// hooks +import { useIssueDetails } from "@/hooks/store"; +import useToast from "@/hooks/use-toast"; // store -import { useMobxStore } from "@/hooks/store"; -import { IPeekMode } from "@/store/issue_details"; -import { RootStore } from "@/store/root.store"; -// lib -import useToast from "hooks/use-toast"; +import { IPeekMode } from "@/store/issue-detail.store"; // types -import { IIssue } from "types/issue"; +import { IIssue } from "@/types/issue"; type Props = { handleClose: () => void; @@ -42,7 +40,7 @@ const peekModes: { export const PeekOverviewHeader: React.FC = observer((props) => { const { handleClose } = props; - const { issueDetails: issueDetailStore }: RootStore = useMobxStore(); + const { peekMode, setPeekMode } = useIssueDetails(); const { setToastAlert } = useToast(); @@ -62,21 +60,19 @@ export const PeekOverviewHeader: React.FC = observer((props) => { <>
- {issueDetailStore.peekMode === "side" && ( + {peekMode === "side" && ( )} issueDetailStore.setPeekMode(val)} + value={peekMode} + onChange={(val) => setPeekMode(val)} className="relative flex-shrink-0 text-left" > - - m.key === issueDetailStore.peekMode)?.icon ?? ""} /> + + m.key === peekMode)?.icon ?? ""} /> = observer((props) => {
- {(issueDetailStore.peekMode === "side" || issueDetailStore.peekMode === "modal") && ( + {(peekMode === "side" || peekMode === "modal") && (
diff --git a/space/components/issues/peek-overview/issue-emoji-reactions.tsx b/space/components/issues/peek-overview/issue-emoji-reactions.tsx index 7e568461b..ef5688c57 100644 --- a/space/components/issues/peek-overview/issue-emoji-reactions.tsx +++ b/space/components/issues/peek-overview/issue-emoji-reactions.tsx @@ -1,20 +1,22 @@ import { useEffect } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; // lib import { Tooltip } from "@plane/ui"; import { ReactionSelector } from "@/components/ui"; // helpers import { groupReactions, renderEmoji } from "@/helpers/emoji.helper"; // hooks -import { useMobxStore, useUser } from "@/hooks/store"; +import { useIssueDetails, useUser } from "@/hooks/store"; -export const IssueEmojiReactions: React.FC = observer(() => { - // router - const router = useRouter(); - const { workspace_slug, project_slug } = router.query; +type IssueEmojiReactionsProps = { + workspaceSlug: string; + projectId: string; +}; + +export const IssueEmojiReactions: React.FC = observer((props) => { + const { workspaceSlug, projectId } = props; // store - const { issueDetails: issueDetailsStore } = useMobxStore(); + const issueDetailsStore = useIssueDetails(); const { data: user, fetchCurrentUser } = useUser(); const issueId = issueDetailsStore.peekId; @@ -24,20 +26,17 @@ export const IssueEmojiReactions: React.FC = observer(() => { const userReactions = reactions?.filter((r) => r.actor_detail.id === user?.id); const handleAddReaction = (reactionHex: string) => { - if (!workspace_slug || !project_slug || !issueId) return; - - issueDetailsStore.addIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, reactionHex); + if (!workspaceSlug || !projectId || !issueId) return; + issueDetailsStore.addIssueReaction(workspaceSlug.toString(), projectId.toString(), issueId, reactionHex); }; const handleRemoveReaction = (reactionHex: string) => { - if (!workspace_slug || !project_slug || !issueId) return; - - issueDetailsStore.removeIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, reactionHex); + if (!workspaceSlug || !projectId || !issueId) return; + issueDetailsStore.removeIssueReaction(workspaceSlug.toString(), projectId.toString(), issueId, reactionHex); }; const handleReactionClick = (reactionHex: string) => { const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex); - if (userReaction) handleRemoveReaction(reactionHex); else handleAddReaction(reactionHex); }; diff --git a/space/components/issues/peek-overview/issue-properties.tsx b/space/components/issues/peek-overview/issue-properties.tsx index 1018c22f7..d31e8dd6d 100644 --- a/space/components/issues/peek-overview/issue-properties.tsx +++ b/space/components/issues/peek-overview/issue-properties.tsx @@ -8,7 +8,7 @@ import { issueGroupFilter, issuePriorityFilter } from "@/constants/data"; import { renderFullDate } from "@/helpers/date-time.helper"; import { copyTextToClipboard, addSpaceIfCamelCase } from "@/helpers/string.helper"; // types -import { IPeekMode } from "@/store/issue_details"; +import { IPeekMode } from "@/store/issue-detail.store"; // constants import useToast from "hooks/use-toast"; import { IIssue } from "types/issue"; diff --git a/space/components/issues/peek-overview/issue-reaction.tsx b/space/components/issues/peek-overview/issue-reaction.tsx index 5bc60cb34..ae569a52e 100644 --- a/space/components/issues/peek-overview/issue-reaction.tsx +++ b/space/components/issues/peek-overview/issue-reaction.tsx @@ -1,12 +1,20 @@ +import { useParams } from "next/navigation"; import { IssueEmojiReactions, IssueVotes } from "@/components/issues/peek-overview"; -import { useMobxStore } from "@/hooks/store"; +import { useProject } from "@/hooks/store"; + +// type IssueReactionsProps = { +// workspaceSlug: string; +// projectId: string; +// }; export const IssueReactions: React.FC = () => { - const { project: projectStore } = useMobxStore(); + const { workspace_slug: workspaceSlug, project_id: projectId } = useParams(); + + const { canVote, canReact } = useProject(); return (
- {projectStore?.deploySettings?.votes && ( + {canVote && ( <>
@@ -14,9 +22,9 @@ export const IssueReactions: React.FC = () => {
)} - {projectStore?.deploySettings?.reactions && ( + {canReact && (
- +
)}
diff --git a/space/components/issues/peek-overview/issue-vote-reactions.tsx b/space/components/issues/peek-overview/issue-vote-reactions.tsx index 64568f66c..cab6f73ad 100644 --- a/space/components/issues/peek-overview/issue-vote-reactions.tsx +++ b/space/components/issues/peek-overview/issue-vote-reactions.tsx @@ -1,18 +1,17 @@ +"use client"; + import { useState, useEffect } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; import { Tooltip } from "@plane/ui"; // hooks -import { useMobxStore, useUser } from "@/hooks/store"; +import { useIssueDetails, useUser } from "@/hooks/store"; -export const IssueVotes: React.FC = observer(() => { +export const IssueVotes: React.FC = observer((props: any) => { + const { workspaceSlug, projectId } = props; + // states const [isSubmitting, setIsSubmitting] = useState(false); - const router = useRouter(); - - const { workspace_slug, project_slug } = router.query; - - const { issueDetails: issueDetailsStore } = useMobxStore(); + const issueDetailsStore = useIssueDetails(); const { data: user, fetchCurrentUser } = useUser(); const issueId = issueDetailsStore.peekId; @@ -26,16 +25,16 @@ export const IssueVotes: React.FC = observer(() => { const isDownVotedByUser = allDownVotes?.some((vote) => vote.actor === user?.id); const handleVote = async (e: any, voteValue: 1 | -1) => { - if (!workspace_slug || !project_slug || !issueId) return; + if (!workspaceSlug || !projectId || !issueId) return; setIsSubmitting(true); const actionPerformed = votes?.find((vote) => vote.actor === user?.id && vote.vote === voteValue); if (actionPerformed) - await issueDetailsStore.removeIssueVote(workspace_slug.toString(), project_slug.toString(), issueId); + await issueDetailsStore.removeIssueVote(workspaceSlug.toString(), projectId.toString(), issueId); else - await issueDetailsStore.addIssueVote(workspace_slug.toString(), project_slug.toString(), issueId, { + await issueDetailsStore.addIssueVote(workspaceSlug.toString(), projectId.toString(), issueId, { vote: voteValue, }); diff --git a/space/components/issues/peek-overview/layout.tsx b/space/components/issues/peek-overview/layout.tsx index 01183fb2d..aa4163610 100644 --- a/space/components/issues/peek-overview/layout.tsx +++ b/space/components/issues/peek-overview/layout.tsx @@ -1,42 +1,32 @@ +"use client"; + import React, { useEffect, useState } from "react"; - import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; - -// mobx // headless ui import { Dialog, Transition } from "@headlessui/react"; // components import { FullScreenPeekView, SidePeekView } from "@/components/issues/peek-overview"; -// lib -import { useMobxStore } from "@/hooks/store"; +// store +import { useIssue, useIssueDetails } from "@/hooks/store"; -export const IssuePeekOverview: React.FC = observer(() => { +export const IssuePeekOverview: React.FC = observer((props: any) => { + const { workspaceSlug, projectId, peekId, board, priorities, states, labels } = props; // states const [isSidePeekOpen, setIsSidePeekOpen] = useState(false); const [isModalPeekOpen, setIsModalPeekOpen] = useState(false); - // router - const router = useRouter(); - const { workspace_slug, project_slug, peekId, board, priorities, states, labels } = router.query as { - workspace_slug: string; - project_slug: string; - peekId: string; - board: string; - priorities: string; - states: string; - labels: string; - }; // store - const { issueDetails: issueDetailStore, issue: issueStore } = useMobxStore(); + const issueDetailStore = useIssueDetails(); + const issueStore = useIssue(); + const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined; useEffect(() => { - if (workspace_slug && project_slug && peekId && issueStore.issues && issueStore.issues.length > 0) { + if (workspaceSlug && projectId && peekId && issueStore.issues && issueStore.issues.length > 0) { if (!issueDetails) { - issueDetailStore.fetchIssueDetails(workspace_slug.toString(), project_slug.toString(), peekId.toString()); + issueDetailStore.fetchIssueDetails(workspaceSlug.toString(), projectId.toString(), peekId.toString()); } } - }, [workspace_slug, project_slug, issueDetailStore, issueDetails, peekId, issueStore.issues]); + }, [workspaceSlug, projectId, issueDetailStore, issueDetails, peekId, issueStore.issues]); const handleClose = () => { issueDetailStore.setPeekId(null); @@ -45,10 +35,8 @@ export const IssuePeekOverview: React.FC = observer(() => { if (states && states.length > 0) params.states = states; if (priorities && priorities.length > 0) params.priorities = priorities; if (labels && labels.length > 0) params.labels = labels; - - router.replace({ pathname: `/${workspace_slug?.toString()}/${project_slug}`, query: { ...params } }, undefined, { - shallow: true, - }); + // TODO: fix this redirection + // router.push( encodeURI(`/${workspaceSlug?.toString()}/${projectId}`, ) { pathname: `/${workspaceSlug?.toString()}/${projectId}`, query: { ...params } }); }; useEffect(() => { @@ -80,7 +68,12 @@ export const IssuePeekOverview: React.FC = observer(() => { leaveTo="translate-x-full" > - + @@ -114,13 +107,19 @@ export const IssuePeekOverview: React.FC = observer(() => { }`} > {issueDetailStore.peekMode === "modal" && ( - + )} {issueDetailStore.peekMode === "full" && ( )}
diff --git a/space/components/issues/peek-overview/side-peek-view.tsx b/space/components/issues/peek-overview/side-peek-view.tsx index 8a8636edc..baa2b4ee5 100644 --- a/space/components/issues/peek-overview/side-peek-view.tsx +++ b/space/components/issues/peek-overview/side-peek-view.tsx @@ -7,16 +7,18 @@ import { PeekOverviewIssueDetails, PeekOverviewIssueProperties, } from "@/components/issues/peek-overview"; - -import { IIssue } from "types/issue"; +// types +import { IIssue } from "@/types/issue"; type Props = { handleClose: () => void; issueDetails: IIssue | undefined; + workspaceSlug: string; + projectId: string; }; export const SidePeekView: React.FC = observer((props) => { - const { handleClose, issueDetails } = props; + const { handleClose, issueDetails, workspaceSlug, projectId } = props; return (
@@ -37,7 +39,11 @@ export const SidePeekView: React.FC = observer((props) => {
{/* issue activity/comments */}
- +
) : ( diff --git a/space/components/views/auth.tsx b/space/components/views/auth.tsx index cb36a6146..87ff8c366 100644 --- a/space/components/views/auth.tsx +++ b/space/components/views/auth.tsx @@ -1,3 +1,5 @@ +"use client"; + import { observer } from "mobx-react-lite"; import Image from "next/image"; // ui @@ -5,7 +7,7 @@ import { useTheme } from "next-themes"; import useSWR from "swr"; import { Spinner } from "@plane/ui"; // components -import { AuthRoot, UserLoggedIn } from "@/components/accounts"; +import { AuthRoot } from "@/components/accounts"; // hooks import { useUser } from "@/hooks/store"; // images @@ -17,12 +19,15 @@ export const AuthView = observer(() => { // hooks const { resolvedTheme } = useTheme(); // store - const { data: currentUser, fetchCurrentUser, isLoading } = useUser(); + const { fetchCurrentUser, isLoading } = useUser(); // fetching user information const { isLoading: isSWRLoading } = useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), { shouldRetryOnError: false, revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnReconnect: true, + errorRetryCount: 1, }); return ( @@ -33,30 +38,26 @@ export const AuthView = observer(() => {
) : ( <> - {currentUser ? ( - - ) : ( -
-
- Plane background pattern +
+
+ Plane background pattern +
+
+
+
+ Plane Logo + Plane +
-
-
-
- Plane Logo - Plane -
-
-
- -
+
+
- )} +
)} diff --git a/space/components/views/project-details.tsx b/space/components/views/project-details.tsx index ef51a4512..f0fff758c 100644 --- a/space/components/views/project-details.tsx +++ b/space/components/views/project-details.tsx @@ -1,7 +1,10 @@ -import { useEffect } from "react"; +"use client"; + +import { FC, useEffect } from "react"; import { observer } from "mobx-react-lite"; import Image from "next/image"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; // components import { IssueCalendarView } from "@/components/issues/board-views/calendar"; import { IssueGanttView } from "@/components/issues/board-views/gantt"; @@ -11,16 +14,31 @@ import { IssueSpreadsheetView } from "@/components/issues/board-views/spreadshee import { IssueAppliedFilters } from "@/components/issues/filters/applied-filters/root"; import { IssuePeekOverview } from "@/components/issues/peek-overview"; // mobx store -import { useMobxStore, useUser } from "@/hooks/store"; -import { RootStore } from "@/store/root.store"; +import { useIssue, useUser, useProject, useIssueDetails } from "@/hooks/store"; // assets import SomethingWentWrongImage from "public/something-went-wrong.svg"; -export const ProjectDetailsView = observer(() => { - const router = useRouter(); - const { workspace_slug, project_slug, states, labels, priorities, peekId } = router.query; +type ProjectDetailsViewProps = { + workspaceSlug: string; + projectId: string; + peekId: string; +}; - const { issue: issueStore, project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore(); +export const ProjectDetailsView: FC = observer((props) => { + const { workspaceSlug, projectId, peekId } = props; + // router + const params = useParams(); + // store hooks + const { fetchPublicIssues } = useIssue(); + const { activeLayout } = useProject(); + // fetching public issues + useSWR( + workspaceSlug && projectId ? "PROJECT_PUBLIC_ISSUES" : null, + workspaceSlug && projectId ? () => fetchPublicIssues(workspaceSlug, projectId, params) : null + ); + // store hooks + const issueStore = useIssue(); + const issueDetailStore = useIssueDetails(); const { data: currentUser, fetchCurrentUser } = useUser(); useEffect(() => { @@ -30,25 +48,14 @@ export const ProjectDetailsView = observer(() => { }, [currentUser, fetchCurrentUser]); useEffect(() => { - if (workspace_slug && project_slug) { - const params = { - state: states || null, - labels: labels || null, - priority: priorities || null, - }; - issueStore.fetchPublicIssues(workspace_slug?.toString(), project_slug.toString(), params); - } - }, [workspace_slug, project_slug, issueStore, states, labels, priorities]); - - useEffect(() => { - if (peekId && workspace_slug && project_slug) { + if (peekId && workspaceSlug && projectId) { issueDetailStore.setPeekId(peekId.toString()); } - }, [peekId, issueDetailStore, project_slug, workspace_slug]); + }, [peekId, issueDetailStore, projectId, workspaceSlug]); return (
- {workspace_slug && } + {workspaceSlug && } {issueStore?.loader && !issueStore.issues ? (
Loading...
@@ -67,24 +74,24 @@ export const ProjectDetailsView = observer(() => {
) : ( - projectStore?.activeBoard && ( + activeLayout && (
{/* applied filters */} - {projectStore?.activeBoard === "list" && ( + {activeLayout === "list" && (
- +
)} - {projectStore?.activeBoard === "kanban" && ( + {activeLayout === "kanban" && (
- +
)} - {projectStore?.activeBoard === "calendar" && } - {projectStore?.activeBoard === "spreadsheet" && } - {projectStore?.activeBoard === "gantt" && } + {activeLayout === "calendar" && } + {activeLayout === "spreadsheet" && } + {activeLayout === "gantt" && }
) )} diff --git a/space/constants/issue.ts b/space/constants/issue.ts new file mode 100644 index 000000000..147d840fc --- /dev/null +++ b/space/constants/issue.ts @@ -0,0 +1,20 @@ +import { ILayoutDisplayFiltersOptions } from "@/types/issue-filters"; + +export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { + [pageType: string]: { [layoutType: string]: ILayoutDisplayFiltersOptions }; +} = { + issues: { + list: { + filters: ["priority", "state", "labels"], + display_properties: null, + display_filters: null, + extra_options: null, + }, + kanban: { + filters: ["priority", "state", "labels"], + display_properties: null, + display_filters: null, + extra_options: null, + }, + }, +}; diff --git a/space/hooks/store/index.ts b/space/hooks/store/index.ts index 3b7ef07c9..76b6f9315 100644 --- a/space/hooks/store/index.ts +++ b/space/hooks/store/index.ts @@ -1,4 +1,7 @@ -export * from "./user-mobx-provider"; - export * from "./use-instance"; -export * from "./user"; +export * from "./use-project"; +export * from "./use-issue"; +export * from "./use-user"; +export * from "./use-user-profile"; +export * from "./use-issue-details"; +export * from "./use-issue-filter"; diff --git a/space/hooks/store/use-instance.ts b/space/hooks/store/use-instance.ts index 92165e2bb..62aa0baae 100644 --- a/space/hooks/store/use-instance.ts +++ b/space/hooks/store/use-instance.ts @@ -1,10 +1,11 @@ import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/app-providers"; // store -import { StoreContext } from "@/lib/store-context"; import { IInstanceStore } from "@/store/instance.store"; export const useInstance = (): IInstanceStore => { const context = useContext(StoreContext); - if (context === undefined) throw new Error("useInstance must be used within StoreProvider"); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); return context.instance; }; diff --git a/space/hooks/store/use-issue-details.tsx b/space/hooks/store/use-issue-details.tsx new file mode 100644 index 000000000..56ee48627 --- /dev/null +++ b/space/hooks/store/use-issue-details.tsx @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/app-providers"; +// store +import { IIssueDetailStore } from "@/store/issue-detail.store"; + +export const useIssueDetails = (): IIssueDetailStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.issueDetail; +}; diff --git a/space/hooks/store/use-issue-filter.ts b/space/hooks/store/use-issue-filter.ts new file mode 100644 index 000000000..a80d9761b --- /dev/null +++ b/space/hooks/store/use-issue-filter.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/app-providers"; +// store +import { IIssueFilterStore } from "@/store/issue-filters.store"; + +export const useIssueFilter = (): IIssueFilterStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.issueFilter; +}; diff --git a/space/hooks/store/use-issue.ts b/space/hooks/store/use-issue.ts new file mode 100644 index 000000000..8ccd95ac4 --- /dev/null +++ b/space/hooks/store/use-issue.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/app-providers"; +// store +import { IIssueStore } from "@/store/issue.store"; + +export const useIssue = (): IIssueStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.issue; +}; diff --git a/space/hooks/store/use-project.ts b/space/hooks/store/use-project.ts new file mode 100644 index 000000000..0bc7d8f8a --- /dev/null +++ b/space/hooks/store/use-project.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/app-providers"; +// store +import { IProjectStore } from "@/store/project.store"; + +export const useProject = (): IProjectStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.project; +}; diff --git a/space/hooks/store/user/use-user-profile.ts b/space/hooks/store/use-user-profile.ts similarity index 62% rename from space/hooks/store/user/use-user-profile.ts rename to space/hooks/store/use-user-profile.ts index 5b1d8149d..042f16c0d 100644 --- a/space/hooks/store/user/use-user-profile.ts +++ b/space/hooks/store/use-user-profile.ts @@ -1,10 +1,11 @@ import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/app-providers"; // store -import { StoreContext } from "@/lib/store-context"; -import { IProfileStore } from "@/store/user/profile.store"; +import { IProfileStore } from "@/store/profile.store"; export const useUserProfile = (): IProfileStore => { const context = useContext(StoreContext); if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); - return context.user.userProfile; + return context.user.profile; }; diff --git a/space/hooks/store/user/use-user.ts b/space/hooks/store/use-user.ts similarity index 69% rename from space/hooks/store/user/use-user.ts rename to space/hooks/store/use-user.ts index e491d88a2..c935946f8 100644 --- a/space/hooks/store/user/use-user.ts +++ b/space/hooks/store/use-user.ts @@ -1,7 +1,8 @@ import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/app-providers"; // store -import { StoreContext } from "@/lib/store-context"; -import { IUserStore } from "@/store/user"; +import { IUserStore } from "@/store/user.store"; export const useUser = (): IUserStore => { const context = useContext(StoreContext); diff --git a/space/hooks/store/user-mobx-provider.ts b/space/hooks/store/user-mobx-provider.ts deleted file mode 100644 index 4fbc5591f..000000000 --- a/space/hooks/store/user-mobx-provider.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useContext } from "react"; -// store -import { StoreContext } from "@/lib/store-context"; -import { RootStore } from "@/store/root.store"; - -export const useMobxStore = (): RootStore => { - const context = useContext(StoreContext); - if (context === undefined) throw new Error("useMobxStore must be used within StoreProvider"); - return context; -}; diff --git a/space/hooks/store/user/index.ts b/space/hooks/store/user/index.ts deleted file mode 100644 index 72660f100..000000000 --- a/space/hooks/store/user/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./use-user"; -export * from "./use-user-profile"; diff --git a/space/hooks/use-editor-suggestions.tsx b/space/hooks/use-editor-suggestions.tsx deleted file mode 100644 index 937306f7b..000000000 --- a/space/hooks/use-editor-suggestions.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useMobxStore } from "@/hooks/store"; -import { RootStore } from "@/store/root.store"; - -const useEditorSuggestions = () => { - const { mentionsStore }: RootStore = useMobxStore(); - - return { - // mentionSuggestions: mentionsStore.mentionSuggestions, - mentionHighlights: mentionsStore.mentionHighlights, - }; -}; - -export default useEditorSuggestions; diff --git a/space/layouts/project-layout.tsx b/space/layouts/project-layout.tsx deleted file mode 100644 index 0411bcbcc..000000000 --- a/space/layouts/project-layout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { observer } from "mobx-react-lite"; -import Image from "next/image"; -// components -import IssueNavbar from "@/components/issues/navbar"; -// logo -import planeLogo from "public/plane-logo.svg"; - -const ProjectLayout = ({ children }: { children: React.ReactNode }) => ( - -); - -export default observer(ProjectLayout); diff --git a/space/lib/app-providers.tsx b/space/lib/app-providers.tsx new file mode 100644 index 000000000..389d68ab2 --- /dev/null +++ b/space/lib/app-providers.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { ReactNode, createContext } from "react"; +import { ThemeProvider } from "next-themes"; +// store +import { RootStore } from "@/store/root.store"; + +let rootStore = new RootStore(); + +export const StoreContext = createContext(rootStore); + +function initializeStore(initialData = {}) { + const singletonRootStore = rootStore ?? new RootStore(); + // If your page has Next.js data fetching methods that use a Mobx store, it will + // get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details + if (initialData) { + singletonRootStore.hydrate(initialData); + } + // For SSG and SSR always create a new store + if (typeof window === "undefined") return singletonRootStore; + // Create the store once in the client + if (!rootStore) rootStore = singletonRootStore; + return singletonRootStore; +} + +export type AppProviderProps = { + children: ReactNode; + initialState: any; +}; + +export const AppProvider = ({ children, initialState = {} }: AppProviderProps) => { + const store = initializeStore(initialState); + return ( + + {children} + + ); +}; diff --git a/space/lib/index.ts b/space/lib/index.ts deleted file mode 100644 index a10356821..000000000 --- a/space/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const init = {}; diff --git a/space/lib/store-context.tsx b/space/lib/store-context.tsx deleted file mode 100644 index 1eff1ddde..000000000 --- a/space/lib/store-context.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ReactElement, createContext } from "react"; -// mobx store -import { RootStore } from "@/store/root.store"; - -export let rootStore = new RootStore(); - -export const StoreContext = createContext(rootStore); - -const initializeStore = () => { - const singletonRootStore = rootStore ?? new RootStore(); - if (typeof window === "undefined") return singletonRootStore; - if (!rootStore) rootStore = singletonRootStore; - return singletonRootStore; -}; - -export const StoreProvider = ({ children }: { children: ReactElement }) => { - const store = initializeStore(); - return {children}; -}; diff --git a/space/lib/wrappers/auth-wrapper.tsx b/space/lib/wrappers/auth-wrapper.tsx index 3b49e80d5..ba1fae2e5 100644 --- a/space/lib/wrappers/auth-wrapper.tsx +++ b/space/lib/wrappers/auth-wrapper.tsx @@ -1,6 +1,6 @@ import { FC, ReactNode } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; +import { useRouter } from "next/navigation"; import useSWR from "swr"; import { Spinner } from "@plane/ui"; // helpers diff --git a/space/lib/wrappers/index.ts b/space/lib/wrappers/index.ts index 51fab70a6..d40c4c886 100644 --- a/space/lib/wrappers/index.ts +++ b/space/lib/wrappers/index.ts @@ -1,2 +1 @@ -export * from "./instance-wrapper"; export * from "./auth-wrapper"; diff --git a/space/lib/wrappers/instance-wrapper.tsx b/space/lib/wrappers/instance-wrapper.tsx deleted file mode 100644 index 3be92ed05..000000000 --- a/space/lib/wrappers/instance-wrapper.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { FC, ReactNode } from "react"; -import { observer } from "mobx-react-lite"; -import useSWR from "swr"; -// ui -import { Spinner } from "@plane/ui"; -// components -import { InstanceNotReady, InstanceFailureView } from "@/components/instance"; -// hooks -import { useInstance } from "@/hooks/store"; - -type TInstanceWrapper = { - children: ReactNode; -}; - -export const InstanceWrapper: FC = observer((props) => { - const { children } = props; - // hooks - const { isLoading, instance, fetchInstanceInfo } = useInstance(); - - const { isLoading: isSWRLoading, mutate } = useSWR("INSTANCE_INFORMATION", () => fetchInstanceInfo(), { - revalidateOnFocus: false, - revalidateIfStale: false, - revalidateOnReconnect: false, - errorRetryCount: 0, - }); - - if (isSWRLoading || isLoading) - return ( -
- -
- ); - - if (!instance) return ; - - if (instance?.instance?.is_setup_done === false) return ; - - return <>{children}; -}); diff --git a/space/pages/404.tsx b/space/pages/404.tsx deleted file mode 100644 index 4591f71f8..000000000 --- a/space/pages/404.tsx +++ /dev/null @@ -1,42 +0,0 @@ -// next imports -import { observer } from "mobx-react-lite"; -import Image from "next/image"; -// hooks -import { useInstance } from "@/hooks/store"; -// images -import notFoundImage from "public/404.svg"; - -const Custom404Error = observer(() => { - // hooks - const { instance } = useInstance(); - - const redirectionUrl = instance?.config?.app_base_url || "/"; - - return ( -
-
-
-
- 404- Page not found -
-
Oops! Something went wrong.
-
- Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is - temporarily unavailable. -
-
- - -
-
- ); -}); - -export default Custom404Error; diff --git a/space/pages/[workspace_slug]/[project_slug]/index.tsx b/space/pages/[workspace_slug]/[project_slug]/index.tsx deleted file mode 100644 index aaec7672e..000000000 --- a/space/pages/[workspace_slug]/[project_slug]/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import Head from "next/head"; -import { useRouter } from "next/router"; -import useSWR from "swr"; -// components -import { ProjectDetailsView } from "@/components/views"; -// helpers -import { EPageTypes } from "@/helpers/authentication.helper"; -// hooks -import { useMobxStore } from "@/hooks/store"; -// layouts -import ProjectLayout from "@/layouts/project-layout"; -// wrappers -import { AuthWrapper } from "@/lib/wrappers"; - -const WorkspaceProjectPage = (props: any) => { - const SITE_TITLE = props?.project_settings?.project_details?.name || "Plane | Deploy"; - - const router = useRouter(); - const { workspace_slug, project_slug, states, labels, priorities } = router.query; - - const { project: projectStore, issue: issueStore } = useMobxStore(); - - useSWR("REVALIDATE_ALL", () => { - if (workspace_slug && project_slug) { - projectStore.fetchProjectSettings(workspace_slug.toString(), project_slug.toString()); - const params = { - state: states || null, - labels: labels || null, - priority: priorities || null, - }; - issueStore.fetchPublicIssues(workspace_slug.toString(), project_slug.toString(), params); - } - }); - - return ( - - - - {SITE_TITLE} - - - - - ); -}; - -export default WorkspaceProjectPage; diff --git a/space/pages/[workspace_slug]/index.tsx b/space/pages/[workspace_slug]/index.tsx deleted file mode 100644 index 635f3fdf9..000000000 --- a/space/pages/[workspace_slug]/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const WorkspaceProjectPage = () => ( -
Plane Workspace Space
-); - -export default WorkspaceProjectPage; diff --git a/space/pages/_app.tsx b/space/pages/_app.tsx deleted file mode 100644 index 363b61510..000000000 --- a/space/pages/_app.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { AppProps } from "next/app"; -import Head from "next/head"; -import { ThemeProvider } from "next-themes"; -// styles -import "@/styles/globals.css"; -// contexts -import { SITE_NAME, SITE_DESCRIPTION, SITE_URL, TWITTER_USER_NAME, SITE_KEYWORDS, SITE_TITLE } from "@/constants/seo"; -import { ToastContextProvider } from "@/contexts/toast.context"; -// mobx store provider -import { StoreProvider } from "@/lib/store-context"; -// wrappers -import { InstanceWrapper } from "@/lib/wrappers"; - -const prefix = "/spaces/"; - -function MyApp({ Component, pageProps }: AppProps) { - return ( - <> - - {SITE_TITLE} - - - - - - - - - - - - - - - - - - - - - - - - ); -} - -export default MyApp; diff --git a/space/pages/_document.tsx b/space/pages/_document.tsx deleted file mode 100644 index ae4455438..000000000 --- a/space/pages/_document.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import Document, { Html, Head, Main, NextScript } from "next/document"; - -class MyDocument extends Document { - render() { - return ( - - - -
-
- - - - ); - } -} - -export default MyDocument; diff --git a/space/pages/accounts/forgot-password.tsx b/space/pages/accounts/forgot-password.tsx deleted file mode 100644 index 494eae9d3..000000000 --- a/space/pages/accounts/forgot-password.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { NextPage } from "next"; -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { useTheme } from "next-themes"; -import { Controller, useForm } from "react-hook-form"; -// icons -import { CircleCheck } from "lucide-react"; -// ui -import { Button, Input, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; -// helpers -import { EPageTypes } from "@/helpers/authentication.helper"; -import { cn } from "@/helpers/common.helper"; -import { checkEmailValidity } from "@/helpers/string.helper"; -// hooks -import useTimer from "@/hooks/use-timer"; -// wrappers -import { AuthWrapper } from "@/lib/wrappers"; -// services -import { AuthService } from "@/services/authentication.service"; -// images -import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg"; -import PlaneBackgroundPattern from "public/auth/background-pattern.svg"; -import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; - -type TForgotPasswordFormValues = { - email: string; -}; - -const defaultValues: TForgotPasswordFormValues = { - email: "", -}; - -// services -const authService = new AuthService(); - -const ForgotPasswordPage: NextPage = () => { - // router - const router = useRouter(); - const { email } = router.query; - // hooks - const { resolvedTheme } = useTheme(); - // timer - const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); - - // form info - const { - control, - formState: { errors, isSubmitting, isValid }, - handleSubmit, - } = useForm({ - defaultValues: { - ...defaultValues, - email: email?.toString() ?? "", - }, - }); - - const handleForgotPassword = async (formData: TForgotPasswordFormValues) => { - await authService - .sendResetPasswordLink({ - email: formData.email, - }) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Email sent", - message: - "Check your inbox for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder.", - }); - setResendCodeTimer(30); - }) - .catch((err: any) => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }); - }); - }; - - return ( - -
-
- Plane background pattern -
-
-
-
- Plane Logo - Plane -
-
-
-
-
-
-

- Reset your password -

-

- Enter your user account{"'"}s verified email address and we will send you a password reset link. -

-
-
-
- - checkEmailValidity(value) || "Email is invalid", - }} - render={({ field: { value, onChange, ref } }) => ( - 0} - /> - )} - /> - {resendTimerCode > 0 && ( -

- - We sent the reset link to your email address -

- )} -
- - - Back to sign in - -
-
-
-
-
-
-
- ); -}; - -export default ForgotPasswordPage; diff --git a/space/pages/accounts/reset-password.tsx b/space/pages/accounts/reset-password.tsx deleted file mode 100644 index 773acb10e..000000000 --- a/space/pages/accounts/reset-password.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { NextPage } from "next"; -import Image from "next/image"; -import { useRouter } from "next/router"; -// icons -import { useTheme } from "next-themes"; -import { Eye, EyeOff } from "lucide-react"; -// ui -import { Button, Input } from "@plane/ui"; -// components -import { PasswordStrengthMeter } from "@/components/accounts"; -// helpers -import { EPageTypes } from "@/helpers/authentication.helper"; -import { API_BASE_URL } from "@/helpers/common.helper"; -import { getPasswordStrength } from "@/helpers/password.helper"; -// wrappers -import { AuthWrapper } from "@/lib/wrappers"; -// services -import { AuthService } from "@/services/authentication.service"; -// images -import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg"; -import PlaneBackgroundPattern from "public/auth/background-pattern.svg"; -import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; - -type TResetPasswordFormValues = { - email: string; - password: string; - confirm_password?: string; -}; - -const defaultValues: TResetPasswordFormValues = { - email: "", - password: "", -}; - -// services -const authService = new AuthService(); - -const ResetPasswordPage: NextPage = () => { - // router - const router = useRouter(); - const { uidb64, token, email } = router.query; - // states - const [showPassword, setShowPassword] = useState(false); - const [resetFormData, setResetFormData] = useState({ - ...defaultValues, - email: email ? email.toString() : "", - }); - const [csrfToken, setCsrfToken] = useState(undefined); - const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); - // hooks - const { resolvedTheme } = useTheme(); - - useEffect(() => { - if (email && !resetFormData.email) { - setResetFormData((prev) => ({ ...prev, email: email.toString() })); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [email]); - - const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) => - setResetFormData((prev) => ({ ...prev, [key]: value })); - - useEffect(() => { - if (csrfToken === undefined) - authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); - }, [csrfToken]); - - const isButtonDisabled = useMemo( - () => - !!resetFormData.password && - getPasswordStrength(resetFormData.password) >= 3 && - resetFormData.password === resetFormData.confirm_password - ? false - : true, - [resetFormData] - ); - - return ( - -
-
- Plane background pattern -
-
-
-
- Plane Logo - Plane -
-
-
-
-
-
-

- Set new password -

-

Secure your account with a strong password

-
-
- -
- -
- -
-
-
- -
- handleFormChange("password", e.target.value)} - //hasError={Boolean(errors.password)} - placeholder="Enter password" - className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" - minLength={8} - onFocus={() => setIsPasswordInputFocused(true)} - onBlur={() => setIsPasswordInputFocused(false)} - autoFocus - /> - {showPassword ? ( - setShowPassword(false)} - /> - ) : ( - setShowPassword(true)} - /> - )} -
- {isPasswordInputFocused && } -
- {getPasswordStrength(resetFormData.password) >= 3 && ( -
- -
- handleFormChange("confirm_password", e.target.value)} - placeholder="Confirm password" - className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" - /> - {showPassword ? ( - setShowPassword(false)} - /> - ) : ( - setShowPassword(true)} - /> - )} -
- {!!resetFormData.confirm_password && - resetFormData.password !== resetFormData.confirm_password && ( - Passwords don{"'"}t match - )} -
- )} - -
-
-
-
-
-
-
- ); -}; - -export default ResetPasswordPage; diff --git a/space/pages/index.tsx b/space/pages/index.tsx deleted file mode 100644 index 8bba85cca..000000000 --- a/space/pages/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { observer } from "mobx-react-lite"; -import { NextPage } from "next"; -// components -import { AuthView } from "@/components/views"; -// helpers -import { EPageTypes } from "@/helpers/authentication.helper"; -// wrapper -import { AuthWrapper } from "@/lib/wrappers"; - -const Index: NextPage = observer(() => ( - - - -)); - -export default Index; diff --git a/space/pages/onboarding/index.tsx b/space/pages/onboarding/index.tsx deleted file mode 100644 index 98404f577..000000000 --- a/space/pages/onboarding/index.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react-lite"; -import Image from "next/image"; -import { useRouter } from "next/router"; -import { useTheme } from "next-themes"; -// ui -import { Avatar } from "@plane/ui"; -// components -import { OnBoardingForm } from "@/components/accounts/onboarding-form"; -// helpers -import { EPageTypes } from "@/helpers/authentication.helper"; -import { ASSET_PREFIX } from "@/helpers/common.helper"; -// hooks -import { useUser, useUserProfile } from "@/hooks/store"; -// wrappers -import { AuthWrapper } from "@/lib/wrappers"; -// assets -import ProfileSetupDark from "public/onboarding/profile-setup-dark.svg"; -import ProfileSetup from "public/onboarding/profile-setup-light.svg"; - -const OnBoardingPage = observer(() => { - // router - const router = useRouter(); - const { next_path } = router.query; - - // hooks - const { resolvedTheme } = useTheme(); - - const { data: user } = useUser(); - const { data: currentUserProfile, updateUserProfile } = useUserProfile(); - - if (!user) { - router.push("/"); - return <>; - } - - // complete onboarding - const finishOnboarding = async () => { - if (!user) return; - - await updateUserProfile({ - onboarding_step: { - ...currentUserProfile?.onboarding_step, - profile_complete: true, - }, - }).catch(() => { - console.log("Failed to update onboarding status"); - }); - - if (next_path) router.push(next_path.toString()); - router.push("/"); - }; - - return ( - -
-
-
-
-
- Plane Logo -
-
-
-
-
- {user?.avatar && ( - - )} - - {user?.first_name ? `${user?.first_name} ${user?.last_name ?? ""}` : user?.email} - -
-
-
-
-
-
-

Welcome to Plane!

-

- Let’s setup your profile, tell us a bit about yourself. -

-
- -
-
-
-
-
- {user?.avatar && ( - - )} - - {user?.first_name ? `${user?.first_name} ${user?.last_name ?? ""}` : user?.email} - -
-
-
- Profile setup -
-
-
-
- ); -}); - -export default OnBoardingPage; diff --git a/space/pages/project-not-published/index.tsx b/space/pages/project-not-published/index.tsx deleted file mode 100644 index 0bd25dd6e..000000000 --- a/space/pages/project-not-published/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -// next imports -import { observer } from "mobx-react-lite"; -import Image from "next/image"; -// helpers -import { EPageTypes } from "@/helpers/authentication.helper"; -// hooks -import { useInstance } from "@/hooks/store"; -// wrappers -import { AuthWrapper } from "@/lib/wrappers"; -// images -import projectNotPublishedImage from "@/public/project-not-published.svg"; - -const CustomProjectNotPublishedError = observer(() => { - // hooks - const { instance } = useInstance(); - - const redirectionUrl = instance?.config?.app_base_url || "/"; - - return ( - -
-
-
-
- 404- Page not found -
-
- Oops! The page you{`'`}re looking for isn{`'`}t live at the moment. -
-
- If this is your project, login to your workspace to adjust its visibility settings and make it public. -
-
- - -
-
-
- ); -}); - -export default CustomProjectNotPublishedError; diff --git a/space/services/api.service.ts b/space/services/api.service.ts index b6d353ccc..a5fe3e93d 100644 --- a/space/services/api.service.ts +++ b/space/services/api.service.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import axios, { AxiosInstance } from "axios"; // store -import { rootStore } from "@/lib/store-context"; +// import { rootStore } from "@/lib/store-context"; abstract class APIService { protected baseURL: string; @@ -18,14 +18,14 @@ abstract class APIService { } private setupInterceptors() { - this.axiosInstance.interceptors.response.use( - (response) => response, - (error) => { - const store = rootStore; - if (error.response && error.response.status === 401 && store.user.data) store.user.reset(); - return Promise.reject(error); - } - ); + // this.axiosInstance.interceptors.response.use( + // (response) => response, + // (error) => { + // const store = rootStore; + // if (error.response && error.response.status === 401 && store.user.data) store.user.reset(); + // return Promise.reject(error); + // } + // ); } get(url: string, params = {}) { diff --git a/space/services/authentication.service.ts b/space/services/auth.service.ts similarity index 100% rename from space/services/authentication.service.ts rename to space/services/auth.service.ts diff --git a/space/services/issue.service.ts b/space/services/issue.service.ts index b6f2e3be2..aa54e500e 100644 --- a/space/services/issue.service.ts +++ b/space/services/issue.service.ts @@ -1,6 +1,6 @@ +import { API_BASE_URL } from "@/helpers/common.helper"; // services import APIService from "@/services/api.service"; -import { API_BASE_URL } from "@/helpers/common.helper"; class IssueService extends APIService { constructor() { diff --git a/space/services/project.service.ts b/space/services/project.service.ts index 2e173d282..bff754595 100644 --- a/space/services/project.service.ts +++ b/space/services/project.service.ts @@ -1,6 +1,6 @@ +import { API_BASE_URL } from "@/helpers/common.helper"; // services import APIService from "@/services/api.service"; -import { API_BASE_URL } from "@/helpers/common.helper"; class ProjectService extends APIService { constructor() { diff --git a/space/store/instance.store.ts b/space/store/instance.store.ts index db4fd87a9..4a410d851 100644 --- a/space/store/instance.store.ts +++ b/space/store/instance.store.ts @@ -18,15 +18,18 @@ type TError = { export interface IInstanceStore { // issues isLoading: boolean; - instance: IInstance | undefined; + data: IInstance | NonNullable; + config: Record; error: TError | undefined; // action fetchInstanceInfo: () => Promise; + hydrate: (data: Record, config: Record) => void; } export class InstanceStore implements IInstanceStore { isLoading: boolean = true; - instance: IInstance | undefined = undefined; + data: IInstance | Record = {}; + config: Record = {}; error: TError | undefined = undefined; // services instanceService; @@ -35,15 +38,22 @@ export class InstanceStore implements IInstanceStore { makeObservable(this, { // observable isLoading: observable.ref, - instance: observable, + data: observable, + config: observable, error: observable, // actions fetchInstanceInfo: action, + hydrate: action, }); // services this.instanceService = new InstanceService(); } + hydrate = (data: Record, config: Record) => { + this.data = { ...this.data, ...data }; + this.config = { ...this.config, ...config }; + }; + /** * @description fetching instance information */ @@ -51,10 +61,11 @@ export class InstanceStore implements IInstanceStore { try { this.isLoading = true; this.error = undefined; - const instance = await this.instanceService.getInstanceInfo(); + const instanceDetails = await this.instanceService.getInstanceInfo(); runInAction(() => { this.isLoading = false; - this.instance = instance; + this.data = instanceDetails.instance; + this.config = instanceDetails.config; }); } catch (error) { runInAction(() => { diff --git a/space/store/issue_details.ts b/space/store/issue-detail.store.ts similarity index 99% rename from space/store/issue_details.ts rename to space/store/issue-detail.store.ts index 3bbf0e581..b6734640b 100644 --- a/space/store/issue_details.ts +++ b/space/store/issue-detail.store.ts @@ -55,7 +55,7 @@ export interface IIssueDetailStore { removeIssueVote: (workspaceId: string, projectId: string, issueId: string) => Promise; } -class IssueDetailStore implements IIssueDetailStore { +export class IssueDetailStore implements IIssueDetailStore { loader: boolean = false; error: any = null; peekId: string | null = null; @@ -431,5 +431,3 @@ class IssueDetailStore implements IIssueDetailStore { } }; } - -export default IssueDetailStore; diff --git a/space/store/issues/issue-filters.store.ts b/space/store/issue-filters.store.ts similarity index 55% rename from space/store/issues/issue-filters.store.ts rename to space/store/issue-filters.store.ts index e62e57933..d137753be 100644 --- a/space/store/issues/issue-filters.store.ts +++ b/space/store/issue-filters.store.ts @@ -1,15 +1,16 @@ import { action, makeObservable, observable, runInAction, computed } from "mobx"; -// types +// constants +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +// store import { RootStore } from "@/store/root.store"; -import { IIssueFilterOptions, TIssueParams } from "./types"; -import { handleIssueQueryParamsByLayout } from "./helpers"; -import { IssueFilterBaseStore } from "./base-issue-filter.store"; +// types +import { TIssueBoardKeys, IIssueFilterOptions, TIssueParams } from "@/types/issue"; interface IFiltersOptions { filters: IIssueFilterOptions; } -export interface IIssuesFilterStore { +export interface IIssueFilterStore { // observables projectIssueFilters: { [projectId: string]: IFiltersOptions } | undefined; // computed @@ -21,15 +22,13 @@ export interface IIssuesFilterStore { updateFilters: (projectId: string, filters: IIssueFilterOptions) => Promise; } -export class IssuesFilterStore extends IssueFilterBaseStore implements IIssuesFilterStore { +export class IssueFilterStore implements IIssueFilterStore { // observables projectIssueFilters: { [projectId: string]: IFiltersOptions } | undefined = undefined; // root store rootStore; constructor(_rootStore: RootStore) { - super(_rootStore); - makeObservable(this, { // observables projectIssueFilters: observable.ref, @@ -43,35 +42,61 @@ export class IssuesFilterStore extends IssueFilterBaseStore implements IIssuesFi this.rootStore = _rootStore; } + // helper methods + computedFilter = (filters: any, filteredParams: any) => { + const computedFilters: any = {}; + Object.keys(filters).map((key) => { + if (filters[key] != undefined && filteredParams.includes(key)) + computedFilters[key] = + typeof filters[key] === "string" || typeof filters[key] === "boolean" ? filters[key] : filters[key].join(","); + }); + + return computedFilters; + }; + // helpers issueDisplayFilters = (projectId: string) => { if (!projectId) return undefined; return this.projectIssueFilters?.[projectId] || undefined; }; - // actions + handleIssueQueryParamsByLayout = (layout: TIssueBoardKeys | undefined, viewType: "issues"): TIssueParams[] | null => { + const queryParams: TIssueParams[] = []; + if (!layout) return null; + + const layoutOptions = ISSUE_DISPLAY_FILTERS_BY_LAYOUT[viewType][layout]; + + // add filters query params + layoutOptions.filters.forEach((option: any) => { + queryParams.push(option); + }); + + return queryParams; + }; + + // actions updateFilters = async (projectId: string, filters: IIssueFilterOptions) => { try { - let _projectIssueFilters = { ...this.projectIssueFilters }; - if (!_projectIssueFilters) _projectIssueFilters = {}; - if (!_projectIssueFilters[projectId]) _projectIssueFilters[projectId] = { filters: {} }; + let issueFilters = { ...this.projectIssueFilters }; + if (!issueFilters) issueFilters = {}; + if (!issueFilters[projectId]) issueFilters[projectId] = { filters: {} }; - const _filters = { - filters: { ..._projectIssueFilters[projectId].filters }, + const newFilters = { + filters: { ...issueFilters[projectId].filters }, }; - _filters.filters = { ..._filters.filters, ...filters }; + newFilters.filters = { ...newFilters.filters, ...filters }; - _projectIssueFilters[projectId] = { - filters: _filters.filters, + issueFilters[projectId] = { + filters: newFilters.filters, }; runInAction(() => { - this.projectIssueFilters = _projectIssueFilters; + this.projectIssueFilters = issueFilters; }); - return _filters; + return newFilters; } catch (error) { throw error; } @@ -89,7 +114,7 @@ export class IssuesFilterStore extends IssueFilterBaseStore implements IIssuesFi get appliedFilters() { const userFilters = this.issueFilters; - const layout = this.rootStore.project?.activeBoard; + const layout = this.rootStore.project?.activeLayout; if (!userFilters || !layout) return undefined; let filteredRouteParams: any = { @@ -98,7 +123,7 @@ export class IssuesFilterStore extends IssueFilterBaseStore implements IIssuesFi labels: userFilters?.filters?.labels || undefined, }; - const filteredParams = handleIssueQueryParamsByLayout(layout, "issues"); + const filteredParams = this.handleIssueQueryParamsByLayout(layout, "issues"); if (filteredParams) filteredRouteParams = this.computedFilter(filteredRouteParams, filteredParams); return filteredRouteParams; diff --git a/space/store/issue.ts b/space/store/issue.store.ts similarity index 82% rename from space/store/issue.ts rename to space/store/issue.store.ts index c6ed8ee71..bbaf47f79 100644 --- a/space/store/issue.ts +++ b/space/store/issue.store.ts @@ -1,11 +1,11 @@ -import { observable, action, computed, makeObservable, runInAction } from "mobx"; +import { observable, action, makeObservable, runInAction } from "mobx"; // services import IssueService from "@/services/issue.service"; +// types +import { IIssue, IIssueState, IIssueLabel } from "@/types/issue"; // store import { RootStore } from "./root.store"; -// types // import { IssueDetailType, TIssueBoardKeys } from "types/issue"; -import { IIssue, IIssueState, IIssueLabel } from "types/issue"; export interface IIssueStore { loader: boolean; @@ -26,7 +26,7 @@ export interface IIssueStore { getFilteredIssuesByState: (state: string) => IIssue[]; } -class IssueStore implements IIssueStore { +export class IssueStore implements IIssueStore { loader: boolean = false; error: any | null = null; @@ -75,13 +75,13 @@ class IssueStore implements IIssueStore { const response = await this.issueService.getPublicIssues(workspaceSlug, projectId, params); if (response) { - const _states: IIssueState[] = [...response?.states]; - const _labels: IIssueLabel[] = [...response?.labels]; - const _issues: IIssue[] = [...response?.issues]; + const states: IIssueState[] = [...response?.states]; + const labels: IIssueLabel[] = [...response?.labels]; + const issues: IIssue[] = [...response?.issues]; runInAction(() => { - this.states = _states; - this.labels = _labels; - this.issues = _issues; + this.states = states; + this.labels = labels; + this.issues = issues; this.loader = false; }); } @@ -99,5 +99,3 @@ class IssueStore implements IIssueStore { getFilteredIssuesByState = (state_id: string): IIssue[] | [] => this.issues?.filter((issue) => issue.state == state_id) || []; } - -export default IssueStore; diff --git a/space/store/issues/base-issue-filter.store.ts b/space/store/issues/base-issue-filter.store.ts deleted file mode 100644 index d3aaae5f7..000000000 --- a/space/store/issues/base-issue-filter.store.ts +++ /dev/null @@ -1,29 +0,0 @@ -// types -import { RootStore } from "@/store/root.store"; - -export interface IIssueFilterBaseStore { - // helper methods - computedFilter(filters: any, filteredParams: any): any; -} - -export class IssueFilterBaseStore implements IIssueFilterBaseStore { - // root store - rootStore; - - constructor(_rootStore: RootStore) { - // root store - this.rootStore = _rootStore; - } - - // helper methods - computedFilter = (filters: any, filteredParams: any) => { - const computedFilters: any = {}; - Object.keys(filters).map((key) => { - if (filters[key] != undefined && filteredParams.includes(key)) - computedFilters[key] = - typeof filters[key] === "string" || typeof filters[key] === "boolean" ? filters[key] : filters[key].join(","); - }); - - return computedFilters; - }; -} diff --git a/space/store/issues/helpers.ts b/space/store/issues/helpers.ts deleted file mode 100644 index a862ca6e0..000000000 --- a/space/store/issues/helpers.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { TIssueBoardKeys } from "types/issue"; -import { IIssueFilterOptions, TIssueParams } from "./types"; - -export const isNil = (value: any) => { - if (value === undefined || value === null) return true; - - return false; -}; - -export interface ILayoutDisplayFiltersOptions { - filters: (keyof IIssueFilterOptions)[]; - display_properties: boolean | null; - display_filters: null; - extra_options: null; -} - -export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { - [pageType: string]: { [layoutType: string]: ILayoutDisplayFiltersOptions }; -} = { - issues: { - list: { - filters: ["priority", "state", "labels"], - display_properties: null, - display_filters: null, - extra_options: null, - }, - kanban: { - filters: ["priority", "state", "labels"], - display_properties: null, - display_filters: null, - extra_options: null, - }, - }, -}; - -export const handleIssueQueryParamsByLayout = ( - layout: TIssueBoardKeys | undefined, - viewType: "issues" -): TIssueParams[] | null => { - const queryParams: TIssueParams[] = []; - - if (!layout) return null; - - const layoutOptions = ISSUE_DISPLAY_FILTERS_BY_LAYOUT[viewType][layout]; - - // add filters query params - layoutOptions.filters.forEach((option) => { - queryParams.push(option); - }); - - return queryParams; -}; diff --git a/space/store/issues/types.ts b/space/store/issues/types.ts deleted file mode 100644 index d1de0a5ea..000000000 --- a/space/store/issues/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { IIssue } from "types/issue"; - -export type TIssueGroupByOptions = "state" | "priority" | "labels" | null; - -export type TIssueParams = "priority" | "state" | "labels"; - -export interface IIssueFilterOptions { - state?: string[] | null; - labels?: string[] | null; - priority?: string[] | null; -} - -// issues -export interface IGroupedIssues { - [group_id: string]: string[]; -} - -export interface ISubGroupedIssues { - [sub_grouped_id: string]: { - [group_id: string]: string[]; - }; -} - -export type TUnGroupedIssues = string[]; - -export interface IIssueResponse { - [issue_id: string]: IIssue; -} - -export type TLoader = "init-loader" | "mutation" | undefined; - -export interface ViewFlags { - enableQuickAdd: boolean; - enableIssueCreation: boolean; - enableInlineEditing: boolean; -} diff --git a/space/store/user/profile.store.ts b/space/store/profile.store.ts similarity index 100% rename from space/store/user/profile.store.ts rename to space/store/profile.store.ts diff --git a/space/store/project.ts b/space/store/project.store.ts similarity index 57% rename from space/store/project.ts rename to space/store/project.store.ts index dd2c37c00..e382b6792 100644 --- a/space/store/project.ts +++ b/space/store/project.store.ts @@ -11,11 +11,15 @@ export interface IProjectStore { error: any | null; workspace: IWorkspace | null; project: IProject | null; - deploySettings: IProjectSettings | null; - viewOptions: any; - activeBoard: TIssueBoardKeys | null; + settings: IProjectSettings | null; + activeLayout: TIssueBoardKeys; + layoutOptions: Record; + canReact: boolean; + canComment: boolean; + canVote: boolean; fetchProjectSettings: (workspace_slug: string, project_slug: string) => Promise; - setActiveBoard: (value: TIssueBoardKeys) => void; + setActiveLayout: (value: TIssueBoardKeys) => void; + hydrate: (projectSettings: any) => void; } export class ProjectStore implements IProjectStore { @@ -24,9 +28,18 @@ export class ProjectStore implements IProjectStore { // data workspace: IWorkspace | null = null; project: IProject | null = null; - deploySettings: IProjectSettings | null = null; - viewOptions: any = null; - activeBoard: TIssueBoardKeys | null = null; + settings: IProjectSettings | null = null; + activeLayout: TIssueBoardKeys = "list"; + layoutOptions: Record = { + list: true, + kanban: true, + calendar: false, + gantt: false, + spreadsheet: false, + }; + canReact: boolean = false; + canComment: boolean = false; + canVote: boolean = false; // root store rootStore; // service @@ -38,14 +51,18 @@ export class ProjectStore implements IProjectStore { loader: observable, error: observable.ref, // observable - workspace: observable.ref, - project: observable.ref, - deploySettings: observable.ref, - viewOptions: observable.ref, - activeBoard: observable.ref, + workspace: observable, + project: observable, + settings: observable, + layoutOptions: observable, + activeLayout: observable.ref, + canReact: observable.ref, + canComment: observable.ref, + canVote: observable.ref, // actions fetchProjectSettings: action, - setActiveBoard: action, + setActiveLayout: action, + hydrate: action, // computed }); @@ -53,6 +70,20 @@ export class ProjectStore implements IProjectStore { this.projectService = new ProjectService(); } + hydrate = (projectSettings: any) => { + const { workspace_detail, project_details, views, votes, comments, reactions } = projectSettings; + this.workspace = workspace_detail; + this.project = project_details; + this.layoutOptions = views; + this.canComment = comments; + this.canVote = votes; + this.canReact = reactions; + }; + + setActiveLayout = (boardValue: TIssueBoardKeys) => { + this.activeLayout = boardValue; + }; + fetchProjectSettings = async (workspace_slug: string, project_slug: string) => { try { this.loader = true; @@ -68,8 +99,8 @@ export class ProjectStore implements IProjectStore { runInAction(() => { this.project = currentProject; this.workspace = currentWorkspace; - this.viewOptions = currentViewOptions; - this.deploySettings = currentDeploySettings; + this.layoutOptions = currentViewOptions; + this.settings = currentDeploySettings; this.loader = false; }); } @@ -80,8 +111,4 @@ export class ProjectStore implements IProjectStore { return error; } }; - - setActiveBoard = (boardValue: TIssueBoardKeys) => { - this.activeBoard = boardValue; - }; } diff --git a/space/store/root.store.ts b/space/store/root.store.ts index 77fce9613..8b6b10f51 100644 --- a/space/store/root.store.ts +++ b/space/store/root.store.ts @@ -1,13 +1,11 @@ -// mobx lite import { enableStaticRendering } from "mobx-react-lite"; // store imports import { IInstanceStore, InstanceStore } from "@/store/instance.store"; -import { IProjectStore, ProjectStore } from "@/store/project"; -import { IUserStore, UserStore } from "@/store/user"; - -import IssueStore, { IIssueStore } from "./issue"; -import IssueDetailStore, { IIssueDetailStore } from "./issue_details"; -import { IIssuesFilterStore, IssuesFilterStore } from "./issues/issue-filters.store"; +import { IssueDetailStore, IIssueDetailStore } from "@/store/issue-detail.store"; +import { IssueStore, IIssueStore } from "@/store/issue.store"; +import { IProjectStore, ProjectStore } from "@/store/project.store"; +import { IUserStore, UserStore } from "@/store/user.store"; +import { IssueFilterStore, IIssueFilterStore } from "./issue-filters.store"; import { IMentionsStore, MentionsStore } from "./mentions.store"; enableStaticRendering(typeof window === "undefined"); @@ -16,33 +14,36 @@ export class RootStore { instance: IInstanceStore; user: IUserStore; project: IProjectStore; - issue: IIssueStore; - issueDetails: IIssueDetailStore; - mentionsStore: IMentionsStore; - issuesFilter: IIssuesFilterStore; + issueDetail: IIssueDetailStore; + mentionStore: IMentionsStore; + issueFilter: IIssueFilterStore; constructor() { this.instance = new InstanceStore(this); this.user = new UserStore(this); - this.project = new ProjectStore(this); this.issue = new IssueStore(this); - this.issueDetails = new IssueDetailStore(this); - this.mentionsStore = new MentionsStore(this); - this.issuesFilter = new IssuesFilterStore(this); + this.issueDetail = new IssueDetailStore(this); + this.mentionStore = new MentionsStore(this); + this.issueFilter = new IssueFilterStore(this); } - resetOnSignOut = () => { - localStorage.setItem("theme", "system"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hydrate = (data: any) => { + if (!data) return; + this.instance.hydrate(data?.instance || {}, data?.config || {}); + this.user.hydrate(data?.user || {}); + }; + reset = () => { + localStorage.setItem("theme", "system"); this.instance = new InstanceStore(this); this.user = new UserStore(this); this.project = new ProjectStore(this); - this.issue = new IssueStore(this); - this.issueDetails = new IssueDetailStore(this); - this.mentionsStore = new MentionsStore(this); - this.issuesFilter = new IssuesFilterStore(this); + this.issueDetail = new IssueDetailStore(this); + this.mentionStore = new MentionsStore(this); + this.issueFilter = new IssueFilterStore(this); }; } diff --git a/space/store/user/index.ts b/space/store/user.store.ts similarity index 90% rename from space/store/user/index.ts rename to space/store/user.store.ts index e5bfc41c6..2f228b629 100644 --- a/space/store/user/index.ts +++ b/space/store/user.store.ts @@ -3,11 +3,11 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx" // types import { IUser } from "@plane/types"; // services -import { AuthService } from "@/services/authentication.service"; +import { AuthService } from "@/services/auth.service"; import { UserService } from "@/services/user.service"; // stores import { RootStore } from "@/store/root.store"; -import { ProfileStore, IProfileStore } from "@/store/user/profile.store"; +import { ProfileStore, IProfileStore } from "@/store/profile.store"; import { ActorDetail } from "@/types/issue"; type TUserErrorStatus = { @@ -22,12 +22,13 @@ export interface IUserStore { error: TUserErrorStatus | undefined; data: IUser | undefined; // store observables - userProfile: IProfileStore; + profile: IProfileStore; // computed currentActor: ActorDetail; // actions fetchCurrentUser: () => Promise; updateCurrentUser: (data: Partial) => Promise; + hydrate: (data: IUser) => void; reset: () => void; signOut: () => Promise; } @@ -39,14 +40,14 @@ export class UserStore implements IUserStore { error: TUserErrorStatus | undefined = undefined; data: IUser | undefined = undefined; // store observables - userProfile: IProfileStore; + profile: IProfileStore; // service userService: UserService; authService: AuthService; constructor(private store: RootStore) { // stores - this.userProfile = new ProfileStore(store); + this.profile = new ProfileStore(store); // service this.userService = new UserService(); this.authService = new AuthService(); @@ -58,7 +59,7 @@ export class UserStore implements IUserStore { error: observable, // model observables data: observable, - userProfile: observable, + profile: observable, // computed currentActor: computed, // actions @@ -94,7 +95,7 @@ export class UserStore implements IUserStore { }); const user = await this.userService.currentUser(); if (user && user?.id) { - await this.userProfile.fetchUserProfile(); + await this.profile.fetchUserProfile(); runInAction(() => { this.data = user; this.isLoading = false; @@ -153,6 +154,10 @@ export class UserStore implements IUserStore { } }; + hydrate = (data: IUser): void => { + this.data = { ...this.data, ...data }; + }; + /** * @description resets the user store * @returns {void} @@ -163,7 +168,7 @@ export class UserStore implements IUserStore { this.isLoading = false; this.error = undefined; this.data = undefined; - this.userProfile = new ProfileStore(this.store); + this.profile = new ProfileStore(this.store); }); }; diff --git a/space/tsconfig.json b/space/tsconfig.json index 9d3e164be..1305e698f 100644 --- a/space/tsconfig.json +++ b/space/tsconfig.json @@ -1,12 +1,23 @@ { "extends": "tsconfig/nextjs.json", - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "additional.d.ts"], + "plugins": [ + { + "name": "next" + } + ], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "additional.d.ts", ".next/types/**/*.ts"], "exclude": ["node_modules"], "compilerOptions": { "baseUrl": ".", "jsx": "preserve", "paths": { "@/*": ["*"] - } + }, + "plugins": [ + { + "name": "next" + } + ], + "strictNullChecks": true } } diff --git a/space/types/issue-filters.d.ts b/space/types/issue-filters.d.ts new file mode 100644 index 000000000..0ec82f40e --- /dev/null +++ b/space/types/issue-filters.d.ts @@ -0,0 +1,6 @@ +export interface ILayoutDisplayFiltersOptions { + filters: (keyof IIssueFilterOptions)[]; + display_properties: boolean | null; + display_filters: null; + extra_options: null; +} diff --git a/space/types/issue.d.ts b/space/types/issue.d.ts index 4b76c75e8..2b7d3e673 100644 --- a/space/types/issue.d.ts +++ b/space/types/issue.d.ts @@ -170,3 +170,38 @@ export interface IssueDetailType { votes: any[]; }; } + +export type TIssueGroupByOptions = "state" | "priority" | "labels" | null; + +export type TIssueParams = "priority" | "state" | "labels"; + +export interface IIssueFilterOptions { + state?: string[] | null; + labels?: string[] | null; + priority?: string[] | null; +} + +// issues +export interface IGroupedIssues { + [group_id: string]: string[]; +} + +export interface ISubGroupedIssues { + [sub_grouped_id: string]: { + [group_id: string]: string[]; + }; +} + +export type TUnGroupedIssues = string[]; + +export interface IIssueResponse { + [issue_id: string]: IIssue; +} + +export type TLoader = "init-loader" | "mutation" | undefined; + +export interface ViewFlags { + enableQuickAdd: boolean; + enableIssueCreation: boolean; + enableInlineEditing: boolean; +} From ab6f1ef780e0230b6b3528356c153f7eb21cfb8d Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 14 May 2024 19:22:08 +0530 Subject: [PATCH 30/37] [WEB-1298] chore: project cycle revamp (#4454) * chore: cycle endpoint changes * chore: completed cycle icon updated * chore: project cycle list revamp and code refactor * chore: cycle page improvement * chore: added created by in retrieve endopoint * fix: build error * chore: cycle list page disclosure button improvement --------- Co-authored-by: NarayanBavisetti --- apiserver/plane/app/views/cycle/base.py | 3 + .../src/icons/cycle/circle-dot-full-icon.tsx | 6 +- .../cycles/active-cycle/cycle-stats.tsx | 3 +- .../cycles/active-cycle/productivity.tsx | 2 +- .../cycles/active-cycle/progress.tsx | 2 +- web/components/cycles/active-cycle/root.tsx | 84 ++++----- web/components/cycles/cycles-view-header.tsx | 161 ++++++------------ web/components/cycles/cycles-view.tsx | 51 ++---- web/components/cycles/index.ts | 1 + .../cycles/list/cycle-list-group-header.tsx | 39 +++++ .../cycles/list/cycle-list-item-action.tsx | 6 + web/components/cycles/list/index.ts | 1 + web/components/cycles/list/root.tsx | 81 +++++---- web/components/headers/cycles.tsx | 4 +- .../projects/[projectId]/cycles/index.tsx | 74 ++------ 15 files changed, 230 insertions(+), 288 deletions(-) create mode 100644 web/components/cycles/list/cycle-list-group-header.tsx diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 621c1dcb7..bc55d9abb 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -241,6 +241,7 @@ class CycleViewSet(BaseViewSet): "backlog_issues", "assignee_ids", "status", + "created_by" ) if data: @@ -365,6 +366,7 @@ class CycleViewSet(BaseViewSet): "backlog_issues", "assignee_ids", "status", + "created_by", ) return Response(data, status=status.HTTP_200_OK) @@ -564,6 +566,7 @@ class CycleViewSet(BaseViewSet): "backlog_issues", "assignee_ids", "status", + "created_by", ) .first() ) diff --git a/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx b/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx index dd063e79c..f8b528a61 100644 --- a/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx +++ b/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx @@ -3,8 +3,8 @@ import * as React from "react"; import { ISvgIcons } from "../type"; export const CircleDotFullIcon: React.FC = ({ className = "text-current", ...rest }) => ( - - - + + + ); diff --git a/web/components/cycles/active-cycle/cycle-stats.tsx b/web/components/cycles/active-cycle/cycle-stats.tsx index 2eb128763..e31e9af53 100644 --- a/web/components/cycles/active-cycle/cycle-stats.tsx +++ b/web/components/cycles/active-cycle/cycle-stats.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react"; import Link from "next/link"; import useSWR from "swr"; import { CalendarCheck } from "lucide-react"; +// headless ui import { Tab } from "@headlessui/react"; // types import { ICycle, TIssue } from "@plane/types"; @@ -60,7 +61,7 @@ export const ActiveCycleStats: FC = observer((props) => { const cycleIssues = activeCycleIssues ?? []; return ( -
+
= (props) const { cycle } = props; return ( -
+

Issue burndown

diff --git a/web/components/cycles/active-cycle/progress.tsx b/web/components/cycles/active-cycle/progress.tsx index 752f72bcc..6aae998be 100644 --- a/web/components/cycles/active-cycle/progress.tsx +++ b/web/components/cycles/active-cycle/progress.tsx @@ -31,7 +31,7 @@ export const ActiveCycleProgress: FC = (props) => { }; return ( -
+

Progress

diff --git a/web/components/cycles/active-cycle/root.tsx b/web/components/cycles/active-cycle/root.tsx index 60a60abd9..625210fd4 100644 --- a/web/components/cycles/active-cycle/root.tsx +++ b/web/components/cycles/active-cycle/root.tsx @@ -1,20 +1,21 @@ import { observer } from "mobx-react-lite"; import useSWR from "swr"; // ui +import { Disclosure } from "@headlessui/react"; import { Loader } from "@plane/ui"; // components import { - ActiveCycleHeader, ActiveCycleProductivity, ActiveCycleProgress, ActiveCycleStats, - UpcomingCyclesList, + CycleListGroupHeader, + CyclesListItem, } from "@/components/cycles"; import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; // hooks -import { useCycle, useCycleFilter } from "@/hooks/store"; +import { useCycle } from "@/hooks/store"; interface IActiveCycleDetails { workspaceSlug: string; @@ -25,10 +26,7 @@ export const ActiveCycleRoot: React.FC = observer((props) = // props const { workspaceSlug, projectId } = props; // store hooks - const { fetchActiveCycle, currentProjectActiveCycleId, currentProjectUpcomingCycleIds, getActiveCycleById } = - useCycle(); - // cycle filters hook - const { updateDisplayFilters } = useCycleFilter(); + const { fetchActiveCycle, currentProjectActiveCycleId, getActiveCycleById } = useCycle(); // derived values const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; // fetch active cycle details @@ -37,11 +35,6 @@ export const ActiveCycleRoot: React.FC = observer((props) = workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null ); - const handleEmptyStateAction = () => - updateDisplayFilters(projectId, { - active_tab: "all", - }); - // show loader if active cycle is loading if (!activeCycle && isLoading) return ( @@ -50,43 +43,40 @@ export const ActiveCycleRoot: React.FC = observer((props) = ); - if (!activeCycle) { - // show empty state if no active cycle is present - if (currentProjectUpcomingCycleIds?.length === 0) - return ; - // show upcoming cycles list, if present - else - return ( - <> -
-
-
No active cycle
-

- Create new cycles to find them here or check -
- {"'"}All{"'"} cycles tab to see all cycles or{" "} - -

-
-
- - - ); - } - return ( <> -
- -
- - - -
-
- {currentProjectUpcomingCycleIds && } + + {({ open }) => ( + <> + + + + + {!activeCycle ? ( + + ) : ( +
+ {currentProjectActiveCycleId && ( + + )} +
+
+ + + +
+
+
+ )} +
+ + )} +
); }); diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx index 394084a9c..adb0eea4b 100644 --- a/web/components/cycles/cycles-view-header.tsx +++ b/web/components/cycles/cycles-view-header.tsx @@ -2,24 +2,17 @@ import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; // icons import { ListFilter, Search, X } from "lucide-react"; -// headless ui -import { Tab } from "@headlessui/react"; // types import { TCycleFilters } from "@plane/types"; -// ui -import { Tooltip } from "@plane/ui"; // components import { CycleFiltersSelection } from "@/components/cycles"; import { FiltersDropdown } from "@/components/issues"; -// constants -import { CYCLE_TABS_LIST, CYCLE_VIEW_LAYOUTS } from "@/constants/cycle"; // helpers import { cn } from "@/helpers/common.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useCycleFilter } from "@/hooks/store"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; -import { usePlatformOS } from "@/hooks/use-platform-os"; type Props = { projectId: string; @@ -30,23 +23,13 @@ export const CyclesViewHeader: React.FC = observer((props) => { // refs const inputRef = useRef(null); // hooks - const { - currentProjectDisplayFilters, - currentProjectFilters, - searchQuery, - updateDisplayFilters, - updateFilters, - updateSearchQuery, - } = useCycleFilter(); - const { isMobile } = usePlatformOS(); + const { currentProjectFilters, searchQuery, updateFilters, updateSearchQuery } = useCycleFilter(); // states const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false); // outside click detector hook useOutsideClickDetector(inputRef, () => { if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); }); - // derived values - const activeLayout = currentProjectDisplayFilters?.layout ?? "list"; const handleFilters = useCallback( (key: keyof TCycleFilters, value: string | string[]) => { @@ -81,99 +64,57 @@ export const CyclesViewHeader: React.FC = observer((props) => { const isFiltersApplied = calculateTotalFilters(currentProjectFilters ?? {}) !== 0; return ( -
- - {CYCLE_TABS_LIST.map((tab) => ( - - `border-b-2 p-4 text-sm font-medium outline-none ${ - selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent" - }` - } - > - {tab.name} - - ))} - - {currentProjectDisplayFilters?.active_tab !== "active" && ( -
- {!isSearchOpen && ( - - )} -
- - updateSearchQuery(e.target.value)} - onKeyDown={handleInputKeyDown} - /> - {isSearchOpen && ( - - )} -
- } - title="Filters" - placement="bottom-end" - isFiltersApplied={isFiltersApplied} - > - - -
- {CYCLE_VIEW_LAYOUTS.map((layout) => ( - - - - ))} -
-
+
+ {!isSearchOpen && ( + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+ } + title="Filters" + placement="bottom-end" + isFiltersApplied={isFiltersApplied} + > + +
); }); diff --git a/web/components/cycles/cycles-view.tsx b/web/components/cycles/cycles-view.tsx index 2c536d44f..938674f92 100644 --- a/web/components/cycles/cycles-view.tsx +++ b/web/components/cycles/cycles-view.tsx @@ -1,12 +1,10 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; import Image from "next/image"; -// types -import { TCycleLayoutOptions } from "@plane/types"; // components -import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "@/components/cycles"; +import { CyclesList } from "@/components/cycles"; // ui -import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "@/components/ui"; +import { CycleModuleListLayout } from "@/components/ui"; // hooks import { useCycle, useCycleFilter } from "@/hooks/store"; // assets @@ -14,29 +12,23 @@ import AllFiltersImage from "public/empty-state/cycle/all-filters.svg"; import NameFilterImage from "public/empty-state/cycle/name-filter.svg"; export interface ICyclesView { - layout: TCycleLayoutOptions; workspaceSlug: string; projectId: string; - peekCycle: string | undefined; } export const CyclesView: FC = observer((props) => { - const { layout, workspaceSlug, projectId, peekCycle } = props; + const { workspaceSlug, projectId } = props; // store hooks - const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader } = useCycle(); + const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader, currentProjectActiveCycleId } = useCycle(); const { searchQuery } = useCycleFilter(); // derived values - const filteredCycleIds = getFilteredCycleIds(projectId, layout === "gantt"); + const filteredCycleIds = getFilteredCycleIds(projectId, false); const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId); + const filteredUpcomingCycleIds = (filteredCycleIds ?? []).filter( + (cycleId) => cycleId !== currentProjectActiveCycleId + ); - if (loader || !filteredCycleIds) - return ( - <> - {layout === "list" && } - {layout === "board" && } - {layout === "gantt" && } - - ); + if (loader || !filteredCycleIds) return ; if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0) return ( @@ -59,24 +51,13 @@ export const CyclesView: FC = observer((props) => { return ( <> - {layout === "list" && ( - - )} - {layout === "board" && ( - - )} - {layout === "gantt" && } + ); }); diff --git a/web/components/cycles/index.ts b/web/components/cycles/index.ts index b1b718175..12fc7564d 100644 --- a/web/components/cycles/index.ts +++ b/web/components/cycles/index.ts @@ -14,6 +14,7 @@ export * from "./quick-actions"; export * from "./sidebar"; export * from "./transfer-issues-modal"; export * from "./transfer-issues"; +export * from "./cycles-view-header"; // archived cycles export * from "./archived-cycles"; diff --git a/web/components/cycles/list/cycle-list-group-header.tsx b/web/components/cycles/list/cycle-list-group-header.tsx new file mode 100644 index 000000000..469a83d90 --- /dev/null +++ b/web/components/cycles/list/cycle-list-group-header.tsx @@ -0,0 +1,39 @@ +import React, { FC } from "react"; +import { ChevronDown } from "lucide-react"; +// types +import { TCycleGroups } from "@plane/types"; +// icons +import { CycleGroupIcon } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type Props = { + type: TCycleGroups; + title: string; + count?: number; + showCount?: boolean; + isExpanded?: boolean; +}; + +export const CycleListGroupHeader: FC = (props) => { + const { type, title, count, showCount = false, isExpanded = false } = props; + return ( +
+
+
+ +
+ +
+
{title}
+ {showCount &&
{`${count ?? "0"}`}
} +
+
+ +
+ ); +}; diff --git a/web/components/cycles/list/cycle-list-item-action.tsx b/web/components/cycles/list/cycle-list-item-action.tsx index 05db2e2fa..e16636ebe 100644 --- a/web/components/cycles/list/cycle-list-item-action.tsx +++ b/web/components/cycles/list/cycle-list-item-action.tsx @@ -8,6 +8,7 @@ import { Avatar, AvatarGroup, Tooltip, setPromiseToast } from "@plane/ui"; // components import { FavoriteStar } from "@/components/core"; import { CycleQuickActions } from "@/components/cycles"; +import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; // constants import { CYCLE_STATUS } from "@/constants/cycle"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; @@ -104,6 +105,8 @@ export const CycleListItemAction: FC = observer((props) => { }); }; + const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined; + return ( <> {renderDate && ( @@ -130,6 +133,9 @@ export const CycleListItemAction: FC = observer((props) => {
)} + {/* created by */} + {createdByDetails && } +
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? ( diff --git a/web/components/cycles/list/index.ts b/web/components/cycles/list/index.ts index 5eda32861..4eebc5779 100644 --- a/web/components/cycles/list/index.ts +++ b/web/components/cycles/list/index.ts @@ -2,3 +2,4 @@ export * from "./cycles-list-item"; export * from "./cycles-list-map"; export * from "./root"; export * from "./cycle-list-item-action"; +export * from "./cycle-list-group-header"; diff --git a/web/components/cycles/list/root.tsx b/web/components/cycles/list/root.tsx index 34e34acf0..4c4852fce 100644 --- a/web/components/cycles/list/root.tsx +++ b/web/components/cycles/list/root.tsx @@ -1,15 +1,13 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; -import { ChevronRight } from "lucide-react"; import { Disclosure } from "@headlessui/react"; // components import { ListLayout } from "@/components/core/list"; -import { CyclePeekOverview, CyclesListMap } from "@/components/cycles"; -// helpers -import { cn } from "@/helpers/common.helper"; +import { ActiveCycleRoot, CycleListGroupHeader, CyclePeekOverview, CyclesListMap } from "@/components/cycles"; export interface ICyclesList { completedCycleIds: string[]; + upcomingCycleIds?: string[] | undefined; cycleIds: string[]; workspaceSlug: string; projectId: string; @@ -17,39 +15,62 @@ export interface ICyclesList { } export const CyclesList: FC = observer((props) => { - const { completedCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props; + const { completedCycleIds, upcomingCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props; return ( -
-
- - - {completedCycleIds.length !== 0 && ( - - +
+ + {isArchived ? ( + <> + + + ) : ( + <> + + + {upcomingCycleIds && ( + {({ open }) => ( <> - Completed cycles ({completedCycleIds.length}) - + + + + + + )} - - - - + + )} + + + {({ open }) => ( + <> + + + + + + + + )} - )} - - -
+ + )} +
+
); }); diff --git a/web/components/headers/cycles.tsx b/web/components/headers/cycles.tsx index c2be61d82..7b78e27fd 100644 --- a/web/components/headers/cycles.tsx +++ b/web/components/headers/cycles.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/router"; import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common"; +import { CyclesViewHeader } from "@/components/cycles"; import { ProjectLogo } from "@/components/project"; // constants import { EUserProjectRoles } from "@/constants/project"; @@ -54,8 +55,9 @@ export const CyclesHeader: FC = observer(() => {
- {canUserCreateCycle && ( + {canUserCreateCycle && currentProjectDetails && (
+
); - if (loader) - return ( - <> - {cycleLayout === "list" && } - {cycleLayout === "board" && } - {cycleLayout === "gantt" && } - - ); + if (loader) return ; return ( <> @@ -103,21 +84,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { />
) : ( - i.key == cycleTab)} - selectedIndex={CYCLE_TABS_LIST.findIndex((i) => i.key == cycleTab)} - onChange={(i) => { - if (!projectId) return; - const tab = CYCLE_TABS_LIST[i]; - if (!tab) return; - updateDisplayFilters(projectId.toString(), { - active_tab: tab.key, - }); - }} - > - + <> {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && (
{ />
)} - - - - - - - - -
+ + + )}
From 9b7b23f5a214bee38a8f76621a8748819865cde7 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 14 May 2024 20:53:51 +0530 Subject: [PATCH 31/37] [WEB-1309] fix: auth fixes (#4456) * dev: magic link login and email password disable * dev: user account deactivation * dev: change nginx conf routes * feat: changemod space * fix: space app dir fixes * dev: invalidate cache for instances when creating workspace * dev: update email templates for test email * dev: fix build errors * fix: auth fixes and improvement (#4452) * chore: change password api updated and missing password error code added * chore: auth helper updated * chore: disable send code input suggestion * chore: change password function updated * fix: application error on sign in page * chore: change password validation added and enhancement * dev: space base path in web * dev: admin user deactivated * dev: user and instance admin session endpoint * fix: last_workspace_id endpoint updated * fix: magic sign in and email password check added --------- Co-authored-by: pablohashescobar Co-authored-by: sriram veeraghanta Co-authored-by: guru_sainath --- admin/package.json | 1 + apiserver/plane/app/urls/user.py | 6 + apiserver/plane/app/views/__init__.py | 2 +- apiserver/plane/app/views/user/base.py | 20 + apiserver/plane/app/views/workspace/base.py | 9 +- .../plane/authentication/adapter/base.py | 6 + .../plane/authentication/adapter/error.py | 5 + .../provider/credentials/email.py | 19 + .../provider/credentials/magic_code.py | 40 +- .../plane/authentication/views/app/check.py | 189 +++++---- .../plane/authentication/views/app/email.py | 41 +- .../plane/authentication/views/app/magic.py | 40 +- .../views/app/password_management.py | 24 +- .../plane/authentication/views/common.py | 91 +++-- .../plane/authentication/views/space/check.py | 11 + .../plane/authentication/views/space/email.py | 42 +- .../plane/authentication/views/space/magic.py | 48 ++- .../authentication/views/space/signout.py | 3 - .../db/management/commands/test_email.py | 18 +- apiserver/plane/license/api/views/__init__.py | 1 + apiserver/plane/license/api/views/admin.py | 38 ++ apiserver/plane/license/urls.py | 6 + apiserver/templates/emails/test_email.html | 6 + nginx/nginx.conf.dev | 4 +- package.json | 5 +- packages/constants/package.json | 10 + packages/constants/src/auth.ts | 371 ++++++++++++++++++ packages/constants/src/index.ts | 1 + space/components/accounts/auth-forms/root.tsx | 6 +- .../accounts/auth-forms/unique-code.tsx | 1 + space/components/issues/navbar/index.tsx | 28 +- space/helpers/authentication.helper.tsx | 14 +- space/package.json | 1 + .../account/auth-forms/auth-root.tsx | 16 +- .../onboarding/create-workspace.tsx | 7 +- .../project/publish-project/modal.tsx | 2 +- web/components/workspace/sidebar-dropdown.tsx | 8 +- web/helpers/authentication.helper.tsx | 12 + web/package.json | 1 + web/pages/create-workspace.tsx | 6 +- web/pages/invitations/index.tsx | 9 +- web/pages/profile/change-password.tsx | 234 +++++++---- web/services/user.service.ts | 8 +- yarn.lock | 23 +- 44 files changed, 1114 insertions(+), 319 deletions(-) create mode 100644 apiserver/templates/emails/test_email.html create mode 100644 packages/constants/package.json create mode 100644 packages/constants/src/auth.ts create mode 100644 packages/constants/src/index.ts diff --git a/admin/package.json b/admin/package.json index 713a83e57..8d20fd5c4 100644 --- a/admin/package.json +++ b/admin/package.json @@ -14,6 +14,7 @@ "@headlessui/react": "^1.7.19", "@plane/types": "*", "@plane/ui": "*", + "@plane/constants": "*", "@tailwindcss/typography": "^0.5.9", "@types/lodash": "^4.17.0", "autoprefixer": "10.4.14", diff --git a/apiserver/plane/app/urls/user.py b/apiserver/plane/app/urls/user.py index c069467a2..fd18ea87b 100644 --- a/apiserver/plane/app/urls/user.py +++ b/apiserver/plane/app/urls/user.py @@ -11,6 +11,7 @@ from plane.app.views import ( UserEndpoint, UserIssueCompletedGraphEndpoint, UserWorkspaceDashboardEndpoint, + UserSessionEndpoint, ## End User ## Workspaces UserWorkSpacesEndpoint, @@ -29,6 +30,11 @@ urlpatterns = [ ), name="users", ), + path( + "users/session/", + UserSessionEndpoint.as_view(), + name="user-session", + ), path( "users/me/settings/", UserEndpoint.as_view( diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index e652e003e..bf765e719 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -222,4 +222,4 @@ from .error_404 import custom_404_view from .exporter.base import ExportIssuesEndpoint from .notification.base import MarkAllReadNotificationViewSet -from .user.base import AccountEndpoint, ProfileEndpoint +from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index 9fb514d11..805f2a9f7 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -6,6 +6,7 @@ from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response +from rest_framework.permissions import AllowAny # Module imports from plane.app.serializers import ( @@ -180,6 +181,25 @@ class UserEndpoint(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) +class UserSessionEndpoint(BaseAPIView): + + permission_classes = [ + AllowAny, + ] + + def get(self, request): + if request.user.is_authenticated: + user = User.objects.get(pk=request.user.id) + serializer = UserMeSerializer(user) + data = {"is_authenticated": True} + data["user"] = serializer.data + return Response(data, status=status.HTTP_200_OK) + else: + return Response( + {"is_authenticated": False}, status=status.HTTP_200_OK + ) + + class UpdateUserOnBoardedEndpoint(BaseAPIView): @invalidate_cache(path="/api/users/me/") diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index 24a3d7302..830ae1dc2 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -96,6 +96,7 @@ class WorkSpaceViewSet(BaseViewSet): @invalidate_cache(path="/api/workspaces/", user=False) @invalidate_cache(path="/api/users/me/workspaces/") + @invalidate_cache(path="/api/instances/", user=False) def create(self, request): try: serializer = WorkSpaceSerializer(data=request.data) @@ -151,8 +152,12 @@ class WorkSpaceViewSet(BaseViewSet): return super().partial_update(request, *args, **kwargs) @invalidate_cache(path="/api/workspaces/", user=False) - @invalidate_cache(path="/api/users/me/workspaces/", multiple=True, user=False) - @invalidate_cache(path="/api/users/me/settings/", multiple=True, user=False) + @invalidate_cache( + path="/api/users/me/workspaces/", multiple=True, user=False + ) + @invalidate_cache( + path="/api/users/me/settings/", multiple=True, user=False + ) def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py index 97d0bf908..c8e7bd316 100644 --- a/apiserver/plane/authentication/adapter/base.py +++ b/apiserver/plane/authentication/adapter/base.py @@ -100,6 +100,12 @@ class Adapter: user.save() Profile.objects.create(user=user) + if not user.is_active: + raise AuthenticationException( + AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + # Update user details user.last_login_medium = self.provider user.last_active = timezone.now() diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 73809b9ad..aefceb3eb 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -4,6 +4,9 @@ AUTHENTICATION_ERROR_CODES = { "INVALID_EMAIL": 5005, "EMAIL_REQUIRED": 5010, "SIGNUP_DISABLED": 5015, + "MAGIC_LINK_LOGIN_DISABLED": 5016, + "PASSWORD_LOGIN_DISABLED": 5018, + "USER_ACCOUNT_DEACTIVATED": 5019, # Password strength "INVALID_PASSWORD": 5020, "SMTP_NOT_CONFIGURED": 5025, @@ -35,6 +38,7 @@ AUTHENTICATION_ERROR_CODES = { "EXPIRED_PASSWORD_TOKEN": 5130, # Change password "INCORRECT_OLD_PASSWORD": 5135, + "MISSING_PASSWORD": 5138, "INVALID_NEW_PASSWORD": 5140, # set passowrd "PASSWORD_ALREADY_SET": 5145, @@ -47,6 +51,7 @@ AUTHENTICATION_ERROR_CODES = { "ADMIN_AUTHENTICATION_FAILED": 5175, "ADMIN_USER_ALREADY_EXIST": 5180, "ADMIN_USER_DOES_NOT_EXIST": 5185, + "ADMIN_USER_DEACTIVATED": 5190, } diff --git a/apiserver/plane/authentication/provider/credentials/email.py b/apiserver/plane/authentication/provider/credentials/email.py index 430c6db2a..6306399aa 100644 --- a/apiserver/plane/authentication/provider/credentials/email.py +++ b/apiserver/plane/authentication/provider/credentials/email.py @@ -1,3 +1,6 @@ +# Python imports +import os + # Module imports from plane.authentication.adapter.credential import CredentialAdapter from plane.db.models import User @@ -5,6 +8,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) +from plane.license.utils.instance_value import get_configuration_value class EmailProvider(CredentialAdapter): @@ -23,6 +27,21 @@ class EmailProvider(CredentialAdapter): self.code = code self.is_signup = is_signup + (ENABLE_EMAIL_PASSWORD,) = get_configuration_value( + [ + { + "key": "ENABLE_EMAIL_PASSWORD", + "default": os.environ.get("ENABLE_EMAIL_PASSWORD"), + }, + ] + ) + + if ENABLE_EMAIL_PASSWORD == "0": + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["ENABLE_EMAIL_PASSWORD"], + error_message="ENABLE_EMAIL_PASSWORD", + ) + def set_user_data(self): if self.is_signup: # Check if the user already exists diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py index c1207d14d..1496544c7 100644 --- a/apiserver/plane/authentication/provider/credentials/magic_code.py +++ b/apiserver/plane/authentication/provider/credentials/magic_code.py @@ -26,23 +26,20 @@ class MagicCodeProvider(CredentialAdapter): code=None, ): - (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = ( - get_configuration_value( - [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST"), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER"), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD"), - }, - ] - ) + ( + EMAIL_HOST, + ENABLE_MAGIC_LINK_LOGIN, + ) = get_configuration_value( + [ + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST"), + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), + }, + ] ) if not (EMAIL_HOST): @@ -52,6 +49,15 @@ class MagicCodeProvider(CredentialAdapter): payload={"email": str(self.key)}, ) + if ENABLE_MAGIC_LINK_LOGIN == "0": + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "MAGIC_LINK_LOGIN_DISABLED" + ], + error_message="MAGIC_LINK_LOGIN_DISABLED", + payload={"email": str(self.key)}, + ) + super().__init__(request, self.provider) self.key = key self.code = code diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py index 0abefd79f..2b7e4075a 100644 --- a/apiserver/plane/authentication/views/app/check.py +++ b/apiserver/plane/authentication/views/app/check.py @@ -24,62 +24,59 @@ class EmailCheckSignUpEndpoint(APIView): ] def post(self, request): - # Check instance configuration - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INSTANCE_NOT_CONFIGURED" - ], - error_message="INSTANCE_NOT_CONFIGURED", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - email = request.data.get("email", False) - - # Return error if email is not present - if not email: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], - error_message="EMAIL_REQUIRED", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - # Validate email try: + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + email = request.data.get("email", False) + + # Return error if email is not present + if not email: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], + error_message="EMAIL_REQUIRED", + ) + + # Validate email validate_email(email) + + existing_user = User.objects.filter(email=email).first() + + if existing_user: + # check if the account is the deactivated + if not existing_user.is_active: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ALREADY_EXIST" + ], + error_message="USER_ALREADY_EXIST", + ) + return Response( + {"status": True}, + status=status.HTTP_200_OK, + ) except ValidationError: - exc = AuthenticationException( + raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], error_message="INVALID_EMAIL", ) + except AuthenticationException as e: return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, + e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST ) - existing_user = User.objects.filter(email=email).first() - - if existing_user: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], - error_message="USER_ALREADY_EXIST", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - return Response( - {"status": True}, - status=status.HTTP_200_OK, - ) - class EmailCheckSignInEndpoint(APIView): @@ -88,61 +85,59 @@ class EmailCheckSignInEndpoint(APIView): ] def post(self, request): - # Check instance configuration - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INSTANCE_NOT_CONFIGURED" - ], - error_message="INSTANCE_NOT_CONFIGURED", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - email = request.data.get("email", False) - - # Return error if email is not present - if not email: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], - error_message="EMAIL_REQUIRED", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - - # Validate email try: + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + + email = request.data.get("email", False) + + # Return error if email is not present + if not email: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], + error_message="EMAIL_REQUIRED", + ) + + # Validate email validate_email(email) + + existing_user = User.objects.filter(email=email).first() + + # If existing user + if existing_user: + # Raise different exception when user is not active + if not existing_user.is_active: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + + return Response( + { + "status": True, + "is_password_autoset": existing_user.is_password_autoset, + }, + status=status.HTTP_200_OK, + ) + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + ) except ValidationError: - exc = AuthenticationException( + raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], error_message="INVALID_EMAIL", ) + except AuthenticationException as e: return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, + e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST ) - - existing_user = User.objects.filter(email=email).first() - - if existing_user: - return Response( - { - "status": True, - "is_password_autoset": existing_user.is_password_autoset, - }, - status=status.HTTP_200_OK, - ) - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], - error_message="USER_DOES_NOT_EXIST", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index 4093be108..52fcdbc24 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -90,7 +90,9 @@ class SignInAuthEndpoint(View): ) return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): + existing_user = User.objects.filter(email=email).first() + + if not existing_user: exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], error_message="USER_DOES_NOT_EXIST", @@ -105,6 +107,22 @@ class SignInAuthEndpoint(View): ) return HttpResponseRedirect(url) + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + try: provider = EmailProvider( request=request, key=email, code=password, is_signup=False @@ -197,7 +215,26 @@ class SignUpAuthEndpoint(View): ) return HttpResponseRedirect(url) - if User.objects.filter(email=email).exists(): + existing_user = User.objects.filter(email=email).first() + + if existing_user: + # Existing User + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], error_message="USER_ALREADY_EXIST", diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py index 0fa529674..3335eda7d 100644 --- a/apiserver/plane/authentication/views/app/magic.py +++ b/apiserver/plane/authentication/views/app/magic.py @@ -95,7 +95,26 @@ class MagicSignInEndpoint(View): ) return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): + # Existing User + existing_user = User.objects.filter(email=email).first() + + if not existing_user: + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "sign-in?" + urlencode(params), + ) + return HttpResponseRedirect(url) + exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], error_message="USER_DOES_NOT_EXIST", @@ -167,8 +186,25 @@ class MagicSignUpEndpoint(View): "?" + urlencode(params), ) return HttpResponseRedirect(url) + # Existing user + existing_user = User.objects.filter(email=email).first() + if not existing_user: + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) - if User.objects.filter(email=email).exists(): exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], error_message="USER_ALREADY_EXIST", diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py index b26b57760..dd14ceb91 100644 --- a/apiserver/plane/authentication/views/app/password_management.py +++ b/apiserver/plane/authentication/views/app/password_management.py @@ -63,23 +63,13 @@ class ForgotPasswordEndpoint(APIView): status=status.HTTP_400_BAD_REQUEST, ) - (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = ( - get_configuration_value( - [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST"), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER"), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD"), - }, - ] - ) + (EMAIL_HOST,) = get_configuration_value( + [ + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST"), + }, + ] ) if not (EMAIL_HOST): diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py index 16ac058b0..3d17e93f5 100644 --- a/apiserver/plane/authentication/views/common.py +++ b/apiserver/plane/authentication/views/common.py @@ -36,55 +36,60 @@ class CSRFTokenEndpoint(APIView): class ChangePasswordEndpoint(APIView): def post(self, request): - serializer = ChangePasswordSerializer(data=request.data) user = User.objects.get(pk=request.user.id) - if serializer.is_valid(): - if not user.check_password(serializer.data.get("old_password")): - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INCORRECT_OLD_PASSWORD" - ], - error_message="INCORRECT_OLD_PASSWORD", - payload={"error": "Old password is not correct"}, - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) - # check the password score - results = zxcvbn(serializer.data.get("new_password")) - if results["score"] < 3: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INVALID_NEW_PASSWORD" - ], - error_message="INVALID_NEW_PASSWORD", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + old_password = request.data.get("old_password", False) + new_password = request.data.get("new_password", False) - # set_password also hashes the password that the user will get - user.set_password(serializer.data.get("new_password")) - user.is_password_autoset = False - user.save() - user_login(user=user, request=request, is_app=True) - return Response( - {"message": "Password updated successfully"}, - status=status.HTTP_200_OK, + if not old_password or not new_password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"], + error_message="MISSING_PASSWORD", + payload={"error": "Old or new password is missing"}, + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, ) - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", - ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + if not user.check_password(old_password): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INCORRECT_OLD_PASSWORD" + ], + error_message="INCORRECT_OLD_PASSWORD", + payload={"error": "Old password is not correct"}, + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + # check the password score + results = zxcvbn(new_password) + if results["score"] < 3: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INVALID_NEW_PASSWORD" + ], + error_message="INVALID_NEW_PASSWORD", + ) + return Response( + exc.get_error_dict(), + status=status.HTTP_400_BAD_REQUEST, + ) + + # set_password also hashes the password that the user will get + user.set_password(new_password) + user.is_password_autoset = False + user.save() + user_login(user=user, request=request, is_app=True) + return Response( + {"message": "Password updated successfully"}, + status=status.HTTP_200_OK, + ) + class SetUserPasswordEndpoint(APIView): def post(self, request): user = User.objects.get(pk=request.user.id) diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py index 49baad081..83f52e28f 100644 --- a/apiserver/plane/authentication/views/space/check.py +++ b/apiserver/plane/authentication/views/space/check.py @@ -68,6 +68,17 @@ class EmailCheckEndpoint(APIView): # If existing user if existing_user: + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + return Response( + exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST + ) + return Response( { "existing": True, diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py index e11ab29b5..73690e2fa 100644 --- a/apiserver/plane/authentication/views/space/email.py +++ b/apiserver/plane/authentication/views/space/email.py @@ -83,7 +83,10 @@ class SignInAuthSpaceEndpoint(View): ) return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): + # Existing User + existing_user = User.objects.filter(email=email).first() + + if not existing_user: exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], error_message="USER_DOES_NOT_EXIST", @@ -98,6 +101,22 @@ class SignInAuthSpaceEndpoint(View): ) return HttpResponseRedirect(url) + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_space=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + try: provider = EmailProvider( request=request, key=email, code=password, is_signup=False @@ -185,7 +204,26 @@ class SignUpAuthSpaceEndpoint(View): ) return HttpResponseRedirect(url) - if User.objects.filter(email=email).exists(): + # Existing User + existing_user = User.objects.filter(email=email).first() + + if existing_user: + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_space=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], error_message="USER_ALREADY_EXIST", diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py index 45a8e3755..650b8955a 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -90,11 +90,14 @@ class MagicSignInSpaceEndpoint(View): ) return HttpResponseRedirect(url) - if not User.objects.filter(email=email).exists(): - params = { - "error_code": "USER_DOES_NOT_EXIST", - "error_message": "User could not be found with the given email.", - } + existing_user = User.objects.filter(email=email).first() + + if not existing_user: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + error_message="USER_DOES_NOT_EXIST", + ) + params = exc.get_error_dict() if next_path: params["next_path"] = str(next_path) url = urljoin( @@ -103,6 +106,22 @@ class MagicSignInSpaceEndpoint(View): ) return HttpResponseRedirect(url) + # Active User + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_space=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) try: provider = MagicCodeProvider( request=request, key=f"magic_{email}", code=code @@ -155,8 +174,25 @@ class MagicSignUpSpaceEndpoint(View): "?" + urlencode(params), ) return HttpResponseRedirect(url) + # Existing User + existing_user = User.objects.filter(email=email).first() + if existing_user: + if not existing_user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "USER_ACCOUNT_DEACTIVATED" + ], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_space=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) - if User.objects.filter(email=email).exists(): exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], error_message="USER_ALREADY_EXIST", diff --git a/apiserver/plane/authentication/views/space/signout.py b/apiserver/plane/authentication/views/space/signout.py index 655d8b1c8..58bf54b80 100644 --- a/apiserver/plane/authentication/views/space/signout.py +++ b/apiserver/plane/authentication/views/space/signout.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode, urljoin - # Django imports from django.views import View from django.contrib.auth import logout diff --git a/apiserver/plane/db/management/commands/test_email.py b/apiserver/plane/db/management/commands/test_email.py index 63b602518..facea7e9c 100644 --- a/apiserver/plane/db/management/commands/test_email.py +++ b/apiserver/plane/db/management/commands/test_email.py @@ -1,6 +1,9 @@ from django.core.mail import EmailMultiAlternatives, get_connection from django.core.management import BaseCommand, CommandError +from django.template.loader import render_to_string +from django.utils.html import strip_tags +# Module imports from plane.license.utils.instance_value import get_email_configuration @@ -37,10 +40,10 @@ class Command(BaseCommand): timeout=30, ) # Prepare email details - subject = "Email Notification from Plane" - message = ( - "This is a sample email notification sent from Plane application." - ) + subject = "Test email from Plane" + + html_content = render_to_string("emails/test_email.html") + text_content = strip_tags(html_content) self.stdout.write(self.style.SUCCESS("Trying to send test email...")) @@ -48,11 +51,14 @@ class Command(BaseCommand): try: msg = EmailMultiAlternatives( subject=subject, - body=message, + body=text_content, from_email=EMAIL_FROM, - to=[receiver_email], + to=[ + receiver_email, + ], connection=connection, ) + msg.attach_alternative(html_content, "text/html") msg.send() self.stdout.write(self.style.SUCCESS("Email successfully sent")) except Exception as e: diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py index cddaff0eb..b10702b8a 100644 --- a/apiserver/plane/license/api/views/__init__.py +++ b/apiserver/plane/license/api/views/__init__.py @@ -16,4 +16,5 @@ from .admin import ( InstanceAdminSignUpEndpoint, InstanceAdminUserMeEndpoint, InstanceAdminSignOutEndpoint, + InstanceAdminUserSessionEndpoint, ) diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py index 945f4b1b1..0abac6b14 100644 --- a/apiserver/plane/license/api/views/admin.py +++ b/apiserver/plane/license/api/views/admin.py @@ -316,6 +316,20 @@ class InstanceAdminSignInEndpoint(View): # Fetch the user user = User.objects.filter(email=email).first() + # is_active + if not user.is_active: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "ADMIN_USER_DEACTIVATED" + ], + error_message="ADMIN_USER_DEACTIVATED", + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + # Error out if the user is not present if not user: exc = AuthenticationException( @@ -395,6 +409,30 @@ class InstanceAdminUserMeEndpoint(BaseAPIView): ) +class InstanceAdminUserSessionEndpoint(BaseAPIView): + + permission_classes = [ + AllowAny, + ] + + def get(self, request): + if ( + request.user.is_authenticated + and InstanceAdmin.objects.filter(user=request.user).exists() + ): + serializer = InstanceAdminMeSerializer(request.user) + data = {"is_authenticated": True} + data["user"] = serializer.data + return Response( + data, + status=status.HTTP_200_OK, + ) + else: + return Response( + {"is_authenticated": False}, status=status.HTTP_200_OK + ) + + class InstanceAdminSignOutEndpoint(View): permission_classes = [ diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index b95ae74d6..b4f19e52c 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -10,6 +10,7 @@ from plane.license.api.views import ( SignUpScreenVisitedEndpoint, InstanceAdminUserMeEndpoint, InstanceAdminSignOutEndpoint, + InstanceAdminUserSessionEndpoint, ) urlpatterns = [ @@ -28,6 +29,11 @@ urlpatterns = [ InstanceAdminUserMeEndpoint.as_view(), name="instance-admins", ), + path( + "admins/session/", + InstanceAdminUserSessionEndpoint.as_view(), + name="instance-admin-session", + ), path( "admins/sign-out/", InstanceAdminSignOutEndpoint.as_view(), diff --git a/apiserver/templates/emails/test_email.html b/apiserver/templates/emails/test_email.html new file mode 100644 index 000000000..4e4d3d940 --- /dev/null +++ b/apiserver/templates/emails/test_email.html @@ -0,0 +1,6 @@ + + +

This is a test email sent to verify if email configuration is working as expected in your Plane instance.

+ +

Regards,
Team Plane

+ \ No newline at end of file diff --git a/nginx/nginx.conf.dev b/nginx/nginx.conf.dev index c87849dd8..869c2e807 100644 --- a/nginx/nginx.conf.dev +++ b/nginx/nginx.conf.dev @@ -28,7 +28,7 @@ http { } location /god-mode/ { - proxy_pass http://admin:3000/god-mode/; + proxy_pass http://admin:3001/god-mode/; } location /api/ { @@ -45,7 +45,7 @@ http { location /spaces/ { rewrite ^/spaces/?$ /spaces/login break; - proxy_pass http://space:4000/spaces/; + proxy_pass http://space:3002/spaces/; } location /${BUCKET_NAME}/ { diff --git a/package.json b/package.json index 05c1c7f24..a63bbf340 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "packages/tailwind-config-custom", "packages/tsconfig", "packages/ui", - "packages/types" + "packages/types", + "packages/constants" ], "scripts": { "build": "turbo run build", @@ -25,7 +26,7 @@ "devDependencies": { "autoprefixer": "^10.4.15", "eslint-config-custom": "*", - "postcss": "^8.4.38", + "postcss": "^8.4.29", "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", "tailwindcss": "^3.3.3", diff --git a/packages/constants/package.json b/packages/constants/package.json new file mode 100644 index 000000000..be581d08a --- /dev/null +++ b/packages/constants/package.json @@ -0,0 +1,10 @@ +{ + "name": "@plane/constants", + "version": "0.0.1", + "private": true, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*" + } +} diff --git a/packages/constants/src/auth.ts b/packages/constants/src/auth.ts new file mode 100644 index 000000000..86e75f4ff --- /dev/null +++ b/packages/constants/src/auth.ts @@ -0,0 +1,371 @@ +import { ReactNode } from "react"; +import Link from "next/link"; + +export enum EPageTypes { + PUBLIC = "PUBLIC", + NON_AUTHENTICATED = "NON_AUTHENTICATED", + SET_PASSWORD = "SET_PASSWORD", + ONBOARDING = "ONBOARDING", + AUTHENTICATED = "AUTHENTICATED", +} + +export enum EAuthModes { + SIGN_IN = "SIGN_IN", + SIGN_UP = "SIGN_UP", +} + +export enum EAuthSteps { + EMAIL = "EMAIL", + PASSWORD = "PASSWORD", + UNIQUE_CODE = "UNIQUE_CODE", +} + +// TODO: remove this +export enum EErrorAlertType { + BANNER_ALERT = "BANNER_ALERT", + INLINE_FIRST_NAME = "INLINE_FIRST_NAME", + INLINE_EMAIL = "INLINE_EMAIL", + INLINE_PASSWORD = "INLINE_PASSWORD", + INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", +} + +export enum EAuthErrorCodes { + // Global + INSTANCE_NOT_CONFIGURED = "5000", + INVALID_EMAIL = "5005", + EMAIL_REQUIRED = "5010", + SIGNUP_DISABLED = "5015", + MAGIC_LINK_LOGIN_DISABLED = "5017", + PASSWORD_LOGIN_DISABLED = "5019", + SMTP_NOT_CONFIGURED = "5025", + // Password strength + INVALID_PASSWORD = "5020", + // Sign Up + USER_ACCOUNT_DEACTIVATED = "5019", + USER_ALREADY_EXIST = "5030", + AUTHENTICATION_FAILED_SIGN_UP = "5035", + REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040", + INVALID_EMAIL_SIGN_UP = "5045", + INVALID_EMAIL_MAGIC_SIGN_UP = "5050", + MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055", + // Sign In + USER_DOES_NOT_EXIST = "5060", + AUTHENTICATION_FAILED_SIGN_IN = "5065", + REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070", + INVALID_EMAIL_SIGN_IN = "5075", + INVALID_EMAIL_MAGIC_SIGN_IN = "5080", + MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085", + // Both Sign in and Sign up for magic + INVALID_MAGIC_CODE = "5090", + EXPIRED_MAGIC_CODE = "5095", + EMAIL_CODE_ATTEMPT_EXHAUSTED = "5100", + // Oauth + GOOGLE_NOT_CONFIGURED = "5105", + GITHUB_NOT_CONFIGURED = "5110", + GOOGLE_OAUTH_PROVIDER_ERROR = "5115", + GITHUB_OAUTH_PROVIDER_ERROR = "5120", + // Reset Password + INVALID_PASSWORD_TOKEN = "5125", + EXPIRED_PASSWORD_TOKEN = "5130", + // Change password + INCORRECT_OLD_PASSWORD = "5135", + MISSING_PASSWORD= "5138", + INVALID_NEW_PASSWORD = "5140", + // set passowrd + PASSWORD_ALREADY_SET = "5145", + // Admin + ADMIN_ALREADY_EXIST = "5150", + REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155", + INVALID_ADMIN_EMAIL = "5160", + INVALID_ADMIN_PASSWORD = "5165", + REQUIRED_ADMIN_EMAIL_PASSWORD = "5170", + ADMIN_AUTHENTICATION_FAILED = "5175", + ADMIN_USER_ALREADY_EXIST = "5180", + ADMIN_USER_DOES_NOT_EXIST = "5185", +} + +export type TAuthErrorInfo = { + type: EErrorAlertType; + code: EAuthErrorCodes; + title: string; + message: ReactNode; +}; + +const errorCodeMessages: { + [key in EAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; +} = { + // global + [EAuthErrorCodes.INSTANCE_NOT_CONFIGURED]: { + title: `Instance not configured`, + message: () => `Instance not configured. Please contact your administrator.`, + }, + [EAuthErrorCodes.SIGNUP_DISABLED]: { + title: `Sign up disabled`, + message: () => `Sign up disabled. Please contact your administrator.`, + }, + [EAuthErrorCodes.INVALID_PASSWORD]: { + title: `Invalid password`, + message: () => `Invalid password. Please try again.`, + }, + [EAuthErrorCodes.SMTP_NOT_CONFIGURED]: { + title: `SMTP not configured`, + message: () => `SMTP not configured. Please contact your administrator.`, + }, + + // email check in both sign up and sign in + [EAuthErrorCodes.INVALID_EMAIL]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.EMAIL_REQUIRED]: { + title: `Email required`, + message: () => `Email required. Please try again.`, + }, + + // sign up + [EAuthenticationErrorCodes.USER_ALREADY_EXIST]: { + title: `User already exists`, + message: (email = undefined) => ( +
+ Your account is already registered.  + + Sign In + +  now. +
+ ), + }, + [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED]: { + title: `Email and code required`, + message: () => `Email and code required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + + // sign in + [EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: { + title: `User account deactivated`, + message: () =>
Your account is deactivated. Contact support@plane.so.
, + }, + [EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: { + title: `User does not exist`, + message: (email = undefined) => ( +
+ No account found.  + + Create one + +  to get started. +
+ ), + }, + [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED]: { + title: `Email and code required`, + message: () => `Email and code required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + + // Both Sign in and Sign up + [EAuthenticationErrorCodes.INVALID_MAGIC_CODE]: { + title: `Authentication failed`, + message: () => `Invalid magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + + // Oauth + [EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: { + title: `Google not configured`, + message: () => `Google not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED]: { + title: `GitHub not configured`, + message: () => `GitHub not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: { + title: `Google OAuth provider error`, + message: () => `Google OAuth provider error. Please try again.`, + }, + [EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR]: { + title: `GitHub OAuth provider error`, + message: () => `GitHub OAuth provider error. Please try again.`, + }, + + // Reset Password + [EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: { + title: `Invalid password token`, + message: () => `Invalid password token. Please try again.`, + }, + [EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN]: { + title: `Expired password token`, + message: () => `Expired password token. Please try again.`, + }, + + // Change password + + [EAuthenticationErrorCodes.MISSING_PASSWORD]: { + title: `Password required`, + message: () => `Password required. Please try again.`, + }, + [EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD]: { + title: `Incorrect old password`, + message: () => `Incorrect old password. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_NEW_PASSWORD]: { + title: `Invalid new password`, + message: () => `Invalid new password. Please try again.`, + }, + + // set password + [EAuthenticationErrorCodes.PASSWORD_ALREADY_SET]: { + title: `Password already set`, + message: () => `Password already set. Please try again.`, + }, + + // admin + [EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: { + title: `Admin already exists`, + message: () => `Admin already exists. Please try again.`, + }, + [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { + title: `Email, password and first name required`, + message: () => `Email, password and first name required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: { + title: `Invalid admin email`, + message: () => `Invalid admin email. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: { + title: `Invalid admin password`, + message: () => `Invalid admin password. Please try again.`, + }, + [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: { + title: `Admin user already exists`, + message: () => ( +
+ Admin user already exists.  + + Sign In + +  now. +
+ ), + }, + [EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { + title: `Admin user does not exist`, + message: () => ( +
+ Admin user does not exist.  + + Sign In + +  now. +
+ ), + }, +}; + +export const authErrorHandler = ( + errorCode: EAuthenticationErrorCodes, + email?: string | undefined +): TAuthErrorInfo | undefined => { + const bannerAlertErrorCodes = [ + EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED, + EAuthenticationErrorCodes.INVALID_EMAIL, + EAuthenticationErrorCodes.EMAIL_REQUIRED, + EAuthenticationErrorCodes.SIGNUP_DISABLED, + EAuthenticationErrorCodes.INVALID_PASSWORD, + EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED, + EAuthenticationErrorCodes.USER_ALREADY_EXIST, + EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP, + EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP, + EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP, + EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED, + EAuthenticationErrorCodes.USER_DOES_NOT_EXIST, + EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN, + EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, + EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED, + EAuthenticationErrorCodes.INVALID_MAGIC_CODE, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED, + EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED, + EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED, + EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR, + EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR, + EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN, + EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN, + EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD, + EAuthenticationErrorCodes.INVALID_NEW_PASSWORD, + EAuthenticationErrorCodes.PASSWORD_ALREADY_SET, + EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST, + EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, + EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL, + EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD, + EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD, + EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED, + EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST, + EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + ]; + + if (bannerAlertErrorCodes.includes(errorCode)) + return { + type: EErrorAlertType.BANNER_ALERT, + code: errorCode, + title: errorCodeMessages[errorCode]?.title || "Error", + message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", + }; + + return undefined; +}; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts new file mode 100644 index 000000000..97ccf7649 --- /dev/null +++ b/packages/constants/src/index.ts @@ -0,0 +1 @@ +export * from "./auth"; diff --git a/space/components/accounts/auth-forms/root.tsx b/space/components/accounts/auth-forms/root.tsx index 273a438bf..e51cac92f 100644 --- a/space/components/accounts/auth-forms/root.tsx +++ b/space/components/accounts/auth-forms/root.tsx @@ -65,6 +65,8 @@ export const AuthRoot = observer(() => { const { config: instanceConfig } = useInstance(); // derived values const isSmtpConfigured = instanceConfig?.is_smtp_configured; + const isMagicLoginEnabled = instanceConfig?.is_magic_login_enabled; + const isEmailPasswordEnabled = instanceConfig?.is_email_password_enabled; const { header, subHeader } = getHeaderSubHeader(authMode); @@ -87,9 +89,9 @@ export const AuthRoot = observer(() => { setAuthStep(EAuthSteps.PASSWORD); } else { // Else if SMTP is configured, move to unique code sign-in/ sign-up. - if (isSmtpConfigured) { + if (isSmtpConfigured && isMagicLoginEnabled) { setAuthStep(EAuthSteps.UNIQUE_CODE); - } else { + } else if (isEmailPasswordEnabled) { // Else show error message if SMTP is not configured and password is not set. if (res.existing) { setAuthMode(null); diff --git a/space/components/accounts/auth-forms/unique-code.tsx b/space/components/accounts/auth-forms/unique-code.tsx index fc04938bc..785d921a6 100644 --- a/space/components/accounts/auth-forms/unique-code.tsx +++ b/space/components/accounts/auth-forms/unique-code.tsx @@ -151,6 +151,7 @@ export const UniqueCodeForm: React.FC = (props) => { placeholder="gets-sets-flys" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" autoFocus + autoComplete="off" />

diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx index 97cd0c500..0139ae9ad 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/index.tsx @@ -40,7 +40,7 @@ const IssueNavbar: FC = observer((props) => { useEffect(() => { if (workspaceSlug && projectId && settings) { const viewsAcceptable: string[] = []; - let currentBoard: TIssueBoardKeys | null = null; + const currentBoard: TIssueBoardKeys | null = null; if (settings?.views?.list) viewsAcceptable.push("list"); if (settings?.views?.kanban) viewsAcceptable.push("kanban"); @@ -48,19 +48,19 @@ const IssueNavbar: FC = observer((props) => { if (settings?.views?.gantt) viewsAcceptable.push("gantt"); if (settings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet"); - if (board) { - if (viewsAcceptable.includes(board.toString())) { - currentBoard = board.toString() as TIssueBoardKeys; - } else { - if (viewsAcceptable && viewsAcceptable.length > 0) { - currentBoard = viewsAcceptable[0] as TIssueBoardKeys; - } - } - } else { - if (viewsAcceptable && viewsAcceptable.length > 0) { - currentBoard = viewsAcceptable[0] as TIssueBoardKeys; - } - } + // if (board) { + // if (viewsAcceptable.includes(board.toString())) { + // currentBoard = board.toString() as TIssueBoardKeys; + // } else { + // if (viewsAcceptable && viewsAcceptable.length > 0) { + // currentBoard = viewsAcceptable[0] as TIssueBoardKeys; + // } + // } + // } else { + // if (viewsAcceptable && viewsAcceptable.length > 0) { + // currentBoard = viewsAcceptable[0] as TIssueBoardKeys; + // } + // } if (currentBoard) { if (activeLayout === null || activeLayout !== currentBoard) { diff --git a/space/helpers/authentication.helper.tsx b/space/helpers/authentication.helper.tsx index 579fe0496..2626b35c2 100644 --- a/space/helpers/authentication.helper.tsx +++ b/space/helpers/authentication.helper.tsx @@ -39,12 +39,12 @@ export enum EAuthenticationErrorCodes { INVALID_EMAIL = "5012", EMAIL_REQUIRED = "5013", // Sign Up + USER_ACCOUNT_DEACTIVATED = "5019", USER_ALREADY_EXIST = "5003", REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5015", AUTHENTICATION_FAILED_SIGN_UP = "5006", INVALID_EMAIL_SIGN_UP = "5017", MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5023", - INVALID_EMAIL_MAGIC_SIGN_UP = "5019", // Sign In USER_DOES_NOT_EXIST = "5004", REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5014", @@ -140,12 +140,14 @@ const errorCodeMessages: { title: `Email and code required`, message: () => `Email and code required. Please try again.`, }, - [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, // sign in + + [EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: { + title: `User account deactivated`, + message: () =>

Your account is deactivated. Please reach out to support@plane.so
, + }, + [EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: { title: `User does not exist`, message: (email = undefined) => ( @@ -250,7 +252,6 @@ export const authErrorHandler = ( EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP, EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP, EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED, - EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP, EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN, EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN, EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, @@ -273,6 +274,7 @@ export const authErrorHandler = ( EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP, EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN, EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED, + EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED, ]; if (toastAlertErrorCodes.includes(errorCode)) diff --git a/space/package.json b/space/package.json index a10d190d2..48fe001b7 100644 --- a/space/package.json +++ b/space/package.json @@ -22,6 +22,7 @@ "@plane/rich-text-editor": "*", "@plane/types": "*", "@plane/ui": "*", + "@plane/constants": "*", "@sentry/nextjs": "^7.108.0", "axios": "^1.3.4", "clsx": "^2.0.0", diff --git a/web/components/account/auth-forms/auth-root.tsx b/web/components/account/auth-forms/auth-root.tsx index 4c479af28..0c0eb4755 100644 --- a/web/components/account/auth-forms/auth-root.tsx +++ b/web/components/account/auth-forms/auth-root.tsx @@ -68,6 +68,10 @@ export const AuthRoot: FC = observer((props) => { } }, [error_code, authMode]); + const isSMTPConfigured = instance?.config?.is_smtp_configured || false; + const isMagicLoginEnabled = instance?.config?.is_magic_login_enabled || false; + const isEmailPasswordEnabled = instance?.config?.is_email_password_enabled || false; + // submit handler- email verification const handleEmailVerification = async (data: IEmailCheckData) => { setEmail(data.email); @@ -80,19 +84,21 @@ export const AuthRoot: FC = observer((props) => { if (response.is_password_autoset) { setAuthStep(EAuthSteps.UNIQUE_CODE); generateEmailUniqueCode(data.email); - } else { + } else if (isEmailPasswordEnabled) { setIsPasswordAutoset(false); setAuthStep(EAuthSteps.PASSWORD); } } else { - if (instance && instance?.config?.is_smtp_configured) { + if (isSMTPConfigured && isMagicLoginEnabled) { setAuthStep(EAuthSteps.UNIQUE_CODE); generateEmailUniqueCode(data.email); - } else setAuthStep(EAuthSteps.PASSWORD); + } else if (isEmailPasswordEnabled) { + setAuthStep(EAuthSteps.PASSWORD); + } } }) .catch((error) => { - const errorhandler = authErrorHandler(error?.error_code.toString(), data?.email || undefined); + const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined); if (errorhandler?.type) setErrorInfo(errorhandler); }); }; @@ -113,8 +119,6 @@ export const AuthRoot: FC = observer((props) => { const isOAuthEnabled = (instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled)) || false; - const isSMTPConfigured = (instance?.config && instance?.config?.is_smtp_configured) || false; - return (
= (props) => { const [slugError, setSlugError] = useState(false); const [invalidSlug, setInvalidSlug] = useState(false); // store hooks - const { updateCurrentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); + const { createWorkspace, fetchWorkspaces, workspaces } = useWorkspace(); const { captureWorkspaceEvent } = useEventTracker(); // form info @@ -111,7 +112,7 @@ export const CreateWorkspace: React.FC = (props) => { }; await stepChange(payload); - await updateCurrentUser({ + await updateUserProfile({ last_workspace_id: firstWorkspace?.id, }); }; diff --git a/web/components/project/publish-project/modal.tsx b/web/components/project/publish-project/modal.tsx index 6f2f68fad..c9534781e 100644 --- a/web/components/project/publish-project/modal.tsx +++ b/web/components/project/publish-project/modal.tsx @@ -62,7 +62,7 @@ export const PublishProjectModal: React.FC = observer((props) => { const [isUpdateRequired, setIsUpdateRequired] = useState(false); // const plane_deploy_url = instance?.config?.space_base_url || ""; - const SPACE_URL = SPACE_BASE_URL + SPACE_BASE_PATH; + const SPACE_URL = (SPACE_BASE_URL === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH; // router const router = useRouter(); diff --git a/web/components/workspace/sidebar-dropdown.tsx b/web/components/workspace/sidebar-dropdown.tsx index fd26d1248..348eb9832 100644 --- a/web/components/workspace/sidebar-dropdown.tsx +++ b/web/components/workspace/sidebar-dropdown.tsx @@ -12,7 +12,7 @@ import { IWorkspace } from "@plane/types"; // plane ui import { Avatar, Loader, TOAST_TYPE, setToast } from "@plane/ui"; // hooks -import { useAppTheme, useUser, useWorkspace } from "@/hooks/store"; +import { useAppTheme, useUser, useUserProfile, useWorkspace } from "@/hooks/store"; import { WorkspaceLogo } from "./logo"; // types // Static Data @@ -56,10 +56,12 @@ export const WorkspaceSidebarDropdown = observer(() => { const { sidebarCollapsed, toggleSidebar } = useAppTheme(); const { data: currentUser } = useUser(); const { - updateCurrentUser, + // updateCurrentUser, // isUserInstanceAdmin, signOut, } = useUser(); + const { updateUserProfile } = useUserProfile(); + const isUserInstanceAdmin = false; const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace(); // popper-js refs @@ -78,7 +80,7 @@ export const WorkspaceSidebarDropdown = observer(() => { ], }); const handleWorkspaceNavigation = (workspace: IWorkspace) => - updateCurrentUser({ + updateUserProfile({ last_workspace_id: workspace?.id, }); const handleSignOut = async () => { diff --git a/web/helpers/authentication.helper.tsx b/web/helpers/authentication.helper.tsx index 88a40dd57..0f331394b 100644 --- a/web/helpers/authentication.helper.tsx +++ b/web/helpers/authentication.helper.tsx @@ -45,6 +45,7 @@ export enum EAuthenticationErrorCodes { INVALID_EMAIL_MAGIC_SIGN_UP = "5050", MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055", // Sign In + USER_ACCOUNT_DEACTIVATED = "5019", USER_DOES_NOT_EXIST = "5060", AUTHENTICATION_FAILED_SIGN_IN = "5065", REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070", @@ -65,6 +66,7 @@ export enum EAuthenticationErrorCodes { EXPIRED_PASSWORD_TOKEN = "5130", // Change password INCORRECT_OLD_PASSWORD = "5135", + MISSING_PASSWORD = "5138", INVALID_NEW_PASSWORD = "5140", // set passowrd PASSWORD_ALREADY_SET = "5145", @@ -155,6 +157,11 @@ const errorCodeMessages: { }, // sign in + [EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: { + title: `User account deactivated`, + message: () =>
Your account is deactivated. Contact support@plane.so.
, + }, + [EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: { title: `User does not exist`, message: (email = undefined) => ( @@ -234,6 +241,10 @@ const errorCodeMessages: { }, // Change password + [EAuthenticationErrorCodes.MISSING_PASSWORD]: { + title: `Password required`, + message: () => `Password required. Please try again.`, + }, [EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD]: { title: `Incorrect old password`, message: () => `Incorrect old password. Please try again.`, @@ -343,6 +354,7 @@ export const authErrorHandler = ( EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED, EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST, EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED, ]; if (bannerAlertErrorCodes.includes(errorCode)) diff --git a/web/package.json b/web/package.json index 06efa5a84..c3de0c238 100644 --- a/web/package.json +++ b/web/package.json @@ -30,6 +30,7 @@ "@plane/rich-text-editor": "*", "@plane/types": "*", "@plane/ui": "*", + "@plane/constants": "*", "@popperjs/core": "^2.11.8", "@sentry/nextjs": "^7.108.0", "axios": "^1.1.3", diff --git a/web/pages/create-workspace.tsx b/web/pages/create-workspace.tsx index 174d7f344..9365716ba 100644 --- a/web/pages/create-workspace.tsx +++ b/web/pages/create-workspace.tsx @@ -8,7 +8,7 @@ import { IWorkspace } from "@plane/types"; // hooks import { PageHead } from "@/components/core"; import { CreateWorkspaceForm } from "@/components/workspace"; -import { useUser } from "@/hooks/store"; +import { useUser, useUserProfile } from "@/hooks/store"; // layouts import DefaultLayout from "@/layouts/default-layout"; // components @@ -25,7 +25,7 @@ const CreateWorkspacePage: NextPageWithLayout = observer(() => { const router = useRouter(); // store hooks const { data: currentUser } = useUser(); - const { updateCurrentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); // states const [defaultValues, setDefaultValues] = useState({ name: "", @@ -36,7 +36,7 @@ const CreateWorkspacePage: NextPageWithLayout = observer(() => { const { theme } = useTheme(); const onSubmit = async (workspace: IWorkspace) => { - await updateCurrentUser({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); + await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); }; return ( diff --git a/web/pages/invitations/index.tsx b/web/pages/invitations/index.tsx index 67465f34b..7e14d2d74 100644 --- a/web/pages/invitations/index.tsx +++ b/web/pages/invitations/index.tsx @@ -21,14 +21,13 @@ import { ROLE } from "@/constants/workspace"; // helpers import { truncateText } from "@/helpers/string.helper"; import { getUserRole } from "@/helpers/user.helper"; -import { useEventTracker, useUser, useWorkspace } from "@/hooks/store"; +import { useEventTracker, useUser, useUserProfile, useWorkspace } from "@/hooks/store"; import DefaultLayout from "@/layouts/default-layout"; // types import { NextPageWithLayout } from "@/lib/types"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; // services -import { UserService } from "@/services/user.service"; import { WorkspaceService } from "@/services/workspace.service"; // images import emptyInvitation from "public/empty-state/invitation.svg"; @@ -36,7 +35,6 @@ import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-l import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg"; const workspaceService = new WorkspaceService(); -const userService = new UserService(); const UserInvitationsPage: NextPageWithLayout = observer(() => { // states @@ -47,6 +45,8 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { // store hooks const { captureEvent, joinWorkspaceMetricGroup } = useEventTracker(); const { data: currentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); + const { fetchWorkspaces } = useWorkspace(); // next-themes const { theme } = useTheme(); @@ -95,8 +95,7 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { state: "SUCCESS", element: "Workspace invitations page", }); - userService - .updateUser({ last_workspace_id: redirectWorkspace?.id }) + updateUserProfile({ last_workspace_id: redirectWorkspace?.id }) .then(() => { setIsJoiningWorkspaces(false); fetchWorkspaces().then(() => { diff --git a/web/pages/profile/change-password.tsx b/web/pages/profile/change-password.tsx index ae4e7be70..b8979ea14 100644 --- a/web/pages/profile/change-password.tsx +++ b/web/pages/profile/change-password.tsx @@ -1,12 +1,15 @@ -import { ReactElement, useEffect, useState } from "react"; +import { ReactElement, useEffect, useMemo, useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; +import { Eye, EyeOff } from "lucide-react"; // ui import { Button, Input, Spinner, TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; // components +import { PasswordStrengthMeter } from "@/components/account"; import { PageHead } from "@/components/core"; import { SidebarHamburgerToggle } from "@/components/core/sidebar"; +import { getPasswordStrength } from "@/helpers/password.helper"; // hooks import { useAppTheme, useUser } from "@/hooks/store"; // layout @@ -14,6 +17,7 @@ import { ProfileSettingsLayout } from "@/layouts/settings-layout"; // types import { NextPageWithLayout } from "@/lib/types"; // services +import { AuthService } from "@/services/auth.service"; import { UserService } from "@/services/user.service"; export interface FormValues { @@ -29,9 +33,18 @@ const defaultValues: FormValues = { }; export const userService = new UserService(); +export const authService = new AuthService(); const ChangePasswordPage: NextPageWithLayout = observer(() => { + const [csrfToken, setCsrfToken] = useState(undefined); const [isPageLoading, setIsPageLoading] = useState(true); + const [showPassword, setShowPassword] = useState({ + oldPassword: false, + password: false, + retypePassword: false, + }); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + // router const router = useRouter(); // store hooks @@ -42,32 +55,54 @@ const ChangePasswordPage: NextPageWithLayout = observer(() => { const { control, handleSubmit, + watch, formState: { errors, isSubmitting }, + reset, } = useForm({ defaultValues }); + const oldPassword = watch("old_password"); + const password = watch("new_password"); + const retypePassword = watch("confirm_password"); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + const handleChangePassword = async (formData: FormValues) => { - if (formData.new_password !== formData.confirm_password) { + try { + if (!csrfToken) throw new Error("csrf token not found"); + const changePasswordPromise = userService + .changePassword(csrfToken, { + old_password: formData.old_password, + new_password: formData.new_password, + }) + .then(() => { + reset(defaultValues); + }); + setPromiseToast(changePasswordPromise, { + loading: "Changing password...", + success: { + title: "Success!", + message: () => "Password changed successfully.", + }, + error: { + title: "Error!", + message: () => "Something went wrong. Please try again 1.", + }, + }); + } catch (err: any) { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "The new password and the confirm password don't match.", + message: err?.error ?? "Something went wrong. Please try again 2.", }); - return; } - const changePasswordPromise = userService.changePassword(formData); - setPromiseToast(changePasswordPromise, { - loading: "Changing password...", - success: { - title: "Success!", - message: () => "Password changed successfully.", - }, - error: { - title: "Error!", - message: () => "Something went wrong. Please try again.", - }, - }); }; + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + useEffect(() => { if (!currentUser) return; @@ -75,6 +110,25 @@ const ChangePasswordPage: NextPageWithLayout = observer(() => { else setIsPageLoading(false); }, [currentUser, router]); + const isButtonDisabled = useMemo( + () => + !isSubmitting && + !!oldPassword && + !!password && + !!retypePassword && + getPasswordStrength(password) >= 3 && + password === retypePassword && + password !== oldPassword + ? false + : true, + + [isSubmitting, oldPassword, password, retypePassword] + ); + + const passwordSupport = password.length > 0 && (getPasswordStrength(password) < 3 || isPasswordInputFocused) && ( + + ); + if (isPageLoading) return (
@@ -93,82 +147,126 @@ const ChangePasswordPage: NextPageWithLayout = observer(() => { onSubmit={handleSubmit(handleChangePassword)} className="mx-auto md:mt-16 mt-10 flex h-full w-full flex-col gap-8 px-4 md:px-8 pb-8 lg:w-3/5" > + +

Change password

Current password

- ( - + ( + + )} + /> + {showPassword?.oldPassword ? ( + handleShowPassword("oldPassword")} + /> + ) : ( + handleShowPassword("oldPassword")} /> )} - /> +
+ {errors.old_password && {errors.old_password.message}}

New password

- ( - + ( + setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + /> + )} + /> + {showPassword?.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} /> )} - /> - {errors.new_password && {errors.new_password.message}} +
+ {passwordSupport}

Confirm password

- ( - + ( + + )} + /> + {showPassword?.retypePassword ? ( + handleShowPassword("retypePassword")} + /> + ) : ( + handleShowPassword("retypePassword")} /> )} - /> - {errors.confirm_password && ( - {errors.confirm_password.message} +
+ {!!retypePassword && password !== retypePassword && ( + Passwords don{"'"}t match )}
-
diff --git a/web/services/user.service.ts b/web/services/user.service.ts index b9f9ce0fa..fa8a06542 100644 --- a/web/services/user.service.ts +++ b/web/services/user.service.ts @@ -143,8 +143,12 @@ export class UserService extends APIService { }); } - async changePassword(data: { old_password: string; new_password: string; confirm_password: string }): Promise { - return this.post(`/api/users/me/change-password/`, data) + async changePassword(token: string, data: { old_password: string; new_password: string }): Promise { + return this.post(`/auth/change-password/`, data, { + headers: { + "X-CSRFTOKEN": token, + }, + }) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/yarn.lock b/yarn.lock index ecd59ae01..d1b91f8cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6917,7 +6917,7 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.4.23, postcss@^8.4.38: +postcss@^8.4.23, postcss@^8.4.29, postcss@^8.4.38: version "8.4.38" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== @@ -7945,8 +7945,16 @@ streamx@^2.15.0, streamx@^2.16.1: optionalDependencies: bare-events "^2.2.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8026,7 +8034,14 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== From bcc4524f7f60301e8a0c04dfa143e04f5cddc07c Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Tue, 14 May 2024 20:54:49 +0530 Subject: [PATCH 32/37] fix: admin auth related fixes --- admin/.eslintrc.js | 40 +++++++- admin/app/ai/components/index.ts | 1 - .../ai-config-form.tsx => form.tsx} | 4 +- admin/app/ai/layout.tsx | 18 +--- admin/app/ai/page.tsx | 5 +- .../authentication-method-card.tsx | 0 .../authentication/components/common/index.ts | 1 - .../components/email-config-switch.tsx | 4 +- .../root.tsx => components/github-config.tsx} | 12 +-- .../root.tsx => components/google-config.tsx} | 12 +-- admin/app/authentication/components/index.ts | 4 +- .../components/password-config-switch.tsx | 4 +- .../authentication/github/components/index.ts | 2 - .../github-config-form.tsx => form.tsx} | 35 ++++--- admin/app/authentication/github/page.tsx | 11 ++- .../authentication/google/components/index.ts | 2 - .../google-config-form.tsx => form.tsx} | 33 ++++--- admin/app/authentication/google/page.tsx | 7 +- admin/app/authentication/layout.tsx | 18 +--- admin/app/authentication/page.tsx | 19 ++-- .../email/components/email-config-form.tsx | 4 +- admin/app/email/layout.tsx | 2 +- admin/app/email/page.tsx | 4 +- admin/app/error.tsx | 9 ++ admin/app/general/components/index.ts | 1 - .../general-config-form.tsx => form.tsx} | 14 ++- admin/app/general/layout.tsx | 27 ++---- admin/app/general/page.tsx | 19 ++-- admin/app/image/components/index.ts | 1 - .../image-config-form.tsx => form.tsx} | 4 +- admin/app/image/layout.tsx | 14 +-- admin/app/image/page.tsx | 5 +- admin/app/layout.tsx | 88 ++++++++++-------- admin/app/page.tsx | 33 ++----- admin/app/setup/components/sign-up-form.tsx | 38 +++++--- admin/app/setup/layout.tsx | 22 +++-- admin/app/setup/page.tsx | 25 ++--- .../components/admin-sidebar/help-section.tsx | 6 +- admin/components/admin-sidebar/root.tsx | 2 +- .../admin-sidebar/sidebar-dropdown.tsx | 4 +- .../sidebar-menu-hamburger-toogle.tsx | 2 +- .../components/admin-sidebar/sidebar-menu.tsx | 4 +- admin/components/auth-header.tsx | 4 +- admin/components/common/controller-input.tsx | 4 +- admin/components/common/copy-field.tsx | 4 +- .../common/password-strength-meter.tsx | 2 +- admin/components/instance/index.ts | 1 + .../instance/instance-failure-view.tsx | 42 +++++++++ .../instance/instance-not-ready.tsx | 2 +- admin/components/login/sign-in-form.tsx | 16 ++-- admin/hooks/store/use-instance.tsx | 2 +- admin/hooks/store/use-theme.tsx | 2 +- admin/hooks/store/use-user.tsx | 2 +- admin/layouts/admin-layout.tsx | 39 +++++++- admin/layouts/default-layout.tsx | 7 +- admin/layouts/index.ts | 2 - admin/lib/app-providers.tsx | 44 +++++++++ admin/lib/store-context.tsx | 21 ----- admin/lib/wrappers/app-wrapper.tsx | 6 +- admin/lib/wrappers/auth-wrapper.tsx | 4 +- admin/lib/wrappers/instance-wrapper.tsx | 12 +-- .../public/instance/instance-failure-dark.svg | 40 ++++++++ admin/public/instance/instance-failure.svg | 40 ++++++++ .../instance/plane-instance-not-ready.webp | Bin 0 -> 45894 bytes admin/public/instance/plane-takeoff.png | Bin 0 -> 47818 bytes admin/services/api.service.ts | 18 ++-- admin/services/auth.service.ts | 4 +- admin/services/instance.service.ts | 1 + admin/services/user.service.ts | 14 ++- admin/store/instance.store.ts | 24 +++-- admin/store/{root-store.ts => root.store.ts} | 9 +- admin/store/theme.store.ts | 7 +- admin/store/user.store.ts | 9 +- apiserver/plane/license/api/views/admin.py | 1 + apiserver/plane/settings/local.py | 4 +- apiserver/plane/utils/exception_logger.py | 1 + space/app/layout.tsx | 10 +- space/services/api.service.ts | 4 +- space/services/instance.service.ts | 2 +- web/helpers/common.helper.ts | 2 +- 80 files changed, 606 insertions(+), 360 deletions(-) delete mode 100644 admin/app/ai/components/index.ts rename admin/app/ai/{components/ai-config-form.tsx => form.tsx} (97%) rename admin/app/authentication/components/{common => }/authentication-method-card.tsx (100%) delete mode 100644 admin/app/authentication/components/common/index.ts rename admin/app/authentication/{github/components/root.tsx => components/github-config.tsx} (97%) rename admin/app/authentication/{google/components/root.tsx => components/google-config.tsx} (97%) delete mode 100644 admin/app/authentication/github/components/index.ts rename admin/app/authentication/github/{components/github-config-form.tsx => form.tsx} (90%) delete mode 100644 admin/app/authentication/google/components/index.ts rename admin/app/authentication/google/{components/google-config-form.tsx => form.tsx} (91%) create mode 100644 admin/app/error.tsx delete mode 100644 admin/app/general/components/index.ts rename admin/app/general/{components/general-config-form.tsx => form.tsx} (94%) delete mode 100644 admin/app/image/components/index.ts rename admin/app/image/{components/image-config-form.tsx => form.tsx} (97%) create mode 100644 admin/components/instance/instance-failure-view.tsx delete mode 100644 admin/layouts/index.ts create mode 100644 admin/lib/app-providers.tsx delete mode 100644 admin/lib/store-context.tsx create mode 100644 admin/public/instance/instance-failure-dark.svg create mode 100644 admin/public/instance/instance-failure.svg create mode 100644 admin/public/instance/plane-instance-not-ready.webp create mode 100644 admin/public/instance/plane-takeoff.png rename admin/store/{root-store.ts => root.store.ts} (81%) diff --git a/admin/.eslintrc.js b/admin/.eslintrc.js index 2278de30f..a82c768a0 100644 --- a/admin/.eslintrc.js +++ b/admin/.eslintrc.js @@ -10,5 +10,43 @@ module.exports = { }, }, }, - rules: {} + rules: { + "import/order": [ + "error", + { + groups: ["builtin", "external", "internal", "parent", "sibling",], + pathGroups: [ + { + pattern: "react", + group: "external", + position: "before", + }, + { + pattern: "lucide-react", + group: "external", + position: "after", + }, + { + pattern: "@headlessui/**", + group: "external", + position: "after", + }, + { + pattern: "@plane/**", + group: "external", + position: "after", + }, + { + pattern: "@/**", + group: "internal", + } + ], + pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + }, + ], + }, } \ No newline at end of file diff --git a/admin/app/ai/components/index.ts b/admin/app/ai/components/index.ts deleted file mode 100644 index 2a7609401..000000000 --- a/admin/app/ai/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./ai-config-form"; diff --git a/admin/app/ai/components/ai-config-form.tsx b/admin/app/ai/form.tsx similarity index 97% rename from admin/app/ai/components/ai-config-form.tsx rename to admin/app/ai/form.tsx index fda70611c..cec5c0748 100644 --- a/admin/app/ai/components/ai-config-form.tsx +++ b/admin/app/ai/form.tsx @@ -1,10 +1,10 @@ import { FC } from "react"; import { useForm } from "react-hook-form"; import { Lightbulb } from "lucide-react"; -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // components -import { ControllerInput, TControllerInputFormField } from "components/common"; +import { ControllerInput, TControllerInputFormField } from "@/components/common"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/admin/app/ai/layout.tsx b/admin/app/ai/layout.tsx index 61df8ebd9..e3fd537bc 100644 --- a/admin/app/ai/layout.tsx +++ b/admin/app/ai/layout.tsx @@ -2,20 +2,8 @@ import { ReactNode } from "react"; // layouts -import { AdminLayout } from "@/layouts"; -// lib -import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +import { AdminLayout } from "@/layouts/admin-layout"; -interface AILayoutProps { - children: ReactNode; +export default function AILayout({ children }: { children: ReactNode }) { + return {children}; } - -const AILayout = ({ children }: AILayoutProps) => ( - - - {children} - - -); - -export default AILayout; diff --git a/admin/app/ai/page.tsx b/admin/app/ai/page.tsx index 5d002ca55..0979bbabe 100644 --- a/admin/app/ai/page.tsx +++ b/admin/app/ai/page.tsx @@ -1,13 +1,14 @@ "use client"; -import useSWR from "swr"; import { observer } from "mobx-react-lite"; +import useSWR from "swr"; import { Loader } from "@plane/ui"; // components import { PageHeader } from "@/components/core"; -import { InstanceAIForm } from "./components"; // hooks import { useInstance } from "@/hooks/store"; +// components +import { InstanceAIForm } from "./form"; const InstanceAIPage = observer(() => { // store diff --git a/admin/app/authentication/components/common/authentication-method-card.tsx b/admin/app/authentication/components/authentication-method-card.tsx similarity index 100% rename from admin/app/authentication/components/common/authentication-method-card.tsx rename to admin/app/authentication/components/authentication-method-card.tsx diff --git a/admin/app/authentication/components/common/index.ts b/admin/app/authentication/components/common/index.ts deleted file mode 100644 index 0f5713cdb..000000000 --- a/admin/app/authentication/components/common/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./authentication-method-card"; diff --git a/admin/app/authentication/components/email-config-switch.tsx b/admin/app/authentication/components/email-config-switch.tsx index 9c23901fe..0f09cf82c 100644 --- a/admin/app/authentication/components/email-config-switch.tsx +++ b/admin/app/authentication/components/email-config-switch.tsx @@ -3,11 +3,11 @@ import React from "react"; import { observer } from "mobx-react-lite"; // hooks +import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; import { useInstance } from "@/hooks/store"; // ui -import { ToggleSwitch } from "@plane/ui"; // types -import { TInstanceAuthenticationMethodKeys } from "@plane/types"; type Props = { disabled: boolean; diff --git a/admin/app/authentication/github/components/root.tsx b/admin/app/authentication/components/github-config.tsx similarity index 97% rename from admin/app/authentication/github/components/root.tsx rename to admin/app/authentication/components/github-config.tsx index d820bc8a2..27264d460 100644 --- a/admin/app/authentication/github/components/root.tsx +++ b/admin/app/authentication/components/github-config.tsx @@ -1,18 +1,18 @@ "use client"; import React from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; -// hooks -import { useInstance } from "@/hooks/store"; -// ui -import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +import Link from "next/link"; // icons import { Settings2 } from "lucide-react"; // types import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +// ui +import { ToggleSwitch, getButtonStyling } from "@plane/ui"; // helpers -import { cn } from "helpers/common.helper"; +import { cn } from "@/helpers/common.helper"; +// hooks +import { useInstance } from "@/hooks/store"; type Props = { disabled: boolean; diff --git a/admin/app/authentication/google/components/root.tsx b/admin/app/authentication/components/google-config.tsx similarity index 97% rename from admin/app/authentication/google/components/root.tsx rename to admin/app/authentication/components/google-config.tsx index 5432c95bf..9fde70dac 100644 --- a/admin/app/authentication/google/components/root.tsx +++ b/admin/app/authentication/components/google-config.tsx @@ -1,18 +1,18 @@ "use client"; import React from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; -// hooks -import { useInstance } from "@/hooks/store"; -// ui -import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +import Link from "next/link"; // icons import { Settings2 } from "lucide-react"; // types import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +// ui +import { ToggleSwitch, getButtonStyling } from "@plane/ui"; // helpers -import { cn } from "helpers/common.helper"; +import { cn } from "@/helpers/common.helper"; +// hooks +import { useInstance } from "@/hooks/store"; type Props = { disabled: boolean; diff --git a/admin/app/authentication/components/index.ts b/admin/app/authentication/components/index.ts index 59760f00d..d76d61f57 100644 --- a/admin/app/authentication/components/index.ts +++ b/admin/app/authentication/components/index.ts @@ -1,3 +1,5 @@ -export * from "./common"; export * from "./email-config-switch"; export * from "./password-config-switch"; +export * from "./authentication-method-card"; +export * from "./github-config"; +export * from "./google-config"; diff --git a/admin/app/authentication/components/password-config-switch.tsx b/admin/app/authentication/components/password-config-switch.tsx index ce33cd329..901cce862 100644 --- a/admin/app/authentication/components/password-config-switch.tsx +++ b/admin/app/authentication/components/password-config-switch.tsx @@ -3,11 +3,11 @@ import React from "react"; import { observer } from "mobx-react-lite"; // hooks +import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; import { useInstance } from "@/hooks/store"; // ui -import { ToggleSwitch } from "@plane/ui"; // types -import { TInstanceAuthenticationMethodKeys } from "@plane/types"; type Props = { disabled: boolean; diff --git a/admin/app/authentication/github/components/index.ts b/admin/app/authentication/github/components/index.ts deleted file mode 100644 index e9e36e988..000000000 --- a/admin/app/authentication/github/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./root"; -export * from "./github-config-form"; \ No newline at end of file diff --git a/admin/app/authentication/github/components/github-config-form.tsx b/admin/app/authentication/github/form.tsx similarity index 90% rename from admin/app/authentication/github/components/github-config-form.tsx rename to admin/app/authentication/github/form.tsx index 43d220575..75c76e7a5 100644 --- a/admin/app/authentication/github/components/github-config-form.tsx +++ b/admin/app/authentication/github/form.tsx @@ -1,8 +1,9 @@ import { FC, useState } from "react"; -import { useForm } from "react-hook-form"; +import isEmpty from "lodash/isEmpty"; import Link from "next/link"; -// hooks -import { useInstance } from "@/hooks/store"; +import { useForm } from "react-hook-form"; +// types +import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; // ui import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; // components @@ -12,12 +13,11 @@ import { CopyField, TControllerInputFormField, TCopyField, -} from "components/common"; -// types -import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; +} from "@/components/common"; // helpers -import { API_BASE_URL, cn } from "helpers/common.helper"; -import isEmpty from "lodash/isEmpty"; +import { API_BASE_URL, cn } from "@/helpers/common.helper"; +// hooks +import { useInstance } from "@/hooks/store"; type Props = { config: IFormattedInstanceConfiguration; @@ -46,7 +46,7 @@ export const InstanceGithubConfigForm: FC = (props) => { const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; - const githubFormFields: TControllerInputFormField[] = [ + const GITHUB_FORM_FIELDS: TControllerInputFormField[] = [ { key: "GITHUB_CLIENT_ID", type: "text", @@ -55,6 +55,7 @@ export const InstanceGithubConfigForm: FC = (props) => { <> You will get this from your{" "}
= (props) => { <> Your client secret is also found in your{" "} = (props) => { }, ]; - const githubCopyFields: TCopyField[] = [ + const GITHUB_SERVICE_FIELD: TCopyField[] = [ { key: "Origin_URL", label: "Origin URL", @@ -100,6 +102,7 @@ export const InstanceGithubConfigForm: FC = (props) => { <> We will auto-generate this. Paste this into the Authorized origin URL field{" "} = (props) => { <> We will auto-generate this. Paste this into your Authorized Callback URI field{" "} = (props) => { const payload: Partial = { ...formData }; await updateInstanceConfigurations(payload) - .then(() => { + .then((response = []) => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Success", message: "Github Configuration Settings updated successfully", }); - reset(); + reset({ + GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, + GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value, + }); }) .catch((err) => console.error(err)); }; @@ -163,7 +170,7 @@ export const InstanceGithubConfigForm: FC = (props) => {
Configuration
- {githubFormFields.map((field) => ( + {GITHUB_FORM_FIELDS.map((field) => ( = (props) => {
Service provider details
- {githubCopyFields.map((field) => ( + {GITHUB_SERVICE_FIELD.map((field) => ( ))}
diff --git a/admin/app/authentication/github/page.tsx b/admin/app/authentication/github/page.tsx index 893762d47..b65b99205 100644 --- a/admin/app/authentication/github/page.tsx +++ b/admin/app/authentication/github/page.tsx @@ -1,22 +1,23 @@ "use client"; import { useState } from "react"; +import { observer } from "mobx-react-lite"; import Image from "next/image"; import { useTheme } from "next-themes"; -import { observer } from "mobx-react-lite"; import useSWR from "swr"; import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; // components import { PageHeader } from "@/components/core"; -import { AuthenticationMethodCard } from "../components"; -import { InstanceGithubConfigForm } from "./components"; -// hooks -import { useInstance } from "@/hooks/store"; // helpers import { resolveGeneralTheme } from "@/helpers/common.helper"; +// hooks +import { useInstance } from "@/hooks/store"; // icons import githubLightModeImage from "@/public/logos/github-black.png"; import githubDarkModeImage from "@/public/logos/github-white.png"; +// local components +import { AuthenticationMethodCard } from "../components"; +import { InstanceGithubConfigForm } from "./form"; const InstanceGithubAuthenticationPage = observer(() => { // store diff --git a/admin/app/authentication/google/components/index.ts b/admin/app/authentication/google/components/index.ts deleted file mode 100644 index d0d37f305..000000000 --- a/admin/app/authentication/google/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./root"; -export * from "./google-config-form"; \ No newline at end of file diff --git a/admin/app/authentication/google/components/google-config-form.tsx b/admin/app/authentication/google/form.tsx similarity index 91% rename from admin/app/authentication/google/components/google-config-form.tsx rename to admin/app/authentication/google/form.tsx index f07021694..fd2e7c73c 100644 --- a/admin/app/authentication/google/components/google-config-form.tsx +++ b/admin/app/authentication/google/form.tsx @@ -1,8 +1,9 @@ import { FC, useState } from "react"; -import { useForm } from "react-hook-form"; +import isEmpty from "lodash/isEmpty"; import Link from "next/link"; -// hooks -import { useInstance } from "@/hooks/store"; +import { useForm } from "react-hook-form"; +// types +import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; // ui import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; // components @@ -12,12 +13,11 @@ import { CopyField, TControllerInputFormField, TCopyField, -} from "components/common"; -// types -import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; +} from "@/components/common"; // helpers -import { API_BASE_URL, cn } from "helpers/common.helper"; -import isEmpty from "lodash/isEmpty"; +import { API_BASE_URL, cn } from "@/helpers/common.helper"; +// hooks +import { useInstance } from "@/hooks/store"; type Props = { config: IFormattedInstanceConfiguration; @@ -46,7 +46,7 @@ export const InstanceGoogleConfigForm: FC = (props) => { const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; - const googleFormFields: TControllerInputFormField[] = [ + const GOOGLE_FORM_FIELDS: TControllerInputFormField[] = [ { key: "GOOGLE_CLIENT_ID", type: "text", @@ -55,6 +55,7 @@ export const InstanceGoogleConfigForm: FC = (props) => { <> Your client ID lives in your Google API Console.{" "}
= (props) => { <> Your client secret should also be in your Google API Console.{" "} = (props) => { }, ]; - const googleCopyFeilds: TCopyField[] = [ + const GOOGLE_SERVICE_DETAILS: TCopyField[] = [ { key: "Origin_URL", label: "Origin URL", @@ -134,13 +136,16 @@ export const InstanceGoogleConfigForm: FC = (props) => { const payload: Partial = { ...formData }; await updateInstanceConfigurations(payload) - .then(() => { + .then((response = []) => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Success", message: "Google Configuration Settings updated successfully", }); - reset(); + reset({ + GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value, + GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value, + }); }) .catch((err) => console.error(err)); }; @@ -163,7 +168,7 @@ export const InstanceGoogleConfigForm: FC = (props) => {
Configuration
- {googleFormFields.map((field) => ( + {GOOGLE_FORM_FIELDS.map((field) => ( = (props) => {
Service provider details
- {googleCopyFeilds.map((field) => ( + {GOOGLE_SERVICE_DETAILS.map((field) => ( ))}
diff --git a/admin/app/authentication/google/page.tsx b/admin/app/authentication/google/page.tsx index 9b02842af..05117dbe3 100644 --- a/admin/app/authentication/google/page.tsx +++ b/admin/app/authentication/google/page.tsx @@ -1,18 +1,19 @@ "use client"; import { useState } from "react"; -import Image from "next/image"; import { observer } from "mobx-react-lite"; +import Image from "next/image"; import useSWR from "swr"; import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; // components import { PageHeader } from "@/components/core"; -import { AuthenticationMethodCard } from "../components"; -import { InstanceGoogleConfigForm } from "./components"; // hooks import { useInstance } from "@/hooks/store"; // icons import GoogleLogo from "@/public/logos/google-logo.svg"; +// local components +import { AuthenticationMethodCard } from "../components"; +import { InstanceGoogleConfigForm } from "./form"; const InstanceGoogleAuthenticationPage = observer(() => { // store diff --git a/admin/app/authentication/layout.tsx b/admin/app/authentication/layout.tsx index c6f146ff5..2568859db 100644 --- a/admin/app/authentication/layout.tsx +++ b/admin/app/authentication/layout.tsx @@ -2,20 +2,8 @@ import { ReactNode } from "react"; // layouts -import { AdminLayout } from "@/layouts"; -// lib -import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +import { AdminLayout } from "@/layouts/admin-layout"; -interface AuthenticationLayoutProps { - children: ReactNode; +export default function AuthenticationLayout({ children }: { children: ReactNode }) { + return {children}; } - -const AuthenticationLayout = ({ children }: AuthenticationLayoutProps) => ( - - - {children} - - -); - -export default AuthenticationLayout; diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx index 068592468..25be147ca 100644 --- a/admin/app/authentication/page.tsx +++ b/admin/app/authentication/page.tsx @@ -1,26 +1,31 @@ "use client"; import { useState } from "react"; +import { observer } from "mobx-react-lite"; import Image from "next/image"; import { useTheme } from "next-themes"; import useSWR from "swr"; -import { observer } from "mobx-react-lite"; import { Mails, KeyRound } from "lucide-react"; -import { Loader, setPromiseToast } from "@plane/ui"; import { TInstanceConfigurationKeys } from "@plane/types"; +import { Loader, setPromiseToast } from "@plane/ui"; // components -import { AuthenticationMethodCard, EmailCodesConfiguration, PasswordLoginConfiguration } from "./components"; -import { GoogleConfiguration } from "./google/components"; -import { GithubConfiguration } from "./github/components"; import { PageHeader } from "@/components/core"; // hooks -import { useInstance } from "@/hooks/store"; // helpers import { resolveGeneralTheme } from "@/helpers/common.helper"; +import { useInstance } from "@/hooks/store"; // images -import GoogleLogo from "@/public/logos/google-logo.svg"; import githubLightModeImage from "@/public/logos/github-black.png"; import githubDarkModeImage from "@/public/logos/github-white.png"; +import GoogleLogo from "@/public/logos/google-logo.svg"; +// local components +import { + AuthenticationMethodCard, + EmailCodesConfiguration, + PasswordLoginConfiguration, + GithubConfiguration, + GoogleConfiguration, +} from "./components"; type TInstanceAuthenticationMethodCard = { key: string; diff --git a/admin/app/email/components/email-config-form.tsx b/admin/app/email/components/email-config-form.tsx index 50c867132..eb73e18b9 100644 --- a/admin/app/email/components/email-config-form.tsx +++ b/admin/app/email/components/email-config-form.tsx @@ -1,14 +1,14 @@ import React, { FC, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; // hooks +import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types"; +import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; import { useInstance } from "@/hooks/store"; // ui -import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; // components import { ControllerInput, TControllerInputFormField } from "components/common"; import { SendTestEmailModal } from "./test-email-modal"; // types -import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types"; type IInstanceEmailForm = { config: IFormattedInstanceConfiguration; diff --git a/admin/app/email/layout.tsx b/admin/app/email/layout.tsx index ce1164ead..5eb8af8ee 100644 --- a/admin/app/email/layout.tsx +++ b/admin/app/email/layout.tsx @@ -2,7 +2,7 @@ import { ReactNode } from "react"; // layouts -import { AdminLayout } from "@/layouts"; +import { AdminLayout } from "@/layouts/admin-layout"; // lib import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; diff --git a/admin/app/email/page.tsx b/admin/app/email/page.tsx index 6ffebc904..51566e5f2 100644 --- a/admin/app/email/page.tsx +++ b/admin/app/email/page.tsx @@ -1,13 +1,13 @@ "use client"; -import useSWR from "swr"; import { observer } from "mobx-react-lite"; +import useSWR from "swr"; import { Loader } from "@plane/ui"; // components import { PageHeader } from "@/components/core"; +import { useInstance } from "@/hooks/store"; import { InstanceEmailForm } from "./components"; // hooks -import { useInstance } from "@/hooks/store"; const InstanceEmailPage = observer(() => { // store diff --git a/admin/app/error.tsx b/admin/app/error.tsx new file mode 100644 index 000000000..76794e04a --- /dev/null +++ b/admin/app/error.tsx @@ -0,0 +1,9 @@ +"use client"; + +export default function RootErrorPage() { + return ( +
+

Something went wrong.

+
+ ); +} diff --git a/admin/app/general/components/index.ts b/admin/app/general/components/index.ts deleted file mode 100644 index a144f8d63..000000000 --- a/admin/app/general/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./general-config-form"; \ No newline at end of file diff --git a/admin/app/general/components/general-config-form.tsx b/admin/app/general/form.tsx similarity index 94% rename from admin/app/general/components/general-config-form.tsx rename to admin/app/general/form.tsx index 5e360e048..09079028d 100644 --- a/admin/app/general/components/general-config-form.tsx +++ b/admin/app/general/form.tsx @@ -1,10 +1,14 @@ +"use client"; import { FC } from "react"; +import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; import { Telescope } from "lucide-react"; +// types import { IInstance, IInstanceAdmin } from "@plane/types"; +// ui import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; // components -import { ControllerInput } from "components/common"; +import { ControllerInput } from "@/components/common"; // hooks import { useInstance } from "@/hooks/store"; @@ -13,7 +17,7 @@ export interface IGeneralConfigurationForm { instanceAdmins: IInstanceAdmin[]; } -export const GeneralConfigurationForm: FC = (props) => { +export const GeneralConfigurationForm: FC = observer((props) => { const { instance, instanceAdmins } = props; // hooks const { updateInstanceInfo } = useInstance(); @@ -24,8 +28,8 @@ export const GeneralConfigurationForm: FC = (props) = formState: { errors, isSubmitting }, } = useForm>({ defaultValues: { - instance_name: instance.instance_name, - is_telemetry_enabled: instance.is_telemetry_enabled, + instance_name: instance?.instance_name, + is_telemetry_enabled: instance?.is_telemetry_enabled, }, }); @@ -133,4 +137,4 @@ export const GeneralConfigurationForm: FC = (props) =
); -}; +}); diff --git a/admin/app/general/layout.tsx b/admin/app/general/layout.tsx index 1761f9689..371264e92 100644 --- a/admin/app/general/layout.tsx +++ b/admin/app/general/layout.tsx @@ -1,21 +1,12 @@ -"use client"; - import { ReactNode } from "react"; -// layouts -import { AdminLayout } from "@/layouts"; -// lib -import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +import { Metadata } from "next"; +// components +import { AdminLayout } from "@/layouts/admin-layout"; -interface GeneralLayoutProps { - children: ReactNode; +export const metadata: Metadata = { + title: "General Settings - God Mode", +}; + +export default function GeneralLayout({ children }: { children: ReactNode }) { + return {children}; } - -const GeneralLayout = ({ children }: GeneralLayoutProps) => ( - - - {children} - - -); - -export default GeneralLayout; diff --git a/admin/app/general/page.tsx b/admin/app/general/page.tsx index accaf01d1..399482ea6 100644 --- a/admin/app/general/page.tsx +++ b/admin/app/general/page.tsx @@ -1,18 +1,15 @@ "use client"; - import { observer } from "mobx-react-lite"; -// components -import { PageHeader } from "@/components/core"; -import { GeneralConfigurationForm } from "./components"; // hooks import { useInstance } from "@/hooks/store"; +// components +import { GeneralConfigurationForm } from "./form"; -const GeneralPage = observer(() => { +function GeneralPage() { const { instance, instanceAdmins } = useInstance(); - + console.log("instance", instanceAdmins); return ( <> -
General settings
@@ -22,13 +19,13 @@ const GeneralPage = observer(() => {
- {instance?.instance && instanceAdmins && instanceAdmins?.length > 0 && ( - + {instance?.instance && instanceAdmins && ( + )}
); -}); +} -export default GeneralPage; +export default observer(GeneralPage); diff --git a/admin/app/image/components/index.ts b/admin/app/image/components/index.ts deleted file mode 100644 index ad9b60a10..000000000 --- a/admin/app/image/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./image-config-form"; \ No newline at end of file diff --git a/admin/app/image/components/image-config-form.tsx b/admin/app/image/form.tsx similarity index 97% rename from admin/app/image/components/image-config-form.tsx rename to admin/app/image/form.tsx index 1779468fa..a6fe2945b 100644 --- a/admin/app/image/components/image-config-form.tsx +++ b/admin/app/image/form.tsx @@ -1,9 +1,9 @@ import { FC } from "react"; import { useForm } from "react-hook-form"; -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // components -import { ControllerInput } from "components/common"; +import { ControllerInput } from "@/components/common"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/admin/app/image/layout.tsx b/admin/app/image/layout.tsx index 4a42facfb..039c10202 100644 --- a/admin/app/image/layout.tsx +++ b/admin/app/image/layout.tsx @@ -1,21 +1,11 @@ -"use client"; - import { ReactNode } from "react"; // layouts -import { AdminLayout } from "@/layouts"; -// lib -import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +import { AdminLayout } from "@/layouts/admin-layout"; interface ImageLayoutProps { children: ReactNode; } -const ImageLayout = ({ children }: ImageLayoutProps) => ( - - - {children} - - -); +const ImageLayout = ({ children }: ImageLayoutProps) => {children}; export default ImageLayout; diff --git a/admin/app/image/page.tsx b/admin/app/image/page.tsx index cbf4a8f4d..5c1b838be 100644 --- a/admin/app/image/page.tsx +++ b/admin/app/image/page.tsx @@ -1,13 +1,14 @@ "use client"; -import useSWR from "swr"; import { observer } from "mobx-react-lite"; +import useSWR from "swr"; import { Loader } from "@plane/ui"; // components import { PageHeader } from "@/components/core"; -import { InstanceImageConfigForm } from "./components"; // hooks import { useInstance } from "@/hooks/store"; +// local +import { InstanceImageConfigForm } from "./form"; const InstanceImagePage = observer(() => { // store diff --git a/admin/app/layout.tsx b/admin/app/layout.tsx index 3352cbfae..d2df31d59 100644 --- a/admin/app/layout.tsx +++ b/admin/app/layout.tsx @@ -1,46 +1,56 @@ -"use client"; - import { ReactNode } from "react"; -import { ThemeProvider } from "next-themes"; -// lib -import { StoreProvider } from "@/lib/store-context"; -import { AppWrapper } from "@/lib/wrappers"; -// constants -import { SITE_NAME, SITE_DESCRIPTION, SITE_URL, TWITTER_USER_NAME, SITE_KEYWORDS, SITE_TITLE } from "@/constants/seo"; +import { Metadata } from "next"; +// components +import { InstanceFailureView, InstanceNotReady } from "@/components/instance"; // helpers import { ASSET_PREFIX } from "@/helpers/common.helper"; +// lib +import { AppProvider } from "@/lib/app-providers"; // styles import "./globals.css"; +// services +import { InstanceService } from "@/services"; -interface RootLayoutProps { - children: ReactNode; +const instanceService = new InstanceService(); + +export const metadata: Metadata = { + title: "Plane | Simple, extensible, open-source project management tool.", + description: + "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.", + openGraph: { + title: "Plane | Simple, extensible, open-source project management tool.", + description: + "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.", + url: "https://plane.so/", + }, + keywords: + "software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + }, +}; + +export default async function RootLayout({ children }: { children: ReactNode }) { + const instanceDetails = await instanceService.getInstanceInfo().catch(() => null); + + return ( + + + + + + + + + + + {instanceDetails ? ( + <>{instanceDetails?.instance?.is_setup_done ? <>{children} : } + ) : ( + + )} + + + + ); } - -const RootLayout = ({ children, ...pageProps }: RootLayoutProps) => ( - - - {SITE_TITLE} - - - - - - - - - - - - - - - - - {children} - - - - -); - -export default RootLayout; diff --git a/admin/app/page.tsx b/admin/app/page.tsx index 05e9e8237..c7e6b975e 100644 --- a/admin/app/page.tsx +++ b/admin/app/page.tsx @@ -1,26 +1,11 @@ -"use client"; - -// layouts -import { DefaultLayout } from "@/layouts"; -// components -import { PageHeader } from "@/components/core"; import { InstanceSignInForm } from "@/components/login"; -// lib -import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; -// helpers -import { EAuthenticationPageType, EInstancePageType } from "@/helpers"; +// layouts +import { DefaultLayout } from "@/layouts/default-layout"; -const LoginPage = () => ( - <> - - - - - - - - - -); - -export default LoginPage; +export default async function LoginPage() { + return ( + + + + ); +} diff --git a/admin/app/setup/components/sign-up-form.tsx b/admin/app/setup/components/sign-up-form.tsx index 0058b799e..1a117620d 100644 --- a/admin/app/setup/components/sign-up-form.tsx +++ b/admin/app/setup/components/sign-up-form.tsx @@ -2,17 +2,17 @@ import { FC, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; -// services -import { AuthService } from "@/services/auth.service"; +// icons +import { Eye, EyeOff } from "lucide-react"; // ui import { Button, Checkbox, Input, Spinner } from "@plane/ui"; // components -import { Banner, PasswordStrengthMeter } from "components/common"; -// icons -import { Eye, EyeOff } from "lucide-react"; +import { Banner, PasswordStrengthMeter } from "@/components/common"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; import { getPasswordStrength } from "@/helpers/password.helper"; +// services +import { AuthService } from "@/services/auth.service"; // service initialization const authService = new AuthService(); @@ -154,7 +154,7 @@ export const InstanceSignUpForm: FC = (props) => { First name * {
{ Email * {
{ diff --git a/admin/app/setup/layout.tsx b/admin/app/setup/layout.tsx index 07f42cd71..ba889b7ae 100644 --- a/admin/app/setup/layout.tsx +++ b/admin/app/setup/layout.tsx @@ -1,19 +1,23 @@ "use client"; import { ReactNode } from "react"; -// lib -import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; // helpers import { EAuthenticationPageType, EInstancePageType } from "@/helpers"; +// lib +import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; interface SetupLayoutProps { children: ReactNode; + params: any; } -const SetupLayout = ({ children }: SetupLayoutProps) => ( - - {children} - -); - -export default SetupLayout; +export default function SetupLayout(props: SetupLayoutProps) { + const { children, params } = props; + const { error_code } = params; + console.log("error_code", error_code); + return ( + + {children} + + ); +} diff --git a/admin/app/setup/page.tsx b/admin/app/setup/page.tsx index 641155c85..1ef22b1f2 100644 --- a/admin/app/setup/page.tsx +++ b/admin/app/setup/page.tsx @@ -1,16 +1,19 @@ +import { Metadata } from "next"; // layouts -import { DefaultLayout } from "@/layouts"; +import { DefaultLayout } from "@/layouts/default-layout"; // components -import { PageHeader } from "@/components/core"; import { InstanceSignUpForm } from "./components"; -const SetupPage = () => ( - <> - - - - - -); +export const metadata: Metadata = { + title: "Setup - God Mode", +}; -export default SetupPage; +export default function SetupPage() { + return ( + <> + + + + + ); +} diff --git a/admin/components/admin-sidebar/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx index 84e28c67a..371bb49d8 100644 --- a/admin/components/admin-sidebar/help-section.tsx +++ b/admin/components/admin-sidebar/help-section.tsx @@ -1,16 +1,16 @@ "use client"; import { FC, useState, useRef } from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; -import { Transition } from "@headlessui/react"; +import Link from "next/link"; import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; +import { Transition } from "@headlessui/react"; import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; // hooks +import { WEB_BASE_URL } from "@/helpers/common.helper"; import { useTheme } from "@/hooks/store"; // assets import packageJson from "package.json"; -import { WEB_BASE_URL } from "@/helpers/common.helper"; const helpOptions = [ { diff --git a/admin/components/admin-sidebar/root.tsx b/admin/components/admin-sidebar/root.tsx index 654769924..ff94bf228 100644 --- a/admin/components/admin-sidebar/root.tsx +++ b/admin/components/admin-sidebar/root.tsx @@ -3,10 +3,10 @@ import { FC, useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; // hooks +import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar"; import { useTheme } from "@/hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components -import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar"; export interface IInstanceSidebar {} diff --git a/admin/components/admin-sidebar/sidebar-dropdown.tsx b/admin/components/admin-sidebar/sidebar-dropdown.tsx index f248f852f..f7de3f277 100644 --- a/admin/components/admin-sidebar/sidebar-dropdown.tsx +++ b/admin/components/admin-sidebar/sidebar-dropdown.tsx @@ -1,15 +1,15 @@ "use client"; import { Fragment, useEffect, useState } from "react"; -import { useTheme as useNextTheme } from "next-themes"; import { observer } from "mobx-react-lite"; +import { useTheme as useNextTheme } from "next-themes"; import { LogOut, UserCog2, Palette } from "lucide-react"; import { Menu, Transition } from "@headlessui/react"; import { Avatar } from "@plane/ui"; // hooks +import { API_BASE_URL, cn } from "@/helpers/common.helper"; import { useTheme, useUser } from "@/hooks/store"; // helpers -import { API_BASE_URL, cn } from "@/helpers/common.helper"; // services import { AuthService } from "@/services"; diff --git a/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx b/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx index d6ed65541..2e8539488 100644 --- a/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx +++ b/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx @@ -3,9 +3,9 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; // hooks +import { Menu } from "lucide-react"; import { useTheme } from "@/hooks/store"; // icons -import { Menu } from "lucide-react"; export const SidebarHamburgerToggle: FC = observer(() => { const { isSidebarCollapsed, toggleSidebar } = useTheme(); diff --git a/admin/components/admin-sidebar/sidebar-menu.tsx b/admin/components/admin-sidebar/sidebar-menu.tsx index dfb410051..f7c146fa2 100644 --- a/admin/components/admin-sidebar/sidebar-menu.tsx +++ b/admin/components/admin-sidebar/sidebar-menu.tsx @@ -1,14 +1,14 @@ "use client"; +import { observer } from "mobx-react-lite"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { observer } from "mobx-react-lite"; import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; import { Tooltip } from "@plane/ui"; // hooks +import { cn } from "@/helpers/common.helper"; import { useTheme } from "@/hooks/store"; // helpers -import { cn } from "@/helpers/common.helper"; const INSTANCE_ADMIN_LINKS = [ { diff --git a/admin/components/auth-header.tsx b/admin/components/auth-header.tsx index 21871aed4..4becf928f 100644 --- a/admin/components/auth-header.tsx +++ b/admin/components/auth-header.tsx @@ -1,16 +1,16 @@ "use client"; import { FC } from "react"; +import { observer } from "mobx-react-lite"; import { usePathname } from "next/navigation"; // mobx -import { observer } from "mobx-react-lite"; // ui import { Settings } from "lucide-react"; // icons import { Breadcrumbs } from "@plane/ui"; // components -import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "@/components/admin-sidebar"; +import { BreadcrumbLink } from "components/common"; export const InstanceHeader: FC = observer(() => { const pathName = usePathname(); diff --git a/admin/components/common/controller-input.tsx b/admin/components/common/controller-input.tsx index d47fe43f9..8f2265954 100644 --- a/admin/components/common/controller-input.tsx +++ b/admin/components/common/controller-input.tsx @@ -3,9 +3,9 @@ import React, { useState } from "react"; import { Controller, Control } from "react-hook-form"; // ui +import { Eye, EyeOff } from "lucide-react"; import { Input } from "@plane/ui"; // icons -import { Eye, EyeOff } from "lucide-react"; // helpers import { cn } from "@/helpers/common.helper"; @@ -62,6 +62,7 @@ export const ControllerInput: React.FC = (props) => { {type === "password" && (showPassword ? ( ) : ( -

{description}

+
{description}
); }; diff --git a/admin/components/common/password-strength-meter.tsx b/admin/components/common/password-strength-meter.tsx index fabb186f9..5cdba30b7 100644 --- a/admin/components/common/password-strength-meter.tsx +++ b/admin/components/common/password-strength-meter.tsx @@ -1,10 +1,10 @@ "use client"; // helpers +import { CircleCheck } from "lucide-react"; import { cn } from "@/helpers/common.helper"; import { getPasswordStrength } from "@/helpers/password.helper"; // icons -import { CircleCheck } from "lucide-react"; type Props = { password: string; diff --git a/admin/components/instance/index.ts b/admin/components/instance/index.ts index 373ba7057..1f52843a0 100644 --- a/admin/components/instance/index.ts +++ b/admin/components/instance/index.ts @@ -1 +1,2 @@ export * from "./instance-not-ready"; +export * from "./instance-failure-view"; diff --git a/admin/components/instance/instance-failure-view.tsx b/admin/components/instance/instance-failure-view.tsx new file mode 100644 index 000000000..b86750031 --- /dev/null +++ b/admin/components/instance/instance-failure-view.tsx @@ -0,0 +1,42 @@ +"use client"; +import { FC } from "react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import { Button } from "@plane/ui"; +// assets +import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg"; +import InstanceFailureImage from "@/public/instance/instance-failure.svg"; + +type InstanceFailureViewProps = { + // mutate: () => void; +}; + +export const InstanceFailureView: FC = () => { + const { resolvedTheme } = useTheme(); + + const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; + + const handleRetry = () => { + window.location.reload(); + }; + + return ( +
+
+
+ Plane Logo +

Unable to fetch instance details.

+

+ We were unable to fetch the details of the instance.
+ Fret not, it might just be a connectivity issue. +

+
+
+ +
+
+
+ ); +}; diff --git a/admin/components/instance/instance-not-ready.tsx b/admin/components/instance/instance-not-ready.tsx index d3df5bd66..874013f52 100644 --- a/admin/components/instance/instance-not-ready.tsx +++ b/admin/components/instance/instance-not-ready.tsx @@ -1,8 +1,8 @@ "use client"; import { FC } from "react"; -import Link from "next/link"; import Image from "next/image"; +import Link from "next/link"; import { Button } from "@plane/ui"; // assets import PlaneTakeOffImage from "@/public/images/plane-takeoff.png"; diff --git a/admin/components/login/sign-in-form.tsx b/admin/components/login/sign-in-form.tsx index 12df47edf..b68e78197 100644 --- a/admin/components/login/sign-in-form.tsx +++ b/admin/components/login/sign-in-form.tsx @@ -3,15 +3,15 @@ import { FC, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; // services -import { AuthService } from "@/services/auth.service"; -// ui +import { Eye, EyeOff } from "lucide-react"; import { Button, Input, Spinner } from "@plane/ui"; // components -import { Banner } from "components/common"; -// icons -import { Eye, EyeOff } from "lucide-react"; +import { Banner } from "@/components/common"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; +import { AuthService } from "@/services/auth.service"; +// ui +// icons // service initialization const authService = new AuthService(); @@ -57,6 +57,8 @@ export const InstanceSignInForm: FC = (props) => { const handleFormChange = (key: keyof TFormData, value: string | boolean) => setFormData((prev) => ({ ...prev, [key]: value })); + console.log("csrfToken", csrfToken); + useEffect(() => { if (csrfToken === undefined) authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); @@ -119,7 +121,7 @@ export const InstanceSignInForm: FC = (props) => { Email * {
{ diff --git a/admin/hooks/store/use-theme.tsx b/admin/hooks/store/use-theme.tsx index dc4f9dbf8..95d2aa05e 100644 --- a/admin/hooks/store/use-theme.tsx +++ b/admin/hooks/store/use-theme.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/lib/store-context"; +import { StoreContext } from "@/lib/app-providers"; import { IThemeStore } from "@/store/theme.store"; export const useTheme = (): IThemeStore => { diff --git a/admin/hooks/store/use-user.tsx b/admin/hooks/store/use-user.tsx index d1e114ae4..c8cb45250 100644 --- a/admin/hooks/store/use-user.tsx +++ b/admin/hooks/store/use-user.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/lib/store-context"; +import { StoreContext } from "@/lib/app-providers"; import { IUserStore } from "@/store/user.store"; export const useUser = (): IUserStore => { diff --git a/admin/layouts/admin-layout.tsx b/admin/layouts/admin-layout.tsx index 131fb7fcd..041a5b8bf 100644 --- a/admin/layouts/admin-layout.tsx +++ b/admin/layouts/admin-layout.tsx @@ -1,15 +1,48 @@ -import { FC, ReactNode } from "react"; +"use client"; +import { FC, ReactNode, useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/navigation"; +import useSWR from "swr"; +// ui +import { Spinner } from "@plane/ui"; // components import { InstanceSidebar } from "@/components/admin-sidebar"; import { InstanceHeader } from "@/components/auth-header"; import { NewUserPopup } from "@/components/new-user-popup"; +// hooks +import { useInstance, useUser } from "@/hooks/store"; type TAdminLayout = { children: ReactNode; }; -export const AdminLayout: FC = (props) => { +export const AdminLayout: FC = observer((props) => { const { children } = props; + // router + const router = useRouter(); + // hooks + const { fetchInstanceAdmins } = useInstance(); + const { fetchCurrentUser, isUserLoggedIn } = useUser(); + + useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins()); + + useSWR("CURRENT_USER", () => fetchCurrentUser(), { + shouldRetryOnError: false, + }); + + useEffect(() => { + if (isUserLoggedIn === false) { + router.push("/"); + } + }, [router, isUserLoggedIn]); + + if (isUserLoggedIn === undefined) { + return ( +
+ +
+ ); + } return (
@@ -21,4 +54,4 @@ export const AdminLayout: FC = (props) => {
); -}; +}); diff --git a/admin/layouts/default-layout.tsx b/admin/layouts/default-layout.tsx index e0952e994..26bab1221 100644 --- a/admin/layouts/default-layout.tsx +++ b/admin/layouts/default-layout.tsx @@ -17,6 +17,7 @@ export const DefaultLayout: FC = (props) => { const { children, withoutBackground = false } = props; // hooks const { resolvedTheme } = useTheme(); + const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; return (
@@ -29,11 +30,7 @@ export const DefaultLayout: FC = (props) => {
{!withoutBackground && (
- Plane background pattern + Plane background pattern
)}
{children}
diff --git a/admin/layouts/index.ts b/admin/layouts/index.ts deleted file mode 100644 index 5e4a7c023..000000000 --- a/admin/layouts/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./default-layout"; -export * from "./admin-layout"; diff --git a/admin/lib/app-providers.tsx b/admin/lib/app-providers.tsx new file mode 100644 index 000000000..28bfdd08b --- /dev/null +++ b/admin/lib/app-providers.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { ReactNode, createContext } from "react"; +import { ThemeProvider } from "next-themes"; +// ui +import { AppWrapper } from "@/lib/wrappers"; +// store +import { RootStore } from "@/store/root.store"; + +let rootStore = new RootStore(); + +export const StoreContext = createContext(rootStore); + +function initializeStore(initialData = {}) { + const singletonRootStore = rootStore ?? new RootStore(); + // If your page has Next.js data fetching methods that use a Mobx store, it will + // get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details + if (initialData) { + console.log("initialState", initialData); + singletonRootStore.hydrate(initialData); + } + // For SSG and SSR always create a new store + if (typeof window === "undefined") return singletonRootStore; + // Create the store once in the client + if (!rootStore) rootStore = singletonRootStore; + return singletonRootStore; +} + +export type AppProviderProps = { + children: ReactNode; + initialState: any; +}; + +export const AppProvider = ({ children, initialState = {} }: AppProviderProps) => { + const store = initializeStore(initialState); + + return ( + + + {children} + + + ); +}; diff --git a/admin/lib/store-context.tsx b/admin/lib/store-context.tsx deleted file mode 100644 index 8893f1a78..000000000 --- a/admin/lib/store-context.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { ReactElement, createContext } from "react"; -// mobx store -import { RootStore } from "@/store/root-store"; - -export let rootStore = new RootStore(); - -export const StoreContext = createContext(rootStore); - -const initializeStore = () => { - const newRootStore = rootStore ?? new RootStore(); - if (typeof window === "undefined") return newRootStore; - if (!rootStore) rootStore = newRootStore; - return newRootStore; -}; - -export const StoreProvider = ({ children }: { children: ReactElement }) => { - const store = initializeStore(); - return {children}; -}; diff --git a/admin/lib/wrappers/app-wrapper.tsx b/admin/lib/wrappers/app-wrapper.tsx index aa6e26330..daf030a5c 100644 --- a/admin/lib/wrappers/app-wrapper.tsx +++ b/admin/lib/wrappers/app-wrapper.tsx @@ -3,14 +3,14 @@ import { FC, ReactNode, useEffect, Suspense } from "react"; import { observer } from "mobx-react-lite"; import { SWRConfig } from "swr"; -// hooks -import { useTheme, useUser } from "@/hooks/store"; // ui import { Toast } from "@plane/ui"; // constants import { SWR_CONFIG } from "@/constants/swr-config"; // helpers -import { resolveGeneralTheme } from "helpers/common.helper"; +import { resolveGeneralTheme } from "@/helpers/common.helper"; +// hooks +import { useTheme, useUser } from "@/hooks/store"; interface IAppWrapper { children: ReactNode; diff --git a/admin/lib/wrappers/auth-wrapper.tsx b/admin/lib/wrappers/auth-wrapper.tsx index 00f947047..c471b3a23 100644 --- a/admin/lib/wrappers/auth-wrapper.tsx +++ b/admin/lib/wrappers/auth-wrapper.tsx @@ -1,14 +1,14 @@ "use client"; import { FC, ReactNode } from "react"; -import { useRouter } from "next/navigation"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/navigation"; import useSWR from "swr"; import { Spinner } from "@plane/ui"; // hooks +import { EAuthenticationPageType } from "@/helpers"; import { useInstance, useUser } from "@/hooks/store"; // helpers -import { EAuthenticationPageType } from "@/helpers"; export interface IAuthWrapper { children: ReactNode; diff --git a/admin/lib/wrappers/instance-wrapper.tsx b/admin/lib/wrappers/instance-wrapper.tsx index f86adfdce..bfc5cc289 100644 --- a/admin/lib/wrappers/instance-wrapper.tsx +++ b/admin/lib/wrappers/instance-wrapper.tsx @@ -1,19 +1,19 @@ "use client"; import { FC, ReactNode } from "react"; -import { redirect, useSearchParams } from "next/navigation"; import { observer } from "mobx-react-lite"; +import { redirect, useSearchParams } from "next/navigation"; import useSWR from "swr"; import { Spinner } from "@plane/ui"; -// layouts -import { DefaultLayout } from "@/layouts"; // components +import { EmptyState } from "@/components/common"; import { InstanceNotReady } from "@/components/instance"; -// hooks -import { useInstance } from "@/hooks/store"; // helpers import { EInstancePageType } from "@/helpers"; -import { EmptyState } from "@/components/common"; +// hooks +import { useInstance } from "@/hooks/store"; +// layouts +import { DefaultLayout } from "@/layouts/default-layout"; type TInstanceWrapper = { children: ReactNode; diff --git a/admin/public/instance/instance-failure-dark.svg b/admin/public/instance/instance-failure-dark.svg new file mode 100644 index 000000000..58d691705 --- /dev/null +++ b/admin/public/instance/instance-failure-dark.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/instance/instance-failure.svg b/admin/public/instance/instance-failure.svg new file mode 100644 index 000000000..a59862283 --- /dev/null +++ b/admin/public/instance/instance-failure.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/public/instance/plane-instance-not-ready.webp b/admin/public/instance/plane-instance-not-ready.webp new file mode 100644 index 0000000000000000000000000000000000000000..a0efca52c85b06c27136c032b6d84adff96f05fc GIT binary patch literal 45894 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 bT!XEBss9Hmi^{f7kbk~e{_C>(ziR&j&=8$T literal 0 HcmV?d00001 diff --git a/admin/public/instance/plane-takeoff.png b/admin/public/instance/plane-takeoff.png new file mode 100644 index 0000000000000000000000000000000000000000..417ff82999890f25a8d61da4e174375b58f457a3 GIT binary patch literal 47818 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

;B*sK>V_Ek<3UIhOnk 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 response, - (error) => { - const store = rootStore; - if (error.response && error.response.status === 401 && store.user.currentUser) store.user.reset(); - return Promise.reject(error); - } - ); + // this.axiosInstance.interceptors.response.use( + // (response) => response, + // (error) => { + // const store = rootStore; + // if (error.response && error.response.status === 401 && store.user.currentUser) store.user.reset(); + // return Promise.reject(error); + // } + // ); } get(url: string, params = {}): Promise> { diff --git a/admin/services/auth.service.ts b/admin/services/auth.service.ts index 6e173140e..ef7b7b151 100644 --- a/admin/services/auth.service.ts +++ b/admin/services/auth.service.ts @@ -1,7 +1,7 @@ -// services -import { APIService } from "services/api.service"; // helpers import { API_BASE_URL } from "helpers/common.helper"; +// services +import { APIService } from "services/api.service"; type TCsrfTokenResponse = { csrf_token: string; diff --git a/admin/services/instance.service.ts b/admin/services/instance.service.ts index 109b52e44..e995ee821 100644 --- a/admin/services/instance.service.ts +++ b/admin/services/instance.service.ts @@ -13,6 +13,7 @@ export class InstanceService extends APIService { return this.get("/api/instances/") .then((response) => response.data) .catch((error) => { + console.log("error", error); throw error; }); } diff --git a/admin/services/user.service.ts b/admin/services/user.service.ts index 9209ec460..bef384daf 100644 --- a/admin/services/user.service.ts +++ b/admin/services/user.service.ts @@ -1,15 +1,25 @@ +// helpers +import { API_BASE_URL } from "helpers/common.helper"; // services import { APIService } from "services/api.service"; // types import type { IUser } from "@plane/types"; -// helpers -import { API_BASE_URL } from "helpers/common.helper"; + +interface IUserSession extends IUser { + isAuthenticated: boolean; +} export class UserService extends APIService { constructor() { super(API_BASE_URL); } + async authCheck(): Promise { + return this.get("/api/instances/admins/me/") + .then((response) => ({ ...response?.data, isAuthenticated: true })) + .catch(() => ({ isAuthenticated: false })); + } + async currentUser(): Promise { return this.get("/api/instances/admins/me/") .then((response) => response?.data) diff --git a/admin/store/instance.store.ts b/admin/store/instance.store.ts index 70b505ad0..e168b15b6 100644 --- a/admin/store/instance.store.ts +++ b/admin/store/instance.store.ts @@ -1,12 +1,12 @@ -import { observable, action, computed, makeObservable, runInAction } from "mobx"; import set from "lodash/set"; +import { observable, action, computed, makeObservable, runInAction } from "mobx"; import { IInstance, IInstanceAdmin, IInstanceConfiguration, IFormattedInstanceConfiguration } from "@plane/types"; // helpers import { EInstanceStatus, TInstanceStatus } from "@/helpers"; // services import { InstanceService } from "@/services/instance.service"; // root store -import { RootStore } from "@/store/root-store"; +import { RootStore } from "@/store/root.store"; export interface IInstanceStore { // issues @@ -18,11 +18,12 @@ export interface IInstanceStore { // computed formattedConfig: IFormattedInstanceConfiguration | undefined; // action + hydrate: (data: any) => void; fetchInstanceInfo: () => Promise; updateInstanceInfo: (data: Partial) => Promise; fetchInstanceAdmins: () => Promise; fetchInstanceConfigurations: () => Promise; - updateInstanceConfigurations: (data: Partial) => Promise; + updateInstanceConfigurations: (data: Partial) => Promise; } export class InstanceStore implements IInstanceStore { @@ -45,6 +46,7 @@ export class InstanceStore implements IInstanceStore { // computed formattedConfig: computed, // actions + hydrate: action, fetchInstanceInfo: action, fetchInstanceAdmins: action, updateInstanceInfo: action, @@ -55,6 +57,10 @@ export class InstanceStore implements IInstanceStore { this.instanceService = new InstanceService(); } + hydrate = (data: any) => { + if (data) this.instance = data; + }; + /** * computed value for instance configurations data for forms. * @returns configurations in the form of {key, value} pair. @@ -148,13 +154,15 @@ export class InstanceStore implements IInstanceStore { */ updateInstanceConfigurations = async (data: Partial) => { try { - await this.instanceService.updateInstanceConfigurations(data).then((response) => { - runInAction(() => { - this.instanceConfigurations = this.instanceConfigurations - ? [...this.instanceConfigurations, ...response] - : response; + const response = await this.instanceService.updateInstanceConfigurations(data); + runInAction(() => { + this.instanceConfigurations = this.instanceConfigurations?.map((config) => { + const item = response.find((item) => item.key === config.key); + if (item) return item; + return config; }); }); + return response; } catch (error) { console.error("Error updating the instance configurations"); throw error; diff --git a/admin/store/root-store.ts b/admin/store/root.store.ts similarity index 81% rename from admin/store/root-store.ts rename to admin/store/root.store.ts index c05cce37f..553a22200 100644 --- a/admin/store/root-store.ts +++ b/admin/store/root.store.ts @@ -1,7 +1,7 @@ import { enableStaticRendering } from "mobx-react-lite"; // stores -import { IThemeStore, ThemeStore } from "./theme.store"; import { IInstanceStore, InstanceStore } from "./instance.store"; +import { IThemeStore, ThemeStore } from "./theme.store"; import { IUserStore, UserStore } from "./user.store"; enableStaticRendering(typeof window === "undefined"); @@ -17,9 +17,14 @@ export class RootStore { this.user = new UserStore(this); } + hydrate(initialData: any) { + this.theme.hydrate(initialData.theme); + this.instance.hydrate(initialData.instance); + this.user.hydrate(initialData.user); + } + resetOnSignOut() { localStorage.setItem("theme", "system"); - this.instance = new InstanceStore(this); this.user = new UserStore(this); this.theme = new ThemeStore(this); diff --git a/admin/store/theme.store.ts b/admin/store/theme.store.ts index 886507922..a3f3b3d5a 100644 --- a/admin/store/theme.store.ts +++ b/admin/store/theme.store.ts @@ -1,6 +1,6 @@ import { action, observable, makeObservable } from "mobx"; // root store -import { RootStore } from "@/store/root-store"; +import { RootStore } from "@/store/root.store"; type TTheme = "dark" | "light"; export interface IThemeStore { @@ -9,6 +9,7 @@ export interface IThemeStore { theme: string | undefined; isSidebarCollapsed: boolean | undefined; // actions + hydrate: (data: any) => void; toggleNewUserPopup: () => void; toggleSidebar: (collapsed: boolean) => void; setTheme: (currentTheme: TTheme) => void; @@ -33,6 +34,10 @@ export class ThemeStore implements IThemeStore { }); } + hydrate = (data: any) => { + if (data) this.theme = data; + }; + /** * @description Toggle the new user popup modal */ diff --git a/admin/store/user.store.ts b/admin/store/user.store.ts index 10b5eab81..271c6be34 100644 --- a/admin/store/user.store.ts +++ b/admin/store/user.store.ts @@ -3,10 +3,10 @@ import { IUser } from "@plane/types"; // helpers import { EUserStatus, TUserStatus } from "@/helpers"; // services +import { AuthService } from "@/services"; import { UserService } from "@/services/user.service"; // root store -import { RootStore } from "@/store/root-store"; -import { AuthService } from "@/services"; +import { RootStore } from "@/store/root.store"; export interface IUserStore { // observables @@ -15,6 +15,7 @@ export interface IUserStore { isUserLoggedIn: boolean | undefined; currentUser: IUser | undefined; // fetch actions + hydrate: (data: any) => void; fetchCurrentUser: () => Promise; reset: () => void; signOut: () => void; @@ -46,6 +47,10 @@ export class UserStore implements IUserStore { this.authService = new AuthService(); } + hydrate = (data: any) => { + if (data) this.currentUser = data; + }; + /** * @description Fetches the current user * @returns Promise diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py index 0abac6b14..6f354d286 100644 --- a/apiserver/plane/license/api/views/admin.py +++ b/apiserver/plane/license/api/views/admin.py @@ -243,6 +243,7 @@ class InstanceAdminSignUpEndpoint(View): ) # Make the setup flag True instance.is_setup_done = True + instance.instance_name = company_name instance.is_telemetry_enabled = is_telemetry_enabled instance.save() diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index e8b9db447..b175e4c83 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -13,7 +13,9 @@ MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # noqa DEBUG_TOOLBAR_PATCH_SETTINGS = False # Only show emails in console don't send it to smtp -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +EMAIL_BACKEND = os.environ.get( + "EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend" +) CACHES = { "default": { diff --git a/apiserver/plane/utils/exception_logger.py b/apiserver/plane/utils/exception_logger.py index f7bb50de2..0938f054b 100644 --- a/apiserver/plane/utils/exception_logger.py +++ b/apiserver/plane/utils/exception_logger.py @@ -6,6 +6,7 @@ from sentry_sdk import capture_exception def log_exception(e): + print(e) # Log the error logger = logging.getLogger("plane") logger.error(e) diff --git a/space/app/layout.tsx b/space/app/layout.tsx index b5501957e..78b4a698b 100644 --- a/space/app/layout.tsx +++ b/space/app/layout.tsx @@ -23,11 +23,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo return ( - - - - - + + + + + {children} diff --git a/space/services/api.service.ts b/space/services/api.service.ts index a5fe3e93d..28be5a131 100644 --- a/space/services/api.service.ts +++ b/space/services/api.service.ts @@ -3,7 +3,7 @@ import axios, { AxiosInstance } from "axios"; // store // import { rootStore } from "@/lib/store-context"; -abstract class APIService { +export abstract class APIService { protected baseURL: string; private axiosInstance: AxiosInstance; @@ -52,5 +52,3 @@ abstract class APIService { return this.axiosInstance(config); } } - -export default APIService; diff --git a/space/services/instance.service.ts b/space/services/instance.service.ts index fb7ab5896..7744f1f65 100644 --- a/space/services/instance.service.ts +++ b/space/services/instance.service.ts @@ -3,7 +3,7 @@ import type { IInstance } from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; // services -import APIService from "@/services/api.service"; +import { APIService } from "@/services/api.service"; export class InstanceService extends APIService { constructor() { diff --git a/web/helpers/common.helper.ts b/web/helpers/common.helper.ts index 2f4814194..cc173b497 100644 --- a/web/helpers/common.helper.ts +++ b/web/helpers/common.helper.ts @@ -6,7 +6,7 @@ export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || ""; export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || ""; -export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || ""; +export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || window.location.origin; export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || ""; export const debounce = (func: any, wait: number, immediate: boolean = false) => { From 8f6d9b8acafa38afcad0d43f76c24dbbd9146d3b Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Tue, 14 May 2024 22:09:29 +0530 Subject: [PATCH 33/37] fix: build errors --- space/services/auth.service.ts | 2 +- space/services/file.service.ts | 2 +- space/services/issue.service.ts | 2 +- space/services/project-member.service.ts | 2 +- space/services/project.service.ts | 2 +- space/services/user.service.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/space/services/auth.service.ts b/space/services/auth.service.ts index e18205479..8333e412c 100644 --- a/space/services/auth.service.ts +++ b/space/services/auth.service.ts @@ -3,7 +3,7 @@ import { ICsrfTokenData, IEmailCheckData, IEmailCheckResponse } from "@plane/typ // helpers import { API_BASE_URL } from "@/helpers/common.helper"; // services -import APIService from "@/services/api.service"; +import { APIService } from "@/services/api.service"; export class AuthService extends APIService { constructor() { diff --git a/space/services/file.service.ts b/space/services/file.service.ts index e8ccfcad4..0e277af1e 100644 --- a/space/services/file.service.ts +++ b/space/services/file.service.ts @@ -2,7 +2,7 @@ import axios from "axios"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; // services -import APIService from "@/services/api.service"; +import { APIService } from "@/services/api.service"; interface UnSplashImage { id: string; diff --git a/space/services/issue.service.ts b/space/services/issue.service.ts index aa54e500e..1913b678e 100644 --- a/space/services/issue.service.ts +++ b/space/services/issue.service.ts @@ -1,6 +1,6 @@ import { API_BASE_URL } from "@/helpers/common.helper"; // services -import APIService from "@/services/api.service"; +import { APIService } from "@/services/api.service"; class IssueService extends APIService { constructor() { diff --git a/space/services/project-member.service.ts b/space/services/project-member.service.ts index c1c2c2732..264d53386 100644 --- a/space/services/project-member.service.ts +++ b/space/services/project-member.service.ts @@ -2,7 +2,7 @@ import type { IProjectMember, IProjectMembership } from "@plane/types"; import { API_BASE_URL } from "@/helpers/common.helper"; // services -import APIService from "@/services/api.service"; +import { APIService } from "@/services/api.service"; export class ProjectMemberService extends APIService { constructor() { diff --git a/space/services/project.service.ts b/space/services/project.service.ts index bff754595..14ed7837b 100644 --- a/space/services/project.service.ts +++ b/space/services/project.service.ts @@ -1,6 +1,6 @@ import { API_BASE_URL } from "@/helpers/common.helper"; // services -import APIService from "@/services/api.service"; +import { APIService } from "@/services/api.service"; class ProjectService extends APIService { constructor() { diff --git a/space/services/user.service.ts b/space/services/user.service.ts index 72500a048..1aeb13466 100644 --- a/space/services/user.service.ts +++ b/space/services/user.service.ts @@ -3,7 +3,7 @@ import { IUser, TUserProfile } from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; // services -import APIService from "@/services/api.service"; +import { APIService } from "@/services/api.service"; export class UserService extends APIService { constructor() { From 2b196ba1f11d0fee097cd128155eea7076576a36 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Tue, 14 May 2024 22:51:07 +0530 Subject: [PATCH 34/37] fix: window workflow build error --- web/helpers/common.helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/helpers/common.helper.ts b/web/helpers/common.helper.ts index cc173b497..2f4814194 100644 --- a/web/helpers/common.helper.ts +++ b/web/helpers/common.helper.ts @@ -6,7 +6,7 @@ export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || ""; export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || ""; -export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || window.location.origin; +export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || ""; export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || ""; export const debounce = (func: any, wait: number, immediate: boolean = false) => { From a2fbd6132b1981a72464c2e78c76d3d035b9eee9 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Wed, 15 May 2024 02:25:38 +0530 Subject: [PATCH 35/37] refactor: publish boards --- .../[workspace_slug]/[project_id]/error.tsx | 5 - .../[workspace_slug]/[project_id]/layout.tsx | 7 +- .../[project_id]/not-found.tsx | 3 - .../[workspace_slug]/[project_id]/page.tsx | 3 +- space/app/layout.tsx | 22 ++- space/app/not-found.tsx | 37 ++--- space/app/page.tsx | 25 +--- .../issues/board-views/kanban/header.tsx | 7 +- .../issues/board-views/list/block.tsx | 1 + .../issues/board-views/list/header.tsx | 8 +- .../issues/board-views/list/index.tsx | 1 + space/components/issues/navbar/controls.tsx | 129 ++++++++++++++++++ space/components/issues/navbar/index.tsx | 116 +--------------- 13 files changed, 187 insertions(+), 177 deletions(-) delete mode 100644 space/app/[workspace_slug]/[project_id]/error.tsx delete mode 100644 space/app/[workspace_slug]/[project_id]/not-found.tsx create mode 100644 space/components/issues/navbar/controls.tsx diff --git a/space/app/[workspace_slug]/[project_id]/error.tsx b/space/app/[workspace_slug]/[project_id]/error.tsx deleted file mode 100644 index b666762d1..000000000 --- a/space/app/[workspace_slug]/[project_id]/error.tsx +++ /dev/null @@ -1,5 +0,0 @@ -"use client"; - -export default function ProjectError() { - return <>Project Error; -} diff --git a/space/app/[workspace_slug]/[project_id]/layout.tsx b/space/app/[workspace_slug]/[project_id]/layout.tsx index 37fc7c544..ad713db18 100644 --- a/space/app/[workspace_slug]/[project_id]/layout.tsx +++ b/space/app/[workspace_slug]/[project_id]/layout.tsx @@ -1,4 +1,5 @@ import Image from "next/image"; +import { notFound } from "next/navigation"; // components import IssueNavbar from "@/components/issues/navbar"; // services @@ -10,7 +11,11 @@ const projectService = new ProjectService(); export default async function ProjectLayout({ children, params }: { children: React.ReactNode; params: any }) { const { workspace_slug, project_id } = params; - const projectSettings = await projectService.getProjectSettings(workspace_slug, project_id); + const projectSettings = await projectService.getProjectSettings(workspace_slug, project_id).catch(() => null); + + if (!projectSettings) { + notFound(); + } return (

diff --git a/space/app/[workspace_slug]/[project_id]/not-found.tsx b/space/app/[workspace_slug]/[project_id]/not-found.tsx deleted file mode 100644 index 9da31ad5e..000000000 --- a/space/app/[workspace_slug]/[project_id]/not-found.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function ProjectSettingsNotFound() { - return <>Project Settings not found; -} diff --git a/space/app/[workspace_slug]/[project_id]/page.tsx b/space/app/[workspace_slug]/[project_id]/page.tsx index 25893ed86..e8491c917 100644 --- a/space/app/[workspace_slug]/[project_id]/page.tsx +++ b/space/app/[workspace_slug]/[project_id]/page.tsx @@ -1,7 +1,8 @@ +"use client"; // components import { ProjectDetailsView } from "@/components/views"; -export default async function WorkspaceProjectPage({ params }: { params: any }) { +export default function WorkspaceProjectPage({ params }: { params: any }) { const { workspace_slug, project_id, peekId } = params; return ; diff --git a/space/app/layout.tsx b/space/app/layout.tsx index 78b4a698b..cc9a94b15 100644 --- a/space/app/layout.tsx +++ b/space/app/layout.tsx @@ -1,8 +1,16 @@ import { Metadata } from "next"; // styles import "@/styles/globals.css"; +// components +import { InstanceNotReady, InstanceFailureView } from "@/components/instance"; // helpers import { ASSET_PREFIX } from "@/helpers/common.helper"; +// lib +import { AppProvider } from "@/lib/app-providers"; +// services +import { InstanceService } from "@/services/instance.service"; + +const instanceService = new InstanceService(); export const metadata: Metadata = { title: "Plane Deploy | Make your Plane boards public with one-click", @@ -20,6 +28,8 @@ export const metadata: Metadata = { }; export default async function RootLayout({ children }: { children: React.ReactNode }) { + const instanceDetails = await instanceService.getInstanceInfo().catch(() => undefined); + return ( @@ -29,7 +39,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo - {children} + + + {!instanceDetails ? ( + + ) : ( + <>{instanceDetails.instance.is_setup_done ? <>{children} : } + )} + + {children} + + ); } diff --git a/space/app/not-found.tsx b/space/app/not-found.tsx index 98c4f95ca..2dd51630f 100644 --- a/space/app/not-found.tsx +++ b/space/app/not-found.tsx @@ -1,36 +1,21 @@ "use client"; import Image from "next/image"; -import { useTheme } from "next-themes"; -import { Button } from "@plane/ui"; // assets -import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg"; -import InstanceFailureImage from "@/public/instance/instance-failure.svg"; +import UserLoggedInImage from "public/user-logged-in.svg"; export default function InstanceNotFound() { - const { resolvedTheme } = useTheme(); - - const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; - - const handleRetry = () => { - window.location.reload(); - }; - return (
-
-
-
- Plane Logo -

Unable to fetch instance details.

-

- We were unable to fetch the details of the instance.
- Fret not, it might just be a connectivity issue. -

-
-
- +
+
+
+
+
+ User already logged in +
+
+

Not Found

+

Please enter the appropriate project URL to view the issue board.

diff --git a/space/app/page.tsx b/space/app/page.tsx index b2c032287..5ac59d7e1 100644 --- a/space/app/page.tsx +++ b/space/app/page.tsx @@ -1,43 +1,20 @@ // components import { UserLoggedIn } from "@/components/accounts"; -import { InstanceNotReady, InstanceFailureView } from "@/components/instance"; import { AuthView } from "@/components/views"; -// helpers -// import { EPageTypes } from "@/helpers/authentication.helper"; -// import { useInstance, useUser } from "@/hooks/store"; -// wrapper -// import { AuthWrapper } from "@/lib/wrappers"; -// lib -import { AppProvider } from "@/lib/app-providers"; // services -import { InstanceService } from "@/services/instance.service"; import { UserService } from "@/services/user.service"; const userServices = new UserService(); -const instanceService = new InstanceService(); export default async function HomePage() { - const instanceDetails = await instanceService.getInstanceInfo().catch(() => undefined); const user = await userServices .currentUser() .then((user) => ({ ...user, isAuthenticated: true })) .catch(() => ({ isAuthenticated: false })); - if (!instanceDetails) { - return ; - } - - if (!instanceDetails?.instance?.is_setup_done) { - ; - } - if (user.isAuthenticated) { return ; } - return ( - - - - ); + return ; } diff --git a/space/components/issues/board-views/kanban/header.tsx b/space/components/issues/board-views/kanban/header.tsx index 5a4d9a226..9e88b6322 100644 --- a/space/components/issues/board-views/kanban/header.tsx +++ b/space/components/issues/board-views/kanban/header.tsx @@ -1,3 +1,4 @@ +"use client"; // mobx react lite import { observer } from "mobx-react-lite"; // ui @@ -5,12 +6,12 @@ import { StateGroupIcon } from "@plane/ui"; // constants import { issueGroupFilter } from "@/constants/data"; // mobx hook -import { useIssue } from "@/hooks/store"; +// import { useIssue } from "@/hooks/store"; // interfaces import { IIssueState } from "@/types/issue"; export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) => { - const { getCountOfIssuesByState } = useIssue(); + // const { getCountOfIssuesByState } = useIssue(); const stateGroup = issueGroupFilter(state.group); if (stateGroup === null) return <>; @@ -21,7 +22,7 @@ export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) =>
{state?.name}
- {getCountOfIssuesByState(state.id)} + {/* {getCountOfIssuesByState(state.id)} */}
); }); diff --git a/space/components/issues/board-views/list/block.tsx b/space/components/issues/board-views/list/block.tsx index e05ebcb2d..2bc9e8133 100644 --- a/space/components/issues/board-views/list/block.tsx +++ b/space/components/issues/board-views/list/block.tsx @@ -1,3 +1,4 @@ +"use client"; import { FC } from "react"; import { observer } from "mobx-react-lite"; import { useParams, useRouter, useSearchParams } from "next/navigation"; diff --git a/space/components/issues/board-views/list/header.tsx b/space/components/issues/board-views/list/header.tsx index 22a902474..f13a17f5a 100644 --- a/space/components/issues/board-views/list/header.tsx +++ b/space/components/issues/board-views/list/header.tsx @@ -1,16 +1,18 @@ +"use client"; import { observer } from "mobx-react-lite"; // ui import { StateGroupIcon } from "@plane/ui"; // constants import { issueGroupFilter } from "@/constants/data"; // mobx hook -import { useIssue } from "@/hooks/store"; +// import { useIssue } from "@/hooks/store"; // types import { IIssueState } from "@/types/issue"; export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { - const { getCountOfIssuesByState } = useIssue(); + // const { getCountOfIssuesByState } = useIssue(); const stateGroup = issueGroupFilter(state.group); + // const count = getCountOfIssuesByState(state.id); if (stateGroup === null) return <>; @@ -20,7 +22,7 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
{state?.name}
-
{getCountOfIssuesByState(state.id)}
+ {/*
{count}
*/}
); }); diff --git a/space/components/issues/board-views/list/index.tsx b/space/components/issues/board-views/list/index.tsx index 7740bfd58..2a2b958be 100644 --- a/space/components/issues/board-views/list/index.tsx +++ b/space/components/issues/board-views/list/index.tsx @@ -1,3 +1,4 @@ +"use client"; import { FC } from "react"; import { observer } from "mobx-react-lite"; // components diff --git a/space/components/issues/navbar/controls.tsx b/space/components/issues/navbar/controls.tsx new file mode 100644 index 000000000..f344ef622 --- /dev/null +++ b/space/components/issues/navbar/controls.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useEffect, FC } from "react"; +import Link from "next/link"; +import { useRouter, useParams, useSearchParams, usePathname } from "next/navigation"; +// ui +import { Avatar, Button } from "@plane/ui"; +// components +import { IssueFiltersDropdown } from "@/components/issues/filters"; +// hooks +import { useProject, useUser, useIssueFilter } from "@/hooks/store"; +// types +import { TIssueBoardKeys } from "@/types/issue"; +// components +import { NavbarIssueBoardView } from "./issue-board-view"; +import { NavbarTheme } from "./theme"; + +export type NavbarControlsProps = { + workspaceSlug: string; + projectId: string; + projectSettings: any; +}; + +export const NavbarControls: FC = (props) => { + const { workspaceSlug, projectId, projectSettings } = props; + const { views } = projectSettings; + // router + const router = useRouter(); + const { board, labels, states, priorities, peekId } = useParams(); + const searchParams = useSearchParams(); + const pathName = usePathname(); + // store + const { updateFilters } = useIssueFilter(); + const { settings, activeLayout, hydrate, setActiveLayout } = useProject(); + hydrate(projectSettings); + + const { data: user } = useUser(); + + useEffect(() => { + if (workspaceSlug && projectId && settings) { + const viewsAcceptable: string[] = []; + const currentBoard: TIssueBoardKeys | null = null; + + if (settings?.views?.list) viewsAcceptable.push("list"); + if (settings?.views?.kanban) viewsAcceptable.push("kanban"); + if (settings?.views?.calendar) viewsAcceptable.push("calendar"); + if (settings?.views?.gantt) viewsAcceptable.push("gantt"); + if (settings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet"); + + // if (board) { + // if (viewsAcceptable.includes(board.toString())) { + // currentBoard = board.toString() as TIssueBoardKeys; + // } else { + // if (viewsAcceptable && viewsAcceptable.length > 0) { + // currentBoard = viewsAcceptable[0] as TIssueBoardKeys; + // } + // } + // } else { + // if (viewsAcceptable && viewsAcceptable.length > 0) { + // currentBoard = viewsAcceptable[0] as TIssueBoardKeys; + // } + // } + + if (currentBoard) { + if (activeLayout === null || activeLayout !== currentBoard) { + let params: any = { board: currentBoard }; + if (peekId && peekId.length > 0) params = { ...params, peekId: peekId }; + if (priorities && priorities.length > 0) params = { ...params, priorities: priorities }; + if (states && states.length > 0) params = { ...params, states: states }; + if (labels && labels.length > 0) params = { ...params, labels: labels }; + console.log("params", params); + let storeParams: any = {}; + if (priorities && priorities.length > 0) storeParams = { ...storeParams, priority: priorities.split(",") }; + if (states && states.length > 0) storeParams = { ...storeParams, state: states.split(",") }; + if (labels && labels.length > 0) storeParams = { ...storeParams, labels: labels.split(",") }; + + if (storeParams) updateFilters(projectId, storeParams); + setActiveLayout(currentBoard); + router.push(`/${workspaceSlug}/${projectId}?${searchParams}`); + } + } + } + }, [ + board, + workspaceSlug, + projectId, + router, + updateFilters, + labels, + states, + priorities, + peekId, + settings, + activeLayout, + setActiveLayout, + searchParams, + ]); + return ( + <> + {/* issue views */} +
+ +
+ + {/* issue filters */} +
+ +
+ + {/* theming */} +
+ +
+ + {user?.id ? ( +
+ +
{user.display_name}
+
+ ) : ( +
+ + + +
+ )} + + ); +}; diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx index 0139ae9ad..4ce2d2691 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/index.tsx @@ -1,21 +1,11 @@ "use client"; -import { useEffect, FC } from "react"; +import { FC } from "react"; import { observer } from "mobx-react-lite"; -import Link from "next/link"; -import { useRouter, useParams, useSearchParams, usePathname } from "next/navigation"; import { Briefcase } from "lucide-react"; -import { Avatar, Button } from "@plane/ui"; // components import { ProjectLogo } from "@/components/common"; -import { IssueFiltersDropdown } from "@/components/issues/filters"; -// hooks -import { useProject, useUser, useIssueFilter } from "@/hooks/store"; -// types -import { TIssueBoardKeys } from "@/types/issue"; -// components -import { NavbarIssueBoardView } from "./issue-board-view"; -import { NavbarTheme } from "./theme"; +import { NavbarControls } from "./controls"; type IssueNavbarProps = { projectSettings: any; @@ -25,80 +15,10 @@ type IssueNavbarProps = { const IssueNavbar: FC = observer((props) => { const { projectSettings, workspaceSlug, projectId } = props; - const { project_details, views } = projectSettings; - const { board, labels, states, priorities, peekId } = useParams(); - const searchParams = useSearchParams(); - const pathName = usePathname(); - // hooks - const router = useRouter(); - // store - const { settings, activeLayout, hydrate, setActiveLayout } = useProject(); - const { data: user } = useUser(); - const { updateFilters } = useIssueFilter(); - hydrate(projectSettings); - - useEffect(() => { - if (workspaceSlug && projectId && settings) { - const viewsAcceptable: string[] = []; - const currentBoard: TIssueBoardKeys | null = null; - - if (settings?.views?.list) viewsAcceptable.push("list"); - if (settings?.views?.kanban) viewsAcceptable.push("kanban"); - if (settings?.views?.calendar) viewsAcceptable.push("calendar"); - if (settings?.views?.gantt) viewsAcceptable.push("gantt"); - if (settings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet"); - - // if (board) { - // if (viewsAcceptable.includes(board.toString())) { - // currentBoard = board.toString() as TIssueBoardKeys; - // } else { - // if (viewsAcceptable && viewsAcceptable.length > 0) { - // currentBoard = viewsAcceptable[0] as TIssueBoardKeys; - // } - // } - // } else { - // if (viewsAcceptable && viewsAcceptable.length > 0) { - // currentBoard = viewsAcceptable[0] as TIssueBoardKeys; - // } - // } - - if (currentBoard) { - if (activeLayout === null || activeLayout !== currentBoard) { - let params: any = { board: currentBoard }; - if (peekId && peekId.length > 0) params = { ...params, peekId: peekId }; - if (priorities && priorities.length > 0) params = { ...params, priorities: priorities }; - if (states && states.length > 0) params = { ...params, states: states }; - if (labels && labels.length > 0) params = { ...params, labels: labels }; - console.log("params", params); - let storeParams: any = {}; - if (priorities && priorities.length > 0) storeParams = { ...storeParams, priority: priorities.split(",") }; - if (states && states.length > 0) storeParams = { ...storeParams, state: states.split(",") }; - if (labels && labels.length > 0) storeParams = { ...storeParams, labels: labels.split(",") }; - - if (storeParams) updateFilters(projectId, storeParams); - setActiveLayout(currentBoard); - router.push(`/${workspaceSlug}/${projectId}?${searchParams}`); - } - } - } - }, [ - board, - workspaceSlug, - projectId, - router, - updateFilters, - labels, - states, - priorities, - peekId, - settings, - activeLayout, - setActiveLayout, - searchParams, - ]); + const { project_details } = projectSettings; return ( -
+
{/* project detail */}
{project_details ? ( @@ -115,33 +35,9 @@ const IssueNavbar: FC = observer((props) => {
- {/* issue views */} -
- +
+
- - {/* issue filters */} -
- -
- - {/* theming */} -
- -
- - {user?.id ? ( -
- -
{user.display_name}
-
- ) : ( -
- - - -
- )}
); }); From 751a4a3b21065bd29bd182098997ef424836c1d1 Mon Sep 17 00:00:00 2001 From: Ramesh Kumar Chandra <31303617+rameshkumarchandra@users.noreply.github.com> Date: Wed, 15 May 2024 15:55:44 +0530 Subject: [PATCH 36/37] [WEB-1311] fix: Issue link copy shortcut macOS (#4455) * chore: issue link copy shortcut in macos * chore: dynamic shortcut key render in shortcut pop up * chore: changing button depending on the os --- .../command-palette/command-palette.tsx | 4 +- .../shortcuts-modal/commands-list.tsx | 61 ++++++++++--------- web/hooks/use-platform-os.tsx | 19 +++++- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index 0b3635b2d..9143d44c7 100644 --- a/web/components/command-palette/command-palette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -22,6 +22,7 @@ import { EUserWorkspaceRoles } from "@/constants/workspace"; import { copyTextToClipboard } from "@/helpers/string.helper"; // hooks import { useEventTracker, useIssues, useUser, useAppTheme, useCommandPalette } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; // services import { IssueService } from "@/services/issue"; @@ -35,6 +36,7 @@ export const CommandPalette: FC = observer(() => { // store hooks const { toggleSidebar } = useAppTheme(); const { setTrackElement } = useEventTracker(); + const { platform } = usePlatformOS(); const { membership: { currentWorkspaceRole, currentProjectRole }, data: currentUser, @@ -210,7 +212,7 @@ export const CommandPalette: FC = observer(() => { return; if (cmdClicked) { - if (keyPressed === "c" && altKey) { + if (keyPressed === "c" && ((platform === "MacOS" && ctrlKey) || altKey)) { e.preventDefault(); copyIssueUrlToClipboard(); } else if (keyPressed === "b") { diff --git a/web/components/command-palette/shortcuts-modal/commands-list.tsx b/web/components/command-palette/shortcuts-modal/commands-list.tsx index 3c327eb8b..d121f247a 100644 --- a/web/components/command-palette/shortcuts-modal/commands-list.tsx +++ b/web/components/command-palette/shortcuts-modal/commands-list.tsx @@ -1,39 +1,42 @@ import { Command } from "lucide-react"; // helpers import { substringMatch } from "@/helpers/string.helper"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; type Props = { searchQuery: string; }; -const KEYBOARD_SHORTCUTS = [ - { - key: "navigation", - title: "Navigation", - shortcuts: [{ keys: "Ctrl,K", description: "Open command menu" }], - }, - { - key: "common", - title: "Common", - shortcuts: [ - { keys: "P", description: "Create project" }, - { keys: "C", description: "Create issue" }, - { keys: "Q", description: "Create cycle" }, - { keys: "M", description: "Create module" }, - { keys: "V", description: "Create view" }, - { keys: "D", description: "Create page" }, - { keys: "Delete", description: "Bulk delete issues" }, - { keys: "H", description: "Open shortcuts guide" }, - { - keys: "Ctrl,Alt,C", - description: "Copy issue URL from the issue details page", - }, - ], - }, -]; - export const ShortcutCommandsList: React.FC = (props) => { const { searchQuery } = props; + const { platform } = usePlatformOS(); + + const KEYBOARD_SHORTCUTS = [ + { + key: "navigation", + title: "Navigation", + shortcuts: [{ keys: "Ctrl,K", description: "Open command menu" }], + }, + { + key: "common", + title: "Common", + shortcuts: [ + { keys: "P", description: "Create project" }, + { keys: "C", description: "Create issue" }, + { keys: "Q", description: "Create cycle" }, + { keys: "M", description: "Create module" }, + { keys: "V", description: "Create view" }, + { keys: "D", description: "Create page" }, + { keys: "Delete", description: "Bulk delete issues" }, + { keys: "H", description: "Open shortcuts guide" }, + { + keys: platform === "MacOS" ? "Ctrl,control,C" : "Ctrl,Alt,C", + description: "Copy issue URL from the issue details page", + }, + ], + }, + ]; const filteredShortcuts = KEYBOARD_SHORTCUTS.map((category) => { const newCategory = { ...category }; @@ -60,13 +63,13 @@ export const ShortcutCommandsList: React.FC = (props) => { {category.shortcuts.map((shortcut) => (
-

{shortcut.description}

+

{shortcut.description}

{shortcut.keys.split(",").map((key) => (
{key === "Ctrl" ? ( -
- +
+ { platform === "MacOS" ? : 'Ctrl'}
) : ( diff --git a/web/hooks/use-platform-os.tsx b/web/hooks/use-platform-os.tsx index da7dfca11..fb9336fa6 100644 --- a/web/hooks/use-platform-os.tsx +++ b/web/hooks/use-platform-os.tsx @@ -2,11 +2,26 @@ import { useEffect, useState } from "react"; export const usePlatformOS = () => { const [isMobile, setIsMobile] = useState(false); + const [platform, setPlatform] = useState(""); useEffect(() => { const userAgent = window.navigator.userAgent; const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent); + let detectedPlatform = ""; - if (isMobile) setIsMobile(isMobile); + if (isMobile) { + setIsMobile(isMobile) + } else { + if (userAgent.indexOf("Win") !== -1) { + detectedPlatform = "Windows"; + } else if (userAgent.indexOf("Mac") !== -1) { + detectedPlatform = "MacOS"; + } else if (userAgent.indexOf("Linux") !== -1) { + detectedPlatform = "Linux"; + } else { + detectedPlatform = "Unknown"; + } + }; + setPlatform(detectedPlatform); }, []); - return { isMobile }; + return { isMobile, platform }; }; From e1197f2b8f7582bf4116992584775778e5c6adc6 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Wed, 15 May 2024 16:28:38 +0530 Subject: [PATCH 37/37] chore: handled multiple children rendering in the space layout (#4459) --- space/app/error.tsx | 30 +++++++++---------- space/app/layout.tsx | 2 -- space/app/not-found.tsx | 19 ++++++------ .../instance/instance-failure-view.tsx | 11 +++---- space/store/user.store.ts | 5 ++-- 5 files changed, 30 insertions(+), 37 deletions(-) diff --git a/space/app/error.tsx b/space/app/error.tsx index 5b7c6ecfb..2d6f22e90 100644 --- a/space/app/error.tsx +++ b/space/app/error.tsx @@ -17,22 +17,20 @@ export default function InstanceError() { }; return ( -
-
-
-
- Plane Logo -

Unable to fetch instance details.

-

- We were unable to fetch the details of the instance.
- Fret not, it might just be a connectivity issue. -

-
-
- -
+
+
+
+ Plane instance failure image +

Unable to fetch instance details.

+

+ We were unable to fetch the details of the instance.
+ Fret not, it might just be a connectivity issue. +

+
+
+
diff --git a/space/app/layout.tsx b/space/app/layout.tsx index cc9a94b15..c2abf44bc 100644 --- a/space/app/layout.tsx +++ b/space/app/layout.tsx @@ -46,8 +46,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo ) : ( <>{instanceDetails.instance.is_setup_done ? <>{children} : } )} - - {children} diff --git a/space/app/not-found.tsx b/space/app/not-found.tsx index 2dd51630f..468f89a97 100644 --- a/space/app/not-found.tsx +++ b/space/app/not-found.tsx @@ -1,22 +1,21 @@ "use client"; + import Image from "next/image"; // assets import UserLoggedInImage from "public/user-logged-in.svg"; export default function InstanceNotFound() { return ( -
-
-
-
-
-
- User already logged in -
+
+
+
+
+
+ User already logged in
-

Not Found

-

Please enter the appropriate project URL to view the issue board.

+

Not Found

+

Please enter the appropriate project URL to view the issue board.

diff --git a/space/components/instance/instance-failure-view.tsx b/space/components/instance/instance-failure-view.tsx index 1173a6894..50f677c10 100644 --- a/space/components/instance/instance-failure-view.tsx +++ b/space/components/instance/instance-failure-view.tsx @@ -1,4 +1,5 @@ "use client"; + import { FC } from "react"; import Image from "next/image"; import { useTheme } from "next-themes"; @@ -7,11 +8,7 @@ import { Button } from "@plane/ui"; import InstanceFailureDarkImage from "public/instance/instance-failure-dark.svg"; import InstanceFailureImage from "public/instance/instance-failure.svg"; -type InstanceFailureViewProps = { - // mutate: () => void; -}; - -export const InstanceFailureView: FC = () => { +export const InstanceFailureView: FC = () => { const { resolvedTheme } = useTheme(); const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; @@ -21,10 +18,10 @@ export const InstanceFailureView: FC = () => { }; return ( -
+
- Plane Logo + Plane instance failure image

Unable to fetch instance details.

We were unable to fetch the details of the instance.
diff --git a/space/store/user.store.ts b/space/store/user.store.ts index 2f228b629..ee376e09f 100644 --- a/space/store/user.store.ts +++ b/space/store/user.store.ts @@ -5,9 +5,10 @@ import { IUser } from "@plane/types"; // services import { AuthService } from "@/services/auth.service"; import { UserService } from "@/services/user.service"; -// stores -import { RootStore } from "@/store/root.store"; +// store types import { ProfileStore, IProfileStore } from "@/store/profile.store"; +import { RootStore } from "@/store/root.store"; +// types import { ActorDetail } from "@/types/issue"; type TUserErrorStatus = {