chore: updated sign-in workflows for cloud and self-hosted instances (#2994)

* chore: update onboarding workflow

* dev: update user count tasks

* fix: forgot password endpoint

* dev: instance and onboarding updates

* chore: update sign-in workflow for cloud and self-hosted instances (#2993)

* chore: updated auth services

* chore: new signin workflow updated

* chore: updated content

* chore: instance admin setup

* dev: update instance verification task

* dev: run the instance verification task every 4 hours

* dev: update migrations

* chore: update latest features image

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2023-12-06 14:22:59 +05:30 committed by GitHub
parent b957e4e935
commit 11987994a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1017 additions and 1368 deletions

View File

@ -7,6 +7,7 @@ from plane.app.views import (
# Authentication # Authentication
SignInEndpoint, SignInEndpoint,
SignOutEndpoint, SignOutEndpoint,
MagicGenerateEndpoint,
MagicSignInEndpoint, MagicSignInEndpoint,
OauthEndpoint, OauthEndpoint,
EmailCheckEndpoint, EmailCheckEndpoint,
@ -30,6 +31,7 @@ urlpatterns = [
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# magic sign in # magic sign in
path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"),
path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
# Password Manipulation # Password Manipulation

View File

@ -87,6 +87,7 @@ from .auth_extended import (
ChangePasswordEndpoint, ChangePasswordEndpoint,
SetUserPasswordEndpoint, SetUserPasswordEndpoint,
EmailCheckEndpoint, EmailCheckEndpoint,
MagicGenerateEndpoint,
) )

View File

@ -37,9 +37,9 @@ from plane.bgtasks.forgot_password_task import forgot_password
from plane.license.models import Instance, InstanceConfiguration from plane.license.models import Instance, InstanceConfiguration
from plane.settings.redis import redis_instance from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link 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 from plane.bgtasks.event_tracking_task import auth_events
def get_tokens_for_user(user): def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user) refresh = RefreshToken.for_user(user)
return ( return (
@ -108,13 +108,16 @@ class ForgotPasswordEndpoint(BaseAPIView):
try: try:
validate_email(email) validate_email(email)
except ValidationError: except ValidationError:
return Response({"error": "Please enter a valid email"}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "Please enter a valid email"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the user # Get the user
user = User.objects.filter(email=email).first() user = User.objects.filter(email=email).first()
if user: if user:
# Get the reset token for user # Get the reset token for user
uidb64, token = get_tokens_for_user(user=user) uidb64, token = generate_password_token(user=user)
current_site = request.META.get("HTTP_ORIGIN") current_site = request.META.get("HTTP_ORIGIN")
# send the forgot password email # send the forgot password email
forgot_password.delay( forgot_password.delay(
@ -130,7 +133,9 @@ class ForgotPasswordEndpoint(BaseAPIView):
class ResetPasswordEndpoint(BaseAPIView): class ResetPasswordEndpoint(BaseAPIView):
permission_classes = [AllowAny,] permission_classes = [
AllowAny,
]
def post(self, request, uidb64, token): def post(self, request, uidb64, token):
try: try:
@ -219,6 +224,89 @@ class SetUserPasswordEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class MagicGenerateEndpoint(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 or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
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 the email
email = email.strip().lower()
validate_email(email)
# check if the email exists not
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 EmailCheckEndpoint(BaseAPIView): class EmailCheckEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
AllowAny, AllowAny,
@ -237,16 +325,19 @@ class EmailCheckEndpoint(BaseAPIView):
instance_configuration = InstanceConfiguration.objects.values("key", "value") instance_configuration = InstanceConfiguration.objects.values("key", "value")
email = request.data.get("email", False) email = request.data.get("email", False)
type = request.data.get("type", "magic_code")
if not email: if not email:
return Response({"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST
)
# validate the email # validate the email
try: try:
validate_email(email) validate_email(email)
except ValidationError: except ValidationError:
return Response({"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST
)
# Check if the user exists # Check if the user exists
user = User.objects.filter(email=email).first() user = User.objects.filter(email=email).first()
@ -281,15 +372,13 @@ class EmailCheckEndpoint(BaseAPIView):
is_password_autoset=True, is_password_autoset=True,
) )
# Update instance user count
update_user_instance_user_count.delay()
# Case when the user selects magic code if not bool(
if type == "magic_code": get_configuration_value(
if not bool(get_configuration_value(
instance_configuration, instance_configuration,
"ENABLE_MAGIC_LINK_LOGIN", "ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN")), os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
),
): ):
return Response( return Response(
{"error": "Magic link sign in is disabled."}, {"error": "Magic link sign in is disabled."},
@ -309,37 +398,27 @@ class EmailCheckEndpoint(BaseAPIView):
) )
key, token, current_attempt = generate_magic_token(email=email) key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt: if not current_attempt:
return Response({"error": "Max attempts exhausted. Please try again later."}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
# Trigger the email # Trigger the email
magic_link.delay(email, "magic_" + str(email), token, current_site) magic_link.delay(email, "magic_" + str(email), token, current_site)
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) return Response(
else: {"is_password_autoset": user.is_password_autoset, "is_existing": False},
# Get the uidb64 and token for the user status=status.HTTP_200_OK,
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_200_OK)
# Existing user # Existing user
else: else:
if type == "magic_code": if user.is_password_autoset:
## Generate a random token ## Generate a random token
if not bool(get_configuration_value( if not bool(
get_configuration_value(
instance_configuration, instance_configuration,
"ENABLE_MAGIC_LINK_LOGIN", "ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN")), os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
),
): ):
return Response( return Response(
{"error": "Magic link sign in is disabled."}, {"error": "Magic link sign in is disabled."},
@ -360,11 +439,20 @@ class EmailCheckEndpoint(BaseAPIView):
# Generate magic token # Generate magic token
key, token, current_attempt = generate_magic_token(email=email) key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt: if not current_attempt:
return Response({"error": "Max attempts exhausted. Please try again later."}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
# Trigger the email # Trigger the email
magic_link.delay(email, key, token, current_site) magic_link.delay(email, key, token, current_site)
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)
else: else:
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay( auth_events.delay(
@ -377,13 +465,11 @@ class EmailCheckEndpoint(BaseAPIView):
first_time=False, 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 # User should enter password to login
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)

View File

@ -1,8 +1,6 @@
# Python imports # Python imports
import os import os
import uuid import uuid
import random
import string
import json import json
# Django imports # Django imports
@ -10,6 +8,7 @@ from django.utils import timezone
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_email from django.core.validators import validate_email
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -31,7 +30,6 @@ from plane.settings.redis import redis_instance
from plane.license.models import InstanceConfiguration, Instance from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_configuration_value from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.event_tracking_task import auth_events from plane.bgtasks.event_tracking_task import auth_events
from plane.bgtasks.user_count_task import update_user_instance_user_count
def get_tokens_for_user(user): def get_tokens_for_user(user):
@ -58,7 +56,6 @@ class SignUpEndpoint(BaseAPIView):
email = request.data.get("email", False) email = request.data.get("email", False)
password = request.data.get("password", False) password = request.data.get("password", False)
## Raise exception if any of the above are missing ## Raise exception if any of the above are missing
if not email or not password: if not email or not password:
return Response( return Response(
@ -66,8 +63,8 @@ class SignUpEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Validate the email
email = email.strip().lower() email = email.strip().lower()
try: try:
validate_email(email) validate_email(email)
except ValidationError as e: except ValidationError as e:
@ -106,6 +103,7 @@ class SignUpEndpoint(BaseAPIView):
user.set_password(password) user.set_password(password)
# settings last actives for the user # settings last actives for the user
user.is_password_autoset = False
user.last_active = timezone.now() user.last_active = timezone.now()
user.last_login_time = timezone.now() user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR") user.last_login_ip = request.META.get("REMOTE_ADDR")
@ -120,9 +118,6 @@ class SignUpEndpoint(BaseAPIView):
"refresh_token": refresh_token, "refresh_token": refresh_token,
} }
# Update instance user count
update_user_instance_user_count.delay()
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)
@ -148,8 +143,8 @@ class SignInEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Validate email
email = email.strip().lower() email = email.strip().lower()
try: try:
validate_email(email) validate_email(email)
except ValidationError as e: except ValidationError as e:
@ -161,15 +156,8 @@ class SignInEndpoint(BaseAPIView):
# Get the user # Get the user
user = User.objects.filter(email=email).first() user = User.objects.filter(email=email).first()
# User is not present in db # Existing user
if user is None: if user:
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
# Check user password # Check user password
if not user.check_password(password): if not user.check_password(password):
return Response( return Response(
@ -179,6 +167,36 @@ class SignInEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
# Create the user
else:
# Get the configurations
instance_configuration = InstanceConfiguration.objects.values("key", "value")
# 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(password),
is_password_autoset=False,
)
# settings last active for the user # settings last active for the user
user.is_active = True user.is_active = True
user.last_active = timezone.now() user.last_active = timezone.now()

View File

@ -11,7 +11,7 @@ from rest_framework.response import Response
# Module imports # Module imports
from .base import BaseAPIView from .base import BaseAPIView
from plane.license.models import Instance, InstanceConfiguration from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value from plane.license.utils.instance_value import get_configuration_value
@ -104,4 +104,6 @@ class ConfigurationEndpoint(BaseAPIView):
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1")))
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)

View File

@ -32,7 +32,6 @@ from plane.bgtasks.event_tracking_task import auth_events
from .base import BaseAPIView from .base import BaseAPIView
from plane.license.models import InstanceConfiguration, Instance from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_configuration_value 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): def get_tokens_for_user(user):
@ -439,6 +438,4 @@ class OauthEndpoint(BaseAPIView):
"refresh_token": refresh_token, "refresh_token": refresh_token,
} }
# Update the user count
update_user_instance_user_count.delay()
return Response(data, status=status.HTTP_201_CREATED) return Response(data, status=status.HTTP_201_CREATED)

View File

@ -66,30 +66,6 @@ def send_export_email(email, slug, csv_buffer, rows):
EMAIL_FROM, EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration) ) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD:
# Check the instance registration
instance = Instance.objects.first()
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
payload = {
"email": email,
"slug": slug,
"rows": rows,
}
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/analytics/",
headers=headers,
json=payload,
)
return
connection = get_connection( connection = get_connection(
host=EMAIL_HOST, host=EMAIL_HOST,
port=int(EMAIL_PORT), port=int(EMAIL_PORT),

View File

@ -39,32 +39,6 @@ def forgot_password(first_name, email, uidb64, token, current_site):
EMAIL_FROM, EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration) ) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
# 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"{settings.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" subject = "A new password to your Plane account has been requested"
context = { context = {

View File

@ -26,7 +26,6 @@ from plane.db.models import (
IssueProperty, IssueProperty,
) )
from plane.bgtasks.user_welcome_task import send_welcome_slack from plane.bgtasks.user_welcome_task import send_welcome_slack
from plane.bgtasks.user_count_task import update_user_instance_user_count
@shared_task @shared_task
@ -122,9 +121,6 @@ def service_importer(service, importer_id):
ignore_conflicts=True, ignore_conflicts=True,
) )
# Update instance user count
update_user_instance_user_count.delay()
# Check if sync config is on for github importers # Check if sync config is on for github importers
if service == "github" and importer.config.get("sync", False): if service == "github" and importer.config.get("sync", False):
name = importer.metadata.get("name", False) name = importer.metadata.get("name", False)

View File

@ -34,30 +34,6 @@ def magic_link(email, key, token, current_site):
EMAIL_FROM, EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration) ) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
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"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/magic-code/",
headers=headers,
data=json.dumps(payload),
)
return
# Send the mail # Send the mail
subject = f"Your unique Plane login code is {token}" subject = f"Your unique Plane login code is {token}"
context = {"code": token, "email": email} context = {"code": token, "email": email}

View File

