[MOBIL-1007] dev: Improve mobile auth web view — join workspace visibility, password confirmation, and invite handling via Google/GitHub (#3500)

* 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
This commit is contained in:
guru_sainath
2025-06-30 17:41:29 +05:30
committed by GitHub
parent 425f9189a0
commit d06ba8cfd8
7 changed files with 283 additions and 51 deletions

View File

@@ -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(

View File

@@ -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(

View File

@@ -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)

View File

@@ -1,3 +1,4 @@
export * from "./email-confirmation";
export * from "./unique-code";
export * from "./password";
export * from "./password-strength-meter";

View File

@@ -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<TMobilePasswordStrengthMeter> = (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 (
<div className="w-full space-y-2 pt-2">
<div className="space-y-1.5">
<div className="relative flex items-center gap-2">
{strengthBars?.bars.map((color, index) => (
<div key={`${color}-${index}`} className={cn("w-full h-1 rounded-full", color)} />
))}
</div>
<div className={cn(`text-xs font-medium text-custom-text-100`, strengthBars?.textColor)}>
{strengthBars?.text}
</div>
</div>
</div>
);
};

View File

@@ -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<TMobileAuthPasswordForm> = (props) => {
@@ -36,10 +52,12 @@ export const MobileAuthPasswordForm: FC<TMobileAuthPasswordForm> = (props) => {
// states
const [csrfPromise, setCsrfPromise] = useState<Promise<TMobileCSRFToken> | undefined>(undefined);
const [formData, setFormData] = useState<TFormValues>({ ...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<TShowPassword>({ password: false, passwordConfirmation: false });
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [passwordInputFocused, setPasswordInputFocused] = useState<TShowPassword>({
password: false,
passwordConfirmation: false,
});
useEffect(() => {
if (csrfPromise === undefined) {
@@ -51,7 +69,10 @@ export const MobileAuthPasswordForm: FC<TMobileAuthPasswordForm> = (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<TMobileAuthPasswordForm> = (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 (
<form
ref={authFormRef}
@@ -82,7 +127,14 @@ export const MobileAuthPasswordForm: FC<TMobileAuthPasswordForm> = (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<TMobileAuthPasswordForm> = (props) => {
</div>
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
Password
</label>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword ? "text" : "password"}
name="password"
value={formData.password}
onChange={(e) => 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 ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword()}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword()}
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
Password
</label>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword?.password ? "text" : "password"}
name="password"
value={formData.password}
onChange={(e) => 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 ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
)}
</div>
</div>
{((isPasswordConfirmationRequired && formData.password.length > 0 && !isPasswordStrengthValid) ||
passwordInputFocused) && (
<MobilePasswordStrengthMeter password={formData.password} isFocused={passwordInputFocused.password} />
)}
</div>
{isPasswordConfirmationRequired && (
<div>
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
Confirm Password
</label>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword?.passwordConfirmation ? "text" : "password"}
name="passwordConfirmation"
value={formData.passwordConfirmation}
onChange={(e) => 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 ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("passwordConfirmation")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("passwordConfirmation")}
/>
)}
</div>
</div>
{isPasswordConfirmationErrorStatus && (
<span className="text-sm text-red-500">Passwords don&apos;t match</span>
)}
</div>
</div>
)}
<div className="space-y-2.5">
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>

View File

@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from "axios";
import { API_BASE_URL } from "@plane/constants";
import {
TMobileCSRFToken,
TEmailCheckRequest,
@@ -6,8 +7,6 @@ import {
TMobileUser,
TMobileWorkspaceInvitation,
} from "@plane/types";
// helpers
import { API_BASE_URL } from "@plane/constants";
export class MobileAuthService {
axiosInstance: AxiosInstance;