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; }, };