@ -14,7 +14,7 @@ from sentry_sdk import capture_exception
# Module imports # Module imports
from plane.db.models import Project, User, ProjectMemberInvite from plane.db.models import Project, User, ProjectMemberInvite
from plane.license.models import InstanceConfiguration from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value from plane.license.utils.instance_value import get_email_configuration
@shared_task @shared_task
def project_invitation(email, project_id, token, current_site, invitor): def project_invitation(email, project_id, token, current_site, invitor):
@ -48,42 +48,27 @@ def project_invitation(email, project_id, token, current_site, invitor):
# Configure email connection from the database # Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") 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)
connection = get_connection( connection = get_connection(
host=get_configuration_value( host=EMAIL_HOST,
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") port=int(EMAIL_PORT),
), username=EMAIL_HOST_USER,
port=int( password=EMAIL_HOST_PASSWORD,
get_configuration_value( use_tls=bool(EMAIL_USE_TLS),
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"),
)
),
) )
msg = EmailMultiAlternatives( msg = EmailMultiAlternatives(
subject=subject, subject=subject,
body=text_content, body=text_content,
from_email=get_configuration_value( from_email=EMAIL_FROM,
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email], to=[email],
connection=connection, connection=connection,
) )

View File

@ -1,45 +0,0 @@
# 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,
}
# Update the license engine
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
)
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)

View File

@ -50,31 +50,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
EMAIL_FROM, EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration) ) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
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"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/workspace-invitation/",
headers=headers,
data=json.dumps(payload),
)
return
# Subject of the email # Subject of the email
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {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"

View File

@ -28,9 +28,13 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.file_asset_task.delete_file_asset", "task": "plane.bgtasks.file_asset_task.delete_file_asset",
"schedule": crontab(hour=0, minute=0), "schedule": crontab(hour=0, minute=0),
}, },
"check-instance-verification": {
"task": "plane.license.bgtasks.instance_verification_task.instance_verification_task",
"schedule": crontab(minute=0, hour='*/4'),
},
} }
# Load task modules from all registered Django app configs. # Load task modules from all registered Django app configs.
app.autodiscover_tasks() app.autodiscover_tasks()
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler' app.conf.beat_scheduler = "django_celery_beat.schedulers.DatabaseScheduler"

View File

@ -43,7 +43,7 @@ class InstanceConfigurationSerializer(BaseSerializer):
def to_representation(self, instance): def to_representation(self, instance):
data = super().to_representation(instance) data = super().to_representation(instance)
# Decrypt secrets value # 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: if instance.is_encrypted and instance.value is not None:
data["value"] = decrypt_data(instance.value) data["value"] = decrypt_data(instance.value)
return data return data

View File

@ -2,8 +2,6 @@ from .instance import (
InstanceEndpoint, InstanceEndpoint,
InstanceAdminEndpoint, InstanceAdminEndpoint,
InstanceConfigurationEndpoint, InstanceConfigurationEndpoint,
AdminSetupMagicSignInEndpoint, InstanceAdminSignInEndpoint,
SignUpScreenVisitedEndpoint, SignUpScreenVisitedEndpoint,
AdminMagicSignInGenerateEndpoint,
AdminSetUserPasswordEndpoint,
) )

View File

@ -27,14 +27,11 @@ from plane.license.api.serializers import (
InstanceAdminSerializer, InstanceAdminSerializer,
InstanceConfigurationSerializer, InstanceConfigurationSerializer,
) )
from plane.app.serializers import UserSerializer
from plane.license.api.permissions import ( from plane.license.api.permissions import (
InstanceAdminPermission, InstanceAdminPermission,
) )
from plane.db.models import User from plane.db.models import User
from plane.license.utils.encryption import encrypt_data 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
class InstanceEndpoint(BaseAPIView): class InstanceEndpoint(BaseAPIView):
@ -47,61 +44,6 @@ class InstanceEndpoint(BaseAPIView):
AllowAny(), AllowAny(),
] ]
def post(self, request):
# Check if the instance is registered
instance = Instance.objects.first()
# If instance is None then register this instance
if instance is None:
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
headers = {"Content-Type": "application/json"}
payload = {
"instance_key":settings.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"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
)
if response.status_code == 201:
data = response.json()
# Create instance
instance = Instance.objects.create(
instance_name="Plane Free",
instance_id=data.get("id"),
license_key=data.get("license_key"),
api_key=data.get("api_key"),
version=data.get("version"),
last_checked_at=timezone.now(),
user_count=data.get("user_count", 0),
)
serializer = InstanceSerializer(instance)
data = serializer.data
data["is_activated"] = True
return Response(
data,
status=status.HTTP_201_CREATED,
)
return Response(
{"error": "Instance could not be registered"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{"message": "Instance already registered"},
status=status.HTTP_200_OK,
)
def get(self, request): def get(self, request):
instance = Instance.objects.first() instance = Instance.objects.first()
# get the instance # get the instance
@ -122,24 +64,6 @@ class InstanceEndpoint(BaseAPIView):
serializer = InstanceSerializer(instance, data=request.data, partial=True) serializer = InstanceSerializer(instance, data=request.data, partial=True)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
# Save the user in control center
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# Update instance settings in the license engine
_ = requests.patch(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(
{
"is_support_required": serializer.data["is_support_required"],
"is_telemetry_enabled": serializer.data["is_telemetry_enabled"],
"version": serializer.data["version"],
}
),
)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -212,12 +136,7 @@ class InstanceConfigurationEndpoint(BaseAPIView):
bulk_configurations = [] bulk_configurations = []
for configuration in configurations: for configuration in configurations:
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 [ if configuration.is_encrypted:
"OPENAI_API_KEY",
"GITHUB_CLIENT_SECRET",
"EMAIL_HOST_PASSWORD",
"UNSPLASH_ACESS_KEY",
]:
configuration.value = encrypt_data(value) configuration.value = encrypt_data(value)
else: else:
configuration.value = value configuration.value = value
@ -239,15 +158,13 @@ def get_tokens_for_user(user):
) )
class AdminMagicSignInGenerateEndpoint(BaseAPIView): class InstanceAdminSignInEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
AllowAny, AllowAny,
] ]
def post(self, request): def post(self, request):
email = request.data.get("email", False) # Check instance first
# Check the instance registration
instance = Instance.objects.first() instance = Instance.objects.first()
if instance is None: if instance is None:
return Response( return Response(
@ -255,114 +172,57 @@ class AdminMagicSignInGenerateEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# check if the instance is already activated
if InstanceAdmin.objects.first(): if InstanceAdmin.objects.first():
return Response( return Response(
{"error": "Admin for this instance is already registered"}, {"error": "Admin for this instance is already registered"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
if not email: # Get the email and password from all the user
email = request.data.get("email", False)
password = request.data.get("password", False)
# return error if the email and password is not present
if not email or not password:
return Response( return Response(
{"error": "Please provide a valid email address"}, {"error": "Email and password are required"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Clean up # Validate the email
email = email.strip().lower() email = email.strip().lower()
try:
validate_email(email) validate_email(email)
except ValidationError as e:
return Response(
{"error": "Please provide a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
)
# check if the email exists # Check if already a user exists or not
if not User.objects.filter(email=email).exists(): user = User.objects.filter(email=email).first()
# Create a user
_ = User.objects.create( # Existing user
if user:
# Check user password
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
else:
user = User.objects.create(
email=email, email=email,
username=uuid.uuid4().hex, username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex), password=make_password(password),
is_password_autoset=True, is_password_autoset=False,
) )
## Generate a random token # settings last active for the user
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_active = True
user.is_email_verified = True
user.last_active = timezone.now() user.last_active = timezone.now()
user.last_login_time = timezone.now() user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR") user.last_login_ip = request.META.get("REMOTE_ADDR")
@ -370,79 +230,6 @@ class AdminSetupMagicSignInEndpoint(BaseAPIView):
user.token_updated_at = timezone.now() user.token_updated_at = timezone.now()
user.save() 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,
)
# 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"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps({"is_setup_done": True}),
)
# Also register the user as admin
_ = requests.post(
f"{settings.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 # Register the user as an instance admin
_ = InstanceAdmin.objects.create( _ = InstanceAdmin.objects.create(
user=user, user=user,
@ -452,12 +239,13 @@ class AdminSetUserPasswordEndpoint(BaseAPIView):
instance.is_setup_done = True instance.is_setup_done = True
instance.save() instance.save()
# Set the user password # get tokens for user
user.set_password(password) access_token, refresh_token = get_tokens_for_user(user)
user.is_password_autoset = False data = {
user.save() "access_token": access_token,
serializer = UserSerializer(user) "refresh_token": refresh_token,
return Response(serializer.data, status=status.HTTP_200_OK) }
return Response(data, status=status.HTTP_200_OK)
class SignUpScreenVisitedEndpoint(BaseAPIView): class SignUpScreenVisitedEndpoint(BaseAPIView):
@ -467,27 +255,11 @@ class SignUpScreenVisitedEndpoint(BaseAPIView):
def post(self, request): def post(self, request):
instance = Instance.objects.first() instance = Instance.objects.first()
if instance is None: if instance is None:
return Response( return Response(
{"error": "Instance is not configured"}, {"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
if not instance.is_signup_screen_visited:
instance.is_signup_screen_visited = True instance.is_signup_screen_visited = True
instance.save() instance.save()
# set the headers
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# create the payload
payload = {"is_signup_screen_visited": True}
_ = requests.patch(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -0,0 +1,136 @@
# Python imports
import os
import json
import requests
# Django imports
from django.conf import settings
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import User
from plane.license.models import Instance, InstanceAdmin
def instance_verification(instance):
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
headers = {"Content-Type": "application/json"}
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
"machine_signature": os.environ.get("MACHINE_SIGNATURE", "machine-signature"),
"user_count": User.objects.filter(is_bot=False).count(),
}
# Register the instance
response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
timeout=30,
)
# check response status
if response.status_code == 201:
data = response.json()
# Update instance
instance.instance_id = data.get("id")
instance.license_key = data.get("license_key")
instance.api_key = data.get("api_key")
instance.version = data.get("version")
instance.user_count = data.get("user_count", 0)
instance.is_verified = True
instance.save()
else:
return
def admin_verification(instance):
# Save the user in control center
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# Get all the unverified instance admins
instance_admins = InstanceAdmin.objects.filter(is_verified=False).select_related(
"user"
)
updated_instance_admin = []
# Verify the instance admin
for instance_admin in instance_admins:
instance_admin.is_verified = True
# Create the admin
response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/",
headers=headers,
data=json.dumps(
{
"email": str(instance_admin.user.email),
"signup_mode": "EMAIL",
"is_admin": True,
}
),
timeout=30,
)
updated_instance_admin.append(instance_admin)
# update all the instance admins
InstanceAdmin.objects.bulk_update(
updated_instance_admin, ["is_verified"], batch_size=10
)
return
def instance_user_count(instance):
try:
instance_users = User.objects.filter(is_bot=False).count()
# Update the count in the license engine
payload = {
"user_count": instance_users,
}
# Save the user in control center
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# Update the license engine
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
timeout=30,
)
return
except requests.RequestException:
return
@shared_task
def instance_verification_task():
try:
# Get the first instance
instance = Instance.objects.first()
# Only register instance if it is not verified
if not instance.is_verified:
instance_verification(instance=instance)
# Admin verifications
admin_verification(instance=instance)
# Update user count
instance_user_count(instance=instance)
return
except requests.RequestException:
return

View File

@ -21,84 +21,91 @@ class Command(BaseCommand):
"key": "ENABLE_SIGNUP", "key": "ENABLE_SIGNUP",
"value": os.environ.get("ENABLE_SIGNUP", "1"), "value": os.environ.get("ENABLE_SIGNUP", "1"),
"category": "AUTHENTICATION", "category": "AUTHENTICATION",
"is_encrypted": False,
}, },
{ {
"key": "ENABLE_EMAIL_PASSWORD", "key": "ENABLE_EMAIL_PASSWORD",
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), "value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
"category": "AUTHENTICATION", "category": "AUTHENTICATION",
"is_encrypted": False,
}, },
{ {
"key": "ENABLE_MAGIC_LINK_LOGIN", "key": "ENABLE_MAGIC_LINK_LOGIN",
"value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"), "value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"),
"category": "AUTHENTICATION", "category": "AUTHENTICATION",
"is_encrypted": False,
}, },
{ {
"key": "GOOGLE_CLIENT_ID", "key": "GOOGLE_CLIENT_ID",
"value": os.environ.get("GOOGLE_CLIENT_ID"), "value": os.environ.get("GOOGLE_CLIENT_ID"),
"category": "GOOGLE", "category": "GOOGLE",
"is_encrypted": False,
}, },
{ {
"key": "GITHUB_CLIENT_ID", "key": "GITHUB_CLIENT_ID",
"value": os.environ.get("GITHUB_CLIENT_ID"), "value": os.environ.get("GITHUB_CLIENT_ID"),
"category": "GITHUB", "category": "GITHUB",
"is_encrypted": False,
}, },
{ {
"key": "GITHUB_CLIENT_SECRET", "key": "GITHUB_CLIENT_SECRET",
"value": encrypt_data(os.environ.get("GITHUB_CLIENT_SECRET")) "value": os.environ.get("GITHUB_CLIENT_SECRET"),
if os.environ.get("GITHUB_CLIENT_SECRET")
else None,
"category": "GITHUB", "category": "GITHUB",
"is_encrypted": True,
}, },
{ {
"key": "EMAIL_HOST", "key": "EMAIL_HOST",
"value": os.environ.get("EMAIL_HOST", ""), "value": os.environ.get("EMAIL_HOST", ""),
"category": "SMTP", "category": "SMTP",
"is_encrypted": False,
}, },
{ {
"key": "EMAIL_HOST_USER", "key": "EMAIL_HOST_USER",
"value": os.environ.get("EMAIL_HOST_USER", ""), "value": os.environ.get("EMAIL_HOST_USER", ""),
"category": "SMTP", "category": "SMTP",
"is_encrypted": False,
}, },
{ {
"key": "EMAIL_HOST_PASSWORD", "key": "EMAIL_HOST_PASSWORD",
"value": encrypt_data(os.environ.get("EMAIL_HOST_PASSWORD")) "value": os.environ.get("EMAIL_HOST_PASSWORD", ""),
if os.environ.get("EMAIL_HOST_PASSWORD")
else None,
"category": "SMTP", "category": "SMTP",
"is_encrypted": True,
}, },
{ {
"key": "EMAIL_PORT", "key": "EMAIL_PORT",
"value": os.environ.get("EMAIL_PORT", "587"), "value": os.environ.get("EMAIL_PORT", "587"),
"category": "SMTP", "category": "SMTP",
"is_encrypted": False,
}, },
{ {
"key": "EMAIL_FROM", "key": "EMAIL_FROM",
"value": os.environ.get("EMAIL_FROM", ""), "value": os.environ.get("EMAIL_FROM", ""),
"category": "SMTP", "category": "SMTP",
"is_encrypted": False,
}, },
{ {
"key": "EMAIL_USE_TLS", "key": "EMAIL_USE_TLS",
"value": os.environ.get("EMAIL_USE_TLS", "1"), "value": os.environ.get("EMAIL_USE_TLS", "1"),
"category": "SMTP", "category": "SMTP",
"is_encrypted": False,
}, },
{ {
"key": "OPENAI_API_KEY", "key": "OPENAI_API_KEY",
"value": encrypt_data(os.environ.get("OPENAI_API_KEY")) "value": os.environ.get("OPENAI_API_KEY"),
if os.environ.get("OPENAI_API_KEY")
else None,
"category": "OPENAI", "category": "OPENAI",
"is_encrypted": True,
}, },
{ {
"key": "GPT_ENGINE", "key": "GPT_ENGINE",
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), "value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
"category": "SMTP", "category": "SMTP",
"is_encrypted": False,
}, },
{ {
"key": "UNSPLASH_ACCESS_KEY", "key": "UNSPLASH_ACCESS_KEY",
"value": encrypt_data(os.environ.get("UNSPLASH_ACESS_KEY", "")) "value": os.environ.get("UNSPLASH_ACESS_KEY", ""),
if os.environ.get("UNSPLASH_ACESS_KEY")
else None,
"category": "UNSPLASH", "category": "UNSPLASH",
"is_encrypted": True,
}, },
] ]
@ -107,8 +114,12 @@ class Command(BaseCommand):
key=item.get("key") key=item.get("key")
) )
if created: if created:
obj.value = item.get("value")
obj.category = item.get("category") obj.category = item.get("category")
obj.is_encrypted = item.get("is_encrypted", False)
if item.get("is_encrypted", False):
obj.value = encrypt_data(item.get("value"))
else:
obj.value = item.get("value")
obj.save() obj.save()
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(

View File

@ -1,7 +1,7 @@
# Python imports # Python imports
import json import json
import os
import requests import requests
import secrets
# Django imports # Django imports
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
@ -32,12 +32,11 @@ class Command(BaseCommand):
machine_signature = options.get("machine_signature", False) machine_signature = options.get("machine_signature", False)
if not machine_signature: if not machine_signature:
raise CommandError("Machine signature is required") raise CommandError("Machine signature is required")
# Check if machine is online
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
payload = { payload = {
"instance_key": settings.INSTANCE_KEY, "instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1), "version": data.get("version", 0.1),
@ -45,10 +44,12 @@ class Command(BaseCommand):
"user_count": User.objects.filter(is_bot=False).count(), "user_count": User.objects.filter(is_bot=False).count(),
} }
try:
response = requests.post( response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
timeout=30
) )
if response.status_code == 201: if response.status_code == 201:
@ -62,8 +63,25 @@ class Command(BaseCommand):
version=data.get("version"), version=data.get("version"),
last_checked_at=timezone.now(), last_checked_at=timezone.now(),
user_count=data.get("user_count", 0), user_count=data.get("user_count", 0),
is_verified=True,
) )
self.stdout.write(
self.style.SUCCESS(
f"Instance successfully registered and verified"
)
)
return
except requests.RequestException as _e:
instance = Instance.objects.create(
instance_name="Plane Free",
instance_id=secrets.token_hex(12),
license_key=None,
api_key=secrets.token_hex(8),
version=payload.get("version"),
last_checked_at=timezone.now(),
user_count=payload.get("user_count", 0),
)
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(
f"Instance successfully registered" f"Instance successfully registered"

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2.7 on 2023-11-29 14:39 # Generated by Django 4.2.7 on 2023-12-06 06:49
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -34,6 +34,7 @@ class Migration(migrations.Migration):
('is_setup_done', models.BooleanField(default=False)), ('is_setup_done', models.BooleanField(default=False)),
('is_signup_screen_visited', models.BooleanField(default=False)), ('is_signup_screen_visited', models.BooleanField(default=False)),
('user_count', models.PositiveBigIntegerField(default=0)), ('user_count', models.PositiveBigIntegerField(default=0)),
('is_verified', models.BooleanField(default=False)),
('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')), ('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')), ('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')),
], ],
@ -53,6 +54,7 @@ class Migration(migrations.Migration):
('key', models.CharField(max_length=100, unique=True)), ('key', models.CharField(max_length=100, unique=True)),
('value', models.TextField(blank=True, default=None, null=True)), ('value', models.TextField(blank=True, default=None, null=True)),
('category', models.TextField()), ('category', models.TextField()),
('is_encrypted', models.BooleanField(default=False)),
('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')), ('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')), ('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')),
], ],
@ -70,6 +72,7 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified 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)), ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('role', models.PositiveIntegerField(choices=[(20, 'Admin')], default=20)), ('role', models.PositiveIntegerField(choices=[(20, 'Admin')], default=20)),
('is_verified', models.BooleanField(default=False)),
('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')), ('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')), ('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')), ('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')),

