diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 637305457..891ec1472 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -3,16 +3,26 @@ set -e python manage.py wait_for_db python manage.py migrate -# Set default value for ENABLE_REGISTRATION -ENABLE_REGISTRATION=${ENABLE_REGISTRATION:-1} +# Create the default bucket +#!/bin/bash -# Check if ENABLE_REGISTRATION is not set to '0' -if [ "$ENABLE_REGISTRATION" != "0" ]; then - # Register instance - python manage.py register_instance - # Load the configuration variable - python manage.py configure_instance -fi +# Collect system information +HOSTNAME=$(hostname) +MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1) +CPU_INFO=$(cat /proc/cpuinfo) +MEMORY_INFO=$(free -h) +DISK_INFO=$(df -h) + +# Concatenate information and compute SHA-256 hash +SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}') + +# Export the variables +export MACHINE_SIGNATURE=$SIGNATURE + +# Register instance +python manage.py register_instance $MACHINE_SIGNATURE +# Load the configuration variable +python manage.py configure_instance # Create the default bucket python manage.py create_bucket diff --git a/apiserver/plane/app/permissions/workspace.py b/apiserver/plane/app/permissions/workspace.py index 33bcab31c..f73ae1f67 100644 --- a/apiserver/plane/app/permissions/workspace.py +++ b/apiserver/plane/app/permissions/workspace.py @@ -99,7 +99,6 @@ class WorkspaceViewerPermission(BasePermission): return WorkspaceMember.objects.filter( member=request.user, workspace__slug=view.workspace_slug, - role__gte=10, is_active=True, ).exists() diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index ca1547e37..5c9c69e5c 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -26,6 +26,8 @@ class UserSerializer(BaseSerializer): "token_updated_at", "is_onboarded", "is_bot", + "is_password_autoset", + "is_email_verified", ] extra_kwargs = {"password": {"write_only": True}} @@ -60,6 +62,8 @@ class UserMeSerializer(BaseSerializer): "theme", "last_workspace_id", "use_case", + "is_password_autoset", + "is_email_verified", ] read_only_fields = fields @@ -189,4 +193,3 @@ class ResetPasswordSerializer(serializers.Serializer): Serializer for password change endpoint. """ new_password = serializers.CharField(required=True) - confirm_password = serializers.CharField(required=True) diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 2720c177a..48a4bc44e 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -34,6 +34,7 @@ class WorkSpaceSerializer(BaseSerializer): "profile", "spaces", "workspace-invitations", + "password", ]: raise serializers.ValidationError({"slug": "Slug is not valid"}) diff --git a/apiserver/plane/app/urls/authentication.py b/apiserver/plane/app/urls/authentication.py index 6111075f2..ec3fa78ed 100644 --- a/apiserver/plane/app/urls/authentication.py +++ b/apiserver/plane/app/urls/authentication.py @@ -5,18 +5,15 @@ from rest_framework_simplejwt.views import TokenRefreshView from plane.app.views import ( # Authentication - SignUpEndpoint, SignInEndpoint, SignOutEndpoint, MagicSignInEndpoint, - MagicSignInGenerateEndpoint, OauthEndpoint, + EmailCheckEndpoint, ## End Authentication # Auth Extended ForgotPasswordEndpoint, - VerifyEmailEndpoint, ResetPasswordEndpoint, - RequestEmailVerificationEndpoint, ChangePasswordEndpoint, ## End Auth Extender # API Tokens @@ -27,24 +24,14 @@ from plane.app.views import ( urlpatterns = [ # Social Auth + path("email-check/", EmailCheckEndpoint.as_view(), name="email"), path("social-auth/", OauthEndpoint.as_view(), name="oauth"), # Auth - path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"), path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), - # Magic Sign In/Up - path( - "magic-generate/", MagicSignInGenerateEndpoint.as_view(), name="magic-generate" - ), + # magic sign in path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), - # Email verification - path("email-verify/", VerifyEmailEndpoint.as_view(), name="email-verify"), - path( - "request-email-verify/", - RequestEmailVerificationEndpoint.as_view(), - name="request-reset-email", - ), # Password Manipulation path( "users/me/change-password/", diff --git a/apiserver/plane/app/urls/user.py b/apiserver/plane/app/urls/user.py index 6189b96cb..9dae7b5da 100644 --- a/apiserver/plane/app/urls/user.py +++ b/apiserver/plane/app/urls/user.py @@ -7,6 +7,7 @@ from plane.app.views import ( UpdateUserTourCompletedEndpoint, UserActivityEndpoint, ChangePasswordEndpoint, + SetUserPasswordEndpoint, ## End User ## Workspaces UserWorkSpacesEndpoint, @@ -89,5 +90,10 @@ urlpatterns = [ UserWorkspaceDashboardEndpoint.as_view(), name="user-workspace-dashboard", ), + path( + "users/me/set-password/", + SetUserPasswordEndpoint.as_view(), + name="set-password", + ), ## End User Graph ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index d86023458..6453f1dc6 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -82,20 +82,18 @@ from .issue import ( ) from .auth_extended import ( - VerifyEmailEndpoint, - RequestEmailVerificationEndpoint, ForgotPasswordEndpoint, ResetPasswordEndpoint, ChangePasswordEndpoint, + SetUserPasswordEndpoint, + EmailCheckEndpoint, ) from .authentication import ( - SignUpEndpoint, SignInEndpoint, SignOutEndpoint, MagicSignInEndpoint, - MagicSignInGenerateEndpoint, ) from .module import ( @@ -164,4 +162,8 @@ from .exporter import ExportIssuesEndpoint from .config import ConfigurationEndpoint -from .webhook import WebhookEndpoint, WebhookLogsEndpoint, WebhookSecretRegenerateEndpoint \ No newline at end of file +from .webhook import ( + WebhookEndpoint, + WebhookLogsEndpoint, + WebhookSecretRegenerateEndpoint, +) diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py index 37c8b3d85..1de511f89 100644 --- a/apiserver/plane/app/views/auth_extended.py +++ b/apiserver/plane/app/views/auth_extended.py @@ -1,5 +1,9 @@ ## Python imports -import jwt +import uuid +import os +import json +import random +import string ## Django imports from django.contrib.auth.tokens import PasswordResetTokenGenerator @@ -8,65 +12,95 @@ from django.utils.encoding import ( smart_bytes, DjangoUnicodeDecodeError, ) +from django.contrib.auth.hashers import make_password from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.core.validators import validate_email +from django.core.exceptions import ValidationError from django.conf import settings ## Third Party Imports from rest_framework import status from rest_framework.response import Response -from rest_framework import permissions +from rest_framework.permissions import AllowAny from rest_framework_simplejwt.tokens import RefreshToken -from sentry_sdk import capture_exception - ## Module imports from . import BaseAPIView from plane.app.serializers import ( ChangePasswordSerializer, ResetPasswordSerializer, + UserSerializer, ) -from plane.db.models import User -from plane.bgtasks.email_verification_task import email_verification +from plane.db.models import User, WorkspaceMemberInvite +from plane.license.utils.instance_value import get_configuration_value from plane.bgtasks.forgot_password_task import forgot_password +from plane.license.models import Instance, InstanceConfiguration +from plane.settings.redis import redis_instance +from plane.bgtasks.magic_link_code_task import magic_link +from plane.bgtasks.user_count_task import update_user_instance_user_count +from plane.bgtasks.event_tracking_task import auth_events + +def get_tokens_for_user(user): + refresh = RefreshToken.for_user(user) + return ( + str(refresh.access_token), + str(refresh), + ) -class RequestEmailVerificationEndpoint(BaseAPIView): - def get(self, request): - token = RefreshToken.for_user(request.user).access_token - current_site = request.META.get('HTTP_ORIGIN') - email_verification.delay( - request.user.first_name, request.user.email, token, current_site - ) - return Response( - {"message": "Email sent successfully"}, status=status.HTTP_200_OK - ) +def generate_magic_token(email): + key = "magic_" + str(email) + + ## Generate a random token + token = ( + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + ) + + # Initialize the redis instance + ri = redis_instance() + + # Check if the key already exists in python + if ri.exists(key): + data = json.loads(ri.get(key)) + + current_attempt = data["current_attempt"] + 1 + + if data["current_attempt"] > 2: + return key, token, False + + value = { + "current_attempt": current_attempt, + "email": email, + "token": token, + } + expiry = 600 + + ri.set(key, json.dumps(value), ex=expiry) + + else: + value = {"current_attempt": 0, "email": email, "token": token} + expiry = 600 + + ri.set(key, json.dumps(value), ex=expiry) + + return key, token, True -class VerifyEmailEndpoint(BaseAPIView): - def get(self, request): - token = request.GET.get("token") - try: - payload = jwt.decode(token, settings.SECRET_KEY, algorithms="HS256") - user = User.objects.get(id=payload["user_id"]) +def generate_password_token(user): + uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) + token = PasswordResetTokenGenerator().make_token(user) - if not user.is_email_verified: - user.is_email_verified = True - user.save() - return Response( - {"email": "Successfully activated"}, status=status.HTTP_200_OK - ) - except jwt.ExpiredSignatureError as _indentifier: - return Response( - {"email": "Activation expired"}, status=status.HTTP_400_BAD_REQUEST - ) - except jwt.exceptions.DecodeError as _indentifier: - return Response( - {"email": "Invalid token"}, status=status.HTTP_400_BAD_REQUEST - ) + return uidb64, token class ForgotPasswordEndpoint(BaseAPIView): - permission_classes = [permissions.AllowAny] + permission_classes = [ + AllowAny, + ] def post(self, request): email = request.data.get("email") @@ -76,7 +110,7 @@ class ForgotPasswordEndpoint(BaseAPIView): uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) token = PasswordResetTokenGenerator().make_token(user) - current_site = request.META.get('HTTP_ORIGIN') + current_site = request.META.get("HTTP_ORIGIN") forgot_password.delay( user.first_name, user.email, uidb64, token, current_site @@ -92,7 +126,7 @@ class ForgotPasswordEndpoint(BaseAPIView): class ResetPasswordEndpoint(BaseAPIView): - permission_classes = [permissions.AllowAny] + permission_classes = [AllowAny,] def post(self, request, uidb64, token): try: @@ -100,22 +134,26 @@ class ResetPasswordEndpoint(BaseAPIView): user = User.objects.get(id=id) if not PasswordResetTokenGenerator().check_token(user, token): return Response( - {"error": "token is not valid, please check the new one"}, + {"error": "Token is invalid"}, status=status.HTTP_401_UNAUTHORIZED, ) - serializer = ResetPasswordSerializer(data=request.data) + serializer = ResetPasswordSerializer(data=request.data) if serializer.is_valid(): # 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() - response = { - "status": "success", - "code": status.HTTP_200_OK, - "message": "Password updated successfully", + + # Generate access token for the user + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, } - return Response(response) + return Response(data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except DjangoUnicodeDecodeError as indentifier: @@ -138,6 +176,208 @@ class ChangePasswordEndpoint(BaseAPIView): ) # 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() - return Response({"message": "Password updated successfully"}, status=status.HTTP_200_OK) + return Response( + {"message": "Password updated successfully"}, status=status.HTTP_200_OK + ) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class SetUserPasswordEndpoint(BaseAPIView): + def post(self, request): + user = User.objects.get(pk=request.user.id) + password = request.data.get("password", False) + + # If the user password is not autoset then return error + if not user.is_password_autoset: + return Response( + { + "error": "Your password is already set please change your password from profile" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check password validation + if not password and len(str(password)) < 8: + return Response( + {"error": "Password is not valid"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Set the user password + user.set_password(password) + user.is_password_autoset = False + user.save() + serializer = UserSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class EmailCheckEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + # get the email + + # Check the instance registration + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + instance_configuration = InstanceConfiguration.objects.values("key", "value") + + email = request.data.get("email", False) + type = request.data.get("type", "magic_code") + + if not email: + return Response({"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST) + + # validate the email + try: + validate_email(email) + except ValidationError: + return Response({"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST) + + # Check if the user exists + user = User.objects.filter(email=email).first() + current_site = request.META.get("HTTP_ORIGIN") + + # If new user + if user is None: + # Create the user + if ( + get_configuration_value( + instance_configuration, + "ENABLE_SIGNUP", + os.environ.get("ENABLE_SIGNUP", "0"), + ) + == "0" + and not WorkspaceMemberInvite.objects.filter( + email=email, + ).exists() + ): + return Response( + { + "error": "New account creation is disabled. Please contact your site administrator" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + + user = User.objects.create( + email=email, + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + ) + + # Update instance user count + update_user_instance_user_count.delay() + + # Case when the user selects magic code + if type == "magic_code": + if not bool(get_configuration_value( + instance_configuration, + "ENABLE_MAGIC_LINK_LOGIN", + os.environ.get("ENABLE_MAGIC_LINK_LOGIN")), + ): + return Response( + {"error": "Magic link sign in is disabled."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Send event + if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="MAGIC_LINK", + first_time=True, + ) + key, token, current_attempt = generate_magic_token(email=email) + if not current_attempt: + return Response({"error": "Max attempts exhausted. Please try again later."}, status=status.HTTP_400_BAD_REQUEST) + # Trigger the email + magic_link.delay(email, "magic_" + str(email), token, current_site) + return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) + else: + # Get the uidb64 and token for the user + uidb64, token = generate_password_token(user=user) + forgot_password.delay( + user.first_name, user.email, uidb64, token, current_site + ) + # Send event + if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="EMAIL", + first_time=True, + ) + # Automatically send the email + return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_400_BAD_REQUEST) + # Existing user + else: + if type == "magic_code": + ## Generate a random token + if not bool(get_configuration_value( + instance_configuration, + "ENABLE_MAGIC_LINK_LOGIN", + os.environ.get("ENABLE_MAGIC_LINK_LOGIN")), + ): + return Response( + {"error": "Magic link sign in is disabled."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="MAGIC_LINK", + first_time=False, + ) + + # Generate magic token + key, token, current_attempt = generate_magic_token(email=email) + if not current_attempt: + return Response({"error": "Max attempts exhausted. Please try again later."}, status=status.HTTP_400_BAD_REQUEST) + + # Trigger the email + magic_link.delay(email, key, token, current_site) + return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) + else: + if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="EMAIL", + first_time=False, + ) + + if user.is_password_autoset: + # send email + uidb64, token = generate_password_token(user=user) + forgot_password.delay( + user.first_name, user.email, uidb64, token, current_site + ) + return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) + else: + # User should enter password to login + return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py index 5ac9377d3..87118b7d5 100644 --- a/apiserver/plane/app/views/authentication.py +++ b/apiserver/plane/app/views/authentication.py @@ -4,8 +4,6 @@ import uuid import random import string import json -import requests -from requests.exceptions import RequestException # Django imports from django.utils import timezone @@ -20,7 +18,7 @@ from rest_framework.permissions import AllowAny from rest_framework import status from rest_framework_simplejwt.tokens import RefreshToken -from sentry_sdk import capture_exception, capture_message +from sentry_sdk import capture_message # Module imports from . import BaseAPIView @@ -32,10 +30,12 @@ from plane.db.models import ( ProjectMember, ) from plane.settings.redis import redis_instance -from plane.bgtasks.magic_link_code_task import magic_link -from plane.license.models import InstanceConfiguration +from plane.license.models import InstanceConfiguration, Instance from plane.license.utils.instance_value import get_configuration_value from plane.bgtasks.event_tracking_task import auth_events +from plane.bgtasks.magic_link_code_task import magic_link +from plane.bgtasks.user_count_task import update_user_instance_user_count + def get_tokens_for_user(user): refresh = RefreshToken.for_user(user) @@ -50,22 +50,6 @@ class SignUpEndpoint(BaseAPIView): def post(self, request): instance_configuration = InstanceConfiguration.objects.values("key", "value") - if ( - not get_configuration_value( - instance_configuration, - "ENABLE_SIGNUP", - os.environ.get("ENABLE_SIGNUP", "0"), - ) - and not WorkspaceMemberInvite.objects.filter( - email=request.user.email - ).exists() - ): - return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, - status=status.HTTP_400_BAD_REQUEST, - ) email = request.data.get("email", False) password = request.data.get("password", False) @@ -87,6 +71,24 @@ class SignUpEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + if ( + get_configuration_value( + instance_configuration, + "ENABLE_SIGNUP", + os.environ.get("ENABLE_SIGNUP", "0"), + ) + == "0" + and not WorkspaceMemberInvite.objects.filter( + email=email, + ).exists() + ): + return Response( + { + "error": "New account creation is disabled. Please contact your site administrator" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Check if the user already exists if User.objects.filter(email=email).exists(): return Response( @@ -105,81 +107,16 @@ class SignUpEndpoint(BaseAPIView): user.token_updated_at = timezone.now() user.save() - # Check if user has any accepted invites for workspace and add them to workspace - workspace_member_invites = WorkspaceMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=workspace_member_invite.workspace_id, - member=user, - role=workspace_member_invite.role, - ) - for workspace_member_invite in workspace_member_invites - ], - ignore_conflicts=True, - ) - - # Check if user has any project invites - project_member_invites = ProjectMemberInvite.objects.filter( - email=user.email, accepted=True - ) - - # Add user to workspace - WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember( - workspace_id=project_member_invite.workspace_id, - role=project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15, - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - - # Now add the users to project - ProjectMember.objects.bulk_create( - [ - ProjectMember( - workspace_id=project_member_invite.workspace_id, - role=project_member_invite.role - if project_member_invite.role in [5, 10, 15] - else 15, - member=user, - created_by_id=project_member_invite.created_by_id, - ) - for project_member_invite in project_member_invites - ], - ignore_conflicts=True, - ) - # Delete all the invites - workspace_member_invites.delete() - project_member_invites.delete() - - # Send event - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", - first_time=True - ) - access_token, refresh_token = get_tokens_for_user(user) data = { "access_token": access_token, "refresh_token": refresh_token, } + + # Update instance user count + update_user_instance_user_count.delay() + return Response(data, status=status.HTTP_200_OK) @@ -207,8 +144,18 @@ class SignInEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # Check if the instance setup is done or not + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the user user = User.objects.filter(email=email).first() + # User is not present in db if user is None: return Response( { @@ -217,7 +164,7 @@ class SignInEndpoint(BaseAPIView): status=status.HTTP_403_FORBIDDEN, ) - # Sign up Process + # Check user password if not user.check_password(password): return Response( { @@ -292,7 +239,7 @@ class SignInEndpoint(BaseAPIView): # Delete all the invites workspace_member_invites.delete() project_member_invites.delete() - # Send event + # Send event if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: auth_events.delay( user=user.id, @@ -301,7 +248,7 @@ class SignInEndpoint(BaseAPIView): ip=request.META.get("REMOTE_ADDR"), event_name="SIGN_IN", medium="EMAIL", - first_time=False + first_time=False, ) access_token, refresh_token = get_tokens_for_user(user) @@ -335,101 +282,19 @@ class SignOutEndpoint(BaseAPIView): return Response({"message": "success"}, status=status.HTTP_200_OK) -class MagicSignInGenerateEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - email = request.data.get("email", False) - - instance_configuration = InstanceConfiguration.objects.values("key", "value") - if ( - not get_configuration_value( - instance_configuration, - "ENABLE_MAGIC_LINK_LOGIN", - os.environ.get("ENABLE_MAGIC_LINK_LOGIN"), - ) - and not ( - get_configuration_value( - instance_configuration, - "ENABLE_SIGNUP", - os.environ.get("ENABLE_SIGNUP", "0"), - ) - ) - and not WorkspaceMemberInvite.objects.filter( - email=request.user.email - ).exists() - ): - return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - if not email: - return Response( - {"error": "Please provide a valid email address"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Clean up - email = email.strip().lower() - validate_email(email) - - ## Generate a random token - token = ( - "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - ) - - ri = redis_instance() - - key = "magic_" + str(email) - - # Check if the key already exists in python - if ri.exists(key): - data = json.loads(ri.get(key)) - - current_attempt = data["current_attempt"] + 1 - - if data["current_attempt"] > 2: - return Response( - {"error": "Max attempts exhausted. Please try again later."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - value = { - "current_attempt": current_attempt, - "email": email, - "token": token, - } - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - else: - value = {"current_attempt": 0, "email": email, "token": token} - expiry = 600 - - ri.set(key, json.dumps(value), ex=expiry) - - current_site = request.META.get("HTTP_ORIGIN") - magic_link.delay(email, key, token, current_site) - - return Response({"key": key}, status=status.HTTP_200_OK) - - class MagicSignInEndpoint(BaseAPIView): permission_classes = [ AllowAny, ] def post(self, request): + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + user_token = request.data.get("token", "").strip() key = request.data.get("key", False).strip().lower() @@ -448,48 +313,28 @@ class MagicSignInEndpoint(BaseAPIView): email = data["email"] if str(token) == str(user_token): - if User.objects.filter(email=email).exists(): - user = User.objects.get(email=email) - if not user.is_active: - return Response( - { - "error": "Your account has been deactivated. Please contact your site administrator." - }, - status=status.HTTP_403_FORBIDDEN, - ) - # Send event - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", - first_time=False - ) - - else: - user = User.objects.create( + user = User.objects.get(email=email) + if not user.is_active: + return Response( + { + "error": "Your account has been deactivated. Please contact your site administrator." + }, + status=status.HTTP_403_FORBIDDEN, + ) + # Send event + if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: + auth_events.delay( + user=user.id, email=email, - username=uuid.uuid4().hex, - password=make_password(uuid.uuid4().hex), - is_password_autoset=True, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="MAGIC_LINK", + first_time=False, ) - # Send event - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", - first_time=True - ) - user.is_active = True + user.is_email_verified = True user.last_active = timezone.now() user.last_login_time = timezone.now() user.last_login_ip = request.META.get("REMOTE_ADDR") diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py index bab502bd6..0dd7fbaf0 100644 --- a/apiserver/plane/app/views/oauth.py +++ b/apiserver/plane/app/views/oauth.py @@ -2,7 +2,6 @@ import uuid import requests import os -from requests.exceptions import RequestException # Django imports from django.utils import timezone @@ -31,8 +30,9 @@ from plane.db.models import ( ) from plane.bgtasks.event_tracking_task import auth_events from .base import BaseAPIView -from plane.license.models import InstanceConfiguration +from plane.license.models import InstanceConfiguration, Instance from plane.license.utils.instance_value import get_configuration_value +from plane.bgtasks.user_count_task import update_user_instance_user_count def get_tokens_for_user(user): @@ -136,6 +136,14 @@ class OauthEndpoint(BaseAPIView): def post(self, request): try: + # Check if instance is registered or not + instance = Instance.objects.first() + if instance is None and not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + medium = request.data.get("medium", False) id_token = request.data.get("credential", False) client_id = request.data.get("clientId", False) @@ -143,34 +151,17 @@ class OauthEndpoint(BaseAPIView): instance_configuration = InstanceConfiguration.objects.values( "key", "value" ) - if ( - ( - not get_configuration_value( - instance_configuration, - "GOOGLE_CLIENT_ID", - os.environ.get("GOOGLE_CLIENT_ID"), - ) - or not get_configuration_value( - instance_configuration, - "GITHUB_CLIENT_ID", - os.environ.get("GITHUB_CLIENT_ID"), - ) - ) - and not ( - get_configuration_value( - instance_configuration, - "ENABLE_SIGNUP", - os.environ.get("ENABLE_SIGNUP", "0"), - ) - ) - and not WorkspaceMemberInvite.objects.filter( - email=request.user.email - ).exists() + if not get_configuration_value( + instance_configuration, + "GOOGLE_CLIENT_ID", + os.environ.get("GOOGLE_CLIENT_ID"), + ) or not get_configuration_value( + instance_configuration, + "GITHUB_CLIENT_ID", + os.environ.get("GITHUB_CLIENT_ID"), ): return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, + {"error": "Github or Google login is not configured"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -286,8 +277,8 @@ class OauthEndpoint(BaseAPIView): "last_login_at": timezone.now(), }, ) - - # Send event + + # Send event if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: auth_events.delay( user=user.id, @@ -295,8 +286,8 @@ class OauthEndpoint(BaseAPIView): user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), event_name="SIGN_IN", - medium=medium.upper(), - first_time=False + medium=medium.upper(), + first_time=False, ) access_token, refresh_token = get_tokens_for_user(user) @@ -309,6 +300,16 @@ class OauthEndpoint(BaseAPIView): except User.DoesNotExist: ## Signup Case + instance_configuration = InstanceConfiguration.objects.values( + "key", "value" + ) + # Check if instance is registered or not + instance = Instance.objects.first() + if instance is None and not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) if ( get_configuration_value( @@ -316,8 +317,9 @@ class OauthEndpoint(BaseAPIView): "ENABLE_SIGNUP", os.environ.get("ENABLE_SIGNUP", "0"), ) + == "0" and not WorkspaceMemberInvite.objects.filter( - email=request.user.email + email=email, ).exists() ): return Response( @@ -341,7 +343,7 @@ class OauthEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - user = User( + user = User.objects.create( username=username, email=email, mobile_number=mobile_number, @@ -352,7 +354,6 @@ class OauthEndpoint(BaseAPIView): ) user.set_password(uuid.uuid4().hex) - user.is_password_autoset = True user.last_active = timezone.now() user.last_login_time = timezone.now() user.last_login_ip = request.META.get("REMOTE_ADDR") @@ -418,7 +419,7 @@ class OauthEndpoint(BaseAPIView): workspace_member_invites.delete() project_member_invites.delete() - # Send event + # Send event if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: auth_events.delay( user=user.id, @@ -427,7 +428,7 @@ class OauthEndpoint(BaseAPIView): ip=request.META.get("REMOTE_ADDR"), event_name="SIGN_IN", medium=medium.upper(), - first_time=True + first_time=True, ) SocialLoginConnection.objects.update_or_create( @@ -445,4 +446,7 @@ class OauthEndpoint(BaseAPIView): "access_token": access_token, "refresh_token": refresh_token, } + + # Update the user count + update_user_instance_user_count.delay() return Response(data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/app/views/user.py b/apiserver/plane/app/views/user.py index e25434bec..85874f460 100644 --- a/apiserver/plane/app/views/user.py +++ b/apiserver/plane/app/views/user.py @@ -17,7 +17,7 @@ from plane.license.models import Instance, InstanceAdmin from plane.utils.paginator import BasePaginator -from django.db.models import Q, F, Count, Case, When, Value, IntegerField +from django.db.models import Q, F, Count, Case, When, IntegerField class UserEndpoint(BaseViewSet): @@ -52,7 +52,6 @@ class UserEndpoint(BaseViewSet): projects_to_deactivate = [] workspaces_to_deactivate = [] - projects = ProjectMember.objects.filter( member=request.user, is_active=True ).annotate( @@ -155,3 +154,4 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator): issue_activities, many=True ).data, ) + diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py deleted file mode 100644 index 3f8f19fe6..000000000 --- a/apiserver/plane/bgtasks/email_verification_task.py +++ /dev/null @@ -1,90 +0,0 @@ -# Python imports -import os - -# Django imports -from django.core.mail import EmailMultiAlternatives, get_connection -from django.template.loader import render_to_string -from django.utils.html import strip_tags -from django.conf import settings - -# Third party imports -from celery import shared_task - - -from sentry_sdk import capture_exception - -# Module imports -from plane.license.models import InstanceConfiguration -from plane.license.utils.instance_value import get_configuration_value - - -@shared_task -def email_verification(first_name, email, token, current_site): - try: - realtivelink = "/request-email-verification/" + "?token=" + str(token) - abs_url = current_site + realtivelink - - subject = "Verify your Email!" - - context = { - "first_name": first_name, - "verification_url": abs_url, - } - - html_content = render_to_string("emails/auth/email_verification.html", context) - - text_content = strip_tags(html_content) - - # Configure email connection from the database - instance_configuration = InstanceConfiguration.objects.filter( - key__startswith="EMAIL_" - ).values("key", "value") - connection = get_connection( - host=get_configuration_value( - instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") - ), - port=int( - get_configuration_value( - instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") - ) - ), - username=get_configuration_value( - instance_configuration, - "EMAIL_HOST_USER", - os.environ.get("EMAIL_HOST_USER"), - ), - password=get_configuration_value( - instance_configuration, - "EMAIL_HOST_PASSWORD", - os.environ.get("EMAIL_HOST_PASSWORD"), - ), - use_tls=bool( - get_configuration_value( - instance_configuration, - "EMAIL_USE_TLS", - os.environ.get("EMAIL_USE_TLS", "1"), - ) - ), - ) - - # Initiate email alternatives - msg = EmailMultiAlternatives( - subject=subject, - body=text_content, - from_email=get_configuration_value( - instance_configuration, - "EMAIL_FROM", - os.environ.get("EMAIL_FROM", "Team Plane "), - ), - to=[email], - connection=connection, - ) - msg.attach_alternative(html_content, "text/html") - msg.send() - return - except Exception as e: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) - return diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index ca0eeb91d..1d1f44c80 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -1,5 +1,7 @@ # Python import import os +import requests +import json # Django imports from django.core.mail import EmailMultiAlternatives, get_connection @@ -12,17 +14,61 @@ from celery import shared_task from sentry_sdk import capture_exception # Module imports -from plane.license.models import InstanceConfiguration -from plane.license.utils.instance_value import get_configuration_value +from plane.license.models import InstanceConfiguration, Instance +from plane.license.utils.instance_value import get_email_configuration + @shared_task def forgot_password(first_name, email, uidb64, token, current_site): - try: - realtivelink = f"/accounts/reset-password/?uidb64={uidb64}&token={token}" - abs_url = current_site + realtivelink + relative_link = ( + f"/accounts/password/?uidb64={uidb64}&token={token}&email={email}" + ) + abs_url = current_site + relative_link - subject = "Reset Your Password - Plane" + instance_configuration = InstanceConfiguration.objects.filter( + key__startswith="EMAIL_" + ).values("key", "value") + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration(instance_configuration=instance_configuration) + + # Send the email if the users don't have smtp configured + if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD: + # Check the instance registration + instance = Instance.objects.first() + + # send the emails through control center + license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) + + # headers + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + + payload = { + "abs_url": abs_url, + "first_name": first_name, + "email": email, + } + + _ = requests.post( + f"{license_engine_base_url}/api/instances/users/forgot-password/", + headers=headers, + data=json.dumps(payload), + ) + + return + + subject = "A new password to your Plane account has been requested" context = { "first_name": first_name, @@ -33,43 +79,21 @@ def forgot_password(first_name, email, uidb64, token, current_site): text_content = strip_tags(html_content) - instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + instance_configuration = InstanceConfiguration.objects.filter( + key__startswith="EMAIL_" + ).values("key", "value") connection = get_connection( - host=get_configuration_value( - instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") - ), - port=int( - get_configuration_value( - instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") - ) - ), - username=get_configuration_value( - instance_configuration, - "EMAIL_HOST_USER", - os.environ.get("EMAIL_HOST_USER"), - ), - password=get_configuration_value( - instance_configuration, - "EMAIL_HOST_PASSWORD", - os.environ.get("EMAIL_HOST_PASSWORD"), - ), - use_tls=bool( - get_configuration_value( - instance_configuration, - "EMAIL_USE_TLS", - os.environ.get("EMAIL_USE_TLS", "1"), - ) - ), + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=bool(EMAIL_USE_TLS), ) msg = EmailMultiAlternatives( subject=subject, body=text_content, - from_email=get_configuration_value( - instance_configuration, - "EMAIL_FROM", - os.environ.get("EMAIL_FROM", "Team Plane "), - ), + from_email=EMAIL_FROM, to=[email], connection=connection, ) diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index 84d10ecd3..beb012b9f 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -26,6 +26,7 @@ from plane.db.models import ( IssueProperty, ) from plane.bgtasks.user_welcome_task import send_welcome_slack +from plane.bgtasks.user_count_task import update_user_instance_user_count @shared_task @@ -120,6 +121,9 @@ def service_importer(service, importer_id): batch_size=100, ignore_conflicts=True, ) + + # Update instance user count + update_user_instance_user_count.delay() # Check if sync config is on for github importers if service == "github" and importer.config.get("sync", False): diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 0db8e5504..d0138c077 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -1,5 +1,7 @@ # Python imports import os +import requests +import json # Django imports from django.core.mail import EmailMultiAlternatives, get_connection @@ -12,63 +14,78 @@ from celery import shared_task from sentry_sdk import capture_exception # Module imports -from plane.license.models import InstanceConfiguration -from plane.license.utils.instance_value import get_configuration_value +from plane.license.models import InstanceConfiguration, Instance +from plane.license.utils.instance_value import get_email_configuration @shared_task def magic_link(email, key, token, current_site): try: - realtivelink = f"/magic-sign-in/?password={token}&key={key}" - abs_url = current_site + realtivelink - - subject = "Login for Plane" - - context = {"magic_url": abs_url, "code": token} - - html_content = render_to_string("emails/auth/magic_signin.html", context) - - text_content = strip_tags(html_content) + if current_site: + realtivelink = f"/magic-sign-in/?password={token}&key={key}" + abs_url = current_site + realtivelink + else: + abs_url = "" instance_configuration = InstanceConfiguration.objects.filter( key__startswith="EMAIL_" ).values("key", "value") + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration(instance_configuration=instance_configuration) + + # Send the email if the users don't have smtp configured + if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD: + # Check the instance registration + instance = Instance.objects.first() + + # send the emails through control center + license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) + + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + + payload = { + "token": token, + "email": email, + } + + _ = requests.post( + f"{license_engine_base_url}/api/instances/users/magic-code/", + headers=headers, + data=json.dumps(payload), + ) + + return + + # Send the mail + subject = f"Your unique Plane login code is {token}" + context = {"code": token} + + html_content = render_to_string("emails/auth/magic_signin.html", context) + text_content = strip_tags(html_content) + connection = get_connection( - host=get_configuration_value( - instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") - ), - port=int( - get_configuration_value( - instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") - ) - ), - username=get_configuration_value( - instance_configuration, - "EMAIL_HOST_USER", - os.environ.get("EMAIL_HOST_USER"), - ), - password=get_configuration_value( - instance_configuration, - "EMAIL_HOST_PASSWORD", - os.environ.get("EMAIL_HOST_PASSWORD"), - ), - use_tls=bool( - get_configuration_value( - instance_configuration, - "EMAIL_USE_TLS", - os.environ.get("EMAIL_USE_TLS", "1"), - ) - ), + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=bool(EMAIL_USE_TLS), ) msg = EmailMultiAlternatives( subject=subject, body=text_content, - from_email=get_configuration_value( - instance_configuration, - "EMAIL_FROM", - os.environ.get("EMAIL_FROM", "Team Plane "), - ), + from_email=EMAIL_FROM, to=[email], connection=connection, ) diff --git a/apiserver/plane/bgtasks/user_count_task.py b/apiserver/plane/bgtasks/user_count_task.py new file mode 100644 index 000000000..f93c3364e --- /dev/null +++ b/apiserver/plane/bgtasks/user_count_task.py @@ -0,0 +1,49 @@ +# Python imports +import json +import requests +import os + +# django imports +from django.conf import settings + +# Third party imports +from celery import shared_task +from sentry_sdk import capture_exception + +# Module imports +from plane.db.models import User +from plane.license.models import Instance + +@shared_task +def update_user_instance_user_count(): + try: + instance_users = User.objects.filter(is_bot=False).count() + instance = Instance.objects.update(user_count=instance_users) + + # Update the count in the license engine + payload = { + "user_count": User.objects.count(), + } + + # Save the user in control center + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + + license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") + if not license_engine_base_url: + raise Exception("License Engine base url is required") + + # Update the license engine + _ = requests.post( + f"{license_engine_base_url}/api/instances/", + headers=headers, + data=json.dumps(payload), + ) + + except Exception as e: + if settings.DEBUG: + print(e) + capture_exception(e) diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 27a1d1d38..03e6c1353 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -1,5 +1,7 @@ # Python imports import os +import requests +import json # Django imports from django.core.mail import EmailMultiAlternatives, get_connection @@ -15,8 +17,8 @@ from slack_sdk.errors import SlackApiError # Module imports from plane.db.models import Workspace, WorkspaceMemberInvite, User -from plane.license.models import InstanceConfiguration -from plane.license.utils.instance_value import get_configuration_value +from plane.license.models import InstanceConfiguration, Instance +from plane.license.utils.instance_value import get_email_configuration @shared_task @@ -35,14 +37,56 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): # The complete url including the domain abs_url = current_site + relative_link + instance_configuration = InstanceConfiguration.objects.filter( + key__startswith="EMAIL_" + ).values("key", "value") + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration(instance_configuration=instance_configuration) + + # Send the email if the users don't have smtp configured + if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD: + # Check the instance registration + instance = Instance.objects.first() + + # send the emails through control center + license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) + if not license_engine_base_url: + raise Exception("License engine base url is required") + + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + + payload = { + "user": user.first_name or user.display_name or user.email, + "workspace_name": workspace.name, + "invitation_url": abs_url, + "email": email, + } + _ = requests.post( + f"{license_engine_base_url}/api/instances/users/workspace-invitation/", + headers=headers, + data=json.dumps(payload), + ) + + return + # Subject of the email - subject = f"{user.first_name or user.display_name or user.email} invited you to join {workspace.name} on Plane" + subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" context = { "email": email, - "first_name": invitor, + "first_name": user.first_name or user.display_name or user.email, "workspace_name": workspace.name, - "invitation_url": abs_url, } html_content = render_to_string( @@ -58,41 +102,17 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): key__startswith="EMAIL_" ).values("key", "value") connection = get_connection( - host=get_configuration_value( - instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") - ), - port=int( - get_configuration_value( - instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") - ) - ), - username=get_configuration_value( - instance_configuration, - "EMAIL_HOST_USER", - os.environ.get("EMAIL_HOST_USER"), - ), - password=get_configuration_value( - instance_configuration, - "EMAIL_HOST_PASSWORD", - os.environ.get("EMAIL_HOST_PASSWORD"), - ), - use_tls=bool( - get_configuration_value( - instance_configuration, - "EMAIL_USE_TLS", - os.environ.get("EMAIL_USE_TLS", "1"), - ) - ), + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=bool(EMAIL_USE_TLS), ) msg = EmailMultiAlternatives( subject=subject, body=text_content, - from_email=get_configuration_value( - instance_configuration, - "EMAIL_FROM", - os.environ.get("EMAIL_FROM", "Team Plane "), - ), + from_email=EMAIL_FROM, to=[email], connection=connection, ) diff --git a/apiserver/plane/db/migrations/0048_auto_20231116_0713.py b/apiserver/plane/db/migrations/0048_auto_20231116_0713.py index 8c5de417e..8d896b01d 100644 --- a/apiserver/plane/db/migrations/0048_auto_20231116_0713.py +++ b/apiserver/plane/db/migrations/0048_auto_20231116_0713.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), ('transaction', models.UUIDField(default=uuid.uuid4)), ('entity_identifier', models.UUIDField(null=True)), - ('entity_name', models.CharField(choices=[('to_do', 'To Do'), ('issue', 'issue'), ('image', 'Image'), ('video', 'Video'), ('file', 'File'), ('link', 'Link'), ('cycle', 'Cycle'), ('module', 'Module'), ('back_link', 'Back Link'), ('forward_link', 'Forward Link'), ('mention', 'Mention')], max_length=30, verbose_name='Transaction Type')), + ('entity_name', models.CharField(choices=[('to_do', 'To Do'), ('issue', 'issue'), ('image', 'Image'), ('video', 'Video'), ('file', 'File'), ('link', 'Link'), ('cycle', 'Cycle'), ('module', 'Module'), ('back_link', 'Back Link'), ('forward_link', 'Forward Link'), ('page_mention', 'Page Mention'), ('user_mention', 'User Mention')], max_length=30, verbose_name='Transaction Type')), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_log', to='db.page')), ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), diff --git a/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py b/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py index 3f00f0ed9..a8807d104 100644 --- a/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py +++ b/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py @@ -1,6 +1,11 @@ # Generated by Django 4.2.5 on 2023-11-17 08:48 from django.db import migrations, models +import plane.db.models.workspace + +def user_password_autoset(apps, schema_editor): + User = apps.get_model("db", "User") + User.objects.update(is_password_autoset=True) class Migration(migrations.Migration): @@ -20,4 +25,15 @@ class Migration(migrations.Migration): name='organization_size', field=models.CharField(blank=True, max_length=20, null=True), ), + migrations.AddField( + model_name='fileasset', + name='is_deleted', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='workspace', + name='slug', + field=models.SlugField(max_length=48, unique=True, validators=[plane.db.models.workspace.slug_validator]), + ), + migrations.RunPython(user_password_autoset), ] diff --git a/apiserver/plane/db/migrations/0051_fileasset_is_deleted.py b/apiserver/plane/db/migrations/0051_fileasset_is_deleted.py deleted file mode 100644 index 914852bfd..000000000 --- a/apiserver/plane/db/migrations/0051_fileasset_is_deleted.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-20 08:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0050_user_use_case_alter_workspace_organization_size'), - ] - - operations = [ - migrations.AddField( - model_name='fileasset', - name='is_deleted', - field=models.BooleanField(default=False), - ), - ] diff --git a/apiserver/plane/db/migrations/0052_alter_workspace_slug.py b/apiserver/plane/db/migrations/0052_alter_workspace_slug.py deleted file mode 100644 index 8126c1fa3..000000000 --- a/apiserver/plane/db/migrations/0052_alter_workspace_slug.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.5 on 2023-11-23 14:57 - -from django.db import migrations, models -import plane.db.models.workspace - - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0051_fileasset_is_deleted'), - ] - - operations = [ - migrations.AlterField( - model_name='workspace', - name='slug', - field=models.SlugField(max_length=48, unique=True, validators=[plane.db.models.workspace.slug_validator]), - ), - ] diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 81eef5cae..9b293a75d 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -7,7 +7,6 @@ from django.db import models from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver -from django.utils import timezone from django.core.validators import MinValueValidator, MaxValueValidator from django.core.exceptions import ValidationError @@ -141,8 +140,10 @@ class Issue(ProjectBaseModel): )["largest"] # aggregate can return None! Check it first. # If it isn't none, just use the last ID specified (which should be the greatest) and add one to it - if last_id is not None: + if last_id: self.sequence_id = last_id + 1 + else: + self.sequence_id = 1 largest_sort_order = Issue.objects.filter( project=self.project, state=self.state diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index a8e284bb6..de65cb98f 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -57,7 +57,8 @@ class PageLog(ProjectBaseModel): ("module", "Module"), ("back_link", "Back Link"), ("forward_link", "Forward Link"), - ("mention", "Mention"), + ("page_mention", "Page Mention"), + ("user_mention", "User Mention"), ) transaction = models.UUIDField(default=uuid.uuid4) page = models.ForeignKey( diff --git a/apiserver/plane/license/api/permissions/__init__.py b/apiserver/plane/license/api/permissions/__init__.py index 392b228c0..d5bedc4c0 100644 --- a/apiserver/plane/license/api/permissions/__init__.py +++ b/apiserver/plane/license/api/permissions/__init__.py @@ -1 +1 @@ -from .instance import InstanceOwnerPermission, InstanceAdminPermission +from .instance import InstanceAdminPermission diff --git a/apiserver/plane/license/api/permissions/instance.py b/apiserver/plane/license/api/permissions/instance.py index 1d1845f12..dff16605a 100644 --- a/apiserver/plane/license/api/permissions/instance.py +++ b/apiserver/plane/license/api/permissions/instance.py @@ -5,20 +5,6 @@ from rest_framework.permissions import BasePermission from plane.license.models import Instance, InstanceAdmin -class InstanceOwnerPermission(BasePermission): - def has_permission(self, request, view): - - if request.user.is_anonymous: - return False - - instance = Instance.objects.first() - return InstanceAdmin.objects.filter( - role=20, - instance=instance, - user=request.user, - ).exists() - - class InstanceAdminPermission(BasePermission): def has_permission(self, request, view): diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py index f5cff760b..077d90eb8 100644 --- a/apiserver/plane/license/api/serializers/instance.py +++ b/apiserver/plane/license/api/serializers/instance.py @@ -2,7 +2,7 @@ from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration from plane.app.serializers import BaseSerializer from plane.app.serializers import UserAdminLiteSerializer - +from plane.license.utils.encryption import decrypt_data class InstanceSerializer(BaseSerializer): primary_owner_details = UserAdminLiteSerializer(source="primary_owner", read_only=True) @@ -12,14 +12,13 @@ class InstanceSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "id", - "primary_owner", - "primary_email", "instance_id", "license_key", "api_key", "version", "email", "last_checked_at", + "is_setup_done", ] @@ -40,3 +39,11 @@ class InstanceConfigurationSerializer(BaseSerializer): class Meta: model = InstanceConfiguration fields = "__all__" + + def to_representation(self, instance): + data = super().to_representation(instance) + # Decrypt secrets value + if instance.key in ["OPENAI_API_KEY", "GITHUB_CLIENT_SECRET", "EMAIL_HOST_PASSWORD", "UNSPLASH_ACESS_KEY"] and instance.value is not None: + data["value"] = decrypt_data(instance.value) + + return data \ No newline at end of file diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py index 4b925759a..591bb059f 100644 --- a/apiserver/plane/license/api/views/__init__.py +++ b/apiserver/plane/license/api/views/__init__.py @@ -1,6 +1,9 @@ from .instance import ( InstanceEndpoint, - TransferPrimaryOwnerEndpoint, InstanceAdminEndpoint, InstanceConfigurationEndpoint, + AdminSetupMagicSignInEndpoint, + SignUpScreenVisitedEndpoint, + AdminMagicSignInGenerateEndpoint, + AdminSetUserPasswordEndpoint, ) diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 15c779e46..80496ddcd 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -2,13 +2,21 @@ import json import os import requests +import uuid +import random +import string # Django imports from django.utils import timezone +from django.contrib.auth.hashers import make_password +from django.core.validators import validate_email +from django.core.exceptions import ValidationError # Third party imports from rest_framework import status from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework_simplejwt.tokens import RefreshToken # Module imports from plane.app.views import BaseAPIView @@ -18,24 +26,26 @@ from plane.license.api.serializers import ( InstanceAdminSerializer, InstanceConfigurationSerializer, ) +from plane.app.serializers import UserSerializer from plane.license.api.permissions import ( - InstanceOwnerPermission, InstanceAdminPermission, ) from plane.db.models import User +from plane.license.utils.encryption import encrypt_data +from plane.settings.redis import redis_instance +from plane.bgtasks.magic_link_code_task import magic_link +from plane.license.utils.instance_value import get_configuration_value class InstanceEndpoint(BaseAPIView): def get_permissions(self): - if self.request.method in ["POST", "PATCH"]: - self.permission_classes = [ - InstanceOwnerPermission, + if self.request.method == "PATCH": + return [ + InstanceAdminPermission(), ] - else: - self.permission_classes = [ - InstanceAdminPermission, - ] - return super(InstanceEndpoint, self).get_permissions() + return [ + AllowAny(), + ] def post(self, request): # Check if the instance is registered @@ -58,12 +68,14 @@ class InstanceEndpoint(BaseAPIView): headers = {"Content-Type": "application/json"} payload = { - "email": request.user.email, + "instance_key": os.environ.get("INSTANCE_KEY"), "version": data.get("version", 0.1), + "machine_signature": os.environ.get("MACHINE_SIGNATURE"), + "user_count": User.objects.filter(is_bot=False).count(), } response = requests.post( - f"{license_engine_base_url}/api/instances", + f"{license_engine_base_url}/api/instances/", headers=headers, data=json.dumps(payload), ) @@ -77,21 +89,15 @@ class InstanceEndpoint(BaseAPIView): license_key=data.get("license_key"), api_key=data.get("api_key"), version=data.get("version"), - primary_email=data.get("email"), - primary_owner=request.user, last_checked_at=timezone.now(), - ) - # Create instance admin - _ = InstanceAdmin.objects.create( - user=request.user, - instance=instance, - role=20, + user_count=data.get("user_count", 0), ) + serializer = InstanceSerializer(instance) + data = serializer.data + data["is_activated"] = True return Response( - { - "message": f"Instance succesfully registered with owner: {instance.primary_owner.email}" - }, + data, status=status.HTTP_201_CREATED, ) return Response( @@ -100,9 +106,7 @@ class InstanceEndpoint(BaseAPIView): ) else: return Response( - { - "message": f"Instance already registered with instance owner: {instance.primary_owner.email}" - }, + {"message": "Instance already registered"}, status=status.HTTP_200_OK, ) @@ -110,11 +114,15 @@ class InstanceEndpoint(BaseAPIView): instance = Instance.objects.first() # get the instance if instance is None: - return Response({"activated": False}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"is_activated": False, "is_setup_done": False}, + status=status.HTTP_400_BAD_REQUEST, + ) # Return instance serializer = InstanceSerializer(instance) - serializer.data["activated"] = True - return Response(serializer.data, status=status.HTTP_200_OK) + data = serializer.data + data["is_activated"] = True + return Response(data, status=status.HTTP_200_OK) def patch(self, request): # Get the instance @@ -126,58 +134,15 @@ class InstanceEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class TransferPrimaryOwnerEndpoint(BaseAPIView): - permission_classes = [ - InstanceOwnerPermission, - ] - - # Transfer the owner of the instance - def post(self, request): - instance = Instance.objects.first() - - # Get the email of the new user - email = request.data.get("email", False) - if not email: - return Response( - {"error": "User is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - # Get users - user = User.objects.get(email=email) - - # Save the instance user - instance.primary_owner = user - instance.primary_email = user.email - instance.save(update_fields=["owner", "email"]) - - # Add the user to admin - _ = InstanceAdmin.objects.get_or_create( - instance=instance, - user=user, - role=20, - ) - - return Response( - {"message": "Owner successfully updated"}, status=status.HTTP_200_OK - ) - - class InstanceAdminEndpoint(BaseAPIView): - def get_permissions(self): - if self.request.method in ["POST", "DELETE"]: - self.permission_classes = [ - InstanceOwnerPermission, - ] - else: - self.permission_classes = [ - InstanceAdminPermission, - ] - return super(InstanceAdminEndpoint, self).get_permissions() + permission_classes = [ + InstanceAdminPermission, + ] # Create an instance admin def post(self, request): email = request.data.get("email", False) - role = request.data.get("role", 15) + role = request.data.get("role", 20) if not email: return Response( @@ -230,18 +195,301 @@ class InstanceConfigurationEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request): - configurations = InstanceConfiguration.objects.filter(key__in=request.data.keys()) + configurations = InstanceConfiguration.objects.filter( + key__in=request.data.keys() + ) bulk_configurations = [] for configuration in configurations: - configuration.value = request.data.get(configuration.key, configuration.value) + value = request.data.get(configuration.key, configuration.value) + if value is not None and configuration.key in [ + "OPENAI_API_KEY", + "GITHUB_CLIENT_SECRET", + "EMAIL_HOST_PASSWORD", + "UNSPLASH_ACESS_KEY", + ]: + configuration.value = encrypt_data(value) + else: + configuration.value = value bulk_configurations.append(configuration) InstanceConfiguration.objects.bulk_update( - bulk_configurations, - ["value"], - batch_size=100 + bulk_configurations, ["value"], batch_size=100 ) serializer = InstanceConfigurationSerializer(configurations, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + + +def get_tokens_for_user(user): + refresh = RefreshToken.for_user(user) + return ( + str(refresh.access_token), + str(refresh), + ) + + +class AdminMagicSignInGenerateEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + email = request.data.get("email", False) + + # Check the instance registration + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if InstanceAdmin.objects.first(): + return Response( + {"error": "Admin for this instance is already registered"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + + if not email: + return Response( + {"error": "Please provide a valid email address"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Clean up + email = email.strip().lower() + validate_email(email) + + # check if the email exists + if not User.objects.filter(email=email).exists(): + # Create a user + _ = User.objects.create( + email=email, + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + ) + + ## Generate a random token + token = ( + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + ) + + ri = redis_instance() + + key = "magic_" + str(email) + + # Check if the key already exists in python + if ri.exists(key): + data = json.loads(ri.get(key)) + + current_attempt = data["current_attempt"] + 1 + + if data["current_attempt"] > 2: + return Response( + {"error": "Max attempts exhausted. Please try again later."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + value = { + "current_attempt": current_attempt, + "email": email, + "token": token, + } + expiry = 600 + + ri.set(key, json.dumps(value), ex=expiry) + + else: + value = {"current_attempt": 0, "email": email, "token": token} + expiry = 600 + + ri.set(key, json.dumps(value), ex=expiry) + + # If the smtp is configured send through here + current_site = request.META.get("HTTP_ORIGIN") + magic_link.delay(email, key, token, current_site) + + return Response({"key": key}, status=status.HTTP_200_OK) + + +class AdminSetupMagicSignInEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + user_token = request.data.get("token", "").strip() + key = request.data.get("key", "").strip().lower() + + if not key or user_token == "": + return Response( + {"error": "User token and key are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if InstanceAdmin.objects.first(): + return Response( + {"error": "Admin for this instance is already registered"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ri = redis_instance() + + if ri.exists(key): + data = json.loads(ri.get(key)) + + token = data["token"] + email = data["email"] + + if str(token) == str(user_token): + # get the user + user = User.objects.get(email=email) + # get the email + user.is_active = True + user.is_email_verified = True + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + + return Response(data, status=status.HTTP_200_OK) + + else: + return Response( + {"error": "Your login code was incorrect. Please try again."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + else: + return Response( + {"error": "The magic code/link has expired please try again"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class AdminSetUserPasswordEndpoint(BaseAPIView): + def post(self, request): + user = User.objects.get(pk=request.user.id) + password = request.data.get("password", False) + + # If the user password is not autoset then return error + if not user.is_password_autoset: + return Response( + { + "error": "Your password is already set please change your password from profile" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check password validation + if not password and len(str(password)) < 8: + return Response( + {"error": "Password is not valid"}, status=status.HTTP_400_BAD_REQUEST + ) + + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) + if not license_engine_base_url: + return Response( + {"error": "License engine base url is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Save the user in control center + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + _ = requests.patch( + f"{license_engine_base_url}/api/instances/", + headers=headers, + data=json.dumps({"is_setup_done": True}), + ) + + # Also register the user as admin + _ = requests.post( + f"{license_engine_base_url}/api/instances/users/register/", + headers=headers, + data=json.dumps( + { + "email": str(user.email), + "signup_mode": "MAGIC_CODE", + "is_admin": True, + } + ), + ) + + # Register the user as an instance admin + _ = InstanceAdmin.objects.create( + user=user, + instance=instance, + ) + # Make the setup flag True + instance.is_setup_done = True + instance.save() + + # Set the user password + user.set_password(password) + user.is_password_autoset = False + user.save() + serializer = UserSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class SignUpScreenVisitedEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + instance = Instance.objects.first() + + if instance is None: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) + + if not license_engine_base_url: + return Response( + {"error": "License engine base url is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + + payload = {"is_signup_screen_visited": True} + response = requests.patch( + f"{license_engine_base_url}/api/instances/", + headers=headers, + data=json.dumps(payload), + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index c0df0d154..60d8ce237 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -2,48 +2,120 @@ import os # Django imports -from django.core.management.base import BaseCommand, CommandError -from django.utils import timezone +from django.core.management.base import BaseCommand +from django.conf import settings # Module imports from plane.license.models import InstanceConfiguration + class Command(BaseCommand): help = "Configure instance variables" def handle(self, *args, **options): - config_keys = { - # Authentication Settings - "GOOGLE_CLIENT_ID": os.environ.get("GOOGLE_CLIENT_ID"), - "GOOGLE_CLIENT_SECRET": os.environ.get("GOOGLE_CLIENT_SECRET"), - "GITHUB_CLIENT_ID": os.environ.get("GITHUB_CLIENT_ID"), - "GITHUB_CLIENT_SECRET": os.environ.get("GITHUB_CLIENT_SECRET"), - "ENABLE_SIGNUP": os.environ.get("ENABLE_SIGNUP", "1"), - "ENABLE_EMAIL_PASSWORD": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), - "ENABLE_MAGIC_LINK_LOGIN": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"), - # Email Settings - "EMAIL_HOST": os.environ.get("EMAIL_HOST", ""), - "EMAIL_HOST_USER": os.environ.get("EMAIL_HOST_USER", ""), - "EMAIL_HOST_PASSWORD": os.environ.get("EMAIL_HOST_PASSWORD"), - "EMAIL_PORT": os.environ.get("EMAIL_PORT", "587"), - "EMAIL_FROM": os.environ.get("EMAIL_FROM", ""), - "EMAIL_USE_TLS": os.environ.get("EMAIL_USE_TLS", "1"), - "EMAIL_USE_SSL": os.environ.get("EMAIL_USE_SSL", "0"), - # Open AI Settings - "OPENAI_API_BASE": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), - "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", ""), - "GPT_ENGINE": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), - # Unsplash Access Key - "UNSPLASH_ACCESS_KEY": os.environ.get("UNSPLASH_ACESS_KEY", "") - } + from plane.license.utils.encryption import encrypt_data - for key, value in config_keys.items(): + config_keys = [ + # Authentication Settings + { + "key": "ENABLE_SIGNUP", + "value": os.environ.get("ENABLE_SIGNUP", "1"), + "category": "AUTHENTICATION", + }, + { + "key": "ENABLE_EMAIL_PASSWORD", + "value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), + "category": "AUTHENTICATION", + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"), + "category": "AUTHENTICATION", + }, + { + "key": "GOOGLE_CLIENT_ID", + "value": os.environ.get("GOOGLE_CLIENT_ID"), + "category": "GOOGLE", + }, + { + "key": "GITHUB_CLIENT_ID", + "value": os.environ.get("GITHUB_CLIENT_ID"), + "category": "GITHUB", + }, + { + "key": "GITHUB_CLIENT_SECRET", + "value": encrypt_data(os.environ.get("GITHUB_CLIENT_SECRET")) + if os.environ.get("GITHUB_CLIENT_SECRET") + else None, + "category": "GITHUB", + }, + { + "key": "EMAIL_HOST", + "value": os.environ.get("EMAIL_HOST", ""), + "category": "SMTP", + }, + { + "key": "EMAIL_HOST_USER", + "value": os.environ.get("EMAIL_HOST_USER", ""), + "category": "SMTP", + }, + { + "key": "EMAIL_HOST_PASSWORD", + "value": encrypt_data(os.environ.get("EMAIL_HOST_PASSWORD")) + if os.environ.get("EMAIL_HOST_PASSWORD") + else None, + "category": "SMTP", + }, + { + "key": "EMAIL_PORT", + "value": os.environ.get("EMAIL_PORT", "587"), + "category": "SMTP", + }, + { + "key": "EMAIL_FROM", + "value": os.environ.get("EMAIL_FROM", ""), + "category": "SMTP", + }, + { + "key": "EMAIL_USE_TLS", + "value": os.environ.get("EMAIL_USE_TLS", "1"), + "category": "SMTP", + }, + { + "key": "OPENAI_API_KEY", + "value": encrypt_data(os.environ.get("OPENAI_API_KEY")) + if os.environ.get("OPENAI_API_KEY") + else None, + "category": "OPENAI", + }, + { + "key": "GPT_ENGINE", + "value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + "category": "SMTP", + }, + { + "key": "UNSPLASH_ACCESS_KEY", + "value": encrypt_data(os.environ.get("UNSPLASH_ACESS_KEY", "")) + if os.environ.get("UNSPLASH_ACESS_KEY") + else None, + "category": "UNSPLASH", + }, + ] + + for item in config_keys: obj, created = InstanceConfiguration.objects.get_or_create( - key=key + key=item.get("key") ) if created: - obj.value = value + obj.value = item.get("value") + obj.category = item.get("category") obj.save() - self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) + self.stdout.write( + self.style.SUCCESS( + f"{obj.key} loaded with value from environment variable." + ) + ) else: - self.stdout.write(self.style.WARNING(f"{key} configuration already exists")) \ No newline at end of file + self.stdout.write( + self.style.WARNING(f"{obj.key} configuration already exists") + ) diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index 4ab44745d..b486ecb0b 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -2,22 +2,23 @@ import json import os import requests -import uuid # Django imports from django.core.management.base import BaseCommand, CommandError from django.utils import timezone -from django.core.exceptions import ValidationError -from django.core.validators import validate_email # Module imports +from plane.license.models import Instance from plane.db.models import User -from plane.license.models import Instance, InstanceAdmin - class Command(BaseCommand): help = "Check if instance in registered else register" + def add_arguments(self, parser): + # Positional argument + parser.add_argument('machine_signature', type=str, help='Machine signature') + + def handle(self, *args, **options): # Check if the instance is registered instance = Instance.objects.first() @@ -28,25 +29,15 @@ class Command(BaseCommand): # Load JSON content from the file data = json.load(file) - admin_email = os.environ.get("ADMIN_EMAIL") - - try: - validate_email(admin_email) - except ValidationError: - CommandError(f"{admin_email} is not a valid ADMIN_EMAIL") + machine_signature = options.get("machine_signature", False) + instance_key = os.environ.get("INSTANCE_KEY", False) # Raise an exception if the admin email is not provided - if not admin_email: - raise CommandError("ADMIN_EMAIL is required") + if not instance_key: + raise CommandError("INSTANCE_KEY is required") - # Check if the admin email user exists - user = User.objects.filter(email=admin_email).first() - - # If the user does not exist create the user and add him to the database - if user is None: - user = User.objects.create(email=admin_email, username=uuid.uuid4().hex) - user.set_password(admin_email) - user.save() + if not machine_signature: + raise CommandError("Machine signature is required") license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") @@ -56,8 +47,10 @@ class Command(BaseCommand): headers = {"Content-Type": "application/json"} payload = { - "email": user.email, + "instance_key": instance_key, "version": data.get("version", 0.1), + "machine_signature": machine_signature, + "user_count": User.objects.filter(is_bot=False).count(), } response = requests.post( @@ -75,20 +68,13 @@ class Command(BaseCommand): license_key=data.get("license_key"), api_key=data.get("api_key"), version=data.get("version"), - primary_email=data.get("email"), - primary_owner=user, last_checked_at=timezone.now(), - ) - # Create instance admin - _ = InstanceAdmin.objects.create( - user=user, - instance=instance, - role=20, + user_count=data.get("user_count", 0), ) self.stdout.write( self.style.SUCCESS( - f"Instance successfully registered with owner: {instance.primary_owner.email}" + f"Instance successfully registered" ) ) return @@ -96,7 +82,7 @@ class Command(BaseCommand): else: self.stdout.write( self.style.SUCCESS( - f"Instance already registered with instance owner: {instance.primary_owner.email}" + f"Instance already registered" ) ) return diff --git a/apiserver/plane/license/migrations/0001_initial.py b/apiserver/plane/license/migrations/0001_initial.py index db620a18e..884691434 100644 --- a/apiserver/plane/license/migrations/0001_initial.py +++ b/apiserver/plane/license/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.5 on 2023-11-15 14:22 +# Generated by Django 4.2.7 on 2023-11-29 14:39 from django.conf import settings from django.db import migrations, models @@ -27,13 +27,14 @@ class Migration(migrations.Migration): ('license_key', models.CharField(blank=True, max_length=256, null=True)), ('api_key', models.CharField(max_length=16)), ('version', models.CharField(max_length=10)), - ('primary_email', models.CharField(max_length=256)), ('last_checked_at', models.DateTimeField()), ('namespace', models.CharField(blank=True, max_length=50, null=True)), ('is_telemetry_enabled', models.BooleanField(default=True)), ('is_support_required', models.BooleanField(default=True)), + ('is_setup_done', models.BooleanField(default=False)), + ('is_signup_screen_visited', models.BooleanField(default=False)), + ('user_count', models.PositiveBigIntegerField(default=0)), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('primary_owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='instance_primary_owner', to=settings.AUTH_USER_MODEL)), ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), ], options={ @@ -51,6 +52,7 @@ class Migration(migrations.Migration): ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), ('key', models.CharField(max_length=100, unique=True)), ('value', models.TextField(blank=True, default=None, null=True)), + ('category', models.TextField()), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), ], @@ -67,7 +69,7 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('role', models.PositiveIntegerField(choices=[(20, 'Owner'), (15, 'Admin')], default=15)), + ('role', models.PositiveIntegerField(choices=[(20, 'Admin')], default=20)), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admins', to='license.instance')), ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), @@ -78,6 +80,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Instance Admins', 'db_table': 'instance_admins', 'ordering': ('-created_at',), + 'unique_together': {('instance', 'user')}, }, ), ] diff --git a/apiserver/plane/license/migrations/0002_alter_instanceadmin_unique_together.py b/apiserver/plane/license/migrations/0002_alter_instanceadmin_unique_together.py deleted file mode 100644 index 21d4baaf1..000000000 --- a/apiserver/plane/license/migrations/0002_alter_instanceadmin_unique_together.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.5 on 2023-11-16 09:45 - -from django.conf import settings -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('license', '0001_initial'), - ] - - operations = [ - migrations.AlterUniqueTogether( - name='instanceadmin', - unique_together={('instance', 'user')}, - ), - ] diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py index 5f96b6b90..5216a7221 100644 --- a/apiserver/plane/license/models/instance.py +++ b/apiserver/plane/license/models/instance.py @@ -4,11 +4,9 @@ from django.conf import settings # Module imports from plane.db.models import BaseModel -from plane.db.mixins import AuditModel ROLE_CHOICES = ( - (20, "Owner"), - (15, "Admin"), + (20, "Admin"), ) @@ -20,20 +18,18 @@ class Instance(BaseModel): license_key = models.CharField(max_length=256, null=True, blank=True) api_key = models.CharField(max_length=16) version = models.CharField(max_length=10) - # User information - primary_email = models.CharField(max_length=256) - primary_owner = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - related_name="instance_primary_owner", - ) # Instnace specifics last_checked_at = models.DateTimeField() namespace = models.CharField(max_length=50, blank=True, null=True) # telemetry and support is_telemetry_enabled = models.BooleanField(default=True) is_support_required = models.BooleanField(default=True) + # is setup done + is_setup_done = models.BooleanField(default=False) + # signup screen + is_signup_screen_visited = models.BooleanField(default=False) + # users + user_count = models.PositiveBigIntegerField(default=0) class Meta: verbose_name = "Instance" @@ -50,7 +46,7 @@ class InstanceAdmin(BaseModel): related_name="instance_owner", ) instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins") - role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=15) + role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=20) class Meta: unique_together = ["instance", "user"] @@ -64,6 +60,7 @@ class InstanceConfiguration(BaseModel): # The instance configuration variables key = models.CharField(max_length=100, unique=True) value = models.TextField(null=True, blank=True, default=None) + category = models.TextField() class Meta: verbose_name = "Instance Configuration" diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index 6e95329bd..5e416c40b 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -2,9 +2,12 @@ from django.urls import path from plane.license.api.views import ( InstanceEndpoint, - TransferPrimaryOwnerEndpoint, InstanceAdminEndpoint, InstanceConfigurationEndpoint, + AdminMagicSignInGenerateEndpoint, + AdminSetupMagicSignInEndpoint, + AdminSetUserPasswordEndpoint, + SignUpScreenVisitedEndpoint, ) urlpatterns = [ @@ -13,11 +16,6 @@ urlpatterns = [ InstanceEndpoint.as_view(), name="instance", ), - path( - "instances/transfer-primary-owner/", - TransferPrimaryOwnerEndpoint.as_view(), - name="instance", - ), path( "instances/admins/", InstanceAdminEndpoint.as_view(), @@ -33,4 +31,24 @@ urlpatterns = [ InstanceConfigurationEndpoint.as_view(), name="instance-configuration", ), + path( + "instances/admins/magic-generate/", + AdminMagicSignInGenerateEndpoint.as_view(), + name="instance-admins", + ), + path( + "instances/admins/magic-sign-in/", + AdminSetupMagicSignInEndpoint.as_view(), + name="instance-admins", + ), + path( + "instances/admins/set-password/", + AdminSetUserPasswordEndpoint.as_view(), + name="instance-admins", + ), + path( + "instances/admins/sign-up-screen-visited/", + SignUpScreenVisitedEndpoint.as_view(), + name="instance-sign-up", + ), ] diff --git a/apiserver/plane/license/utils/encryption.py b/apiserver/plane/license/utils/encryption.py new file mode 100644 index 000000000..bf6c23f9d --- /dev/null +++ b/apiserver/plane/license/utils/encryption.py @@ -0,0 +1,22 @@ +import base64 +import hashlib +from django.conf import settings +from cryptography.fernet import Fernet + + +def derive_key(secret_key): + # Use a key derivation function to get a suitable encryption key + dk = hashlib.pbkdf2_hmac('sha256', secret_key.encode(), b'salt', 100000) + return base64.urlsafe_b64encode(dk) + + +def encrypt_data(data): + cipher_suite = Fernet(derive_key(settings.SECRET_KEY)) + encrypted_data = cipher_suite.encrypt(data.encode()) + return encrypted_data.decode() # Convert bytes to string + + +def decrypt_data(encrypted_data): + cipher_suite = Fernet(derive_key(settings.SECRET_KEY)) + decrypted_data = cipher_suite.decrypt(encrypted_data.encode()) # Convert string back to bytes + return decrypted_data.decode() diff --git a/apiserver/plane/license/utils/instance_value.py b/apiserver/plane/license/utils/instance_value.py index efca2799c..547467f52 100644 --- a/apiserver/plane/license/utils/instance_value.py +++ b/apiserver/plane/license/utils/instance_value.py @@ -1,6 +1,63 @@ +import os + + # Helper function to return value from the passed key def get_configuration_value(query, key, default=None): for item in query: - if item['key'] == key: + if item["key"] == key: return item.get("value", default) return default + + +def get_email_configuration(instance_configuration): + # Get the configuration variables + EMAIL_HOST_USER = get_configuration_value( + instance_configuration, + "EMAIL_HOST_USER", + os.environ.get("EMAIL_HOST_USER", None), + ) + + EMAIL_HOST_PASSWORD = get_configuration_value( + instance_configuration, + "EMAIL_HOST_PASSWORD", + os.environ.get("EMAIL_HOST_PASSWORD", None), + ) + + EMAIL_HOST = get_configuration_value( + instance_configuration, + "EMAIL_HOST", + os.environ.get("EMAIL_HOST", None), + ) + + EMAIL_FROM = get_configuration_value( + instance_configuration, + "EMAIL_FROM", + os.environ.get("EMAIL_FROM", None), + ) + + EMAIL_USE_TLS = get_configuration_value( + instance_configuration, + "EMAIL_USE_TLS", + os.environ.get("EMAIL_USE_TLS", "1"), + ) + + EMAIL_PORT = get_configuration_value( + instance_configuration, + "EMAIL_PORT", + 587, + ) + + EMAIL_FROM = get_configuration_value( + instance_configuration, + "EMAIL_FROM", + os.environ.get("EMAIL_FROM", "Team Plane "), + ) + + return ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 79c175eb2..ba2c941ef 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -185,6 +185,9 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +# Password reset time the number of seconds the uniquely generated uid will be valid +PASSWORD_RESET_TIMEOUT = 3600 + # Static files (CSS, JavaScript, Images) STATIC_URL = "/static/" STATIC_ROOT = os.path.join(BASE_DIR, "static-assets", "collected-static") @@ -306,7 +309,6 @@ if bool(os.environ.get("SENTRY_DSN", False)): PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) # For External SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) -ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" # Unsplash Access key UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 55329be73..5342da85d 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -36,4 +36,5 @@ scout-apm==2.26.1 openpyxl==3.1.2 beautifulsoup4==4.12.2 dj-database-url==2.1.0 -posthog==3.0.2 \ No newline at end of file +posthog==3.0.2 +cryptography==41.0.5 diff --git a/apiserver/templates/emails/auth/magic_signin.html b/apiserver/templates/emails/auth/magic_signin.html index 63fbe5e32..2ba6c5463 100644 --- a/apiserver/templates/emails/auth/magic_signin.html +++ b/apiserver/templates/emails/auth/magic_signin.html @@ -1,367 +1,1502 @@ - - - - - - - Login for Plane - - - - - - - - - - - - - - + + + + + + diff --git a/apiserver/templates/emails/invitations/workspace_invitation.html b/apiserver/templates/emails/invitations/workspace_invitation.html index 2384aa18d..64ff29fb1 100644 --- a/apiserver/templates/emails/invitations/workspace_invitation.html +++ b/apiserver/templates/emails/invitations/workspace_invitation.html @@ -1,349 +1,1650 @@ - - - - - - - {{first_name}} invited you to join {{workspace_name}} on Plane - - - - - - - - - - - - - - + + + + + +