From d06ba8cfd8cf6487f2e66888ff2faba6f38d652d Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Mon, 30 Jun 2025 17:41:29 +0530 Subject: [PATCH] =?UTF-8?q?[MOBIL-1007]=20dev:=20Improve=20mobile=20auth?= =?UTF-8?q?=20web=20view=20=E2=80=94=20join=20workspace=20visibility,=20pa?= =?UTF-8?q?ssword=20confirmation,=20and=20invite=20handling=20via=20Google?= =?UTF-8?q?/GitHub=20(#3500)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * dev: password confirmation logic in mobile signup * dev: updated the invitation acceptance in google and github authentication * dev: handled the password strength in signin * dev: updated password strength validation --- .../authentication/views/app/mobile/github.py | 46 ++++- .../authentication/views/app/mobile/google.py | 47 +++++- .../plane/ee/views/app/mobile/invitation.py | 4 +- .../mobile/authentication/forms/index.ts | 1 + .../forms/password-strength-meter.tsx | 74 ++++++++ .../mobile/authentication/forms/password.tsx | 159 ++++++++++++++---- web/ee/services/mobile.service.ts | 3 +- 7 files changed, 283 insertions(+), 51 deletions(-) create mode 100644 web/ee/components/mobile/authentication/forms/password-strength-meter.tsx diff --git a/apiserver/plane/authentication/views/app/mobile/github.py b/apiserver/plane/authentication/views/app/mobile/github.py index 47d11b9b4a..0903584c5a 100644 --- a/apiserver/plane/authentication/views/app/mobile/github.py +++ b/apiserver/plane/authentication/views/app/mobile/github.py @@ -2,20 +2,22 @@ import uuid from urllib.parse import urlencode, urljoin # Django import -from django.http import HttpResponseRedirect -from django.views import View from django.conf import settings +from django.http import HttpResponseRedirect +from django.utils import timezone +from django.views import View # Module imports +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) from plane.authentication.provider.oauth.github import GitHubOAuthProvider +from plane.authentication.utils.host import base_host from plane.authentication.utils.mobile.login import ValidateAuthToken from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow +from plane.db.models import Workspace, WorkspaceMember, WorkspaceMemberInvite from plane.license.models import Instance -from plane.authentication.utils.host import base_host -from plane.authentication.adapter.error import ( - AuthenticationException, - AUTHENTICATION_ERROR_CODES, -) class MobileGitHubOauthInitiateEndpoint(View): @@ -115,10 +117,38 @@ class MobileGitHubCallbackEndpoint(View): ) # getting the user from the google provider user = provider.authenticate() + user_id = str(user.id) + user_email = user.email # Login the user and record his device info session_token = ValidateAuthToken() - session_token.set_value(str(user.id)) + session_token.set_value(user_id) + + # if invitation_id is present + if invitation_id != "" and user_email: + # check the invitation is valid + invitation = WorkspaceMemberInvite.objects.filter( + id=invitation_id, email=user_email + ).first() + + # if not invitation.responded_at and invitation.accepted: + if invitation and not invitation.responded_at and invitation.accepted: + # check the workspace is valid + workspace = Workspace.objects.filter( + id=invitation.workspace_id + ).first() + + if workspace: + invitation.responded_at = timezone.now() + invitation.save() + + # add the user to the workspace + workspace_member, _ = WorkspaceMember.objects.get_or_create( + workspace_id=invitation.workspace.id, + member_id=user_id, + ) + workspace_member.role = invitation.role + workspace_member.save() # redirect to referrer path url = urljoin( diff --git a/apiserver/plane/authentication/views/app/mobile/google.py b/apiserver/plane/authentication/views/app/mobile/google.py index d367cba310..d95d8bbbc5 100644 --- a/apiserver/plane/authentication/views/app/mobile/google.py +++ b/apiserver/plane/authentication/views/app/mobile/google.py @@ -3,21 +3,22 @@ import uuid from urllib.parse import urlencode, urljoin # Django import -from django.http import HttpResponseRedirect -from django.views import View from django.conf import settings - +from django.http import HttpResponseRedirect +from django.utils import timezone +from django.views import View # Module imports +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) from plane.authentication.provider.oauth.google import GoogleOAuthProvider +from plane.authentication.utils.host import base_host from plane.authentication.utils.mobile.login import ValidateAuthToken from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow +from plane.db.models import Workspace, WorkspaceMember, WorkspaceMemberInvite from plane.license.models import Instance -from plane.authentication.utils.host import base_host -from plane.authentication.adapter.error import ( - AuthenticationException, - AUTHENTICATION_ERROR_CODES, -) class MobileGoogleOauthInitiateEndpoint(View): @@ -119,10 +120,38 @@ class MobileGoogleCallbackEndpoint(View): # getting the user from the google provider user = provider.authenticate() + user_id = str(user.id) + user_email = user.email # Login the user and record his device info session_token = ValidateAuthToken() - session_token.set_value(str(user.id)) + session_token.set_value(user_id) + + # if invitation_id is present + if invitation_id != "" and user_email: + # check the invitation is valid + invitation = WorkspaceMemberInvite.objects.filter( + id=invitation_id, email=user_email + ).first() + + # if not invitation.responded_at and invitation.accepted: + if invitation and not invitation.responded_at and invitation.accepted: + # check the workspace is valid + workspace = Workspace.objects.filter( + id=invitation.workspace_id + ).first() + + if workspace: + invitation.responded_at = timezone.now() + invitation.save() + + # add the user to the workspace + workspace_member, _ = WorkspaceMember.objects.get_or_create( + workspace_id=invitation.workspace.id, + member_id=user_id, + ) + workspace_member.role = invitation.role + workspace_member.save() # redirect to referrer path url = urljoin( diff --git a/apiserver/plane/ee/views/app/mobile/invitation.py b/apiserver/plane/ee/views/app/mobile/invitation.py index 18b9855540..06128ec89e 100644 --- a/apiserver/plane/ee/views/app/mobile/invitation.py +++ b/apiserver/plane/ee/views/app/mobile/invitation.py @@ -17,7 +17,9 @@ class MobileWorkspaceInvitationEndpoint(APIView): model = WorkspaceMemberInvite try: - workspace_invitation = model.objects.get(id=invitation_id, email=email) + workspace_invitation = model.objects.get( + id=invitation_id, email=email, responded_at__isnull=True + ) if workspace_invitation.accepted: return Response({"error": "Invitation already accepted"}, status=400) diff --git a/web/ee/components/mobile/authentication/forms/index.ts b/web/ee/components/mobile/authentication/forms/index.ts index 5c13e3cc0c..9d8cf5f4ae 100644 --- a/web/ee/components/mobile/authentication/forms/index.ts +++ b/web/ee/components/mobile/authentication/forms/index.ts @@ -1,3 +1,4 @@ export * from "./email-confirmation"; export * from "./unique-code"; export * from "./password"; +export * from "./password-strength-meter"; diff --git a/web/ee/components/mobile/authentication/forms/password-strength-meter.tsx b/web/ee/components/mobile/authentication/forms/password-strength-meter.tsx new file mode 100644 index 0000000000..dc7100d956 --- /dev/null +++ b/web/ee/components/mobile/authentication/forms/password-strength-meter.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { FC, useMemo } from "react"; +import { E_PASSWORD_STRENGTH } from "@plane/constants"; +import { cn, getPasswordStrength } from "@plane/utils"; + +type TMobilePasswordStrengthMeter = { + password: string; + isFocused?: boolean; +}; + +export const MobilePasswordStrengthMeter: FC = (props) => { + const { password, isFocused = false } = props; + + // derived values + const strength = useMemo(() => getPasswordStrength(password), [password]); + const strengthBars = useMemo(() => { + switch (strength) { + case E_PASSWORD_STRENGTH.EMPTY: { + return { + bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], + text: `Please enter your password`, + textColor: `text-custom-text-100`, + }; + } + case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: { + return { + bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], + text: `Password length should me more than 8 characters`, + textColor: `text-red-500`, + }; + } + case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: { + return { + bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], + text: `Password is weak`, + textColor: `text-red-500`, + }; + } + case E_PASSWORD_STRENGTH.STRENGTH_VALID: { + return { + bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`], + text: `Password is strong`, + textColor: `text-green-500`, + }; + } + default: { + return { + bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], + text: `Please enter your password`, + textColor: `text-custom-text-100`, + }; + } + } + }, [strength]); + + const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true; + + if (!isPasswordMeterVisible) return <>; + return ( +
+
+
+ {strengthBars?.bars.map((color, index) => ( +
+ ))} +
+
+ {strengthBars?.text} +
+
+
+ ); +}; diff --git a/web/ee/components/mobile/authentication/forms/password.tsx b/web/ee/components/mobile/authentication/forms/password.tsx index 4e95ab9237..d017043fd3 100644 --- a/web/ee/components/mobile/authentication/forms/password.tsx +++ b/web/ee/components/mobile/authentication/forms/password.tsx @@ -2,11 +2,21 @@ import { FC, useEffect, useRef, useState } from "react"; import { Eye, EyeOff, XCircle } from "lucide-react"; -import { EMobileAuthSteps, EMobileAuthModes, TMobileAuthSteps, TMobileAuthModes, API_BASE_URL } from "@plane/constants"; +import { + EMobileAuthSteps, + EMobileAuthModes, + TMobileAuthSteps, + TMobileAuthModes, + API_BASE_URL, + E_PASSWORD_STRENGTH, +} from "@plane/constants"; import { TMobileCSRFToken } from "@plane/types"; import { Button, Input, Spinner } from "@plane/ui"; +import { getPasswordStrength } from "@plane/utils"; // services import mobileAuthService from "@/plane-web/services/mobile.service"; +// components +import { MobilePasswordStrengthMeter } from "./password-strength-meter"; type TMobileAuthPasswordForm = { authMode: TMobileAuthModes; @@ -21,11 +31,17 @@ type TMobileAuthPasswordForm = { type TFormValues = { email: string; password: string; + passwordConfirmation: string; +}; +type TShowPassword = { + password: boolean; + passwordConfirmation: boolean; }; const defaultFormValues: TFormValues = { email: "", password: "", + passwordConfirmation: "", }; export const MobileAuthPasswordForm: FC = (props) => { @@ -36,10 +52,12 @@ export const MobileAuthPasswordForm: FC = (props) => { // states const [csrfPromise, setCsrfPromise] = useState | undefined>(undefined); const [formData, setFormData] = useState({ ...defaultFormValues, email }); - const [showPassword, setShowPassword] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - // derived values - const isButtonDisabled = formData.password.length === 0 || isSubmitting; + const [showPassword, setShowPassword] = useState({ password: false, passwordConfirmation: false }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [passwordInputFocused, setPasswordInputFocused] = useState({ + password: false, + passwordConfirmation: false, + }); useEffect(() => { if (csrfPromise === undefined) { @@ -51,7 +69,10 @@ export const MobileAuthPasswordForm: FC = (props) => { const handleFormChange = (key: keyof TFormValues, value: string) => setFormData((prev) => ({ ...prev, [key]: value })); - const handleShowPassword = () => setShowPassword((prev) => !prev); + const handleShowPassword = (key: keyof TShowPassword) => setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handlePasswordInputFocused = (key: keyof TShowPassword) => + setPasswordInputFocused((prev) => ({ ...prev, [key]: !prev[key] })); const handleCSRFToken = async () => { if (!authFormRef || !authFormRef.current) return; @@ -72,6 +93,30 @@ export const MobileAuthPasswordForm: FC = (props) => { generateEmailUniqueCode(email); }; + // signup password confirmation derived values and handlers + const isPasswordConfirmationRequired = authMode === EMobileAuthModes.SIGN_UP; + const isPasswordStrengthValid = getPasswordStrength(formData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID; + + const verifyPasswordStrength = () => { + if (formData.password.length <= 0) return false; + return isPasswordStrengthValid; + }; + + const verifyPasswordConfirmation = () => { + if (formData.password !== formData.passwordConfirmation) return false; + return true; + }; + + const isPasswordConfirmationEnabled = isPasswordConfirmationRequired && verifyPasswordStrength(); + const isPasswordConfirmationErrorStatus = + !passwordInputFocused.passwordConfirmation && + isPasswordConfirmationEnabled && + formData.password !== formData.passwordConfirmation; + + const isButtonDisabled = isPasswordConfirmationRequired + ? !(verifyPasswordStrength() && verifyPasswordConfirmation()) || isSubmitting + : (formData.password.length === 0 && isPasswordStrengthValid) || isSubmitting; + return (
= (props) => { event.preventDefault(); // Prevent form from submitting by default setIsSubmitting(true); await handleCSRFToken(); - if (authFormRef.current) authFormRef.current.submit(); + const passwordVerification = isPasswordConfirmationRequired + ? verifyPasswordStrength() && verifyPasswordConfirmation() + : true; + if (passwordVerification) { + if (authFormRef.current) authFormRef.current.submit(); + } else { + setIsSubmitting(false); + } }} onError={() => setIsSubmitting(false)} > @@ -117,33 +169,78 @@ export const MobileAuthPasswordForm: FC = (props) => {
- -
- handleFormChange("password", e.target.value)} - placeholder="Enter password" - className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" - autoComplete="on" - autoFocus - /> - {showPassword ? ( - handleShowPassword()} - /> - ) : ( - handleShowPassword()} +
+ +
+ handleFormChange("password", e.target.value)} + placeholder="Enter password" + className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + autoComplete="on" + autoFocus + onFocus={() => handlePasswordInputFocused("password")} + onBlur={() => handlePasswordInputFocused("password")} /> + {showPassword?.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
+
+ {((isPasswordConfirmationRequired && formData.password.length > 0 && !isPasswordStrengthValid) || + passwordInputFocused) && ( + + )} +
+ + {isPasswordConfirmationRequired && ( +
+
+ +
+ handleFormChange("passwordConfirmation", e.target.value)} + placeholder="Enter password" + className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + disabled={!isPasswordConfirmationEnabled} + onFocus={() => handlePasswordInputFocused("passwordConfirmation")} + onBlur={() => handlePasswordInputFocused("passwordConfirmation")} + /> + {showPassword?.passwordConfirmation ? ( + handleShowPassword("passwordConfirmation")} + /> + ) : ( + handleShowPassword("passwordConfirmation")} + /> + )} +
+
+ {isPasswordConfirmationErrorStatus && ( + Passwords don't match )}
-
+ )}