forked from github/plane
[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:
parent
37cc8d7b77
commit
bab52a2672
@ -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
|
||||
|
@ -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 />
|
||||
) : (
|
||||
|
@ -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
|
||||
|
31
space/components/account/auth-forms/auth-banner.tsx
Normal file
31
space/components/account/auth-forms/auth-banner.tsx
Normal 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>
|
||||
);
|
||||
};
|
57
space/components/account/auth-forms/auth-header.tsx
Normal file
57
space/components/account/auth-forms/auth-header.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
};
|
172
space/components/account/auth-forms/auth-root.tsx
Normal file
172
space/components/account/auth-forms/auth-root.tsx
Normal 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>
|
||||
);
|
||||
});
|
86
space/components/account/auth-forms/email.tsx
Normal file
86
space/components/account/auth-forms/email.tsx
Normal 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>
|
||||
);
|
||||
});
|
8
space/components/account/auth-forms/index.ts
Normal file
8
space/components/account/auth-forms/index.ts
Normal 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";
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
1
space/components/account/helpers/index.ts
Normal file
1
space/components/account/helpers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./password-strength-meter";
|
@ -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";
|
@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
@ -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>
|
||||
</>
|
||||
);
|
@ -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>{" "}
|
@ -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();
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -1,5 +0,0 @@
|
||||
export * from "./email";
|
||||
export * from "./password";
|
||||
export * from "./root";
|
||||
export * from "./unique-code";
|
||||
export * from "./forgot-password-popover";
|
@ -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>
|
||||
);
|
||||
});
|
@ -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>
|
||||
);
|
||||
});
|
@ -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>
|
||||
);
|
||||
});
|
@ -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 (
|
||||
|
@ -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 (
|
||||
|
@ -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]
|
||||
);
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
24
space/helpers/query-param-generator.ts
Normal file
24
space/helpers/query-param-generator.ts
Normal 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(),
|
||||
};
|
||||
};
|
@ -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() {}
|
||||
}
|
||||
|
@ -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(() => {
|
||||
|
@ -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
26
space/types/auth.d.ts
vendored
@ -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
25
space/types/auth.ts
Normal 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
|
Loading…
Reference in New Issue
Block a user