[WEB-1319] chore: New authentication workflow (#4481)

* chore: New authentication workflow

* chore: resolved build erros and updated imports in auth

* chore: code optimisation for query param util

* chore: added client for auth forms
This commit is contained in:
guru_sainath 2024-05-16 17:17:04 +05:30 committed by GitHub
parent 37cc8d7b77
commit bab52a2672
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 584 additions and 1019 deletions

View File

@ -19,7 +19,7 @@ export default async function ProjectLayout({
return (
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
<IssueNavbar workspaceSlug={workspace_slug?.toString()} projectId={project_id?.toString()} />
<IssueNavbar workspaceSlug={workspace_slug} projectId={project_id} />
</div>
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
<a

View File

@ -40,7 +40,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link rel="shortcut icon" href={`${ASSET_PREFIX}/favicon/favicon.ico`} />
</head>
<body>
<AppProvider initialState={{ instance: instanceDetails?.instance }}>
<AppProvider initialState={{ instance: instanceDetails }}>
{!instanceDetails ? (
<InstanceFailureView />
) : (

View File

@ -2,7 +2,7 @@
import { observer } from "mobx-react-lite";
import useSWR from "swr";
// components
import { UserLoggedIn } from "@/components/accounts";
import { UserLoggedIn } from "@/components/account";
import { LogoSpinner } from "@/components/common";
import { AuthView } from "@/components/views";
// hooks

View File

@ -0,0 +1,31 @@
"use client";
import { FC } from "react";
import { Info, X } from "lucide-react";
// helpers
import { TAuthErrorInfo } from "@/helpers/authentication.helper";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
const { bannerData, handleBannerData } = props;
if (!bannerData) return <></>;
return (
<div className="relative flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10">
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
<Info size={16} className="text-custom-primary-100" />
</div>
<div className="w-full text-sm font-medium text-custom-primary-100">{bannerData?.message}</div>
<div
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80"
onClick={() => handleBannerData && handleBannerData(undefined)}
>
<X className="w-4 h-4 flex-shrink-0" />
</div>
</div>
);
};

View File

@ -0,0 +1,57 @@
"use client";
import { FC, ReactNode } from "react";
// helpers
import { EAuthModes } from "@/types/auth";
type TAuthHeader = {
authMode: EAuthModes;
children: ReactNode;
};
type TAuthHeaderContent = {
header: string;
subHeader: string;
};
type TAuthHeaderDetails = {
[mode in EAuthModes]: TAuthHeaderContent;
};
const Titles: TAuthHeaderDetails = {
[EAuthModes.SIGN_IN]: {
header: "Sign in to upvote or comment",
subHeader: "Contribute in nudging the features you want to get built.",
},
[EAuthModes.SIGN_UP]: {
header: "Comment or react to issues",
subHeader: "Use plane to add your valuable inputs to features.",
},
};
export const AuthHeader: FC<TAuthHeader> = (props) => {
const { authMode, children } = props;
const getHeaderSubHeader = (mode: EAuthModes | null): TAuthHeaderContent => {
if (mode) {
return Titles[mode];
}
return {
header: "Comment or react to issues",
subHeader: "Use plane to add your valuable inputs to features.",
};
};
const { header, subHeader } = getHeaderSubHeader(authMode);
return (
<>
<div className="space-y-1 text-center">
<h3 className="text-3xl font-bold text-onboarding-text-100">{header}</h3>
<p className="font-medium text-onboarding-text-400">{subHeader}</p>
</div>
{children}
</>
);
};

View File

@ -0,0 +1,172 @@
"use client";
import React, { FC, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { IEmailCheckData } from "@plane/types";
// components
import {
AuthHeader,
AuthBanner,
AuthEmailForm,
AuthUniqueCodeForm,
AuthPasswordForm,
OAuthOptions,
TermsAndConditions,
} from "@/components/account";
// helpers
import {
EAuthenticationErrorCodes,
EErrorAlertType,
TAuthErrorInfo,
authErrorHandler,
} from "@/helpers/authentication.helper";
// hooks
import { useInstance } from "@/hooks/store";
// services
import { AuthService } from "@/services/auth.service";
// types
import { EAuthModes, EAuthSteps } from "@/types/auth";
const authService = new AuthService();
export const AuthRoot: FC = observer(() => {
// router params
const searchParams = useSearchParams();
const emailParam = searchParams.get("email") || undefined;
const error_code = searchParams.get("error_code") || undefined;
// states
const [authMode, setAuthMode] = useState<EAuthModes>(EAuthModes.SIGN_UP);
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
const [email, setEmail] = useState(emailParam ? emailParam.toString() : "");
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const [isPasswordAutoset, setIsPasswordAutoset] = useState(true);
// hooks
const { instance } = useInstance();
useEffect(() => {
if (error_code) {
const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes);
if (errorhandler) {
if (
[
EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN,
EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP,
].includes(errorhandler.code)
)
setAuthStep(EAuthSteps.PASSWORD);
if (
[EAuthenticationErrorCodes.INVALID_MAGIC_CODE, EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE].includes(
errorhandler.code
)
)
setAuthStep(EAuthSteps.UNIQUE_CODE);
setErrorInfo(errorhandler);
}
}
}, [error_code]);
const isSMTPConfigured = instance?.config?.is_smtp_configured || false;
const isMagicLoginEnabled = instance?.config?.is_magic_login_enabled || false;
const isEmailPasswordEnabled = instance?.config?.is_email_password_enabled || false;
const isOAuthEnabled =
(instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled)) || false;
// submit handler- email verification
const handleEmailVerification = async (data: IEmailCheckData) => {
setEmail(data.email);
await authService
.emailCheck(data)
.then(async (response) => {
let currentAuthMode: EAuthModes = EAuthModes.SIGN_UP;
if (response.existing) {
currentAuthMode = EAuthModes.SIGN_IN;
setAuthMode(() => EAuthModes.SIGN_IN);
} else {
currentAuthMode = EAuthModes.SIGN_UP;
setAuthMode(() => EAuthModes.SIGN_UP);
}
if (currentAuthMode === EAuthModes.SIGN_IN) {
if (response.is_password_autoset && isSMTPConfigured && isMagicLoginEnabled) {
setAuthStep(EAuthSteps.UNIQUE_CODE);
generateEmailUniqueCode(data.email);
} else if (isEmailPasswordEnabled) {
setIsPasswordAutoset(false);
setAuthStep(EAuthSteps.PASSWORD);
} else {
const errorhandler = authErrorHandler("5005" as EAuthenticationErrorCodes);
setErrorInfo(errorhandler);
}
} else {
if (isSMTPConfigured && isMagicLoginEnabled) {
setAuthStep(EAuthSteps.UNIQUE_CODE);
generateEmailUniqueCode(data.email);
} else if (isEmailPasswordEnabled) {
setAuthStep(EAuthSteps.PASSWORD);
} else {
const errorhandler = authErrorHandler("5006" as EAuthenticationErrorCodes);
setErrorInfo(errorhandler);
}
}
})
.catch((error) => {
const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined);
if (errorhandler?.type) setErrorInfo(errorhandler);
});
};
// generating the unique code
const generateEmailUniqueCode = async (email: string): Promise<{ code: string } | undefined> => {
const payload = { email: email };
return await authService
.generateUniqueCode(payload)
.then(() => ({ code: "" }))
.catch((error) => {
const errorhandler = authErrorHandler(error?.error_code.toString());
if (errorhandler?.type) setErrorInfo(errorhandler);
throw error;
});
};
return (
<div className="relative flex flex-col space-y-6">
<AuthHeader authMode={authMode}>
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
)}
{authStep === EAuthSteps.EMAIL && <AuthEmailForm defaultEmail={email} onSubmit={handleEmailVerification} />}
{authStep === EAuthSteps.UNIQUE_CODE && (
<AuthUniqueCodeForm
mode={authMode}
email={email}
handleEmailClear={() => {
setEmail("");
setAuthStep(EAuthSteps.EMAIL);
}}
generateEmailUniqueCode={generateEmailUniqueCode}
/>
)}
{authStep === EAuthSteps.PASSWORD && (
<AuthPasswordForm
mode={authMode}
isPasswordAutoset={isPasswordAutoset}
isSMTPConfigured={isSMTPConfigured}
email={email}
handleEmailClear={() => {
setEmail("");
setAuthStep(EAuthSteps.EMAIL);
}}
handleAuthStep={(step: EAuthSteps) => {
if (step === EAuthSteps.UNIQUE_CODE) generateEmailUniqueCode(email);
setAuthStep(step);
}}
/>
)}
{isOAuthEnabled && <OAuthOptions />}
<TermsAndConditions isSignUp={authMode === EAuthModes.SIGN_UP ? true : false} />
</AuthHeader>
</div>
);
});

View File

@ -0,0 +1,86 @@
"use client";
import { FC, FormEvent, useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
// icons
import { CircleAlert, XCircle } from "lucide-react";
// types
import { IEmailCheckData } from "@plane/types";
// ui
import { Button, Input, Spinner } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
import { checkEmailValidity } from "@/helpers/string.helper";
type TAuthEmailForm = {
defaultEmail: string;
onSubmit: (data: IEmailCheckData) => Promise<void>;
};
export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
const { onSubmit, defaultEmail } = props;
// states
const [isSubmitting, setIsSubmitting] = useState(false);
const [email, setEmail] = useState(defaultEmail);
const [isFocused, setFocused] = useState(false);
const emailError = useMemo(
() => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined),
[email]
);
const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
const payload: IEmailCheckData = {
email: email,
};
await onSubmit(payload);
setIsSubmitting(false);
};
const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting;
return (
<form onSubmit={handleFormSubmit} className="mt-5 space-y-4">
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
Email
</label>
<div
className={cn(
`relative flex items-center rounded-md bg-onboarding-background-200 border`,
!isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-onboarding-border-100`
)}
>
<Input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@company.com"
className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
autoFocus
/>
{email.length > 0 && (
<div className="flex-shrink-0 h-5 w-5 mr-2 bg-onboarding-background-200 hover:cursor-pointer">
<XCircle className="h-5 w-5 stroke-custom-text-400" onClick={() => setEmail("")} />
</div>
)}
</div>
{emailError?.email && !isFocused && (
<p className="flex items-center gap-1 text-xs text-red-600 px-0.5">
<CircleAlert height={12} width={12} />
{emailError.email}
</p>
)}
</div>
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
</Button>
</form>
);
});

View File

@ -0,0 +1,8 @@
export * from "./auth-root";
export * from "./auth-header";
export * from "./auth-banner";
export * from "./email";
export * from "./password";
export * from "./unique-code";

View File

@ -1,26 +1,26 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
// icons
import Link from "next/link";
import { useParams } from "next/navigation";
import { observer } from "mobx-react";
import { Eye, EyeOff, XCircle } from "lucide-react";
// ui
import { Button, Input, Spinner } from "@plane/ui";
import { EAuthModes, EAuthSteps, ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/accounts";
// components
import { PasswordStrengthMeter } from "@/components/account";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { getPasswordStrength } from "@/helpers/password.helper";
// hooks
import { useInstance } from "@/hooks/store";
// services
import { AuthService } from "@/services/auth.service";
// types
import { EAuthModes, EAuthSteps } from "@/types/auth";
type Props = {
email: string;
isPasswordAutoset: boolean;
isSMTPConfigured: boolean;
mode: EAuthModes;
handleEmailClear: () => void;
handleStepChange: (step: EAuthSteps) => void;
handleAuthStep: (step: EAuthSteps) => void;
};
type TPasswordFormValues = {
@ -36,21 +36,21 @@ const defaultValues: TPasswordFormValues = {
const authService = new AuthService();
export const PasswordForm: React.FC<Props> = (props) => {
const { email, mode, handleEmailClear, handleStepChange } = props;
export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
const { email, isSMTPConfigured, handleAuthStep, handleEmailClear, mode } = props;
// states
const [passwordFormData, setPasswordFormData] = useState<TPasswordFormValues>({ ...defaultValues, email });
const [showPassword, setShowPassword] = useState(false);
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
const [passwordFormData, setPasswordFormData] = useState<TPasswordFormValues>({ ...defaultValues, email });
const [showPassword, setShowPassword] = useState({
password: false,
retypePassword: false,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
// hooks
const { data: instance, config: instanceConfig } = useInstance();
// router
const { next_path } = useParams<any>();
// derived values
const isSmtpConfigured = instanceConfig?.is_smtp_configured;
const handleShowPassword = (key: keyof typeof showPassword) =>
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
const handleFormChange = (key: keyof TPasswordFormValues, value: string) =>
setPasswordFormData((prev) => ({ ...prev, [key]: value }));
@ -60,26 +60,13 @@ export const PasswordForm: React.FC<Props> = (props) => {
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
}, [csrfToken]);
const redirectToUniqueCodeLogin = () => {
handleStepChange(EAuthSteps.UNIQUE_CODE);
const redirectToUniqueCodeSignIn = async () => {
handleAuthStep(EAuthSteps.UNIQUE_CODE);
};
const passwordSupport =
mode === EAuthModes.SIGN_IN ? (
<div className="mt-2 w-full pb-3">
{isSmtpConfigured ? (
<Link
href={`/accounts/forgot-password?email=${encodeURIComponent(email)}`}
className="text-xs font-medium text-custom-primary-100"
>
Forgot your password?
</Link>
) : (
<ForgotPasswordPopover />
)}
</div>
) : (
isPasswordInputFocused && <PasswordStrengthMeter password={passwordFormData.password} />
const passwordSupport = passwordFormData.password.length > 0 &&
(getPasswordStrength(passwordFormData.password) < 3 || isPasswordInputFocused) && (
<PasswordStrengthMeter password={passwordFormData.password} />
);
const isButtonDisabled = useMemo(
@ -104,60 +91,63 @@ export const PasswordForm: React.FC<Props> = (props) => {
onError={() => setIsSubmitting(false)}
>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<input type="hidden" name="next_path" value={next_path} />
<input type="hidden" value={passwordFormData.email} name="email" />
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
<label className="text-sm font-medium text-onboarding-text-300" htmlFor="email">
Email
</label>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<div
className={`relative flex items-center rounded-md bg-onboarding-background-200 border border-onboarding-border-100`}
>
<Input
id="email"
name="email"
type="email"
value={passwordFormData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
// hasError={Boolean(errors.email)}
placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 border-0`}
disabled
/>
{passwordFormData.email.length > 0 && (
<XCircle
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={handleEmailClear}
/>
<div className="flex-shrink-0 h-5 w-5 mr-2 bg-onboarding-background-200 hover:cursor-pointer">
<XCircle className="h-5 w-5 stroke-custom-text-400" onClick={handleEmailClear} />
</div>
)}
</div>
</div>
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
{mode === EAuthModes.SIGN_IN ? "Password" : "Set a password"}
</label>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword ? "text" : "password"}
type={showPassword?.password ? "text" : "password"}
name="password"
value={passwordFormData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoFocus
/>
{showPassword ? (
{showPassword?.password ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(false)}
onClick={() => handleShowPassword("password")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(true)}
onClick={() => handleShowPassword("password")}
/>
)}
</div>
{passwordSupport}
</div>
{mode === EAuthModes.SIGN_UP && (
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">
@ -165,24 +155,24 @@ export const PasswordForm: React.FC<Props> = (props) => {
</label>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword ? "text" : "password"}
type={showPassword?.retypePassword ? "text" : "password"}
name="confirm_password"
value={passwordFormData.confirm_password}
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
placeholder="Confirm password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
/>
{showPassword ? (
{showPassword?.retypePassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(false)}
onClick={() => handleShowPassword("retypePassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(true)}
onClick={() => handleShowPassword("retypePassword")}
/>
)}
</div>
@ -191,16 +181,23 @@ export const PasswordForm: React.FC<Props> = (props) => {
!isRetryPasswordInputFocused && <span className="text-sm text-red-500">Passwords don{"'"}t match</span>}
</div>
)}
<div className="space-y-2.5">
{mode === EAuthModes.SIGN_IN ? (
<>
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Go to board"}
{isSubmitting ? (
<Spinner height="20px" width="20px" />
) : isSMTPConfigured ? (
"Continue"
) : (
"Go to workspace"
)}
</Button>
{instance && isSmtpConfigured && (
{isSMTPConfigured && (
<Button
type="button"
onClick={redirectToUniqueCodeLogin}
onClick={redirectToUniqueCodeSignIn}
variant="outline-primary"
className="w-full"
size="lg"
@ -217,4 +214,4 @@ export const PasswordForm: React.FC<Props> = (props) => {
</div>
</form>
);
};
});

View File

@ -1,27 +1,25 @@
"use client";
import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
// icons
import { CircleCheck, XCircle } from "lucide-react";
// ui
import { Button, Input, Spinner } from "@plane/ui";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// hooks
import useTimer from "@/hooks/use-timer";
import useToast from "@/hooks/use-toast";
// services
import { AuthService } from "@/services/auth.service";
// types
import { IEmailCheckData } from "@/types/auth";
import { EAuthModes } from "./root";
import { EAuthModes } from "@/types/auth";
type Props = {
email: string;
// services
const authService = new AuthService();
type TAuthUniqueCodeForm = {
mode: EAuthModes;
email: string;
handleEmailClear: () => void;
submitButtonText: string;
generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>;
};
type TUniqueCodeFormValues = {
@ -34,57 +32,35 @@ const defaultValues: TUniqueCodeFormValues = {
code: "",
};
// services
const authService = new AuthService();
export const UniqueCodeForm: React.FC<Props> = (props) => {
const { email, mode, handleEmailClear, submitButtonText } = props;
export const AuthUniqueCodeForm: React.FC<TAuthUniqueCodeForm> = (props) => {
const { mode, email, handleEmailClear, generateEmailUniqueCode } = props;
// hooks
// const { captureEvent } = useEventTracker();
// derived values
const defaultResetTimerValue = 5;
// states
const [uniqueCodeFormData, setUniqueCodeFormData] = useState<TUniqueCodeFormValues>({ ...defaultValues, email });
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [isSubmitting, setIsSubmitting] = useState(false);
// router
const { next_path } = useParams<any>();
// toast alert
const { setToastAlert } = useToast();
// timer
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0);
const handleFormChange = (key: keyof TUniqueCodeFormValues, value: string) =>
setUniqueCodeFormData((prev) => ({ ...prev, [key]: value }));
const handleSendNewCode = async (email: string) => {
const payload: IEmailCheckData = {
email,
};
await authService
.generateUniqueCode(payload)
.then(() => {
setResendCodeTimer(30);
setToastAlert({
type: "success",
title: "Success!",
message: "A new unique code has been sent to your email.",
});
handleFormChange("code", "");
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
const handleRequestNewCode = async () => {
setIsRequestingNewCode(true);
await handleSendNewCode(uniqueCodeFormData.email)
.then(() => setResendCodeTimer(30))
.finally(() => setIsRequestingNewCode(false));
const generateNewCode = async (email: string) => {
try {
setIsRequestingNewCode(true);
const uniqueCode = await generateEmailUniqueCode(email);
setResendCodeTimer(defaultResetTimerValue);
handleFormChange("code", uniqueCode?.code || "");
setIsRequestingNewCode(false);
} catch {
setResendCodeTimer(0);
console.error("Error while requesting new code");
setIsRequestingNewCode(false);
}
};
useEffect(() => {
@ -92,14 +68,6 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
}, [csrfToken]);
useEffect(() => {
setIsRequestingNewCode(true);
handleSendNewCode(email)
.then(() => setResendCodeTimer(30))
.finally(() => setIsRequestingNewCode(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
const isButtonDisabled = isRequestingNewCode || !uniqueCodeFormData.code || isSubmitting;
@ -112,32 +80,32 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
onError={() => setIsSubmitting(false)}
>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<input type="hidden" name="next_path" value={next_path} />
<input type="hidden" value={uniqueCodeFormData.email} name="email" />
<div className="space-y-1">
<label className="text-sm font-medium text-onboarding-text-300" htmlFor="email">
Email
</label>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<div
className={`relative flex items-center rounded-md bg-onboarding-background-200 border border-onboarding-border-100`}
>
<Input
id="email"
name="email"
type="email"
value={uniqueCodeFormData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
// FIXME:
// hasError={Boolean(errors.email)}
placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 border-0`}
disabled
/>
{uniqueCodeFormData.email.length > 0 && (
<XCircle
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={handleEmailClear}
/>
<div className="flex-shrink-0 h-5 w-5 mr-2 bg-onboarding-background-200 hover:cursor-pointer">
<XCircle className="h-5 w-5 stroke-custom-text-400" onClick={handleEmailClear} />
</div>
)}
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-onboarding-text-300" htmlFor="code">
Unique code
@ -146,21 +114,18 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
name="code"
value={uniqueCodeFormData.code}
onChange={(e) => handleFormChange("code", e.target.value)}
// FIXME:
// hasError={Boolean(errors.code)}
placeholder="gets-sets-flys"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
autoFocus
autoComplete="off"
/>
<div className="flex w-full items-center justify-between px-1 text-xs">
<div className="flex w-full items-center justify-between px-1 text-xs pt-1">
<p className="flex items-center gap-1 font-medium text-green-700">
<CircleCheck height={12} width={12} />
Paste the code sent to your email
</p>
<button
type="button"
onClick={handleRequestNewCode}
onClick={() => generateNewCode(uniqueCodeFormData.email)}
className={`${
isRequestNewCodeDisabled
? "text-onboarding-text-400"
@ -176,15 +141,12 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
</button>
</div>
</div>
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
{isRequestingNewCode ? (
"Sending code"
) : isSubmitting ? (
<Spinner height="20px" width="20px" />
) : (
submitButtonText
)}
</Button>
<div className="space-y-2.5">
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
{isRequestingNewCode ? "Sending code" : isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
</Button>
</div>
</form>
);
};

View File

@ -0,0 +1 @@
export * from "./password-strength-meter";

View File

@ -1,7 +1,5 @@
export * from "./oauth";
export * from "./onboarding-form";
export * from "./user-logged-in";
export * from "./auth-forms";
export * from "./password-strength-meter";
export * from "./oauth";
export * from "./terms-and-conditions";
export * from "./user-image-upload-modal";
export * from "./helpers";
export * from "./user-logged-in";

View File

@ -1,5 +1,3 @@
"use client";
import { FC } from "react";
import Image from "next/image";
import { useTheme } from "next-themes";

View File

@ -1,5 +1,3 @@
"use client";
import { FC } from "react";
import Image from "next/image";
import { useTheme } from "next-themes";

View File

@ -1,29 +1,27 @@
"use client";
import { observer } from "mobx-react-lite";
import { observer } from "mobx-react";
// components
import { GithubOAuthButton, GoogleOAuthButton } from "@/components/accounts";
import { GithubOAuthButton, GoogleOAuthButton } from "@/components/account";
// hooks
import { useInstance } from "@/hooks/store";
export const OAuthOptions: React.FC = observer(() => {
// hooks
const { config: instanceConfig } = useInstance();
const { instance } = useInstance();
return (
<>
<div className="mx-auto mt-4 flex items-center sm:w-96">
<div className="mt-4 flex items-center">
<hr className="w-full border-onboarding-border-100" />
<p className="mx-3 flex-shrink-0 text-center text-sm text-onboarding-text-400">or</p>
<hr className="w-full border-onboarding-border-100" />
</div>
<div className={`mx-auto mt-7 grid gap-4 overflow-hidden sm:w-96`}>
{instanceConfig?.is_google_enabled && (
<div className={`mt-7 grid gap-4 overflow-hidden`}>
{instance?.config?.is_google_enabled && (
<div className="flex h-[42px] items-center !overflow-hidden">
<GoogleOAuthButton text="SignIn with Google" />
</div>
)}
{instanceConfig?.is_github_enabled && <GithubOAuthButton text="SignIn with Github" />}
{instance?.config?.is_github_enabled && <GithubOAuthButton text="SignIn with Github" />}
</div>
</>
);

View File

@ -2,23 +2,17 @@
import React, { FC } from "react";
import Link from "next/link";
import { EAuthModes } from "./auth-forms";
type Props = {
mode: EAuthModes | null;
isSignUp?: boolean;
};
export const TermsAndConditions: FC<Props> = (props) => {
const { mode } = props;
const { isSignUp = false } = props;
return (
<span className="flex items-center justify-center py-6">
<p className="text-center text-sm text-onboarding-text-200 whitespace-pre-line">
{mode
? mode === EAuthModes.SIGN_UP
? "By creating an account"
: "By signing in"
: "By clicking the above button"}
, you agree to our{" \n"}
{isSignUp ? "By creating an account" : "By signing in"}, you agree to our{" \n"}
<Link href="https://plane.so/legals/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="text-sm font-medium underline hover:cursor-pointer">Terms of Service</span>
</Link>{" "}

View File

@ -4,8 +4,8 @@ import Image from "next/image";
// hooks
import { useUser } from "@/hooks/store";
// assets
import PlaneLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg";
import UserLoggedInImage from "public/user-logged-in.svg";
import PlaneLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.svg";
import UserLoggedInImage from "@/public/user-logged-in.svg";
export const UserLoggedIn = () => {
const { data: user } = useUser();

View File

@ -1,94 +0,0 @@
"use client";
import React from "react";
import { Controller, useForm } from "react-hook-form";
// icons
import { XCircle, CircleAlert } from "lucide-react";
// ui
import { Button, Input, Spinner } from "@plane/ui";
// helpers
import { checkEmailValidity } from "@/helpers/string.helper";
// types
import { IEmailCheckData } from "@/types/auth";
type Props = {
onSubmit: (data: IEmailCheckData) => Promise<void>;
};
type TEmailFormValues = {
email: string;
};
export const EmailForm: React.FC<Props> = (props) => {
const { onSubmit } = props;
const {
control,
formState: { errors, isSubmitting, isValid },
handleSubmit,
} = useForm<TEmailFormValues>({
defaultValues: {
email: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const handleFormSubmit = async (data: TEmailFormValues) => {
const payload: IEmailCheckData = {
email: data.email,
};
onSubmit(payload);
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="mt-8 space-y-4">
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
Email
</label>
<Controller
control={control}
name="email"
rules={{
required: "Email is required",
validate: (value) => checkEmailValidity(value) || "Email is invalid",
}}
render={({ field: { value, onChange, ref } }) => (
<>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.email)}
placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/>
{value.length > 0 && (
<XCircle
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => onChange("")}
/>
)}
</div>
{errors.email && (
<p className="flex items-center gap-1 text-xs text-red-600 px-0.5">
<CircleAlert height={12} width={12} />
{errors.email.message}
</p>
)}
</>
)}
/>
</div>
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={!isValid || isSubmitting}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
</Button>
</form>
);
};

View File

@ -1,55 +0,0 @@
"use client";
import { Fragment, useState } from "react";
import { usePopper } from "react-popper";
import { X } from "lucide-react";
import { Popover } from "@headlessui/react";
export const ForgotPasswordPopover = () => {
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "right-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
return (
<Popover className="relative">
<Popover.Button as={Fragment}>
<button
type="button"
ref={setReferenceElement}
className="text-xs font-medium text-custom-primary-100 outline-none"
>
Forgot your password?
</button>
</Popover.Button>
<Popover.Panel className="fixed z-10">
{({ close }) => (
<div
className="border border-onboarding-border-300 bg-onboarding-background-100 rounded z-10 py-1 px-2 w-64 break-words flex items-start gap-3 text-left ml-3"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<span className="flex-shrink-0">🤥</span>
<p className="text-xs">
We see that your god hasn{"'"}t enabled SMTP, we will not be able to send a password reset link
</p>
<button type="button" className="flex-shrink-0" onClick={() => close()}>
<X className="h-3 w-3 text-onboarding-text-200" />
</button>
</div>
)}
</Popover.Panel>
</Popover>
);
};

View File

@ -1,5 +0,0 @@
export * from "./email";
export * from "./password";
export * from "./root";
export * from "./unique-code";
export * from "./forgot-password-popover";

View File

@ -1,160 +0,0 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
// components
import { IEmailCheckData } from "@plane/types";
import { EmailForm, UniqueCodeForm, PasswordForm, OAuthOptions, TermsAndConditions } from "@/components/accounts";
// hooks
import { useInstance } from "@/hooks/store";
import useToast from "@/hooks/use-toast";
// services
import { AuthService } from "@/services/auth.service";
export enum EAuthSteps {
EMAIL = "EMAIL",
PASSWORD = "PASSWORD",
UNIQUE_CODE = "UNIQUE_CODE",
}
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
}
type TTitle = {
header: string;
subHeader: string;
};
type THeaderSubheader = {
[mode in EAuthModes]: TTitle;
};
const titles: THeaderSubheader = {
[EAuthModes.SIGN_IN]: {
header: "Sign in to upvote or comment",
subHeader: "Contribute in nudging the features you want to get built.",
},
[EAuthModes.SIGN_UP]: {
header: "Comment or react to issues",
subHeader: "Use plane to add your valuable inputs to features.",
},
};
const getHeaderSubHeader = (mode: EAuthModes | null): TTitle => {
if (mode) {
return titles[mode];
}
return {
header: "Comment or react to issues",
subHeader: "Use plane to add your valuable inputs to features.",
};
};
const authService = new AuthService();
export const AuthRoot = observer(() => {
const { setToastAlert } = useToast();
// states
const [authMode, setAuthMode] = useState<EAuthModes | null>(null);
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
const [email, setEmail] = useState("");
// hooks
const { config: instanceConfig } = useInstance();
// derived values
const isSmtpConfigured = instanceConfig?.is_smtp_configured;
const isMagicLoginEnabled = instanceConfig?.is_magic_login_enabled;
const isEmailPasswordEnabled = instanceConfig?.is_email_password_enabled;
const { header, subHeader } = getHeaderSubHeader(authMode);
const handelEmailVerification = async (data: IEmailCheckData) => {
// update the global email state
setEmail(data.email);
await authService
.emailCheck(data)
.then((res) => {
// Set authentication mode based on user existing status.
if (res.existing) {
setAuthMode(EAuthModes.SIGN_IN);
} else {
setAuthMode(EAuthModes.SIGN_UP);
}
// If user exists and password is already setup by the user, move to password sign in.
if (res.existing && !res.is_password_autoset) {
setAuthStep(EAuthSteps.PASSWORD);
} else {
// Else if SMTP is configured, move to unique code sign-in/ sign-up.
if (isSmtpConfigured && isMagicLoginEnabled) {
setAuthStep(EAuthSteps.UNIQUE_CODE);
} else if (isEmailPasswordEnabled) {
// Else show error message if SMTP is not configured and password is not set.
if (res.existing) {
setAuthMode(null);
setToastAlert({
type: "error",
title: "Error!",
message: "Unable to process request please contact Administrator to reset password",
});
} else {
// If SMTP is not configured and user is new, move to password sign-up.
setAuthStep(EAuthSteps.PASSWORD);
}
}
}
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
const isOAuthEnabled = instanceConfig && (instanceConfig?.is_google_enabled || instanceConfig?.is_github_enabled);
return (
<div className="relative flex flex-col space-y-6">
<div className="space-y-1 text-center">
<h3 className="text-3xl font-bold text-onboarding-text-100">{header}</h3>
<p className="font-medium text-onboarding-text-400">{subHeader}</p>
</div>
{authStep === EAuthSteps.EMAIL && <EmailForm onSubmit={handelEmailVerification} />}
{authMode && (
<>
{authStep === EAuthSteps.PASSWORD && (
<PasswordForm
email={email}
mode={authMode}
handleEmailClear={() => {
setEmail("");
setAuthMode(null);
setAuthStep(EAuthSteps.EMAIL);
}}
handleStepChange={(step) => setAuthStep(step)}
/>
)}
{authStep === EAuthSteps.UNIQUE_CODE && (
<UniqueCodeForm
email={email}
mode={authMode}
handleEmailClear={() => {
setEmail("");
setAuthMode(null);
setAuthStep(EAuthSteps.EMAIL);
}}
submitButtonText="Continue"
/>
)}
</>
)}
{isOAuthEnabled !== undefined && <OAuthOptions />}
<TermsAndConditions mode={authMode} />
</div>
);
});

View File

@ -1,216 +0,0 @@
"use client";
import React, { useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form";
// types
import { IUser } from "@plane/types";
// ui
import { Button, Input, Spinner, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { UserImageUploadModal } from "@/components/accounts";
// hooks
import { useUser } from "@/hooks/store";
// services
import fileService from "@/services/file.service";
type TProfileSetupFormValues = {
first_name: string;
last_name: string;
avatar?: string | null;
};
const defaultValues: Partial<TProfileSetupFormValues> = {
first_name: "",
last_name: "",
avatar: "",
};
type Props = {
user?: IUser;
finishOnboarding: () => Promise<void>;
};
export const OnBoardingForm: React.FC<Props> = observer((props) => {
const { user, finishOnboarding } = props;
// states
const [isRemoving, setIsRemoving] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
// store hooks
const { updateCurrentUser } = useUser();
// form info
const {
getValues,
handleSubmit,
control,
watch,
setValue,
formState: { errors, isSubmitting, isValid },
} = useForm<TProfileSetupFormValues>({
defaultValues: {
...defaultValues,
first_name: user?.first_name,
last_name: user?.last_name,
avatar: user?.avatar,
},
mode: "onChange",
});
const onSubmit = async (formData: TProfileSetupFormValues) => {
if (!user) return;
const userDetailsPayload: Partial<IUser> = {
first_name: formData.first_name,
last_name: formData.last_name,
avatar: formData.avatar,
};
try {
await updateCurrentUser(userDetailsPayload).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "Profile setup completed!",
});
finishOnboarding();
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error",
message: "Profile setup failed. Please try again!",
});
}
};
const handleDelete = (url: string | null | undefined) => {
if (!url) return;
setIsRemoving(true);
fileService.deleteUserFile(url).finally(() => {
setValue("avatar", "");
setIsRemoving(false);
});
};
const isButtonDisabled = useMemo(() => (isValid && !isSubmitting ? false : true), [isSubmitting, isValid]);
return (
<form onSubmit={handleSubmit(onSubmit)} className="w-full mx-auto mt-2 space-y-4 sm:w-96">
<Controller
control={control}
name="avatar"
render={({ field: { onChange, value } }) => (
<UserImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isRemoving}
handleDelete={() => handleDelete(getValues("avatar"))}
onSuccess={(url) => {
onChange(url);
setIsImageUploadModalOpen(false);
}}
value={value && value.trim() !== "" ? value : null}
/>
)}
/>
<div className="space-y-1 flex items-center justify-center">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{!watch("avatar") || watch("avatar") === "" ? (
<div className="flex flex-col items-center justify-between">
<div className="relative h-14 w-14 overflow-hidden">
<div className="absolute left-0 top-0 flex items-center justify-center h-full w-full rounded-full text-white text-3xl font-medium bg-[#9747FF] uppercase">
{watch("first_name")[0] ?? "R"}
</div>
</div>
<div className="pt-1 text-sm font-medium text-custom-primary-300 hover:text-custom-primary-400">
Choose image
</div>
</div>
) : (
<div className="relative mr-3 h-16 w-16 overflow-hidden">
<img
src={watch("avatar") || undefined}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
onClick={() => setIsImageUploadModalOpen(true)}
alt={user?.display_name}
/>
</div>
)}
</button>
</div>
<div className="flex gap-4">
<div className="space-y-1 w-full">
<label
className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
htmlFor="first_name"
>
First name
</label>
<Controller
control={control}
name="first_name"
rules={{
required: "First name is required",
maxLength: {
value: 24,
message: "First name must be within 24 characters.",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="first_name"
name="first_name"
type="text"
value={value}
autoFocus
onChange={onChange}
ref={ref}
hasError={Boolean(errors.first_name)}
placeholder="RWilbur"
className="w-full border-onboarding-border-100 focus:border-custom-primary-100"
/>
)}
/>
{errors.first_name && <span className="text-sm text-red-500">{errors.first_name.message}</span>}
</div>
<div className="space-y-1 w-full">
<label
className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
htmlFor="last_name"
>
Last name
</label>
<Controller
control={control}
name="last_name"
rules={{
required: "Last name is required",
maxLength: {
value: 24,
message: "Last name must be within 24 characters.",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="last_name"
name="last_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.last_name)}
placeholder="Wright"
className="w-full border-onboarding-border-100 focus:border-custom-primary-100"
/>
)}
/>
{errors.last_name && <span className="text-sm text-red-500">{errors.last_name.message}</span>}
</div>
</div>
<Button variant="primary" type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
</Button>
</form>
);
});

View File

@ -1,186 +0,0 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { useDropzone } from "react-dropzone";
import { UserCircle2 } from "lucide-react";
import { Transition, Dialog } from "@headlessui/react";
// hooks
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
// services
import fileService from "@/services/file.service";
type Props = {
handleDelete?: () => void;
isOpen: boolean;
isRemoving: boolean;
onClose: () => void;
onSuccess: (url: string) => void;
value: string | null;
};
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
export const UserImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete } = props;
// states
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
// store hooks
const { config: instanceConfig } = useInstance();
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: (instanceConfig?.file_size_limit as number) ?? MAX_FILE_SIZE,
multiple: false,
});
const handleClose = () => {
setImage(null);
setIsImageUploading(false);
onClose();
};
const handleSubmit = async () => {
if (!image) return;
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
fileService
.uploadUserFile(formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setImage(null);
if (value) fileService.deleteUserFile(value);
})
.catch((err) =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
)
.finally(() => setIsImageUploading(false));
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-xl sm:p-6">
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Upload Image
</Dialog.Title>
<div className="space-y-3">
<div className="flex items-center justify-center gap-3">
<div
{...getRootProps()}
className={`relative grid h-80 w-80 cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
(image === null && isDragActive) || !value
? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90"
: ""
}`}
>
{image !== null || (value && value !== "") ? (
<>
<button
type="button"
className="absolute right-0 top-0 z-40 -translate-y-1/2 translate-x-1/2 rounded bg-custom-background-90 px-2 py-0.5 text-xs font-medium text-custom-text-200"
>
Edit
</button>
<img
src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image"
className="absolute left-0 top-0 h-full w-full rounded-md object-cover"
/>
</>
) : (
<div>
<UserCircle2 className="mx-auto h-16 w-16 text-custom-text-200" />
<span className="mt-2 block text-sm font-medium text-custom-text-200">
{isDragActive ? "Drop image here to upload" : "Drag & drop image here"}
</span>
</div>
)}
<input {...getInputProps()} type="text" />
</div>
</div>
{fileRejections.length > 0 && (
<p className="text-sm text-red-500">
{fileRejections[0].errors[0].code === "file-too-large"
? "The image size cannot exceed 5 MB."
: "Please upload a file in a valid format."}
</p>
)}
</div>
</div>
<p className="my-4 text-sm text-custom-text-200">
File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p>
<div className="flex items-center justify-between">
{handleDelete && (
<Button variant="danger" size="sm" onClick={handleDelete} disabled={!value}>
{isRemoving ? "Removing..." : "Remove"}
</Button>
)}
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@ -7,6 +7,8 @@ import { useRouter, useSearchParams } from "next/navigation";
import { IssueBlockDueDate } from "@/components/issues/board-views/block-due-date";
import { IssueBlockPriority } from "@/components/issues/board-views/block-priority";
import { IssueBlockState } from "@/components/issues/board-views/block-state";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails, useProject } from "@/hooks/store";
// interfaces
@ -20,22 +22,23 @@ type IssueKanBanBlockProps = {
};
export const IssueKanBanBlock: FC<IssueKanBanBlockProps> = observer((props) => {
const { workspaceSlug, projectId, params, issue } = props;
const { board, priority, states, labels } = params;
// store
const { project } = useProject();
const { setPeekId } = useIssueDetails();
// router
const router = useRouter();
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined;
// props
const { workspaceSlug, projectId, issue } = props;
// hooks
const { project } = useProject();
const { setPeekId } = useIssueDetails();
const handleBlockClick = () => {
setPeekId(issue.id);
const params: any = { board: board, peekId: issue.id };
if (states && states.length > 0) params.states = states;
if (priority && priority.length > 0) params.priority = priority;
if (labels && labels.length > 0) params.labels = labels;
router.push(`/${workspaceSlug}/${projectId}?${searchParams}`);
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`);
};
return (

View File

@ -7,7 +7,9 @@ import { IssueBlockDueDate } from "@/components/issues/board-views/block-due-dat
import { IssueBlockLabels } from "@/components/issues/board-views/block-labels";
import { IssueBlockPriority } from "@/components/issues/board-views/block-priority";
import { IssueBlockState } from "@/components/issues/board-views/block-state";
// mobx hook
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hook
import { useIssueDetails, useProject } from "@/hooks/store";
// interfaces
import { IIssue } from "@/types/issue";
@ -35,12 +37,9 @@ export const IssueListBlock: FC<IssueListBlockProps> = observer((props) => {
const handleBlockClick = () => {
setPeekId(issue.id);
let queryParams: any = { board: board, peekId: issue.id };
if (priority && priority.length > 0) queryParams = { ...queryParams, priority: priority };
if (state && state.length > 0) queryParams = { ...queryParams, state: state };
if (labels && labels.length > 0) queryParams = { ...queryParams, labels: labels };
queryParams = new URLSearchParams(queryParams).toString();
router.push(`/${workspaceSlug}/${projectId}?${queryParams}`);
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`);
};
return (

View File

@ -4,15 +4,17 @@ import { FC, useCallback } from "react";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/navigation";
// components
import { FiltersDropdown } from "@/components/issues/filters/helpers/dropdown";
import { FilterSelection } from "@/components/issues/filters/selection";
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssue, useIssueFilter } from "@/hooks/store";
// types
import { TIssueQueryFilters } from "@/types/issue";
// components
import { FiltersDropdown } from "./helpers/dropdown";
import { FilterSelection } from "./selection";
type IssueFiltersDropdownProps = {
workspaceSlug: string;
@ -34,13 +36,8 @@ export const IssueFiltersDropdown: FC<IssueFiltersDropdownProps> = observer((pro
const priority = key === "priority" ? value : issueFilters?.filters?.priority ?? [];
const labels = key === "labels" ? value : issueFilters?.filters?.labels ?? [];
let params: any = { board: activeLayout || "list" };
if (priority.length > 0) params = { ...params, priority: priority.join(",") };
if (state.length > 0) params = { ...params, state: state.join(",") };
if (labels.length > 0) params = { ...params, labels: labels.join(",") };
params = new URLSearchParams(params).toString();
router.push(`/${workspaceSlug}/${projectId}?${params}`);
const { queryParam } = queryParamGenerator({ board: activeLayout, priority, state, labels });
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`);
},
[workspaceSlug, projectId, activeLayout, issueFilters, router]
);

View File

@ -10,6 +10,8 @@ import { Avatar, Button } from "@plane/ui";
import { IssueFiltersDropdown } from "@/components/issues/filters";
import { NavbarIssueBoardView } from "@/components/issues/navbar/issue-board-view";
import { NavbarTheme } from "@/components/issues/navbar/theme";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useProject, useUser, useIssueFilter, useIssueDetails } from "@/hooks/store";
// types
@ -63,30 +65,19 @@ export const NavbarControls: FC<NavbarControlsProps> = observer((props) => {
if (currentBoard) {
if (activeLayout === undefined || activeLayout !== currentBoard) {
let queryParams: any = { board: currentBoard };
const params: any = { display_filters: { layout: currentBoard }, filters: {} };
if (peekId && peekId.length > 0) {
queryParams = { ...queryParams, peekId: peekId };
setPeekId(peekId);
}
if (priority && priority.length > 0) {
queryParams = { ...queryParams, priority: priority };
params.filters = { ...params.filters, priority: priority.split(",") };
}
if (state && state.length > 0) {
queryParams = { ...queryParams, state: state };
params.filters = { ...params.filters, state: state.split(",") };
}
if (labels && labels.length > 0) {
queryParams = { ...queryParams, labels: labels };
params.filters = { ...params.filters, labels: labels.split(",") };
}
const { query, queryParam } = queryParamGenerator({ board: currentBoard, peekId, priority, state, labels });
const params: any = {
display_filters: { layout: (query?.board as string[])[0] },
filters: {
priority: query?.priority ?? undefined,
state: query?.state ?? undefined,
labels: query?.labels ?? undefined,
},
};
if (!isIssueFiltersUpdated(params)) {
initIssueFilters(projectId, params);
queryParams = new URLSearchParams(queryParams).toString();
router.push(`/${workspaceSlug}/${projectId}?${queryParams}`);
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`);
}
}
}

View File

@ -5,6 +5,8 @@ import { observer } from "mobx-react-lite";
import { useRouter, useSearchParams } from "next/navigation";
// constants
import { issueLayoutViews } from "@/constants/issue";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueFilter } from "@/hooks/store";
// mobx
@ -33,14 +35,8 @@ export const NavbarIssueBoardView: FC<NavbarIssueBoardViewProps> = observer((pro
const handleCurrentBoardView = (boardView: TIssueLayout) => {
updateIssueFilters(projectId, "display_filters", "layout", boardView);
let queryParams: any = { board: boardView };
if (peekId && peekId.length > 0) queryParams = { ...queryParams, peekId: peekId };
if (priority && priority.length > 0) queryParams = { ...queryParams, priority: priority };
if (state && state.length > 0) queryParams = { ...queryParams, state: state };
if (labels && labels.length > 0) queryParams = { ...queryParams, labels: labels };
queryParams = new URLSearchParams(queryParams).toString();
router.push(`/${workspaceSlug}/${projectId}?${queryParams}`);
const { queryParam } = queryParamGenerator({ board: boardView, peekId, priority, state, labels });
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`);
};
return (

View File

@ -2,14 +2,13 @@
import { observer } from "mobx-react-lite";
import Image from "next/image";
// ui
import { useTheme } from "next-themes";
// components
import { AuthRoot } from "@/components/accounts";
import { AuthRoot } from "@/components/account";
// images
import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg";
import PlaneBackgroundPattern from "public/auth/background-pattern.svg";
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text-new.png";
import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg";
import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg";
import BluePlaneLogoWithoutText from "@/public/plane-logos/blue-without-text-new.png";
export const AuthView = observer(() => {
// hooks
@ -31,7 +30,7 @@ export const AuthView = observer(() => {
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
</div>
</div>
<div className="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10">
<div className="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 lg:pt-28 transition-all">
<AuthRoot />
</div>
</div>

View File

@ -9,17 +9,6 @@ export enum EPageTypes {
AUTHENTICATED = "AUTHENTICATED",
}
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
}
export enum EAuthSteps {
EMAIL = "EMAIL",
PASSWORD = "PASSWORD",
UNIQUE_CODE = "UNIQUE_CODE",
}
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
TOAST_ALERT = "TOAST_ALERT",

View File

@ -0,0 +1,24 @@
type TQueryParamValue = string | string[] | boolean | number | bigint | undefined | null;
export const queryParamGenerator = (queryObject: Record<string, TQueryParamValue>) => {
const queryParamObject: Record<string, TQueryParamValue> = {};
const queryParam = new URLSearchParams();
Object.entries(queryObject).forEach(([key, value]) => {
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
queryParamObject[key] = value;
queryParam.append(key, value.toString());
} else if (typeof value === "string" && value.length > 0) {
queryParamObject[key] = value.split(",");
queryParam.append(key, value);
} else if (Array.isArray(value) && value.length > 0) {
queryParamObject[key] = value;
queryParam.append(key, value.toString());
}
});
return {
query: queryParamObject,
queryParam: queryParam.toString(),
};
};

View File

@ -1,9 +1,9 @@
// types
import { ICsrfTokenData, IEmailCheckData, IEmailCheckResponse } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
// types
import { ICsrfTokenData, IEmailCheckData, IEmailCheckResponse } from "@/types/auth";
export class AuthService extends APIService {
constructor() {
@ -26,14 +26,6 @@ export class AuthService extends APIService {
});
}
async sendResetPasswordLink(data: { email: string }): Promise<void> {
return this.post(`/auth/forgot-password/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async generateUniqueCode(data: { email: string }): Promise<void> {
return this.post("/auth/spaces/magic-generate/", data, { headers: {} })
.then((response) => response?.data)
@ -41,6 +33,4 @@ export class AuthService extends APIService {
throw error?.response?.data;
});
}
async signOut() {}
}

View File

@ -1,3 +1,4 @@
import set from "lodash/set";
import { observable, action, makeObservable, runInAction } from "mobx";
// types
import { IInstance } from "@plane/types";
@ -16,20 +17,18 @@ type TError = {
};
export interface IInstanceStore {
// issues
// observables
isLoading: boolean;
data: IInstance | NonNullable<unknown>;
config: Record<string, any>;
instance: IInstance | undefined;
error: TError | undefined;
// action
fetchInstanceInfo: () => Promise<void>;
hydrate: (data: Record<string, unknown>, config: Record<string, unknown>) => void;
hydrate: (data: IInstance) => void;
}
export class InstanceStore implements IInstanceStore {
isLoading: boolean = true;
data: IInstance | Record<string, any> = {};
config: Record<string, unknown> = {};
instance: IInstance | undefined = undefined;
error: TError | undefined = undefined;
// services
instanceService;
@ -38,8 +37,7 @@ export class InstanceStore implements IInstanceStore {
makeObservable(this, {
// observable
isLoading: observable.ref,
data: observable,
config: observable,
instance: observable,
error: observable,
// actions
fetchInstanceInfo: action,
@ -49,10 +47,7 @@ export class InstanceStore implements IInstanceStore {
this.instanceService = new InstanceService();
}
hydrate = (data: Record<string, unknown>, config: Record<string, unknown>) => {
this.data = { ...this.data, ...data };
this.config = { ...this.config, ...config };
};
hydrate = (data: IInstance) => set(this, "instance", data);
/**
* @description fetching instance information
@ -61,11 +56,10 @@ export class InstanceStore implements IInstanceStore {
try {
this.isLoading = true;
this.error = undefined;
const instanceDetails = await this.instanceService.getInstanceInfo();
const instance = await this.instanceService.getInstanceInfo();
runInAction(() => {
this.isLoading = false;
this.data = instanceDetails.instance;
this.config = instanceDetails.config;
this.instance = instance;
});
} catch (error) {
runInAction(() => {

View File

@ -32,7 +32,7 @@ export class RootStore {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hydrate = (data: any) => {
if (!data) return;
this.instance.hydrate(data?.instance || {}, data?.config || {});
this.instance.hydrate(data?.instance || {});
this.user.hydrate(data?.user || {});
};

26
space/types/auth.d.ts vendored
View File

@ -1,26 +0,0 @@
export type TEmailCheckTypes = "magic_code" | "password";
export interface IEmailCheckData {
email: string;
}
export interface IEmailCheckResponse {
is_password_autoset: boolean;
is_existing: boolean;
}
export interface ILoginTokenResponse {
access_token: string;
refresh_token: string;
}
export interface IMagicSignInData {
email: string;
key: string;
token: string;
}
export interface IPasswordSignInData {
email: string;
password: string;
}

25
space/types/auth.ts Normal file
View File

@ -0,0 +1,25 @@
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
}
export enum EAuthSteps {
EMAIL = "EMAIL",
PASSWORD = "PASSWORD",
UNIQUE_CODE = "UNIQUE_CODE",
}
export interface ICsrfTokenData {
csrf_token: string;
}
// email check types starts
export interface IEmailCheckData {
email: string;
}
export interface IEmailCheckResponse {
is_password_autoset: boolean;
existing: boolean;
}
// email check types ends