View File

@ -30,6 +30,7 @@ class Instance(BaseModel):
is_signup_screen_visited = models.BooleanField(default=False) is_signup_screen_visited = models.BooleanField(default=False)
# users # users
user_count = models.PositiveBigIntegerField(default=0) user_count = models.PositiveBigIntegerField(default=0)
is_verified = models.BooleanField(default=False)
class Meta: class Meta:
verbose_name = "Instance" verbose_name = "Instance"
@ -47,6 +48,7 @@ class InstanceAdmin(BaseModel):
) )
instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins") instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins")
role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=20) role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=20)
is_verified = models.BooleanField(default=False)
class Meta: class Meta:
unique_together = ["instance", "user"] unique_together = ["instance", "user"]
@ -61,6 +63,7 @@ class InstanceConfiguration(BaseModel):
key = models.CharField(max_length=100, unique=True) key = models.CharField(max_length=100, unique=True)
value = models.TextField(null=True, blank=True, default=None) value = models.TextField(null=True, blank=True, default=None)
category = models.TextField() category = models.TextField()
is_encrypted = models.BooleanField(default=False)
class Meta: class Meta:
verbose_name = "Instance Configuration" verbose_name = "Instance Configuration"

View File

@ -4,9 +4,7 @@ from plane.license.api.views import (
InstanceEndpoint, InstanceEndpoint,
InstanceAdminEndpoint, InstanceAdminEndpoint,
InstanceConfigurationEndpoint, InstanceConfigurationEndpoint,
AdminMagicSignInGenerateEndpoint, InstanceAdminSignInEndpoint,
AdminSetupMagicSignInEndpoint,
AdminSetUserPasswordEndpoint,
SignUpScreenVisitedEndpoint, SignUpScreenVisitedEndpoint,
) )
@ -32,19 +30,9 @@ urlpatterns = [
name="instance-configuration", name="instance-configuration",
), ),
path( path(
"instances/admins/magic-generate/", "instances/admins/sign-in/",
AdminMagicSignInGenerateEndpoint.as_view(), InstanceAdminSignInEndpoint.as_view(),
name="instance-admins", name="instance-admin-sign-in",
),
path(
"instances/admins/magic-sign-in/",
AdminSetupMagicSignInEndpoint.as_view(),
name="instance-admins",
),
path(
"instances/admins/set-password/",
AdminSetUserPasswordEndpoint.as_view(),
name="instance-admins",
), ),
path( path(
"instances/admins/sign-up-screen-visited/", "instances/admins/sign-up-screen-visited/",

View File

@ -110,7 +110,9 @@ CSRF_COOKIE_SECURE = True
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "") cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "")
# filter out empty strings # filter out empty strings
cors_allowed_origins = [origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()] cors_allowed_origins = [
origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()
]
if cors_allowed_origins: if cors_allowed_origins:
CORS_ALLOWED_ORIGINS = cors_allowed_origins CORS_ALLOWED_ORIGINS = cors_allowed_origins
else: else:
@ -286,6 +288,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.issue_automation_task", "plane.bgtasks.issue_automation_task",
"plane.bgtasks.exporter_expired_task", "plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task", "plane.bgtasks.file_asset_task",
"plane.license.bgtasks.instance_verification_task",
) )
# Sentry Settings # Sentry Settings
@ -327,7 +330,11 @@ POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
# License engine base url # License engine base url
LICENSE_ENGINE_BASE_URL = os.environ.get("LICENSE_ENGINE_BASE_URL", "https://control-center.plane.so") LICENSE_ENGINE_BASE_URL = os.environ.get(
"LICENSE_ENGINE_BASE_URL", "https://control-center.plane.so"
)
# instance key # instance key
INSTANCE_KEY = os.environ.get("INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3") INSTANCE_KEY = os.environ.get(
"INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3"
)

View File

