mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 05:49:40 +02:00
[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:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./email-confirmation";
|
||||
export * from "./unique-code";
|
||||
export * from "./password";
|
||||
export * from "./password-strength-meter";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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't match</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user