@ -38,7 +38,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
setLoginCallBackURL(`${origin}/` as any); setLoginCallBackURL(`${origin}/` as any);
}, []); }, []);
return ( return (
<div className="w-full flex justify-center items-center"> <div className="w-full">
<Link <Link
href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`} href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
> >

View File

@ -16,6 +16,7 @@ type Props = {
email: string; email: string;
handleStepChange: (step: ESignInSteps) => void; handleStepChange: (step: ESignInSteps) => void;
handleSignInRedirection: () => Promise<void>; handleSignInRedirection: () => Promise<void>;
isOnboarded: boolean;
}; };
type TCreatePasswordFormValues = { type TCreatePasswordFormValues = {
@ -32,7 +33,7 @@ const defaultValues: TCreatePasswordFormValues = {
const authService = new AuthService(); const authService = new AuthService();
export const CreatePasswordForm: React.FC<Props> = (props) => { export const CreatePasswordForm: React.FC<Props> = (props) => {
const { email, handleSignInRedirection } = props; const { email, handleSignInRedirection, isOnboarded } = props;
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info // form info
@ -76,9 +77,8 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
return ( return (
<> <>
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100"> <h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">
Let{"'"}s get a new password Get on your flight deck
</h1> </h1>
<form onSubmit={handleSubmit(handleCreatePassword)} className="mt-11 sm:w-96 mx-auto space-y-4"> <form onSubmit={handleSubmit(handleCreatePassword)} className="mt-11 sm:w-96 mx-auto space-y-4">
<Controller <Controller
control={control} control={control}
@ -97,12 +97,11 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@firstflight.com" placeholder="orville.wright@firstflight.com"
className="w-full h-[46px] text-onboarding-text-400 border border-onboarding-border-100 pr-12" className="w-full h-[46px] text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200"
disabled disabled
/> />
)} )}
/> />
<div>
<Controller <Controller
control={control} control={control}
name="password" name="password"
@ -116,18 +115,14 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.password)} hasError={Boolean(errors.password)}
placeholder="Create password" placeholder="Choose password"
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200"
minLength={8} minLength={8}
/> />
)} )}
/> />
<p className="text-xs text-onboarding-text-200 mt-3 pb-2">
Whatever you choose now will be your account{"'"}s password until you change it.
</p>
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}> <Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
{isSubmitting ? "Submitting..." : "Go to workspace"} {isOnboarded ? "Go to workspace" : "Set up workspace"}
</Button> </Button>
<p className="text-xs text-onboarding-text-200"> <p className="text-xs text-onboarding-text-200">
When you click the button above, you agree with our{" "} When you click the button above, you agree with our{" "}

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react"; import { XCircle } from "lucide-react";
// services // services
@ -10,7 +10,7 @@ import { Button, Input } from "@plane/ui";
// helpers // helpers
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// types // types
import { IEmailCheckData, TEmailCheckTypes } from "types/auth"; import { IEmailCheckData } from "types/auth";
// constants // constants
import { ESignInSteps } from "components/account"; import { ESignInSteps } from "components/account";
@ -19,7 +19,7 @@ type Props = {
updateEmail: (email: string) => void; updateEmail: (email: string) => void;
}; };
type TEmailCodeFormValues = { type TEmailFormValues = {
email: string; email: string;
}; };
@ -27,17 +27,14 @@ const authService = new AuthService();
export const EmailForm: React.FC<Props> = (props) => { export const EmailForm: React.FC<Props> = (props) => {
const { handleStepChange, updateEmail } = props; const { handleStepChange, updateEmail } = props;
// states
const [isCheckingEmail, setIsCheckingEmail] = useState<TEmailCheckTypes | null>(null);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { const {
control, control,
formState: { errors, isValid }, formState: { errors, isSubmitting, isValid },
handleSubmit, handleSubmit,
watch, } = useForm<TEmailFormValues>({
} = useForm<TEmailCodeFormValues>({
defaultValues: { defaultValues: {
email: "", email: "",
}, },
@ -45,31 +42,21 @@ export const EmailForm: React.FC<Props> = (props) => {
reValidateMode: "onChange", reValidateMode: "onChange",
}); });
const handleEmailCheck = async (type: TEmailCheckTypes) => { const handleFormSubmit = async (data: TEmailFormValues) => {
setIsCheckingEmail(type);
const email = watch("email");
const payload: IEmailCheckData = { const payload: IEmailCheckData = {
email, email: data.email,
type,
}; };
// update the global email state // update the global email state
updateEmail(email); updateEmail(data.email);
await authService await authService
.emailCheck(payload) .emailCheck(payload)
.then((res) => { .then((res) => {
// if type is magic_code, send the user to magic sign in // if the password has been autoset, send the user to magic sign-in
if (type === "magic_code") handleStepChange(ESignInSteps.UNIQUE_CODE); if (res.is_password_autoset) handleStepChange(ESignInSteps.UNIQUE_CODE);
// if type is password, check if the user has a password set // if the password has not been autoset, send them to password sign-in
if (type === "password") {
// if password is autoset, send them to set new password link
if (res.is_password_autoset) handleStepChange(ESignInSteps.SET_PASSWORD_LINK);
// if password is not autoset, send them to password form
else handleStepChange(ESignInSteps.PASSWORD); else handleStepChange(ESignInSteps.PASSWORD);
}
}) })
.catch((err) => .catch((err) =>
setToastAlert({ setToastAlert({
@ -77,8 +64,7 @@ export const EmailForm: React.FC<Props> = (props) => {
title: "Error!", title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.", message: err?.error ?? "Something went wrong. Please try again.",
}) })
) );
.finally(() => setIsCheckingEmail(null));
}; };
return ( return (
@ -86,11 +72,11 @@ export const EmailForm: React.FC<Props> = (props) => {
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100"> <h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">
Get on your flight deck Get on your flight deck
</h1> </h1>
<p className="text-center text-sm text-onboarding-text-200 mt-3"> <p className="text-center text-sm text-onboarding-text-200 mt-2.5">
Sign in with the email you used to sign up for Plane Create or join a workspace. Start with your e-mail.
</p> </p>
<form onSubmit={handleSubmit(() => {})} className="mt-5 sm:w-96 mx-auto"> <form onSubmit={handleSubmit(handleFormSubmit)} className="mt-8 sm:w-96 mx-auto space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<Controller <Controller
control={control} control={control}
@ -122,30 +108,9 @@ export const EmailForm: React.FC<Props> = (props) => {
)} )}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-2.5 mt-4"> <Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
<Button Continue
type="button"
variant="primary"
className="w-full"
size="xl"
onClick={() => handleEmailCheck("magic_code")}
disabled={!isValid}
loading={Boolean(isCheckingEmail)}
>
{isCheckingEmail === "magic_code" ? "Sending code..." : "Send unique code"}
</Button> </Button>
<Button
type="button"
variant="outline-primary"
className="w-full"
size="xl"
onClick={() => handleEmailCheck("password")}
disabled={!isValid}
loading={Boolean(isCheckingEmail)}
>
{isCheckingEmail === "password" ? "Loading..." : "Use password"}
</Button>
</div>
</form> </form>
</> </>
); );

View File

@ -2,7 +2,8 @@ export * from "./create-password";
export * from "./email-form"; export * from "./email-form";
export * from "./o-auth-options"; export * from "./o-auth-options";
export * from "./optional-set-password"; export * from "./optional-set-password";
export * from "./unique-code";
export * from "./password"; export * from "./password";
export * from "./root"; export * from "./root";
export * from "./self-hosted-sign-in";
export * from "./set-password-link"; export * from "./set-password-link";
export * from "./unique-code";

View File

@ -3,24 +3,20 @@ import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// services // services
import { AuthService } from "services/auth.service"; import { AuthService } from "services/auth.service";
import { UserService } from "services/user.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { ESignInSteps, GithubLoginButton, GoogleLoginButton } from "components/account"; import { GithubLoginButton, GoogleLoginButton } from "components/account";
type Props = { type Props = {
updateEmail: (email: string) => void;
handleStepChange: (step: ESignInSteps) => void;
handleSignInRedirection: () => Promise<void>; handleSignInRedirection: () => Promise<void>;
}; };
// services // services
const authService = new AuthService(); const authService = new AuthService();
const userService = new UserService();
export const OAuthOptions: React.FC<Props> = observer((props) => { export const OAuthOptions: React.FC<Props> = observer((props) => {
const { updateEmail, handleStepChange, handleSignInRedirection } = props; const { handleSignInRedirection } = props;
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// mobx store // mobx store
@ -38,14 +34,7 @@ export const OAuthOptions: React.FC<Props> = observer((props) => {
}; };
const response = await authService.socialAuth(socialAuthPayload); const response = await authService.socialAuth(socialAuthPayload);
if (response) { if (response) handleSignInRedirection();
const currentUser = await userService.currentUser();
updateEmail(currentUser.email);
if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD);
else handleSignInRedirection();
}
} else throw Error("Cant find credentials"); } else throw Error("Cant find credentials");
} catch (err: any) { } catch (err: any) {
setToastAlert({ setToastAlert({
@ -66,14 +55,7 @@ export const OAuthOptions: React.FC<Props> = observer((props) => {
}; };
const response = await authService.socialAuth(socialAuthPayload); const response = await authService.socialAuth(socialAuthPayload);
if (response) { if (response) handleSignInRedirection();
const currentUser = await userService.currentUser();
updateEmail(currentUser.email);
if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD);
else handleSignInRedirection();
}
} else throw Error("Cant find credentials"); } else throw Error("Cant find credentials");
} catch (err: any) { } catch (err: any) {
setToastAlert({ setToastAlert({
@ -87,11 +69,11 @@ export const OAuthOptions: React.FC<Props> = observer((props) => {
return ( return (
<> <>
<div className="flex sm:w-96 items-center mt-4 mx-auto"> <div className="flex sm:w-96 items-center mt-4 mx-auto">
<hr className={`border-onboarding-border-100 w-full`} /> <hr className="border-onboarding-border-100 w-full" />
<p className="text-center text-sm text-onboarding-text-400 mx-3 flex-shrink-0">Or continue with</p> <p className="text-center text-sm text-onboarding-text-400 mx-3 flex-shrink-0">Or continue with</p>
<hr className={`border-onboarding-border-100 w-full`} /> <hr className="border-onboarding-border-100 w-full" />
</div> </div>
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:flex-row sm:w-96 mx-auto overflow-hidden"> <div className="flex flex-col sm:flex-row items-center gap-2 pt-7 sm:w-96 mx-auto overflow-hidden">
{envConfig?.google_client_id && ( {envConfig?.google_client_id && (
<GoogleLoginButton clientId={envConfig?.google_client_id} handleSignIn={handleGoogleSignIn} /> <GoogleLoginButton clientId={envConfig?.google_client_id} handleSignIn={handleGoogleSignIn} />
)} )}

View File

@ -12,13 +12,14 @@ type Props = {
email: string; email: string;
handleStepChange: (step: ESignInSteps) => void; handleStepChange: (step: ESignInSteps) => void;
handleSignInRedirection: () => Promise<void>; handleSignInRedirection: () => Promise<void>;
isOnboarded: boolean;
}; };
export const OptionalSetPasswordForm: React.FC<Props> = (props) => { export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
const { email, handleStepChange, handleSignInRedirection } = props; const { email, handleStepChange, handleSignInRedirection, isOnboarded } = props;
// states // states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
// form info
const { const {
control, control,
formState: { errors, isValid }, formState: { errors, isValid },
@ -39,8 +40,8 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
return ( return (
<> <>
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">Set a password</h1> <h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">Set a password</h1>
<p className="text-center text-sm text-onboarding-text-200 px-20 mt-3"> <p className="text-center text-sm text-onboarding-text-200 px-20 mt-2.5">
If you{"'"}d to do away with codes, set a password here If you{"'"}d like to do away with codes, set a password here.
</p> </p>
<form className="mt-5 sm:w-96 mx-auto space-y-4"> <form className="mt-5 sm:w-96 mx-auto space-y-4">
@ -86,11 +87,13 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
disabled={!isValid} disabled={!isValid}
loading={isGoingToWorkspace} loading={isGoingToWorkspace}
> >
{isGoingToWorkspace ? "Going to app..." : "Go to workspace"} {isOnboarded ? "Go to workspace" : "Set up workspace"}
</Button> </Button>
</div> </div>
<p className="text-xs text-onboarding-text-200"> <p className="text-xs text-onboarding-text-200">
When you click <span className="text-custom-primary-100">Go to workspace</span> above, you agree with our{" "} When you click{" "}
<span className="text-custom-primary-100">{isOnboarded ? "Go to workspace" : "Set up workspace"}</span> above,
you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer"> <Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span> <span className="font-semibold underline">terms and conditions of service.</span>
</Link> </Link>

View File

@ -11,7 +11,7 @@ import { Button, Input } from "@plane/ui";
// helpers // helpers
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// types // types
import { IEmailCheckData, IPasswordSignInData } from "types/auth"; import { IPasswordSignInData } from "types/auth";
// constants // constants
import { ESignInSteps } from "components/account"; import { ESignInSteps } from "components/account";
@ -37,6 +37,7 @@ const authService = new AuthService();
export const PasswordForm: React.FC<Props> = (props) => { export const PasswordForm: React.FC<Props> = (props) => {
const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; const { email, updateEmail, handleStepChange, handleSignInRedirection } = props;
// states // states
const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false);
const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false); const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false);
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -46,7 +47,6 @@ export const PasswordForm: React.FC<Props> = (props) => {
formState: { dirtyFields, errors, isSubmitting, isValid }, formState: { dirtyFields, errors, isSubmitting, isValid },
getValues, getValues,
handleSubmit, handleSubmit,
reset,
setError, setError,
} = useForm<TPasswordFormValues>({ } = useForm<TPasswordFormValues>({
defaultValues: { defaultValues: {
@ -57,7 +57,9 @@ export const PasswordForm: React.FC<Props> = (props) => {
reValidateMode: "onChange", reValidateMode: "onChange",
}); });
const handlePasswordSignIn = async (formData: TPasswordFormValues) => { const handleFormSubmit = async (formData: TPasswordFormValues) => {
updateEmail(formData.email);
const payload: IPasswordSignInData = { const payload: IPasswordSignInData = {
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
@ -75,36 +77,6 @@ export const PasswordForm: React.FC<Props> = (props) => {
); );
}; };
const handleEmailCheck = async (formData: TPasswordFormValues) => {
const payload: IEmailCheckData = {
email: formData.email,
type: "password",
};
await authService
.emailCheck(payload)
.then((res) => {
if (res.is_password_autoset) handleStepChange(ESignInSteps.SET_PASSWORD_LINK);
else
reset({
email: formData.email,
password: "",
});
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
const handleFormSubmit = async (formData: TPasswordFormValues) => {
if (dirtyFields.email) await handleEmailCheck(formData);
else await handlePasswordSignIn(formData);
};
const handleForgotPassword = async () => { const handleForgotPassword = async () => {
const emailFormValue = getValues("email"); const emailFormValue = getValues("email");
@ -130,6 +102,31 @@ export const PasswordForm: React.FC<Props> = (props) => {
.finally(() => setIsSendingResetPasswordLink(false)); .finally(() => setIsSendingResetPasswordLink(false));
}; };
const handleSendUniqueCode = async () => {
const emailFormValue = getValues("email");
const isEmailValid = checkEmailValidity(emailFormValue);
if (!isEmailValid) {
setError("email", { message: "Email is invalid" });
return;
}
setIsSendingUniqueCode(true);
await authService
.generateUniqueCode({ email: emailFormValue })
.then(() => handleStepChange(ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD))
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
)
.finally(() => setIsSendingUniqueCode(false));
};
return ( return (
<> <>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-onboarding-text-100"> <h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-onboarding-text-100">
@ -151,13 +148,7 @@ export const PasswordForm: React.FC<Props> = (props) => {
name="email" name="email"
type="email" type="email"
value={value} value={value}
onChange={(e) => { onChange={onChange}
updateEmail(e.target.value);
onChange(e.target.value);
}}
onBlur={() => {
if (dirtyFields.email) handleEmailCheck(getValues());
}}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@firstflight.com" placeholder="orville.wright@firstflight.com"
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12"
@ -186,7 +177,7 @@ export const PasswordForm: React.FC<Props> = (props) => {
onChange={onChange} onChange={onChange}
hasError={Boolean(errors.password)} hasError={Boolean(errors.password)}
placeholder="Enter password" placeholder="Enter password"
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200"
/> />
)} )}
/> />
@ -199,15 +190,34 @@ export const PasswordForm: React.FC<Props> = (props) => {
}`} }`}
disabled={isSendingResetPasswordLink} disabled={isSendingResetPasswordLink}
> >
{isSendingResetPasswordLink ? "Sending link..." : "Forgot your password?"} {isSendingResetPasswordLink ? "Sending link" : "Forgot your password?"}
</button> </button>
</div> </div>
</div> </div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}> <div className="grid sm:grid-cols-2 gap-2.5">
{isSubmitting ? "Signing in..." : "Go to workspace"} <Button
type="button"
onClick={handleSendUniqueCode}
variant="primary"
className="w-full"
size="xl"
loading={isSendingUniqueCode}
>
{isSendingUniqueCode ? "Sending code" : "Use unique code"}
</Button> </Button>
<Button
type="submit"
variant="outline-primary"
className="w-full"
size="xl"
disabled={!isValid}
loading={isSubmitting}
>
Go to workspace
</Button>
</div>
<p className="text-xs text-onboarding-text-200"> <p className="text-xs text-onboarding-text-200">
When you click the button above, you agree with our{" "} When you click <span className="text-custom-primary-100">Go to workspace</span> above, you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer"> <Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span> <span className="font-semibold underline">terms and conditions of service.</span>
</Link> </Link>

View File

@ -1,7 +1,11 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks // hooks
import useSignInRedirection from "hooks/use-sign-in-redirection"; import useSignInRedirection from "hooks/use-sign-in-redirection";
// components // components
import { LatestFeatureBlock } from "components/common";
import { import {
EmailForm, EmailForm,
UniqueCodeForm, UniqueCodeForm,
@ -10,6 +14,7 @@ import {
OAuthOptions, OAuthOptions,
OptionalSetPasswordForm, OptionalSetPasswordForm,
CreatePasswordForm, CreatePasswordForm,
SelfHostedSignInForm,
} from "components/account"; } from "components/account";
export enum ESignInSteps { export enum ESignInSteps {
@ -19,22 +24,41 @@ export enum ESignInSteps {
UNIQUE_CODE = "UNIQUE_CODE", UNIQUE_CODE = "UNIQUE_CODE",
OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD", OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD",
CREATE_PASSWORD = "CREATE_PASSWORD", CREATE_PASSWORD = "CREATE_PASSWORD",
USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD",
} }
const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD]; const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD];
export const SignInRoot = () => { export const SignInRoot = observer(() => {
// states // states
const [signInStep, setSignInStep] = useState<ESignInSteps>(ESignInSteps.EMAIL); const [signInStep, setSignInStep] = useState<ESignInSteps>(ESignInSteps.EMAIL);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [isOnboarded, setIsOnboarded] = useState(false);
// sign in redirection hook // sign in redirection hook
const { handleRedirection } = useSignInRedirection(); const { handleRedirection } = useSignInRedirection();
// mobx store
const {
appConfig: { envConfig },
} = useMobxStore();
const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id);
return ( return (
<> <>
<div className="mx-auto flex flex-col"> <div className="mx-auto flex flex-col">
{envConfig?.is_self_managed ? (
<SelfHostedSignInForm
email={email}
updateEmail={(newEmail) => setEmail(newEmail)}
handleSignInRedirection={handleRedirection}
/>
) : (
<>
{signInStep === ESignInSteps.EMAIL && ( {signInStep === ESignInSteps.EMAIL && (
<EmailForm handleStepChange={(step) => setSignInStep(step)} updateEmail={(newEmail) => setEmail(newEmail)} /> <EmailForm
handleStepChange={(step) => setSignInStep(step)}
updateEmail={(newEmail) => setEmail(newEmail)}
/>
)} )}
{signInStep === ESignInSteps.PASSWORD && ( {signInStep === ESignInSteps.PASSWORD && (
<PasswordForm <PasswordForm
@ -47,12 +71,24 @@ export const SignInRoot = () => {
{signInStep === ESignInSteps.SET_PASSWORD_LINK && ( {signInStep === ESignInSteps.SET_PASSWORD_LINK && (
<SetPasswordLink email={email} updateEmail={(newEmail) => setEmail(newEmail)} /> <SetPasswordLink email={email} updateEmail={(newEmail) => setEmail(newEmail)} />
)} )}
{signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && (
<UniqueCodeForm
email={email}
updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleRedirection}
submitButtonLabel="Go to workspace"
showTermsAndConditions
updateUserOnboardingStatus={(value) => setIsOnboarded(value)}
/>
)}
{signInStep === ESignInSteps.UNIQUE_CODE && ( {signInStep === ESignInSteps.UNIQUE_CODE && (
<UniqueCodeForm <UniqueCodeForm
email={email} email={email}
updateEmail={(newEmail) => setEmail(newEmail)} updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleRedirection} handleSignInRedirection={handleRedirection}
updateUserOnboardingStatus={(value) => setIsOnboarded(value)}
/> />
)} )}
{signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && (
@ -60,6 +96,7 @@ export const SignInRoot = () => {
email={email} email={email}
handleStepChange={(step) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleRedirection} handleSignInRedirection={handleRedirection}
isOnboarded={isOnboarded}
/> />
)} )}
{signInStep === ESignInSteps.CREATE_PASSWORD && ( {signInStep === ESignInSteps.CREATE_PASSWORD && (
@ -67,16 +104,16 @@ export const SignInRoot = () => {
email={email} email={email}
handleStepChange={(step) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleRedirection} handleSignInRedirection={handleRedirection}
/> isOnboarded={isOnboarded}
)}
</div>
{!OAUTH_HIDDEN_STEPS.includes(signInStep) && (
<OAuthOptions
updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleRedirection}
/> />
)} )}
</> </>
)}
</div>
{isOAuthEnabled && !OAUTH_HIDDEN_STEPS.includes(signInStep) && (
<OAuthOptions handleSignInRedirection={handleRedirection} />
)}
<LatestFeatureBlock />
</>
); );
}; });

View File

@ -0,0 +1,139 @@
import React from "react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react";
// services
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IPasswordSignInData } from "types/auth";
type Props = {
email: string;
updateEmail: (email: string) => void;
handleSignInRedirection: () => Promise<void>;
};
type TPasswordFormValues = {
email: string;
password: string;
};
const defaultValues: TPasswordFormValues = {
email: "",
password: "",
};
const authService = new AuthService();
export const SelfHostedSignInForm: React.FC<Props> = (props) => {
const { email, updateEmail, handleSignInRedirection } = props;
// toast alert
const { setToastAlert } = useToast();
// form info
const {
control,
formState: { dirtyFields, errors, isSubmitting },
handleSubmit,
} = useForm<TPasswordFormValues>({
defaultValues: {
...defaultValues,
email,
},
mode: "onChange",
reValidateMode: "onChange",
});
const handleFormSubmit = async (formData: TPasswordFormValues) => {
const payload: IPasswordSignInData = {
email: formData.email,
password: formData.password,
};
updateEmail(formData.email);
await authService
.passwordSignIn(payload)
.then(async () => await handleSignInRedirection())
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
return (
<>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-onboarding-text-100">
Get on your flight deck
</h1>
<form onSubmit={handleSubmit(handleFormSubmit)} className="mt-11 sm:w-96 mx-auto space-y-4">
<div>
<Controller
control={control}
name="email"
rules={{
required: "Email is required",
validate: (value) => checkEmailValidity(value) || "Email is invalid",
}}
render={({ field: { value, onChange } }) => (
<div className="flex items-center relative rounded-md bg-onboarding-background-200">
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
hasError={Boolean(errors.email)}
placeholder="orville.wright@firstflight.com"
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12"
/>
{value.length > 0 && (
<XCircle
className="h-5 w-5 absolute stroke-custom-text-400 hover:cursor-pointer right-3"
onClick={() => onChange("")}
/>
)}
</div>
)}
/>
</div>
<div>
<Controller
control={control}
name="password"
rules={{
required: dirtyFields.email ? false : "Password is required",
}}
render={({ field: { value, onChange } }) => (
<Input
type="password"
value={value}
onChange={onChange}
hasError={Boolean(errors.password)}
placeholder="Enter password"
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200"
/>
)}
/>
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" loading={isSubmitting}>
Go to workspace
</Button>
<p className="text-xs text-onboarding-text-200">
When you click the button above, you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span>
</Link>
</p>
</form>
</>
);
};

View File

@ -1,7 +1,5 @@
import React, { useState } from "react"; import React from "react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react";
// services // services
import { AuthService } from "services/auth.service"; import { AuthService } from "services/auth.service";
// hooks // hooks
@ -22,14 +20,12 @@ const authService = new AuthService();
export const SetPasswordLink: React.FC<Props> = (props) => { export const SetPasswordLink: React.FC<Props> = (props) => {
const { email, updateEmail } = props; const { email, updateEmail } = props;
// states
const [isSendingNewLink, setIsSendingNewLink] = useState(false);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { const {
control, control,
formState: { errors, isValid }, formState: { errors, isSubmitting, isValid },
handleSubmit, handleSubmit,
} = useForm({ } = useForm({
defaultValues: { defaultValues: {
@ -40,17 +36,14 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
}); });
const handleSendNewLink = async (formData: { email: string }) => { const handleSendNewLink = async (formData: { email: string }) => {
setIsSendingNewLink(true);
updateEmail(formData.email); updateEmail(formData.email);
const payload: IEmailCheckData = { const payload: IEmailCheckData = {
email: formData.email, email: formData.email,
type: "password",
}; };
await authService await authService
.emailCheck(payload) .sendResetPasswordLink(payload)
.then(() => .then(() =>
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -64,8 +57,7 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
title: "Error!", title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.", message: err?.error ?? "Something went wrong. Please try again.",
}) })
) );
.finally(() => setIsSendingNewLink(false));
}; };
return ( return (
@ -73,7 +65,7 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100"> <h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">
Get on your flight deck Get on your flight deck
</h1> </h1>
<p className="text-center text-sm text-onboarding-text-200 px-20 mt-3"> <p className="text-center text-sm text-onboarding-text-200 px-20 mt-2.5">
We have sent a link to <span className="font-semibold text-custom-primary-100">{email},</span> so you can set a We have sent a link to <span className="font-semibold text-custom-primary-100">{email},</span> so you can set a
password password
</p> </p>
@ -87,45 +79,24 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
required: "Email is required", required: "Email is required",
validate: (value) => checkEmailValidity(value) || "Email is invalid", validate: (value) => checkEmailValidity(value) || "Email is invalid",
}} }}
render={({ field: { value, onChange, ref } }) => ( render={({ field: { value, onChange } }) => (
<div className="flex items-center relative rounded-md bg-onboarding-background-200">
<Input <Input
id="email" id="email"
name="email" name="email"
type="email" type="email"
value={value} value={value}
onChange={onChange} onChange={onChange}
ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@firstflight.com" placeholder="orville.wright@firstflight.com"
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" className="w-full h-[46px] text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200"
disabled
/> />
{value.length > 0 && (
<XCircle
className="h-5 w-5 absolute stroke-custom-text-400 hover:cursor-pointer right-3"
onClick={() => onChange("")}
/>
)}
</div>
)} )}
/> />
</div> </div>
<Button <Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
type="submit" {isSubmitting ? "Sending new link" : "Get link again"}
variant="primary"
className="w-full"
size="xl"
disabled={!isValid}
loading={isSendingNewLink}
>
{isSendingNewLink ? "Sending new link..." : "Get link again"}
</Button> </Button>
<p className="text-xs text-onboarding-text-200">
When you click the button above, you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span>
</Link>
</p>
</form> </form>
</> </>
); );

View File

@ -22,6 +22,9 @@ type Props = {
updateEmail: (email: string) => void; updateEmail: (email: string) => void;
handleStepChange: (step: ESignInSteps) => void; handleStepChange: (step: ESignInSteps) => void;
handleSignInRedirection: () => Promise<void>; handleSignInRedirection: () => Promise<void>;
submitButtonLabel?: string;
showTermsAndConditions?: boolean;
updateUserOnboardingStatus: (value: boolean) => void;
}; };
type TUniqueCodeFormValues = { type TUniqueCodeFormValues = {
@ -39,7 +42,15 @@ const authService = new AuthService();
const userService = new UserService(); const userService = new UserService();
export const UniqueCodeForm: React.FC<Props> = (props) => { export const UniqueCodeForm: React.FC<Props> = (props) => {
const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; const {
email,
updateEmail,
handleStepChange,
handleSignInRedirection,
submitButtonLabel = "Continue",
showTermsAndConditions = false,
updateUserOnboardingStatus,
} = props;
// states // states
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
// toast alert // toast alert
@ -74,6 +85,8 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
.then(async () => { .then(async () => {
const currentUser = await userService.currentUser(); const currentUser = await userService.currentUser();
updateUserOnboardingStatus(currentUser.is_onboarded);
if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD); if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD);
else await handleSignInRedirection(); else await handleSignInRedirection();
}) })
@ -86,15 +99,15 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
); );
}; };
const handleSendNewLink = async (formData: TUniqueCodeFormValues) => { const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
const payload: IEmailCheckData = { const payload: IEmailCheckData = {
email: formData.email, email: formData.email,
type: "magic_code",
}; };
await authService await authService
.emailCheck(payload) .generateUniqueCode(payload)
.then(() => { .then(() => {
setResendCodeTimer(30);
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -116,25 +129,30 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
}; };
const handleFormSubmit = async (formData: TUniqueCodeFormValues) => { const handleFormSubmit = async (formData: TUniqueCodeFormValues) => {
if (dirtyFields.email) await handleSendNewLink(formData); updateEmail(formData.email);
if (dirtyFields.email) await handleSendNewCode(formData);
else await handleUniqueCodeSignIn(formData); else await handleUniqueCodeSignIn(formData);
}; };
const handleRequestNewCode = async () => { const handleRequestNewCode = async () => {
setIsRequestingNewCode(true); setIsRequestingNewCode(true);
await handleSendNewLink(getValues()) await handleSendNewCode(getValues())
.then(() => setResendCodeTimer(30)) .then(() => setResendCodeTimer(30))
.finally(() => setIsRequestingNewCode(false)); .finally(() => setIsRequestingNewCode(false));
}; };
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
const hasEmailChanged = dirtyFields.email;
return ( return (
<> <>
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">Moving to the runway</h1> <h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">
<p className="text-center text-sm text-onboarding-text-200 mt-3"> Get on your flight deck
Paste the code you got at <span className="font-semibold text-custom-primary-100">{email}</span> below </h1>
<p className="text-center text-sm text-onboarding-text-200 mt-2.5">
Paste the code you got at <span className="font-semibold text-custom-primary-100">{email}</span> below.
</p> </p>
<form onSubmit={handleSubmit(handleFormSubmit)} className="mt-5 sm:w-96 mx-auto space-y-4"> <form onSubmit={handleSubmit(handleFormSubmit)} className="mt-5 sm:w-96 mx-auto space-y-4">
@ -153,12 +171,9 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
name="email" name="email"
type="email" type="email"
value={value} value={value}
onChange={(e) => { onChange={onChange}
updateEmail(e.target.value);
onChange(e.target.value);
}}
onBlur={() => { onBlur={() => {
if (dirtyFields.email) handleSendNewLink(getValues()); if (hasEmailChanged) handleSendNewCode(getValues());
}} }}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
@ -174,7 +189,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
</div> </div>
)} )}
/> />
{dirtyFields.email && ( {hasEmailChanged && (
<button <button
type="submit" type="submit"
className="text-xs text-onboarding-text-300 mt-1.5 flex items-center gap-1 outline-none bg-transparent border-none" className="text-xs text-onboarding-text-300 mt-1.5 flex items-center gap-1 outline-none bg-transparent border-none"
@ -188,7 +203,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
control={control} control={control}
name="token" name="token"
rules={{ rules={{
required: dirtyFields.email ? false : "Code is required", required: hasEmailChanged ? false : "Code is required",
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<Input <Input
@ -196,7 +211,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
onChange={onChange} onChange={onChange}
hasError={Boolean(errors.token)} hasError={Boolean(errors.token)}
placeholder="gets-sets-flys" placeholder="gets-sets-flys"
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200"
/> />
)} )}
/> />
@ -214,7 +229,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
{resendTimerCode > 0 {resendTimerCode > 0
? `Request new code in ${resendTimerCode}s` ? `Request new code in ${resendTimerCode}s`
: isRequestingNewCode : isRequestingNewCode
? "Requesting new code..." ? "Requesting new code"
: "Request new code"} : "Request new code"}
</button> </button>
</div> </div>
@ -224,17 +239,19 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
variant="primary" variant="primary"
className="w-full" className="w-full"
size="xl" size="xl"
disabled={!isValid || dirtyFields.email} disabled={!isValid || hasEmailChanged}
loading={isSubmitting} loading={isSubmitting}
> >
{isSubmitting ? "Signing in..." : "Confirm"} {submitButtonLabel}
</Button> </Button>
{showTermsAndConditions && (
<p className="text-xs text-onboarding-text-200"> <p className="text-xs text-onboarding-text-200">
When you click Confirm above, you agree with our{" "} When you click the button above, you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer"> <Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span> <span className="font-semibold underline">terms and conditions of service.</span>
</Link> </Link>
</p> </p>
)}
</form> </form>
</> </>
); );

View File

@ -25,7 +25,7 @@ export const LatestFeatureBlock = () => {
<Image <Image
src={latestFeatures} src={latestFeatures}
alt="Plane Issues" alt="Plane Issues"
className={`rounded-md h-full ml-8 -mt-2 ${ className={`rounded-md h-full ml-10 -mt-2 ${
resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70" resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70"
} `} } `}
/> />

View File

@ -17,10 +17,10 @@ export const InstanceNotReady = () => {
<div className="h-full bg-onboarding-gradient-100 md:w-2/3 sm:w-4/5 px-4 pt-4 rounded-t-md mx-auto shadow-sm border-x border-t border-custom-border-100"> <div className="h-full bg-onboarding-gradient-100 md:w-2/3 sm:w-4/5 px-4 pt-4 rounded-t-md mx-auto shadow-sm border-x border-t border-custom-border-100">
<div className="relative px-7 sm:px-0 bg-onboarding-gradient-200 h-full rounded-t-md"> <div className="relative px-7 sm:px-0 bg-onboarding-gradient-200 h-full rounded-t-md">
<div className="flex items-center py-10 justify-center"> <div className="flex items-center py-10 justify-center">
<Image src={planeLogo} className="h-44 w-full" alt="Plane logo" /> <Image src={planeLogo} className="h-[44px] w-full" alt="Plane logo" />
</div> </div>
<div className="mt-20"> <div className="mt-20">
<Image src={instanceNotReady} className="h-56 w-full" alt="Instance not ready" /> <Image src={instanceNotReady} className="w-full" alt="Instance not ready" />
</div> </div>
<div className="flex flex-col gap-5 items-center py-12 pb-20 w-full"> <div className="flex flex-col gap-5 items-center py-12 pb-20 w-full">
<h3 className="text-2xl font-medium">Your Plane instance isn{"'"}t ready yet</h3> <h3 className="text-2xl font-medium">Your Plane instance isn{"'"}t ready yet</h3>

View File

@ -55,7 +55,7 @@ export const InstanceSetupDone = () => {
</p> </p>
</div> </div>
<Button size="lg" prependIcon={<UserCog2 />} onClick={redirectToGodMode} loading={isRedirecting}> <Button size="lg" prependIcon={<UserCog2 />} onClick={redirectToGodMode} loading={isRedirecting}>
{isRedirecting ? "Redirecting..." : "Go to God Mode"} Go to God Mode
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,160 +0,0 @@
import { FC, useState } from "react";
import { useForm, Controller } from "react-hook-form";
// ui
import { Input, Button } from "@plane/ui";
// icons
import { XCircle } from "lucide-react";
// services
import { AuthService } from "services/auth.service";
const authService = new AuthService();
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
export interface InstanceSetupEmailCodeFormValues {
email: string;
token: string;
}
export interface IInstanceSetupEmailCodeForm {
email: string;
handleNextStep: () => void;
moveBack: () => void;
}
export const InstanceSetupEmailCodeForm: FC<IInstanceSetupEmailCodeForm> = (props) => {
const { handleNextStep, email, moveBack } = props;
// states
const [isResendingCode, setIsResendingCode] = useState(false);
// form info
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
} = useForm<InstanceSetupEmailCodeFormValues>({
defaultValues: {
email,
token: "",
},
});
// hooks
const { setToastAlert } = useToast();
const { timer, setTimer } = useTimer(30);
// computed
const isResendDisabled = timer > 0 || isResendingCode;
const handleEmailCodeFormSubmit = async (formValues: InstanceSetupEmailCodeFormValues) =>
await authService
.instanceMagicSignIn({ key: `magic_${formValues.email}`, token: formValues.token })
.then(() => {
reset();
handleNextStep();
})
.catch((err) => {
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
});
});
const resendMagicCode = async () => {
setIsResendingCode(true);
await authService
.instanceAdminEmailCode({ email })
.then(() => setTimer(30))
.catch((err) => {
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
});
})
.finally(() => setIsResendingCode(false));
};
return (
<form onSubmit={handleSubmit(handleEmailCodeFormSubmit)}>
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">
Let{"'"}s secure your instance
</h1>
<p className="text-center text-sm text-onboarding-text-200 mt-3">
Paste the code you got at
<br />
<span className="text-custom-primary-100 font-semibold">{email}</span> below.
</p>
<div className="relative mt-5 w-full sm:w-96 mx-auto space-y-4">
<div>
<Controller
name="email"
control={control}
rules={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
render={({ field: { value, onChange } }) => (
<div className="flex items-center relative rounded-md bg-onboarding-background-200">
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
disabled
placeholder="orville.wright@firstflight.com"
className={`w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12`}
/>
<XCircle
className="h-5 w-5 absolute stroke-custom-text-400 hover:cursor-pointer right-3"
onClick={() => moveBack()}
/>
</div>
)}
/>
<div className="w-full text-right">
<button
type="button"
onClick={resendMagicCode}
className={`text-xs ${
isResendDisabled ? "text-onboarding-text-300" : "text-onboarding-text-200 hover:text-custom-primary-100"
}`}
disabled={isResendDisabled}
>
{timer > 0
? `Request new code in ${timer}s`
: isSubmitting
? "Requesting new code..."
: "Request new code"}
</button>
</div>
</div>
<Controller
name="token"
control={control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<div className={`flex items-center relative rounded-md bg-onboarding-background-200 mb-4`}>
<Input
id="token"
name="token"
type="text"
value={value}
onChange={onChange}
placeholder="gets-sets-fays"
className="border-onboarding-border-100 h-[46px] w-full "
/>
</div>
)}
/>
<Button variant="primary" className="w-full" size="xl" type="submit" loading={isSubmitting}>
{isSubmitting ? "Verifying..." : "Next step"}
</Button>
</div>
</form>
);
};

View File

@ -1,3 +1,2 @@
export * from "./email-code-form";
export * from "./email-form";
export * from "./root"; export * from "./root";
export * from "./sign-in-form";

View File

@ -1,126 +0,0 @@
import React from "react";
import Link from "next/link";
import { useForm, Controller } from "react-hook-form";
// ui
import { Input, Button } from "@plane/ui";
// icons
import { XCircle } from "lucide-react";
// services
import { AuthService } from "services/auth.service";
const authService = new AuthService();
export interface InstanceSetupPasswordFormValues {
email: string;
password: string;
}
export interface IInstanceSetupPasswordForm {
email: string;
onNextStep: () => void;
resetSteps: () => void;
}
export const InstanceSetupPasswordForm: React.FC<IInstanceSetupPasswordForm> = (props) => {
const { onNextStep, email, resetSteps } = props;
// form info
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<InstanceSetupPasswordFormValues>({
defaultValues: {
email,
password: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const handlePasswordSubmit = (formData: InstanceSetupPasswordFormValues) =>
authService.setInstanceAdminPassword({ password: formData.password }).then(() => {
onNextStep();
});
return (
<form onSubmit={handleSubmit(handlePasswordSubmit)}>
<div className="pb-2">
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">
Moving to the runway
</h1>
<p className="text-center text-sm text-onboarding-text-200 mt-3">
Let{"'"}s set a password so you can do away with codes.
</p>
<div className="relative mt-5 w-full sm:w-96 mx-auto space-y-4">
<Controller
control={control}
name="email"
rules={{
required: "Email is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
render={({ field: { value, onChange } }) => (
<div className={`flex items-center relative rounded-md bg-onboarding-background-200`}>
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
placeholder="orville.wright@firstflight.com"
className={`w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12`}
/>
<XCircle
className="h-5 w-5 absolute stroke-custom-text-400 hover:cursor-pointer right-3"
onClick={() => resetSteps()}
/>
</div>
)}
/>
<div>
<Controller
control={control}
name="password"
rules={{
required: "Password is required",
minLength: {
value: 8,
message: "Minimum 8 characters required",
},
}}
render={({ field: { value, onChange } }) => (
<div className={`flex items-center relative rounded-md bg-onboarding-background-200`}>
<Input
id="password"
type="password"
name="password"
value={value}
onChange={onChange}
hasError={Boolean(errors.password)}
placeholder="Enter your password..."
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12"
/>
</div>
)}
/>
<p className="text-xs mt-3 text-onboarding-text-200 pb-2">
Whatever you choose now will be your account{"'"}s password
</p>
</div>
<Button variant="primary" className="w-full mt-4" size="xl" type="submit" loading={isSubmitting}>
{isSubmitting ? "Submitting..." : "Next step"}
</Button>
<p className="text-xs text-onboarding-text-200">
When you click the button above, you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span>
</Link>
</p>
</div>
</div>
</form>
);
};

View File

@ -1,63 +1,25 @@
import { useState } from "react"; import { useState } from "react";
// components // components
import { InstanceSetupEmailCodeForm } from "./email-code-form";
import { InstanceSetupEmailForm } from "./email-form";
import { InstanceSetupPasswordForm } from "./password-form";
import { LatestFeatureBlock } from "components/common"; import { LatestFeatureBlock } from "components/common";
import { InstanceSetupDone } from "components/instance"; import { InstanceSetupDone, InstanceSetupSignInForm } from "components/instance";
export enum EInstanceSetupSteps { export enum EInstanceSetupSteps {
EMAIL = "EMAIL", SIGN_IN = "SIGN_IN",
VERIFY_CODE = "VERIFY_CODE",
PASSWORD = "PASSWORD",
DONE = "DONE", DONE = "DONE",
} }
export const InstanceSetupFormRoot = () => { export const InstanceSetupFormRoot = () => {
// states // states
const [setupStep, setSetupStep] = useState(EInstanceSetupSteps.EMAIL); const [setupStep, setSetupStep] = useState(EInstanceSetupSteps.SIGN_IN);
const [email, setEmail] = useState<string>("");
return ( return (
<> <>
{setupStep === EInstanceSetupSteps.DONE ? ( {setupStep === EInstanceSetupSteps.DONE && <InstanceSetupDone />}
<InstanceSetupDone /> {setupStep === EInstanceSetupSteps.SIGN_IN && (
) : (
<div className="h-full bg-onboarding-gradient-100 md:w-2/3 sm:w-4/5 px-4 pt-4 rounded-t-md mx-auto shadow-sm border-x border-t border-custom-border-200"> <div className="h-full bg-onboarding-gradient-100 md:w-2/3 sm:w-4/5 px-4 pt-4 rounded-t-md mx-auto shadow-sm border-x border-t border-custom-border-200">
<div className="bg-onboarding-gradient-200 h-full pt-24 pb-56 rounded-t-md overflow-auto"> <div className="bg-onboarding-gradient-200 h-full pt-24 pb-56 rounded-t-md overflow-auto">
<div className="mx-auto flex flex-col"> <div className="mx-auto flex flex-col">
{setupStep === EInstanceSetupSteps.EMAIL && ( <InstanceSetupSignInForm handleNextStep={() => setSetupStep(EInstanceSetupSteps.DONE)} />
<InstanceSetupEmailForm
handleNextStep={(email) => {
setEmail(email);
setSetupStep(EInstanceSetupSteps.VERIFY_CODE);
}}
/>
)}
{setupStep === EInstanceSetupSteps.VERIFY_CODE && (
<InstanceSetupEmailCodeForm
email={email}
handleNextStep={() => {
setSetupStep(EInstanceSetupSteps.PASSWORD);
}}
moveBack={() => {
setSetupStep(EInstanceSetupSteps.EMAIL);
}}
/>
)}
{setupStep === EInstanceSetupSteps.PASSWORD && (
<InstanceSetupPasswordForm
email={email}
onNextStep={() => {
setSetupStep(EInstanceSetupSteps.DONE);
}}
resetSteps={() => {
setSetupStep(EInstanceSetupSteps.EMAIL);
}}
/>
)}
</div> </div>
<LatestFeatureBlock /> <LatestFeatureBlock />
</div> </div>

View File

@ -1,5 +1,7 @@
import { FC } from "react"; import { FC } from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui // ui
import { Input, Button } from "@plane/ui"; import { Input, Button } from "@plane/ui";
// icons // icons
@ -9,37 +11,48 @@ import { AuthService } from "services/auth.service";
const authService = new AuthService(); const authService = new AuthService();
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
export interface InstanceSetupEmailFormValues { interface InstanceSetupEmailFormValues {
email: string; email: string;
password: string;
} }
export interface IInstanceSetupEmailForm { export interface IInstanceSetupEmailForm {
handleNextStep: (email: string) => void; handleNextStep: (email: string) => void;
} }
export const InstanceSetupEmailForm: FC<IInstanceSetupEmailForm> = (props) => { export const InstanceSetupSignInForm: FC<IInstanceSetupEmailForm> = (props) => {
const { handleNextStep } = props; const { handleNextStep } = props;
const {
user: { fetchCurrentUser },
} = useMobxStore();
// form info // form info
const { const {
control, control,
formState: { errors, isSubmitting },
handleSubmit, handleSubmit,
setValue, setValue,
reset,
formState: { isSubmitting },
} = useForm<InstanceSetupEmailFormValues>({ } = useForm<InstanceSetupEmailFormValues>({
defaultValues: { defaultValues: {
email: "", email: "",
password: "",
}, },
}); });
// hooks // hooks
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const handleEmailFormSubmit = (formValues: InstanceSetupEmailFormValues) => const handleFormSubmit = async (formValues: InstanceSetupEmailFormValues) => {
authService const payload = {
.instanceAdminEmailCode({ email: formValues.email }) email: formValues.email,
.then(() => { password: formValues.password,
reset(); };
await authService
.instanceAdminSignIn(payload)
.then(async () => {
await fetchCurrentUser();
handleNextStep(formValues.email); handleNextStep(formValues.email);
}) })
.catch((err) => { .catch((err) => {
@ -49,9 +62,10 @@ export const InstanceSetupEmailForm: FC<IInstanceSetupEmailForm> = (props) => {
message: err?.error ?? "Something went wrong. Please try again.", message: err?.error ?? "Something went wrong. Please try again.",
}); });
}); });
};
return ( return (
<form onSubmit={handleSubmit(handleEmailFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100"> <h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">
Let{"'"}s secure your instance Let{"'"}s secure your instance
</h1> </h1>
@ -66,13 +80,10 @@ export const InstanceSetupEmailForm: FC<IInstanceSetupEmailForm> = (props) => {
control={control} control={control}
rules={{ rules={{
required: "Email address is required", required: "Email address is required",
validate: (value) => validate: (value) => checkEmailValidity(value) || "Email is invalid",
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<div className={`flex items-center relative rounded-md bg-onboarding-background-200`}> <div className="flex items-center relative rounded-md bg-onboarding-background-200">
<Input <Input
id="email" id="email"
name="email" name="email"
@ -80,7 +91,7 @@ export const InstanceSetupEmailForm: FC<IInstanceSetupEmailForm> = (props) => {
value={value} value={value}
onChange={onChange} onChange={onChange}
placeholder="orville.wright@firstflight.com" placeholder="orville.wright@firstflight.com"
className={`w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12`} className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12"
/> />
{value.length > 0 && ( {value.length > 0 && (
<XCircle <XCircle
@ -91,11 +102,28 @@ export const InstanceSetupEmailForm: FC<IInstanceSetupEmailForm> = (props) => {
</div> </div>
)} )}
/> />
<Controller
control={control}
name="password"
rules={{
required: "Password is required",
}}
render={({ field: { value, onChange } }) => (
<Input
type="password"
value={value}
onChange={onChange}
hasError={Boolean(errors.password)}
placeholder="Enter password"
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200"
/>
)}
/>
<p className="text-xs text-custom-text-200 pb-2"> <p className="text-xs text-custom-text-200 pb-2">
Use your email address if you are the instance admin. <br /> Use your admins e-mail if you are not. Use your email address if you are the instance admin. <br /> Use your admins e-mail if you are not.
</p> </p>
<Button variant="primary" className="w-full" size="xl" type="submit" loading={isSubmitting}> <Button variant="primary" className="w-full" size="xl" type="submit" loading={isSubmitting}>
{isSubmitting ? "Sending code..." : "Send unique code"} {isSubmitting ? "Signing in..." : "Sign in"}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -1,9 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import Link from "next/link";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Image from "next/image"; import Image from "next/image";
import { useTheme } from "next-themes";
import { Lightbulb } from "lucide-react";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// hooks // hooks
@ -14,7 +11,6 @@ import { SignInRoot } from "components/account";
import { Loader, Spinner } from "@plane/ui"; import { Loader, Spinner } from "@plane/ui";
// images // images
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
import latestFeatures from "public/onboarding/onboarding-pages.svg";
export type AuthType = "sign-in" | "sign-up"; export type AuthType = "sign-in" | "sign-up";
@ -24,8 +20,6 @@ export const SignInView = observer(() => {
user: { currentUser }, user: { currentUser },
appConfig: { envConfig }, appConfig: { envConfig },
} = useMobxStore(); } = useMobxStore();
// next-themes
const { resolvedTheme } = useTheme();
// sign in redirection hook // sign in redirection hook
const { isRedirecting, handleRedirection } = useSignInRedirection(); const { isRedirecting, handleRedirection } = useSignInRedirection();
@ -66,30 +60,7 @@ export const SignInView = observer(() => {
</div> </div>
</div> </div>
) : ( ) : (
<>
<SignInRoot /> <SignInRoot />
<div className="flex py-2 bg-onboarding-background-100 border border-onboarding-border-200 mx-auto rounded-[3.5px] sm:w-96 mt-16">
<Lightbulb className="h-7 w-7 mr-2 mx-3" />
<p className="text-sm text-left text-onboarding-text-100">
Pages gets a facelift! Write anything and use Galileo to help you start.{" "}
<Link href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer">
<span className="font-medium text-sm underline hover:cursor-pointer">Learn more</span>
</Link>
</p>
</div>
<div className="border border-onboarding-border-200 sm:w-96 sm:h-52 object-cover mt-8 mx-auto rounded-md bg-onboarding-background-100 overflow-hidden">
<div className="h-[90%]">
<Image
src={latestFeatures}
alt="Plane Issues"
className={`rounded-md h-full ml-8 -mt-2 ${
resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70"
} `}
/>
</div>
</div>
</>
)} )}
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { FC, ReactNode, useEffect } from "react"; import { FC, ReactNode } from "react";
import useSWR from "swr"; import useSWR from "swr";
@ -18,7 +18,7 @@ type Props = {
const InstanceLayout: FC<Props> = observer(({ children }) => { const InstanceLayout: FC<Props> = observer(({ children }) => {
// store // store
const { const {
instance: { fetchInstanceInfo, instance, createInstance }, instance: { fetchInstanceInfo, instance },
} = useMobxStore(); } = useMobxStore();
const router = useRouter(); const router = useRouter();
@ -28,12 +28,6 @@ const InstanceLayout: FC<Props> = observer(({ children }) => {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
useEffect(() => {
if (instance?.is_activated === false) {
createInstance();
}
}, [instance?.is_activated, createInstance]);
return ( return (
<div className="h-screen w-full overflow-hidden"> <div className="h-screen w-full overflow-hidden">
{instance ? ( {instance ? (

View File

@ -109,7 +109,7 @@ const HomePage: NextPageWithLayout = () => {
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@firstflight.com" placeholder="orville.wright@firstflight.com"
className="w-full h-[46px] text-onboarding-text-400 border border-onboarding-border-100 pr-12" className="w-full h-[46px] text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200"
disabled disabled
/> />
)} )}
@ -128,7 +128,7 @@ const HomePage: NextPageWithLayout = () => {
onChange={onChange} onChange={onChange}
hasError={Boolean(errors.password)} hasError={Boolean(errors.password)}
placeholder="Choose password" placeholder="Choose password"
className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12 !bg-onboarding-background-200"
minLength={8} minLength={8}
/> />
)} )}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@ -3,16 +3,20 @@ import { APIService } from "services/api.service";
// helpers // helpers
import { API_BASE_URL } from "helpers/common.helper"; import { API_BASE_URL } from "helpers/common.helper";
// types // types
import { IEmailCheckData, ILoginTokenResponse, IMagicSignInData, IPasswordSignInData } from "types/auth"; import {
IEmailCheckData,
IEmailCheckResponse,
ILoginTokenResponse,
IMagicSignInData,
IPasswordSignInData,
} from "types/auth";
export class AuthService extends APIService { export class AuthService extends APIService {
constructor() { constructor() {
super(API_BASE_URL); super(API_BASE_URL);
} }
async emailCheck(data: IEmailCheckData): Promise<{ async emailCheck(data: IEmailCheckData): Promise<IEmailCheckResponse> {
is_password_autoset: boolean;
}> {
return this.post("/api/email-check/", data, { headers: {} }) return this.post("/api/email-check/", data, { headers: {} })
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
@ -80,18 +84,6 @@ export class AuthService extends APIService {
}); });
} }
async setInstanceAdminPassword(data: any): Promise<any> {
return this.post("/api/licenses/instances/admins/set-password/", data)
.then((response) => {
this.setAccessToken(response?.data?.access_token);
this.setRefreshToken(response?.data?.refresh_token);
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async socialAuth(data: any): Promise<ILoginTokenResponse> { async socialAuth(data: any): Promise<ILoginTokenResponse> {
return this.post("/api/social-auth/", data, { headers: {} }) return this.post("/api/social-auth/", data, { headers: {} })
.then((response) => { .then((response) => {
@ -104,7 +96,7 @@ export class AuthService extends APIService {
}); });
} }
async emailCode(data: any): Promise<any> { async generateUniqueCode(data: { email: string }): Promise<any> {
return this.post("/api/magic-generate/", data, { headers: {} }) return this.post("/api/magic-generate/", data, { headers: {} })
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
@ -112,14 +104,6 @@ export class AuthService extends APIService {
}); });
} }
async instanceAdminEmailCode(data: any): Promise<any> {
return this.post("/api/licenses/instances/admins/magic-generate/", data, { headers: {} })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async magicSignIn(data: IMagicSignInData): Promise<any> { async magicSignIn(data: IMagicSignInData): Promise<any> {
return await this.post("/api/magic-sign-in/", data, { headers: {} }) return await this.post("/api/magic-sign-in/", data, { headers: {} })
.then((response) => { .then((response) => {
@ -134,14 +118,18 @@ export class AuthService extends APIService {
}); });
} }
async instanceMagicSignIn(data: any): Promise<any> { async instanceAdminSignIn(data: IPasswordSignInData): Promise<ILoginTokenResponse> {
const response = await this.post("/api/licenses/instances/admins/magic-sign-in/", data, { headers: {} }); return await this.post("/api/licenses/instances/admins/sign-in/", data, { headers: {} })
.then((response) => {
if (response?.status === 200) { if (response?.status === 200) {
this.setAccessToken(response?.data?.access_token); this.setAccessToken(response?.data?.access_token);
this.setRefreshToken(response?.data?.refresh_token); this.setRefreshToken(response?.data?.refresh_token);
return response?.data; return response?.data;
} }
throw response.response.data; })
.catch((error) => {
throw error?.response?.data;
});
} }
async signOut(): Promise<any> { async signOut(): Promise<any> {

View File

@ -22,14 +22,6 @@ export class InstanceService extends APIService {
}); });
} }
async createInstance(): Promise<IInstance> {
return this.post("/api/licenses/instances/", {}, { headers: {} })
.then((response) => response.data)
.catch((error) => {
throw error;
});
}
async getInstanceAdmins(): Promise<IInstanceAdmin[]> { async getInstanceAdmins(): Promise<IInstanceAdmin[]> {
return this.get("/api/licenses/instances/admins/") return this.get("/api/licenses/instances/admins/")
.then((response) => response.data) .then((response) => response.data)

View File

@ -17,7 +17,6 @@ export interface IInstanceStore {
formattedConfig: IFormattedInstanceConfiguration | null; formattedConfig: IFormattedInstanceConfiguration | null;
// action // action
fetchInstanceInfo: () => Promise<IInstance>; fetchInstanceInfo: () => Promise<IInstance>;
createInstance: () => Promise<IInstance>;
fetchInstanceAdmins: () => Promise<IInstanceAdmin[]>; fetchInstanceAdmins: () => Promise<IInstanceAdmin[]>;
updateInstanceInfo: (data: Partial<IInstance>) => Promise<IInstance>; updateInstanceInfo: (data: Partial<IInstance>) => Promise<IInstance>;
fetchInstanceConfigurations: () => Promise<any>; fetchInstanceConfigurations: () => Promise<any>;
@ -46,7 +45,6 @@ export class InstanceStore implements IInstanceStore {
formattedConfig: computed, formattedConfig: computed,
// actions // actions
fetchInstanceInfo: action, fetchInstanceInfo: action,
createInstance: action,
fetchInstanceAdmins: action, fetchInstanceAdmins: action,
updateInstanceInfo: action, updateInstanceInfo: action,
fetchInstanceConfigurations: action, fetchInstanceConfigurations: action,
@ -86,22 +84,6 @@ export class InstanceStore implements IInstanceStore {
} }
}; };
/**
* Creating new Instance In case of no instance found
*/
createInstance = async () => {
try {
const instance = await this.instanceService.createInstance();
runInAction(() => {
this.instance = instance;
});
return instance;
} catch (error) {
console.log("Error while creating the instance");
throw error;
}
};
/** /**
* fetch instance admins from API * fetch instance admins from API
*/ */

1
web/types/app.d.ts vendored
View File

@ -14,4 +14,5 @@ export interface IAppConfig {
posthog_host: string | null; posthog_host: string | null;
has_openai_configured: boolean; has_openai_configured: boolean;
has_unsplash_configured: boolean; has_unsplash_configured: boolean;
is_self_managed: boolean;
} }

8
web/types/auth.d.ts vendored
View File

@ -2,12 +2,16 @@ export type TEmailCheckTypes = "magic_code" | "password";
export interface IEmailCheckData { export interface IEmailCheckData {
email: string; email: string;
type: TEmailCheckTypes; }
export interface IEmailCheckResponse {
is_password_autoset: boolean;
is_existing: boolean;
} }
export interface ILoginTokenResponse { export interface ILoginTokenResponse {
access_token: string; access_token: string;
refresh_toke: string; refresh_token: string;
} }
export interface IMagicSignInData { export interface IMagicSignInData {