feat: session auth implementation (#4411)
* feat: session authentication and god-mode implementation (#4302) * dev: move authentication to base class for credentials * chore: new account creation * dev: return error as query parameter * dev: accounts and profile endpoints for user * fix: user store updates * fix: store fixes * fix: type fixes * dev: set is_password_autoset and is_email_verifier for auth providers * dev: move all auth configuration to different apps * dev: fix circular imports * dev: remove unused imports * dev: fix imports for authentication * dev: update endpoints to use rest framework api viewa * fix: onboarding fixes * dev: session model changes * fix: session model and add check for last name first name and avatar * dev: fix referer redirect * dev: remove auth imports * dev: fix imports * dev: update migrations * fix: instance admin login * comflict: conflicts resolved * dev: fix import errors and email check endpoint * fix: error messages and redirects after login * dev: configs api * fix: is github enabled boolean * dev: merge config and instance api * conflict: merge conflict resolved * dev: instance admin sign up endpoint * dev: enable magic link login * dev: configure instance variables for github and google enabled * chore: typo fixes * fix: god mode docker file changes * build-error: resolved build errors * fix: docker compose changes * dev: add email credential check endpoint * fix: minor package changes * fix: docker related changes * dev: add nginx rules in the nginx template * dev: refactor the url patterns * fix: docker changes * fix: docker files for god-mode * fix: static export * fix: nginx conf * dev: smtp sender refused exception * fix: godmode fixes * chore: god mode revamp. * dev: add csrf secured flag * fix: oauth redirect uri and session settings * chore: god mode app changes. (#3982) * chore: send test email functionality. * style: authentication methods page UI revamp. * chore: create workspace popup. * fix: user me endpoint * dev: fix redirection after authentication * dev: handle god mode redirection * fix: redirections * fix: auth related hooks * fix: store related fixes * dev: fix session authentication for rest apis * fix: linting errors * fix: removing references of useStore= * dev: fix redirection and password validation * dev: add useUser hook * fix: build fixes and lint issues * fix: removing useApplication hook * fix: build errors * fix: delete unused files * fix: auth build fixes * fix: bugfixes * dev: alter avatar to support more than 255 chars * dev: fix profile endpoint and increase session expiry time and update session on every request * chore: resolved the migration * chore: resolved merge conflicts * dev: error codes and error messages for the auth flow * dev: instance admin sign up and sign in endpoint * dev: use zxcvbn to validate password strength * dev: add extra parameters when error handling on instance god mode * chore: auth init * chore: signin/ signup form ui updates and password strength meter. * chore: update password fields. * chore: validations and error handling. * chore: updated sign-up form * chore: updated workflow and updated the code structure * chore: instance empty state for god-mode. * chore: instance and auth wrappers update * fix: renaming godmode * fix: docker changes * chore: updated authentication wrappers * chore: updated the authentication workflow and rendered all pages * fix: build errors * fix: docker related fixes * fix: tailing slash added to space and admin for valid nginx locations * chore: seperate pages for signup and login * git-action modified for admin file changes * feature build action updated for admin app * self host modified * chore: resolved build errors and handled signin and signup in a seperate route * chore: sign-in and sign-up revamp. * fix: migration conflicts * dev: migrations * chore: handled redirection * dev: admin url * dev: create seperate endpoint for instance admin me * dev: instance admin endpoint * git action fixed * chore: handled auth wrappers * dev: add serializer and remove print logs * fix: build errors * dev: fix migrations * dev: instance folder structuring * fix: linting errors * chore: resolved build errors * chore: updated store and auth workflow and updates api service types * chore: Replaced Next Link with Anchoer tag for god-mode redirection * add 3333 port to allowed origins * make password login working again * dev: fix redirection, add admin signout endpoint and fix email credential check endpoint * fix unique code sign in * fix small build error * enable sign out * dev: add google client secret variable to configure instance * dev: add referer for redirection * fix origin urls for oauths * admin setup and login separation * dev: fix user redirection and tour completed endpoint * fix build errors * dev: add set password endpoint * dev: remove user creation logic for redirection * fix unique code page * fix forgot password * chore: onboarding revamp. * dev: fix workspace slug redirection in login * chore: invited user onboarding flow update. * chore: fix switch or delete account modal. * fix members exception * refactor auth flows and add invitations to auth flow * fix sig in sign up url * fix action url * fix build errors * dev: fix user set password when logging in * dev: reset password endpoint * chore: confirm password validation for signup and onboarding. * enable reset password * fix build error * chore: minor UI updates. * chore: forgot and reset password UI revamp. * fix authentication re directions * dev: auth redirections * change url paths for signup and signin * dev: make the user logged in when changing passwords * dev: next path redirection for web and space app * dev: next path for magic sign in endpoint * dev: github space endpoint * chore: minor ui updates and fixes in web app. * set password screen * fix multiple unique code generation * dev: next path base redirection * dev: remove print logs * dev: auth space endpoints * fix build errors * dev: invalidate cache on configuration update, god mode exception errors and authentication failed code * dev: fix space endpoints and add extra endpoints * chore: space auth revamp. * dev: add sign up for space app * fix: build errors. * fix: auth redirection logic. * chore: space app onboarding revamp. --------- Co-authored-by: pablohashescobar <nikhilschacko@gmail.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: gurusainath <gurusainath007@gmail.com> Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com> Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Co-authored-by: Manish Gupta <manish@mgupta.me> Co-authored-by: = <=> Co-authored-by: rahulramesha <rahulramesham@gmail.com> * chore: updated file structure for admin * chore: updated admin-sidebar * chore: auth error handling * chore: onboarding UI updates and dark mode fixes. * chore: add `user personalization` step to onboarding profile setup screen. * chore: fix minor UI bugs * chore: authentication workflow changes * chore: handled signin workflow * style: switch or delete account workflow * chore: god mode redirection URL * feat(dashboard): improve label readability (#4321) change none label for all time in dashbard filters * chore: god-mode redirection * chore: onboarding ui updates and accept invitation workflow updates. * chore: rename unique code auth form. * style: space auth ux copy. * chore: updated intance and auth wrapper logic * chore: update default layout style. * chore: update confirm password. * chore: backend redirection * style: update banner ui * chore: minor ui updates and validation fix. * chore: removed old auth hook * chore: handled auth wrapper * chore: handled store loaders in the user * chore: handled logs * chore: add loading spinners for all auth and onboarding form buttons. * chore: add background pattern in admin auth forms and minor ui fixes. * chore: UI changes and revamp components for authentication * chore: auth UI consistency in web, space and admin. * chore: resolved build errors * chore: removed old auth hooks * chore: handled lint errors in use accounts * chore: updated authentication wrapper logic in web app * [WEB -1149] dev: update dependencies (#4333) * dev: upgrade dependencies remove unwanted dependency and add ruff as local dependency * dev: add comments * chore: authentication wrapper fetch user * chore: updated store loader * chore: removed old auth wrapper and replaced the imports with new auth wrapper * chore: join workspace invitation workflow updates * chore: build error resolved in deploy * chore: handled onboarding step error in web app * chore: SMTP Name and Password validation removed * chore: handled seo and signout logic and new user popup * chore: added redirection to plane in the sidebar * chore: resolved build errors * dev: admin session cookie update * chore: updated cookie session time for admin * dev: add start date and end date to projects (#4355) * chore: add email security dropdown and remove SMTP username and password validation. * chore: add tooltip to admin sidebar help-section. * chore: add dropdown to collapsed admin sidebar. * chore: profile themning * chore: updated page error messages and theme in command palette * dev: add email validation in email check apis * dev: remove start date and end date from project * chore: updated space folder structure and updated the store hooks * dev: error codes for authentication * chore: handled authentication in space and web apps * chore: banner redirect handling the email * dev: god mode error codes * chore: updated error codes * chore: updated onboarding images * dev: signout endpoints and saving login domain while creating sessions * feat: Self Host Data Backup (#4383) * feat: implemented backup , support for docker-compose tool, readme updated * minor fix in shell script * codacy fixes * chore: handled build errors in web * chore: updated react, react-dom, and next versions * chore: updated password autioset in the signin * dev: add logo prop to views and pages * chore: updated api service and handled the set password in store * chore: handled build errors and code cleanup * dev: return 401 when the session is not valid * dev: users/me exception for api * chore: installed lodash in space app * dev: add auth route in nginx --------- Co-authored-by: pablohashescobar <nikhilschacko@gmail.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: gurusainath <gurusainath007@gmail.com> Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com> Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Co-authored-by: Manish Gupta <manish@mgupta.me> Co-authored-by: rahulramesha <rahulramesham@gmail.com> Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com> Co-authored-by: Daniel Alba <56451942+redrum15@users.noreply.github.com> Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
|
|
@ -48,7 +48,7 @@ ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
|||
COPY start.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV TURBO_TELEMETRY_DISABLED 1
|
||||
|
||||
EXPOSE 3000
|
||||
|
|
|
|||
92
space/components/accounts/auth-forms/email.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
5
space/components/accounts/auth-forms/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./email";
|
||||
export * from "./password";
|
||||
export * from "./root";
|
||||
export * from "./unique-code";
|
||||
export * from "./forgot-password-popover";
|
||||
216
space/components/accounts/auth-forms/password.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
// icons
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Eye, EyeOff, XCircle } from "lucide-react";
|
||||
// ui
|
||||
import { Button, Input, Spinner } from "@plane/ui";
|
||||
import { EAuthModes, EAuthSteps, ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/accounts";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// services
|
||||
import { getPasswordStrength } from "@/helpers/password.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
mode: EAuthModes;
|
||||
handleEmailClear: () => void;
|
||||
handleStepChange: (step: EAuthSteps) => void;
|
||||
};
|
||||
|
||||
type TPasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
confirm_password?: string;
|
||||
};
|
||||
|
||||
const defaultValues: TPasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const PasswordForm: React.FC<Props> = (props) => {
|
||||
const { email, mode, handleEmailClear, handleStepChange } = 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 [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// hooks
|
||||
const { instance } = useInstance();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { next_path } = router.query;
|
||||
// derived values
|
||||
const isSmtpConfigured = instance?.config?.is_smtp_configured;
|
||||
|
||||
const handleFormChange = (key: keyof TPasswordFormValues, value: string) =>
|
||||
setPasswordFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
const redirectToUniqueCodeLogin = () => {
|
||||
handleStepChange(EAuthSteps.UNIQUE_CODE);
|
||||
};
|
||||
|
||||
const passwordSupport =
|
||||
mode === EAuthModes.SIGN_IN ? (
|
||||
<div className="mt-2 w-full pb-3">
|
||||
{isSmtpConfigured ? (
|
||||
<Link
|
||||
href={`/accounts/forgot-password?email=${email}`}
|
||||
className="text-xs font-medium text-custom-primary-100"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
) : (
|
||||
<ForgotPasswordPopover />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
isPasswordInputFocused && <PasswordStrengthMeter password={passwordFormData.password} />
|
||||
);
|
||||
|
||||
const isButtonDisabled = useMemo(
|
||||
() =>
|
||||
!isSubmitting &&
|
||||
!!passwordFormData.password &&
|
||||
(mode === EAuthModes.SIGN_UP
|
||||
? getPasswordStrength(passwordFormData.password) >= 3 &&
|
||||
passwordFormData.password === passwordFormData.confirm_password
|
||||
: true)
|
||||
? false
|
||||
: true,
|
||||
[isSubmitting, mode, passwordFormData.confirm_password, passwordFormData.password]
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-5 space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/spaces/${mode === EAuthModes.SIGN_IN ? "sign-in" : "sign-up"}/`}
|
||||
onSubmit={() => setIsSubmitting(true)}
|
||||
onError={() => setIsSubmitting(false)}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<input type="hidden" name="next_path" value={next_path} />
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<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"
|
||||
/>
|
||||
{passwordFormData.email.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={handleEmailClear}
|
||||
/>
|
||||
)}
|
||||
</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"}
|
||||
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"
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(false)}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(true)}
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
Confirm password
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "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"
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(false)}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!passwordFormData.confirm_password && passwordFormData.password !== passwordFormData.confirm_password && (
|
||||
<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"}
|
||||
</Button>
|
||||
{instance && isSmtpConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={redirectToUniqueCodeLogin}
|
||||
variant="outline-primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
Sign in with unique code
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Create account"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
156
space/components/accounts/auth-forms/root.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
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/authentication.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 { instance } = useInstance();
|
||||
// derived values
|
||||
const isSmtpConfigured = instance?.config?.is_smtp_configured;
|
||||
|
||||
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) {
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
} else {
|
||||
// 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 =
|
||||
instance?.config && (instance?.config?.is_google_enabled || instance?.config?.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 && <OAuthOptions />}
|
||||
<TermsAndConditions mode={authMode} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
188
space/components/accounts/auth-forms/unique-code.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
// 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/authentication.service";
|
||||
// types
|
||||
import { IEmailCheckData } from "@/types/auth";
|
||||
import { EAuthModes } from "./root";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
mode: EAuthModes;
|
||||
handleEmailClear: () => void;
|
||||
submitButtonText: string;
|
||||
};
|
||||
|
||||
type TUniqueCodeFormValues = {
|
||||
email: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
const defaultValues: TUniqueCodeFormValues = {
|
||||
email: "",
|
||||
code: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
const { email, mode, handleEmailClear, submitButtonText } = props;
|
||||
// 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 router = useRouter();
|
||||
const { next_path } = router.query;
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// timer
|
||||
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
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;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-5 space-y-4"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/spaces/${mode === EAuthModes.SIGN_IN ? "magic-sign-in" : "magic-sign-up"}/`}
|
||||
onSubmit={() => setIsSubmitting(true)}
|
||||
onError={() => setIsSubmitting(false)}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<input type="hidden" name="next_path" value={next_path} />
|
||||
<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">
|
||||
<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"
|
||||
disabled
|
||||
/>
|
||||
{uniqueCodeFormData.email.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={handleEmailClear}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-onboarding-text-300" htmlFor="code">
|
||||
Unique code
|
||||
</label>
|
||||
<Input
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between px-1 text-xs">
|
||||
<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}
|
||||
className={`${
|
||||
isRequestNewCodeDisabled
|
||||
? "text-onboarding-text-400"
|
||||
: "font-medium text-custom-primary-300 hover:text-custom-primary-200"
|
||||
}`}
|
||||
disabled={isRequestNewCodeDisabled}
|
||||
>
|
||||
{resendTimerCode > 0
|
||||
? `Resend in ${resendTimerCode}s`
|
||||
: isRequestingNewCode
|
||||
? "Requesting new code"
|
||||
: "Resend"}
|
||||
</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>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { useEffect, useState, FC } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// images
|
||||
import githubBlackImage from "public/logos/github-black.svg";
|
||||
import githubWhiteImage from "public/logos/github-white.svg";
|
||||
|
||||
type Props = {
|
||||
handleSignIn: React.Dispatch<string>;
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
export const GitHubSignInButton: FC<Props> = (props) => {
|
||||
const { handleSignIn, clientId } = props;
|
||||
// states
|
||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { code } = router.query;
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (code && !gitCode) {
|
||||
setGitCode(code.toString());
|
||||
handleSignIn(code.toString());
|
||||
}
|
||||
}, [code, gitCode, handleSignIn]);
|
||||
|
||||
useEffect(() => {
|
||||
const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
setLoginCallBackURL(`${origin}/` as any);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Link
|
||||
className="w-full"
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
>
|
||||
<button className="flex h-[46px] w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80">
|
||||
<Image
|
||||
src={theme === "dark" ? githubWhiteImage : githubBlackImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
<span>Sign in with GitHub</span>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { FC, useEffect, useRef, useCallback, useState } from "react";
|
||||
import Script from "next/script";
|
||||
|
||||
type Props = {
|
||||
clientId: string;
|
||||
handleSignIn: React.Dispatch<any>;
|
||||
};
|
||||
|
||||
export const GoogleSignInButton: FC<Props> = (props) => {
|
||||
const { handleSignIn, clientId } = props;
|
||||
// refs
|
||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||
// states
|
||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||
|
||||
const loadScript = useCallback(() => {
|
||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||
|
||||
(window as any)?.google?.accounts.id.initialize({
|
||||
client_id: clientId,
|
||||
callback: handleSignIn,
|
||||
});
|
||||
|
||||
try {
|
||||
(window as any)?.google?.accounts.id.renderButton(
|
||||
googleSignInButton.current,
|
||||
{
|
||||
type: "standard",
|
||||
theme: "outline",
|
||||
size: "large",
|
||||
logo_alignment: "center",
|
||||
text: "signin_with",
|
||||
width: 384,
|
||||
} as GsiButtonConfiguration // customization attributes
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
(window as any)?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
||||
|
||||
setGsiScriptLoaded(true);
|
||||
}, [handleSignIn, gsiScriptLoaded, clientId]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((window as any)?.google?.accounts?.id) {
|
||||
loadScript();
|
||||
}
|
||||
return () => {
|
||||
(window as any)?.google?.accounts.id.cancel();
|
||||
};
|
||||
}, [loadScript]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
|
||||
<div className="!w-full overflow-hidden rounded" id="googleSignInButton" ref={googleSignInButton} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
export * from "./github-sign-in";
|
||||
export * from "./google-sign-in";
|
||||
export * from "./oauth";
|
||||
export * from "./onboarding-form";
|
||||
export * from "./user-logged-in";
|
||||
export * from "./sign-in-forms";
|
||||
export * from "./auth-forms";
|
||||
export * from "./password-strength-meter";
|
||||
export * from "./terms-and-conditions";
|
||||
export * from "./user-image-upload-modal";
|
||||
|
|
|
|||
39
space/components/accounts/oauth/github-button.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { FC } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// images
|
||||
import githubLightModeImage from "/public/logos/github-black.png";
|
||||
import githubDarkModeImage from "/public/logos/github-dark.svg";
|
||||
|
||||
export type GithubOAuthButtonProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const GithubOAuthButton: FC<GithubOAuthButtonProps> = (props) => {
|
||||
const { text } = props;
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const handleSignIn = () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/spaces/github/`);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex h-[42px] w-full items-center justify-center gap-2 rounded border px-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-onboarding-background-300 ${
|
||||
resolvedTheme === "dark" ? "border-[#43484F] bg-[#2F3135]" : "border-[#D9E4FF]"
|
||||
}`}
|
||||
onClick={handleSignIn}
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
<span className="text-onboarding-text-200">{text}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
33
space/components/accounts/oauth/google-button.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { FC } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// images
|
||||
import GoogleLogo from "/public/logos/google-logo.svg";
|
||||
|
||||
export type GoogleOAuthButtonProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const GoogleOAuthButton: FC<GoogleOAuthButtonProps> = (props) => {
|
||||
const { text } = props;
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const handleSignIn = () => {
|
||||
window.location.assign(`${API_BASE_URL}/auth/spaces/google/`);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex h-[42px] w-full items-center justify-center gap-2 rounded border px-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-onboarding-background-300 ${
|
||||
resolvedTheme === "dark" ? "border-[#43484F] bg-[#2F3135]" : "border-[#D9E4FF]"
|
||||
}`}
|
||||
onClick={handleSignIn}
|
||||
>
|
||||
<Image src={GoogleLogo} height={18} width={18} alt="Google Logo" />
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
3
space/components/accounts/oauth/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./oauth-options";
|
||||
export * from "./google-button";
|
||||
export * from "./github-button";
|
||||
28
space/components/accounts/oauth/oauth-options.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { GithubOAuthButton, GoogleOAuthButton } from "@/components/accounts";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
export const OAuthOptions: React.FC = observer(() => {
|
||||
// hooks
|
||||
const { instance } = useInstance();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto mt-4 flex items-center sm:w-96">
|
||||
<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`}>
|
||||
{instance?.config?.is_google_enabled && (
|
||||
<div className="flex h-[42px] items-center !overflow-hidden">
|
||||
<GoogleOAuthButton text="SignIn with Google" />
|
||||
</div>
|
||||
)}
|
||||
{instance?.config?.is_github_enabled && <GithubOAuthButton text="SignIn with Github" />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,182 +1,209 @@
|
|||
import { useEffect, Fragment } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// mobx store
|
||||
import { Button, Input } from "@plane/ui";
|
||||
import { USER_ROLES } from "@/constants/workspace";
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
// constants
|
||||
// hooks
|
||||
import { UserService } from "@/services/user.service";
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
// 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 { useMobxStore } from "@/hooks/store";
|
||||
// services
|
||||
import fileService from "@/services/file.service";
|
||||
|
||||
const defaultValues = {
|
||||
type TProfileSetupFormValues = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
avatar?: string | null;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<TProfileSetupFormValues> = {
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
role: "",
|
||||
avatar: "",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
user?: any;
|
||||
user?: IUser;
|
||||
finishOnboarding: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const OnBoardingForm: React.FC<Props> = observer(({ user }) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const { next_path } = router.query;
|
||||
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
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 {
|
||||
register,
|
||||
user: { updateCurrentUser },
|
||||
} = useMobxStore();
|
||||
// form info
|
||||
const {
|
||||
getValues,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
} = useForm<TProfileSetupFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
first_name: user?.first_name,
|
||||
last_name: user?.last_name,
|
||||
avatar: user?.avatar,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: any) => {
|
||||
const payload = {
|
||||
...formData,
|
||||
onboarding_step: {
|
||||
...user.onboarding_step,
|
||||
profile_complete: true,
|
||||
},
|
||||
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,
|
||||
};
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
await userService.updateMe(payload).then((response) => {
|
||||
userStore.setCurrentUser(response);
|
||||
router.push(next_path?.toString() || "/");
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Details updated successfully.",
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
reset({
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
role: user.role,
|
||||
});
|
||||
}
|
||||
}, [user, reset]);
|
||||
const isButtonDisabled = useMemo(() => (isValid && !isSubmitting ? false : true), [isSubmitting, isValid]);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="h-full w-full space-y-7 overflow-y-auto sm:flex sm:flex-col sm:items-start sm:justify-center sm:space-y-10"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="relative sm:text-lg">
|
||||
<div className="absolute -left-3 -top-1 text-gray-800">{'"'}</div>
|
||||
<h5>Hey there 👋🏻</h5>
|
||||
<h5 className="mb-6 mt-5">Let{"'"}s get you onboard!</h5>
|
||||
<h4 className="text-xl font-semibold sm:text-2xl">Set up your Plane profile.</h4>
|
||||
<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="space-y-7 sm:w-3/4 md:w-2/5">
|
||||
<div className="space-y-1 text-sm">
|
||||
<label htmlFor="firstName">First Name</label>
|
||||
<Input
|
||||
id="firstName"
|
||||
autoComplete="off"
|
||||
className="w-full"
|
||||
placeholder="Enter your first name..."
|
||||
{...register("first_name", {
|
||||
<div className="flex gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" 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 && <div className="text-sm text-red-500">{errors.first_name.message}</div>}
|
||||
{errors.first_name && <span className="text-sm text-red-500">{errors.first_name.message}</span>}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<label htmlFor="lastName">Last Name</label>
|
||||
<Input
|
||||
id="lastName"
|
||||
autoComplete="off"
|
||||
className="w-full"
|
||||
placeholder="Enter your last name..."
|
||||
{...register("last_name", {
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" 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 && <div className="text-sm text-red-500">{errors.last_name.message}</div>}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<span>What{"'"}s your role?</span>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
rules={{ required: "This field is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox as="div" value={value} onChange={onChange} className="relative flex-shrink-0 text-left">
|
||||
<Listbox.Button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-1 rounded-md border border-custom-border-300 px-3 py-2 text-sm shadow-sm duration-300 focus:outline-none`}
|
||||
>
|
||||
<span className={value ? "" : "text-custom-text-400"}>{value || "Select your role..."}</span>
|
||||
<ChevronDown className="h-3 w-3" aria-hidden="true" strokeWidth={2} />
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Listbox.Options
|
||||
className={`absolute left-0 z-10 mt-1 max-h-36 w-full origin-top-left overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-90 text-xs shadow-lg focus:outline-none`}
|
||||
>
|
||||
<div className="space-y-1 p-2">
|
||||
{USER_ROLES.map((role) => (
|
||||
<Listbox.Option
|
||||
key={role.value}
|
||||
value={role.value}
|
||||
className={({ active, selected }) =>
|
||||
`cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
||||
active || selected ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{role.label}</span>
|
||||
</div>
|
||||
{selected && <Check className="h-3 w-3 flex-shrink-0" strokeWidth={2} />}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
{errors.role && <span className="text-sm text-red-500">{errors.role.message}</span>}
|
||||
</div>
|
||||
{errors.last_name && <span className="text-sm text-red-500">{errors.last_name.message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" type="submit" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Continue"}
|
||||
<Button variant="primary" type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
|
|
|||
67
space/components/accounts/password-strength-meter.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// icons
|
||||
import { CircleCheck } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getPasswordStrength } from "@/helpers/password.helper";
|
||||
|
||||
type Props = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export const PasswordStrengthMeter: React.FC<Props> = (props: Props) => {
|
||||
const { password } = props;
|
||||
|
||||
const strength = getPasswordStrength(password);
|
||||
let bars = [];
|
||||
let text = "";
|
||||
let textColor = "";
|
||||
|
||||
if (password.length === 0) {
|
||||
bars = [`bg-[#F0F0F3]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`];
|
||||
text = "Password requirements";
|
||||
} else if (password.length < 8) {
|
||||
bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`];
|
||||
text = "Password is too short";
|
||||
textColor = `text-[#DC3E42]`;
|
||||
} else if (strength < 3) {
|
||||
bars = [`bg-[#FFBA18]`, `bg-[#FFBA18]`, `bg-[#F0F0F3]`];
|
||||
text = "Password is weak";
|
||||
textColor = `text-[#FFBA18]`;
|
||||
} else {
|
||||
bars = [`bg-[#3E9B4F]`, `bg-[#3E9B4F]`, `bg-[#3E9B4F]`];
|
||||
text = "Password is strong";
|
||||
textColor = `text-[#3E9B4F]`;
|
||||
}
|
||||
|
||||
const criteria = [
|
||||
{ label: "Min 8 characters", isValid: password.length >= 8 },
|
||||
{ label: "Min 1 upper-case letter", isValid: /[A-Z]/.test(password) },
|
||||
{ label: "Min 1 number", isValid: /\d/.test(password) },
|
||||
{ label: "Min 1 special character", isValid: /[!@#$%^&*]/.test(password) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full p-1">
|
||||
<div className="flex w-full gap-1.5">
|
||||
{bars.map((color, index) => (
|
||||
<div key={index} className={cn("w-full h-1 rounded-full", color)} />
|
||||
))}
|
||||
</div>
|
||||
<p className={cn("text-xs font-medium py-1", textColor)}>{text}</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||
{criteria.map((criterion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs font-medium",
|
||||
criterion.isValid ? `text-[#3E9B4F]` : "text-custom-text-400"
|
||||
)}
|
||||
>
|
||||
<CircleCheck width={14} height={14} />
|
||||
{criterion.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import React, { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// services
|
||||
import { Button, Input } from "@plane/ui";
|
||||
import { ESignInSteps } from "@/components/accounts";
|
||||
import { checkEmailValidity } from "@/helpers/string.helper";
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
// helpers
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
handleStepChange: (step: ESignInSteps) => void;
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
isOnboarded: boolean;
|
||||
};
|
||||
|
||||
type TCreatePasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const defaultValues: TCreatePasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
export const CreatePasswordForm: React.FC<Props> = (props) => {
|
||||
const { email, handleSignInRedirection, isOnboarded } = props;
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
setFocus,
|
||||
handleSubmit,
|
||||
} = useForm<TCreatePasswordFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
email,
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const handleCreatePassword = async (formData: TCreatePasswordFormValues) => {
|
||||
const payload = {
|
||||
password: formData.password,
|
||||
};
|
||||
|
||||
await authService
|
||||
.setPassword(payload)
|
||||
.then(async () => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Password created successfully.",
|
||||
});
|
||||
await handleSignInRedirection();
|
||||
})
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("password");
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||
Get on your flight deck
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit(handleCreatePassword)} className="mx-auto mt-11 space-y-4 sm:w-96">
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: "Email is required",
|
||||
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<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 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Choose password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
minLength={8}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||
{isOnboarded ? "Go to board" : "Set up profile"}
|
||||
</Button>
|
||||
<p className="text-xs text-onboarding-text-200">
|
||||
When you click the button above, you agree with our{" "}
|
||||
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
||||
<span className="font-semibold underline">terms and conditions of service.</span>
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { XCircle } from "lucide-react";
|
||||
// services
|
||||
import { Button, Input } from "@plane/ui";
|
||||
import { ESignInSteps } from "@/components/accounts";
|
||||
import { checkEmailValidity } from "@/helpers/string.helper";
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
// helpers
|
||||
// types
|
||||
import { IEmailCheckData } from "types/auth";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
handleStepChange: (step: ESignInSteps) => void;
|
||||
updateEmail: (email: string) => void;
|
||||
};
|
||||
|
||||
type TEmailFormValues = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const EmailForm: React.FC<Props> = (props) => {
|
||||
const { handleStepChange, updateEmail } = props;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
} = useForm<TEmailFormValues>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (data: TEmailFormValues) => {
|
||||
const payload: IEmailCheckData = {
|
||||
email: data.email,
|
||||
};
|
||||
|
||||
// update the global email state
|
||||
updateEmail(data.email);
|
||||
|
||||
await authService
|
||||
.emailCheck(payload)
|
||||
.then((res) => {
|
||||
// if the password has been autoset, send the user to magic sign-in
|
||||
if (res.is_password_autoset) handleStepChange(ESignInSteps.UNIQUE_CODE);
|
||||
// if the password has not been autoset, send them to password sign-in
|
||||
else handleStepChange(ESignInSteps.PASSWORD);
|
||||
})
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("email");
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||
Get on your flight deck
|
||||
</h1>
|
||||
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
|
||||
Create or join a workspace. Start with your e-mail.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-8 space-y-4 sm:w-96">
|
||||
<div className="space-y-1">
|
||||
<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"
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => onChange("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export * from "./create-password";
|
||||
export * from "./email-form";
|
||||
export * from "./o-auth-options";
|
||||
export * from "./optional-set-password";
|
||||
export * from "./password";
|
||||
export * from "./root";
|
||||
export * from "./self-hosted-sign-in";
|
||||
export * from "./set-password-link";
|
||||
export * from "./unique-code";
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import { GitHubSignInButton, GoogleSignInButton } from "@/components/accounts";
|
||||
import { AppConfigService } from "@/services/app-config.service";
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
|
||||
type Props = {
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
const appConfig = new AppConfigService();
|
||||
|
||||
export const OAuthOptions: React.FC<Props> = observer((props) => {
|
||||
const { handleSignInRedirection } = props;
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: envConfig } = useSWR("APP_CONFIG", () => appConfig.envConfig());
|
||||
|
||||
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
||||
try {
|
||||
if (clientId && credential) {
|
||||
const socialAuthPayload = {
|
||||
medium: "google",
|
||||
credential,
|
||||
clientId,
|
||||
};
|
||||
const response = await authService.socialAuth(socialAuthPayload);
|
||||
|
||||
if (response) handleSignInRedirection();
|
||||
} else throw Error("Can't find credentials");
|
||||
} catch (err: any) {
|
||||
setToastAlert({
|
||||
title: "Error signing in!",
|
||||
type: "error",
|
||||
message: err?.error || "Something went wrong. Please try again later or contact the support team.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubSignIn = async (credential: string) => {
|
||||
try {
|
||||
if (envConfig && envConfig.github_client_id && credential) {
|
||||
const socialAuthPayload = {
|
||||
medium: "github",
|
||||
credential,
|
||||
clientId: envConfig.github_client_id,
|
||||
};
|
||||
const response = await authService.socialAuth(socialAuthPayload);
|
||||
|
||||
if (response) handleSignInRedirection();
|
||||
} else throw Error("Can't find credentials");
|
||||
} catch (err: any) {
|
||||
setToastAlert({
|
||||
title: "Error signing in!",
|
||||
type: "error",
|
||||
message: err?.error || "Something went wrong. Please try again later or contact the support team.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto mt-4 flex items-center sm:w-96">
|
||||
<hr className="w-full border-onboarding-border-100" />
|
||||
<p className="mx-3 flex-shrink-0 text-center text-sm text-onboarding-text-400">Or continue with</p>
|
||||
<hr className="w-full border-onboarding-border-100" />
|
||||
</div>
|
||||
<div className="mx-auto space-y-4 overflow-hidden pt-7 sm:w-96">
|
||||
{envConfig?.google_client_id && (
|
||||
<GoogleSignInButton clientId={envConfig?.google_client_id} handleSignIn={handleGoogleSignIn} />
|
||||
)}
|
||||
{envConfig?.github_client_id && (
|
||||
<GitHubSignInButton clientId={envConfig?.github_client_id} handleSignIn={handleGitHubSignIn} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { ESignInSteps } from "@/components/accounts";
|
||||
import { checkEmailValidity } from "@/helpers/string.helper";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
handleStepChange: (step: ESignInSteps) => void;
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
isOnboarded: boolean;
|
||||
};
|
||||
|
||||
export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
const { email, handleStepChange, handleSignInRedirection, isOnboarded } = props;
|
||||
// states
|
||||
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
email,
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const handleGoToWorkspace = async () => {
|
||||
setIsGoingToWorkspace(true);
|
||||
|
||||
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Set a password</h1>
|
||||
<p className="mt-2.5 px-20 text-center text-sm text-onboarding-text-200">
|
||||
If you{"'"}d like to do away with codes, set a password here.
|
||||
</p>
|
||||
|
||||
<form className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: "Email is required",
|
||||
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<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 text-onboarding-text-400"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => handleStepChange(ESignInSteps.CREATE_PASSWORD)}
|
||||
className="w-full"
|
||||
size="xl"
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create password
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
className="w-full"
|
||||
size="xl"
|
||||
onClick={handleGoToWorkspace}
|
||||
disabled={!isValid}
|
||||
loading={isGoingToWorkspace}
|
||||
>
|
||||
{isOnboarded ? "Go to board" : "Set up profile"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-onboarding-text-200">
|
||||
When you click{" "}
|
||||
<span className="text-custom-primary-100">{isOnboarded ? "Go to board" : "Set up profile"}</span> above, you
|
||||
agree with our{" "}
|
||||
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
||||
<span className="font-semibold underline">terms and conditions of service.</span>
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { XCircle } from "lucide-react";
|
||||
// services
|
||||
import { Button, Input } from "@plane/ui";
|
||||
import { ESignInSteps } from "@/components/accounts";
|
||||
import { checkEmailValidity } from "@/helpers/string.helper";
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
// helpers
|
||||
// types
|
||||
import { IPasswordSignInData } from "types/auth";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
updateEmail: (email: string) => void;
|
||||
handleStepChange: (step: ESignInSteps) => void;
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
};
|
||||
|
||||
type TPasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const defaultValues: TPasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const PasswordForm: React.FC<Props> = (props) => {
|
||||
const { email, updateEmail, handleStepChange, handleSignInRedirection } = props;
|
||||
// states
|
||||
const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false);
|
||||
const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false);
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { dirtyFields, errors, isSubmitting, isValid },
|
||||
getValues,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setFocus,
|
||||
} = useForm<TPasswordFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
email,
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (formData: TPasswordFormValues) => {
|
||||
updateEmail(formData.email);
|
||||
|
||||
const payload: IPasswordSignInData = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
};
|
||||
|
||||
await authService
|
||||
.passwordSignIn(payload)
|
||||
.then(async () => await handleSignInRedirection())
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleForgotPassword = async () => {
|
||||
const emailFormValue = getValues("email");
|
||||
|
||||
const isEmailValid = checkEmailValidity(emailFormValue);
|
||||
|
||||
if (!isEmailValid) {
|
||||
setError("email", { message: "Email is invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingResetPasswordLink(true);
|
||||
|
||||
authService
|
||||
.sendResetPasswordLink({ email: emailFormValue })
|
||||
.then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK))
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsSendingResetPasswordLink(false));
|
||||
};
|
||||
|
||||
const handleSendUniqueCode = async () => {
|
||||
const emailFormValue = getValues("email");
|
||||
|
||||
const isEmailValid = checkEmailValidity(emailFormValue);
|
||||
|
||||
if (!isEmailValid) {
|
||||
setError("email", { message: "Email is invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingUniqueCode(true);
|
||||
|
||||
await authService
|
||||
.generateUniqueCode({ email: emailFormValue })
|
||||
.then(() => handleStepChange(ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD))
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsSendingUniqueCode(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("password");
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-semibold text-onboarding-text-100">
|
||||
Get on your flight deck
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-11 space-y-4 sm:w-96">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: "Email is required",
|
||||
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
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"
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => onChange("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: dirtyFields.email ? false : "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="w-full text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForgotPassword}
|
||||
className={`text-xs font-medium ${
|
||||
isSendingResetPasswordLink ? "text-onboarding-text-300" : "text-custom-primary-100"
|
||||
}`}
|
||||
disabled={isSendingResetPasswordLink}
|
||||
>
|
||||
{isSendingResetPasswordLink ? "Sending link" : "Forgot your password?"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSendUniqueCode}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="xl"
|
||||
loading={isSendingUniqueCode}
|
||||
>
|
||||
{isSendingUniqueCode ? "Sending code" : "Use unique code"}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline-primary"
|
||||
className="w-full"
|
||||
size="xl"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Go to board
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-onboarding-text-200">
|
||||
When you click <span className="text-custom-primary-100">Go to board</span> above, you agree with our{" "}
|
||||
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
||||
<span className="font-semibold underline">terms and conditions of service.</span>
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import {
|
||||
EmailForm,
|
||||
UniqueCodeForm,
|
||||
PasswordForm,
|
||||
SetPasswordLink,
|
||||
OAuthOptions,
|
||||
OptionalSetPasswordForm,
|
||||
CreatePasswordForm,
|
||||
SelfHostedSignInForm,
|
||||
} from "@/components/accounts";
|
||||
import { LatestFeatureBlock } from "@/components/common";
|
||||
import { AppConfigService } from "@/services/app-config.service";
|
||||
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||
// services
|
||||
// components
|
||||
|
||||
export enum ESignInSteps {
|
||||
EMAIL = "EMAIL",
|
||||
PASSWORD = "PASSWORD",
|
||||
SET_PASSWORD_LINK = "SET_PASSWORD_LINK",
|
||||
UNIQUE_CODE = "UNIQUE_CODE",
|
||||
OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD",
|
||||
CREATE_PASSWORD = "CREATE_PASSWORD",
|
||||
USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD",
|
||||
}
|
||||
|
||||
const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD];
|
||||
|
||||
const appConfig = new AppConfigService();
|
||||
|
||||
export const SignInRoot = observer(() => {
|
||||
// states
|
||||
const [signInStep, setSignInStep] = useState<ESignInSteps>(ESignInSteps.EMAIL);
|
||||
const [email, setEmail] = useState("");
|
||||
const [isOnboarded, setIsOnboarded] = useState(false);
|
||||
// sign in redirection hook
|
||||
const { handleRedirection } = useSignInRedirection();
|
||||
|
||||
const { data: envConfig } = useSWR("APP_CONFIG", () => appConfig.envConfig());
|
||||
|
||||
const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id);
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex flex-col">
|
||||
{envConfig?.is_self_managed ? (
|
||||
<SelfHostedSignInForm
|
||||
email={email}
|
||||
updateEmail={(newEmail) => setEmail(newEmail)}
|
||||
handleSignInRedirection={handleRedirection}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{signInStep === ESignInSteps.EMAIL && (
|
||||
<EmailForm
|
||||
handleStepChange={(step) => setSignInStep(step)}
|
||||
updateEmail={(newEmail) => setEmail(newEmail)}
|
||||
/>
|
||||
)}
|
||||
{signInStep === ESignInSteps.PASSWORD && (
|
||||
<PasswordForm
|
||||
email={email}
|
||||
updateEmail={(newEmail) => setEmail(newEmail)}
|
||||
handleStepChange={(step) => setSignInStep(step)}
|
||||
handleSignInRedirection={handleRedirection}
|
||||
/>
|
||||
)}
|
||||
{signInStep === ESignInSteps.SET_PASSWORD_LINK && (
|
||||
<SetPasswordLink email={email} updateEmail={(newEmail) => setEmail(newEmail)} />
|
||||
)}
|
||||
{signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && (
|
||||
<UniqueCodeForm
|
||||
email={email}
|
||||
updateEmail={(newEmail) => setEmail(newEmail)}
|
||||
handleStepChange={(step) => setSignInStep(step)}
|
||||
handleSignInRedirection={handleRedirection}
|
||||
submitButtonLabel="Go to board"
|
||||
showTermsAndConditions
|
||||
updateUserOnboardingStatus={(value) => setIsOnboarded(value)}
|
||||
/>
|
||||
)}
|
||||
{signInStep === ESignInSteps.UNIQUE_CODE && (
|
||||
<UniqueCodeForm
|
||||
email={email}
|
||||
updateEmail={(newEmail) => setEmail(newEmail)}
|
||||
handleStepChange={(step) => setSignInStep(step)}
|
||||
handleSignInRedirection={handleRedirection}
|
||||
updateUserOnboardingStatus={(value) => setIsOnboarded(value)}
|
||||
/>
|
||||
)}
|
||||
{signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && (
|
||||
<OptionalSetPasswordForm
|
||||
email={email}
|
||||
handleStepChange={(step) => setSignInStep(step)}
|
||||
handleSignInRedirection={handleRedirection}
|
||||
isOnboarded={isOnboarded}
|
||||
/>
|
||||
)}
|
||||
{signInStep === ESignInSteps.CREATE_PASSWORD && (
|
||||
<CreatePasswordForm
|
||||
email={email}
|
||||
handleStepChange={(step) => setSignInStep(step)}
|
||||
handleSignInRedirection={handleRedirection}
|
||||
isOnboarded={isOnboarded}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isOAuthEnabled &&
|
||||
!OAUTH_HIDDEN_STEPS.includes(signInStep) &&
|
||||
signInStep !== ESignInSteps.CREATE_PASSWORD &&
|
||||
signInStep !== ESignInSteps.PASSWORD && <OAuthOptions handleSignInRedirection={handleRedirection} />}
|
||||
<LatestFeatureBlock />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import React, { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { XCircle } from "lucide-react";
|
||||
// services
|
||||
import { Button, Input } from "@plane/ui";
|
||||
import { checkEmailValidity } from "@/helpers/string.helper";
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
// helpers
|
||||
// types
|
||||
import { IPasswordSignInData } from "types/auth";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
updateEmail: (email: string) => void;
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
};
|
||||
|
||||
type TPasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const defaultValues: TPasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
||||
const { email, updateEmail, handleSignInRedirection } = props;
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { dirtyFields, errors, isSubmitting },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
} = useForm<TPasswordFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
email,
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (formData: TPasswordFormValues) => {
|
||||
const payload: IPasswordSignInData = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
};
|
||||
|
||||
updateEmail(formData.email);
|
||||
|
||||
await authService
|
||||
.passwordSignIn(payload)
|
||||
.then(async () => await handleSignInRedirection())
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("email");
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-semibold text-onboarding-text-100">
|
||||
Get on your flight deck
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-11 space-y-4 sm:w-96">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: "Email is required",
|
||||
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
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"
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => onChange("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: dirtyFields.email ? false : "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="xl" loading={isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
<p className="text-xs text-onboarding-text-200">
|
||||
When you click the button above, you agree with our{" "}
|
||||
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
||||
<span className="font-semibold underline">terms and conditions of service.</span>
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// services
|
||||
import { Button, Input } from "@plane/ui";
|
||||
import { checkEmailValidity } from "@/helpers/string.helper";
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
// helpers
|
||||
// types
|
||||
import { IEmailCheckData } from "types/auth";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
updateEmail: (email: string) => void;
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const SetPasswordLink: React.FC<Props> = (props) => {
|
||||
const { email, updateEmail } = props;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
handleSubmit,
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
email,
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const handleSendNewLink = async (formData: { email: string }) => {
|
||||
updateEmail(formData.email);
|
||||
|
||||
const payload: IEmailCheckData = {
|
||||
email: formData.email,
|
||||
};
|
||||
|
||||
await authService
|
||||
.sendResetPasswordLink(payload)
|
||||
.then(() =>
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "We have sent a new link to your email.",
|
||||
})
|
||||
)
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||
Get on your flight deck
|
||||
</h1>
|
||||
<p className="mt-2.5 px-20 text-center text-sm text-onboarding-text-200">
|
||||
We have sent a link to <span className="font-semibold text-custom-primary-100">{email},</span> so you can set a
|
||||
password
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit(handleSendNewLink)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||
<div className="space-y-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: "Email is required",
|
||||
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.email)}
|
||||
placeholder="name@company.com"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||
{isSubmitting ? "Sending new link" : "Get link again"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { CornerDownLeft, XCircle } from "lucide-react";
|
||||
// services
|
||||
import { Button, Input } from "@plane/ui";
|
||||
import { ESignInSteps } from "@/components/accounts";
|
||||
import { checkEmailValidity } from "@/helpers/string.helper";
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
import { UserService } from "@/services/user.service";
|
||||
// hooks
|
||||
import useTimer from "hooks/use-timer";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
// helpers
|
||||
// types
|
||||
import { IEmailCheckData, IMagicSignInData } from "types/auth";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
updateEmail: (email: string) => void;
|
||||
handleStepChange: (step: ESignInSteps) => void;
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
submitButtonLabel?: string;
|
||||
showTermsAndConditions?: boolean;
|
||||
updateUserOnboardingStatus: (value: boolean) => void;
|
||||
};
|
||||
|
||||
type TUniqueCodeFormValues = {
|
||||
email: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
const defaultValues: TUniqueCodeFormValues = {
|
||||
email: "",
|
||||
token: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
const userService = new UserService();
|
||||
|
||||
export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
const {
|
||||
email,
|
||||
updateEmail,
|
||||
handleStepChange,
|
||||
handleSignInRedirection,
|
||||
submitButtonLabel = "Continue",
|
||||
showTermsAndConditions = false,
|
||||
updateUserOnboardingStatus,
|
||||
} = props;
|
||||
// states
|
||||
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// timer
|
||||
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { dirtyFields, errors, isSubmitting, isValid },
|
||||
getValues,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setFocus,
|
||||
} = useForm<TUniqueCodeFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
email,
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const handleUniqueCodeSignIn = async (formData: TUniqueCodeFormValues) => {
|
||||
const payload: IMagicSignInData = {
|
||||
email: formData.email,
|
||||
key: `magic_${formData.email}`,
|
||||
token: formData.token,
|
||||
};
|
||||
|
||||
await authService
|
||||
.magicSignIn(payload)
|
||||
.then(async () => {
|
||||
const currentUser = await userService.currentUser();
|
||||
|
||||
updateUserOnboardingStatus(currentUser.onboarding_step.profile_complete ?? false);
|
||||
|
||||
if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD);
|
||||
else await handleSignInRedirection();
|
||||
})
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
|
||||
const payload: IEmailCheckData = {
|
||||
email: formData.email,
|
||||
};
|
||||
|
||||
await authService
|
||||
.generateUniqueCode(payload)
|
||||
.then(() => {
|
||||
setResendCodeTimer(30);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "A new unique code has been sent to your email.",
|
||||
});
|
||||
|
||||
reset({
|
||||
email: formData.email,
|
||||
token: "",
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: TUniqueCodeFormValues) => {
|
||||
updateEmail(formData.email);
|
||||
|
||||
if (dirtyFields.email) await handleSendNewCode(formData);
|
||||
else await handleUniqueCodeSignIn(formData);
|
||||
};
|
||||
|
||||
const handleRequestNewCode = async () => {
|
||||
setIsRequestingNewCode(true);
|
||||
|
||||
await handleSendNewCode(getValues())
|
||||
.then(() => setResendCodeTimer(30))
|
||||
.finally(() => setIsRequestingNewCode(false));
|
||||
};
|
||||
|
||||
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
|
||||
const hasEmailChanged = dirtyFields.email;
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("token");
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||
Get on your flight deck
|
||||
</h1>
|
||||
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
|
||||
Paste the code you got at <span className="font-semibold text-custom-primary-100">{email}</span> below.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||
<div>
|
||||
<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}
|
||||
onBlur={() => {
|
||||
if (hasEmailChanged) handleSendNewCode(getValues());
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => onChange("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{hasEmailChanged && (
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-1.5 flex items-center gap-1 border-none bg-transparent text-xs text-onboarding-text-300 outline-none"
|
||||
>
|
||||
Hit <CornerDownLeft className="h-2.5 w-2.5" /> or <span className="italic">Tab</span> to get a new code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="token"
|
||||
rules={{
|
||||
required: hasEmailChanged ? false : "Code is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.token)}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="w-full text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRequestNewCode}
|
||||
className={`text-xs ${
|
||||
isRequestNewCodeDisabled
|
||||
? "text-onboarding-text-300"
|
||||
: "text-onboarding-text-200 hover:text-custom-primary-100"
|
||||
}`}
|
||||
disabled={isRequestNewCodeDisabled}
|
||||
>
|
||||
{resendTimerCode > 0
|
||||
? `Request new code in ${resendTimerCode}s`
|
||||
: isRequestingNewCode
|
||||
? "Requesting new code"
|
||||
: "Request new code"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="xl"
|
||||
disabled={!isValid || hasEmailChanged}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{submitButtonLabel}
|
||||
</Button>
|
||||
{showTermsAndConditions && (
|
||||
<p className="text-xs text-onboarding-text-200">
|
||||
When you click the button above, you agree with our{" "}
|
||||
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
||||
<span className="font-semibold underline">terms and conditions of service.</span>
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
31
space/components/accounts/terms-and-conditions.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React, { FC } from "react";
|
||||
import Link from "next/link";
|
||||
import { EAuthModes } from "./auth-forms";
|
||||
|
||||
type Props = {
|
||||
mode: EAuthModes | null;
|
||||
};
|
||||
|
||||
export const TermsAndConditions: FC<Props> = (props) => {
|
||||
const { mode } = 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"}
|
||||
<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>{" "}
|
||||
and{" "}
|
||||
<Link href="https://plane.so/legals/privacy-policy" target="_blank" rel="noopener noreferrer">
|
||||
<span className="text-sm font-medium underline hover:cursor-pointer">Privacy Policy</span>
|
||||
</Link>
|
||||
{"."}
|
||||
</p>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
185
space/components/accounts/user-image-upload-modal.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
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 { instance } = useInstance();
|
||||
|
||||
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
|
||||
},
|
||||
maxSize: instance?.config?.file_size_limit ?? 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
import Image from "next/image";
|
||||
|
||||
// mobx
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
// 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";
|
||||
|
||||
export const UserLoggedIn = () => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
const user = userStore.currentUser;
|
||||
const { data: user } = useUser();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col">
|
||||
<div className="relative flex w-full items-center justify-between gap-4 border-b border-custom-border-200 px-6 py-5">
|
||||
|
|
|
|||
1
space/components/instance/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./not-ready-view";
|
||||
42
space/components/instance/not-ready-view.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { FC } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// icons
|
||||
import { UserCog2 } from "lucide-react";
|
||||
// ui
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
// images
|
||||
import instanceNotReady from "public/instance/plane-instance-not-ready.webp";
|
||||
import PlaneBlackLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg";
|
||||
import PlaneWhiteLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg";
|
||||
|
||||
export const InstanceNotReady: FC = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const planeLogo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo;
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full overflow-y-auto bg-onboarding-gradient-100">
|
||||
<div className="h-full w-full pt-24">
|
||||
<div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-100 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3">
|
||||
<div className="relative h-full rounded-t-md bg-onboarding-gradient-200 px-7 sm:px-0">
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Image src={planeLogo} className="h-[44px] w-full" alt="Plane logo" />
|
||||
</div>
|
||||
<div className="mt-20">
|
||||
<Image src={instanceNotReady} className="w-full" alt="Instance not ready" />
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-5 py-12 pb-20">
|
||||
<h3 className="text-2xl font-medium">Your Plane instance isn{"'"}t ready yet</h3>
|
||||
<p className="text-sm">Ask your Instance Admin to complete set-up first.</p>
|
||||
<a href="/god-mode" className={`${getButtonStyling("primary", "md")} mt-4`}>
|
||||
<UserCog2 className="h-3.5 w-3.5" />
|
||||
Get started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,11 +6,11 @@ import { useRouter } from "next/router";
|
|||
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";
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
|
||||
// components
|
||||
// interfaces
|
||||
import { RootStore } from "@/store/root";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { IIssue } from "types/issue";
|
||||
|
||||
export const IssueKanBanBlock = observer(({ issue }: { issue: IIssue }) => {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { StateGroupIcon } from "@plane/ui";
|
|||
import { issueGroupFilter } from "@/constants/data";
|
||||
// ui
|
||||
// mobx hook
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { RootStore } from "@/store/root";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { IIssueState } from "types/issue";
|
||||
|
||||
export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) => {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { IssueKanBanHeader } from "@/components/issues/board-views/kanban/header
|
|||
import { Icon } from "@/components/ui";
|
||||
// interfaces
|
||||
// mobx hook
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { RootStore } from "@/store/root";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { IIssueState, IIssue } from "types/issue";
|
||||
|
||||
export const IssueKanbanView = observer(() => {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ 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
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
// interfaces
|
||||
import { RootStore } from "@/store/root";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { IIssue } from "types/issue";
|
||||
// store
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { StateGroupIcon } from "@plane/ui";
|
|||
// constants
|
||||
import { issueGroupFilter } from "@/constants/data";
|
||||
// mobx hook
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { RootStore } from "@/store/root";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { IIssueState } from "types/issue";
|
||||
|
||||
export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { IssueListBlock } from "@/components/issues/board-views/list/block";
|
|||
import { IssueListHeader } from "@/components/issues/board-views/list/header";
|
||||
// interfaces
|
||||
// mobx hook
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
// store
|
||||
import { RootStore } from "@/store/root";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { IIssueState, IIssue } from "types/issue";
|
||||
|
||||
export const IssueListView = observer(() => {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import { observer } from "mobx-react-lite";
|
|||
import { useRouter } from "next/router";
|
||||
// components
|
||||
// store
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
import { IIssueFilterOptions } from "@/store/issues/types";
|
||||
import { RootStore } from "@/store/root";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { AppliedFiltersList } from "./filters-list";
|
||||
|
||||
export const IssueAppliedFilters: FC = observer(() => {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { FC, useCallback } from "react";
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/store/issues/helpers";
|
||||
import { IIssueFilterOptions } from "@/store/issues/types";
|
||||
import { RootStore } from "@/store/root";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { FiltersDropdown } from "./helpers/dropdown";
|
||||
import { FilterSelection } from "./selection";
|
||||
// types
|
||||
|
|
|
|||
|
|
@ -7,21 +7,20 @@ import { Briefcase } from "lucide-react";
|
|||
import { Avatar, Button } from "@plane/ui";
|
||||
import { ProjectLogo } from "@/components/common";
|
||||
import { IssueFiltersDropdown } from "@/components/issues/filters";
|
||||
// ui
|
||||
// lib
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
// hooks
|
||||
import { useMobxStore, useUser } from "@/hooks/store";
|
||||
// store
|
||||
import { RootStore } from "@/store/root";
|
||||
import { TIssueBoardKeys } from "types/issue";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { TIssueBoardKeys } from "@/types/issue";
|
||||
import { NavbarIssueBoardView } from "./issue-board-view";
|
||||
import { NavbarTheme } from "./theme";
|
||||
|
||||
const IssueNavbar = observer(() => {
|
||||
const {
|
||||
project: projectStore,
|
||||
user: userStore,
|
||||
issuesFilter: { updateFilters },
|
||||
}: RootStore = useMobxStore();
|
||||
const { data: user } = useUser();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug, board, peekId, states, priorities, labels } = router.query as {
|
||||
|
|
@ -34,8 +33,6 @@ const IssueNavbar = observer(() => {
|
|||
labels: string;
|
||||
};
|
||||
|
||||
const user = userStore?.currentUser;
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace_slug && project_slug) {
|
||||
projectStore.fetchProjectSettings(workspace_slug.toString(), project_slug.toString());
|
||||
|
|
@ -142,7 +139,7 @@ const IssueNavbar = observer(() => {
|
|||
|
||||
{user ? (
|
||||
<div className="flex items-center gap-2 rounded border border-custom-border-200 p-2">
|
||||
<Avatar name={user?.display_name} src={user?.avatar} shape="square" size="sm" />
|
||||
<Avatar name={user?.display_name} src={user?.avatar ?? undefined} shape="square" size="sm" />
|
||||
<h6 className="text-xs font-medium">{user.display_name}</h6>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { useRouter } from "next/router";
|
|||
// constants
|
||||
import { issueViews } from "@/constants/data";
|
||||
// mobx
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { RootStore } from "@/store/root";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { TIssueBoardKeys } from "types/issue";
|
||||
|
||||
export const NavbarIssueBoardView = observer(() => {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ import { useForm, Controller } from "react-hook-form";
|
|||
import { EditorRefApi } from "@plane/lite-text-editor";
|
||||
import { LiteTextEditor } from "@/components/editor/lite-text-editor";
|
||||
// hooks
|
||||
import { useMobxStore, useUser } from "@/hooks/store";
|
||||
import useToast from "@/hooks/use-toast";
|
||||
// lib
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
// types
|
||||
import { Comment } from "@/types/issue";
|
||||
|
||||
|
|
@ -29,7 +28,8 @@ export const AddComment: React.FC<Props> = observer(() => {
|
|||
const { workspace_slug, project_slug } = router.query;
|
||||
// store hooks
|
||||
const { project } = useMobxStore();
|
||||
const { user: userStore, issueDetails: issueDetailStore } = useMobxStore();
|
||||
const { issueDetails: issueDetailStore } = useMobxStore();
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const workspaceId = project.workspace?.id;
|
||||
const issueId = issueDetailStore.peekId;
|
||||
|
|
@ -62,6 +62,7 @@ export const AddComment: React.FC<Props> = observer(() => {
|
|||
);
|
||||
};
|
||||
|
||||
// TODO: on click if he user is not logged in redirect to login page
|
||||
return (
|
||||
<div>
|
||||
<div className="issue-comments-section">
|
||||
|
|
@ -70,7 +71,9 @@ export const AddComment: React.FC<Props> = observer(() => {
|
|||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<LiteTextEditor
|
||||
onEnterKeyPress={(e) => userStore.requiredLogin(() => handleSubmit(onSubmit)(e))}
|
||||
onEnterKeyPress={(e) => {
|
||||
if (currentUser) handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
workspaceId={workspaceId as string}
|
||||
workspaceSlug={workspace_slug as string}
|
||||
ref={editorRef}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor";
|
|||
import { CommentReactions } from "@/components/issues/peek-overview";
|
||||
// helpers
|
||||
import { timeAgo } from "@/helpers/date-time.helper";
|
||||
// mobx store
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
// hooks
|
||||
import { useMobxStore, useUser } from "@/hooks/store";
|
||||
// store
|
||||
import { RootStore } from "@/store/root";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
// types
|
||||
import { Comment } from "@/types/issue";
|
||||
|
||||
|
|
@ -27,7 +27,8 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
|||
const workspaceId = project.workspace?.id;
|
||||
|
||||
// store
|
||||
const { user: userStore, issueDetails: issueDetailStore } = useMobxStore();
|
||||
const { issueDetails: issueDetailStore } = useMobxStore();
|
||||
const { data: currentUser } = useUser();
|
||||
// states
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
// refs
|
||||
|
|
@ -139,7 +140,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{userStore?.currentUser?.id === comment?.actor_detail?.id && (
|
||||
{currentUser?.id === comment?.actor_detail?.id && (
|
||||
<Menu as="div" className="relative w-min text-left">
|
||||
<Menu.Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import React from "react";
|
||||
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// ui
|
||||
import { ReactionSelector } from "@/components/ui";
|
||||
// helpers
|
||||
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
// hooks
|
||||
import { useMobxStore, useUser } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
commentId: string;
|
||||
|
|
@ -20,12 +19,11 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
|
|||
|
||||
const router = useRouter();
|
||||
const { workspace_slug } = router.query;
|
||||
|
||||
const { issueDetails: issueDetailsStore, user: userStore } = useMobxStore();
|
||||
// hooks
|
||||
const { issueDetails: issueDetailsStore } = useMobxStore();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const peekId = issueDetailsStore.peekId;
|
||||
const user = userStore.currentUser;
|
||||
|
||||
const commentReactions = peekId
|
||||
? issueDetailsStore.details[peekId].comments.find((c) => c.id === commentId)?.comment_reactions
|
||||
: [];
|
||||
|
|
@ -64,13 +62,13 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
|
|||
else handleAddReaction(reactionHex);
|
||||
};
|
||||
|
||||
// TODO: on onclick redirect to login page if the user is not logged in
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<ReactionSelector
|
||||
onSelect={(value) => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleReactionClick(value);
|
||||
});
|
||||
if (user) handleReactionClick(value);
|
||||
// userStore.requiredLogin(() => {});
|
||||
}}
|
||||
position="top"
|
||||
selected={userReactions?.map((r) => r.reaction)}
|
||||
|
|
@ -98,14 +96,11 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleReactionClick(reaction);
|
||||
});
|
||||
if (user) handleReactionClick(reaction);
|
||||
// userStore.requiredLogin(() => {});
|
||||
}}
|
||||
className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${
|
||||
commentReactions?.some(
|
||||
(r) => r.actor_detail.id === userStore.currentUser?.id && r.reaction === reaction
|
||||
)
|
||||
commentReactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction)
|
||||
? "bg-custom-primary-100/10"
|
||||
: "bg-custom-background-80"
|
||||
}`}
|
||||
|
|
@ -113,9 +108,7 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
|
|||
<span>{renderEmoji(reaction)}</span>
|
||||
<span
|
||||
className={
|
||||
commentReactions?.some(
|
||||
(r) => r.actor_detail.id === userStore.currentUser?.id && r.reaction === reaction
|
||||
)
|
||||
commentReactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction)
|
||||
? "text-custom-primary-100"
|
||||
: ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import { Icon } from "@/components/ui";
|
|||
// helpers
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// store
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
import { IPeekMode } from "@/store/issue_details";
|
||||
import { RootStore } from "@/store/root";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
// lib
|
||||
import useToast from "hooks/use-toast";
|
||||
// types
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
import React from "react";
|
||||
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// mobx
|
||||
// lib
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
import { CommentCard, AddComment } from "@/components/issues/peek-overview";
|
||||
import { Icon } from "@/components/ui";
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
// components
|
||||
// ui
|
||||
// hooks
|
||||
import { useMobxStore, useUser } from "@/hooks/store";
|
||||
// types
|
||||
import { IIssue } from "types/issue";
|
||||
import { IIssue } from "@/types/issue";
|
||||
|
||||
type Props = {
|
||||
issueDetails: IIssue;
|
||||
|
|
@ -24,11 +20,8 @@ export const PeekOverviewIssueActivity: React.FC<Props> = observer(() => {
|
|||
const router = useRouter();
|
||||
const { workspace_slug } = router.query;
|
||||
// store
|
||||
const {
|
||||
issueDetails: issueDetailStore,
|
||||
project: projectStore,
|
||||
user: { currentUser },
|
||||
} = useMobxStore();
|
||||
const { issueDetails: issueDetailStore, project: projectStore } = useMobxStore();
|
||||
const { data: currentUser } = useUser();
|
||||
const comments = issueDetailStore.details[issueDetailStore.peekId || ""]?.comments || [];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,19 +4,19 @@ import { useRouter } from "next/router";
|
|||
// lib
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { ReactionSelector } from "@/components/ui";
|
||||
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
// helpers
|
||||
// components
|
||||
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
|
||||
// hooks
|
||||
import { useMobxStore, useUser } from "@/hooks/store";
|
||||
|
||||
export const IssueEmojiReactions: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspace_slug, project_slug } = router.query;
|
||||
// store
|
||||
const { user: userStore, issueDetails: issueDetailsStore } = useMobxStore();
|
||||
const { issueDetails: issueDetailsStore } = useMobxStore();
|
||||
const { data: user, fetchCurrentUser } = useUser();
|
||||
|
||||
const user = userStore?.currentUser;
|
||||
const issueId = issueDetailsStore.peekId;
|
||||
const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : [];
|
||||
const groupedReactions = groupReactions(reactions, "reaction");
|
||||
|
|
@ -44,16 +44,16 @@ export const IssueEmojiReactions: React.FC = observer(() => {
|
|||
|
||||
useEffect(() => {
|
||||
if (user) return;
|
||||
userStore.fetchCurrentUser();
|
||||
}, [user, userStore]);
|
||||
fetchCurrentUser();
|
||||
}, [user, fetchCurrentUser]);
|
||||
|
||||
// TODO: on onclick of reaction, if user not logged in redirect to login page
|
||||
return (
|
||||
<>
|
||||
<ReactionSelector
|
||||
onSelect={(value) => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleReactionClick(value);
|
||||
});
|
||||
if (user) handleReactionClick(value);
|
||||
// userStore.requiredLogin(() => {});
|
||||
}}
|
||||
selected={userReactions?.map((r) => r.reaction)}
|
||||
size="md"
|
||||
|
|
@ -80,9 +80,8 @@ export const IssueEmojiReactions: React.FC = observer(() => {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleReactionClick(reaction);
|
||||
});
|
||||
if (user) handleReactionClick(reaction);
|
||||
// userStore.requiredLogin(() => {});
|
||||
}}
|
||||
className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${
|
||||
reactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { IssueEmojiReactions, IssueVotes } from "@/components/issues/peek-overview";
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
|
||||
export const IssueReactions: React.FC = () => {
|
||||
const { project: projectStore } = useMobxStore();
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import { useState, useEffect } from "react";
|
||||
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// mobx
|
||||
// lib
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
// ui
|
||||
// hooks
|
||||
import { useMobxStore, useUser } from "@/hooks/store";
|
||||
|
||||
export const IssueVotes: React.FC = observer(() => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
|
@ -16,9 +12,9 @@ export const IssueVotes: React.FC = observer(() => {
|
|||
|
||||
const { workspace_slug, project_slug } = router.query;
|
||||
|
||||
const { user: userStore, issueDetails: issueDetailsStore } = useMobxStore();
|
||||
const { issueDetails: issueDetailsStore } = useMobxStore();
|
||||
const { data: user, fetchCurrentUser } = useUser();
|
||||
|
||||
const user = userStore?.currentUser;
|
||||
const issueId = issueDetailsStore.peekId;
|
||||
|
||||
const votes = issueId ? issueDetailsStore.details[issueId]?.votes : [];
|
||||
|
|
@ -49,8 +45,8 @@ export const IssueVotes: React.FC = observer(() => {
|
|||
useEffect(() => {
|
||||
if (user) return;
|
||||
|
||||
userStore.fetchCurrentUser();
|
||||
}, [user, userStore]);
|
||||
fetchCurrentUser();
|
||||
}, [user, fetchCurrentUser]);
|
||||
|
||||
const VOTES_LIMIT = 1000;
|
||||
|
||||
|
|
@ -78,9 +74,8 @@ export const IssueVotes: React.FC = observer(() => {
|
|||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={(e) => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleVote(e, 1);
|
||||
});
|
||||
if (user) handleVote(e, 1);
|
||||
// userStore.requiredLogin(() => {});
|
||||
}}
|
||||
className={`flex items-center justify-center gap-x-1 overflow-hidden rounded border px-2 focus:outline-none ${
|
||||
isUpVotedByUser ? "border-custom-primary-200 text-custom-primary-200" : "border-custom-border-300"
|
||||
|
|
@ -113,9 +108,8 @@ export const IssueVotes: React.FC = observer(() => {
|
|||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={(e) => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleVote(e, -1);
|
||||
});
|
||||
if (user) handleVote(e, -1);
|
||||
// userStore.requiredLogin(() => {});
|
||||
}}
|
||||
className={`flex items-center justify-center gap-x-1 overflow-hidden rounded border px-2 focus:outline-none ${
|
||||
isDownVotedByUser ? "border-red-600 text-red-600" : "border-custom-border-300"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Dialog, Transition } from "@headlessui/react";
|
|||
// components
|
||||
import { FullScreenPeekView, SidePeekView } from "@/components/issues/peek-overview";
|
||||
// lib
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
|
||||
export const IssuePeekOverview: React.FC = observer(() => {
|
||||
// states
|
||||
|
|
|
|||
64
space/components/views/auth.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { AuthRoot, UserLoggedIn } from "@/components/accounts";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
// 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";
|
||||
|
||||
export const AuthView = observer(() => {
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
// store
|
||||
const { data: currentUser, fetchCurrentUser, isLoading } = useUser();
|
||||
|
||||
// fetching user information
|
||||
const { isLoading: isSWRLoading } = useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), {
|
||||
shouldRetryOnError: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading || isSWRLoading ? (
|
||||
<div className="relative flex h-screen w-screen items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentUser ? (
|
||||
<UserLoggedIn />
|
||||
) : (
|
||||
<div className="relative w-screen h-screen overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern}
|
||||
className="w-full h-full object-cover"
|
||||
alt="Plane background pattern"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10 w-screen h-screen overflow-hidden overflow-y-auto flex flex-col">
|
||||
<div className="container mx-auto px-10 lg:px-0 flex-shrink-0 relative flex items-center justify-between pb-4 transition-all">
|
||||
<div className="flex items-center gap-x-2 py-10">
|
||||
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" />
|
||||
<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">
|
||||
<AuthRoot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from "./login";
|
||||
export * from "./auth";
|
||||
export * from "./project-details";
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import Image from "next/image";
|
||||
|
||||
// mobx
|
||||
import { Loader } from "@plane/ui";
|
||||
import { SignInRoot, UserLoggedIn } from "@/components/accounts";
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
// components
|
||||
// images
|
||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text-new.png";
|
||||
|
||||
export const LoginView = observer(() => {
|
||||
// store
|
||||
const {
|
||||
user: { currentUser, loader },
|
||||
} = useMobxStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
{loader ? (
|
||||
<div className="relative flex h-screen w-screen items-center justify-center">Loading</div> // TODO: Add spinner instead
|
||||
) : (
|
||||
<>
|
||||
{currentUser ? (
|
||||
<UserLoggedIn />
|
||||
) : (
|
||||
<div className={`h-full w-full bg-onboarding-gradient-100`}>
|
||||
<div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28 ">
|
||||
<div className="flex items-center gap-x-2 py-10">
|
||||
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
|
||||
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-200 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3 ">
|
||||
<div className="h-full overflow-auto rounded-t-md bg-onboarding-gradient-200 px-7 pb-56 pt-24 sm:px-0">
|
||||
{!true ? (
|
||||
<div className="mx-auto flex justify-center pt-10">
|
||||
<div>
|
||||
<Loader className="mx-auto w-full space-y-4 pb-4">
|
||||
<Loader.Item height="46px" width="360px" />
|
||||
<Loader.Item height="46px" width="360px" />
|
||||
</Loader>
|
||||
|
||||
<Loader className="mx-auto w-full space-y-4 pt-4">
|
||||
<Loader.Item height="46px" width="360px" />
|
||||
<Loader.Item height="46px" width="360px" />
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SignInRoot />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -11,8 +11,8 @@ import { IssueSpreadsheetView } from "@/components/issues/board-views/spreadshee
|
|||
import { IssueAppliedFilters } from "@/components/issues/filters/applied-filters/root";
|
||||
import { IssuePeekOverview } from "@/components/issues/peek-overview";
|
||||
// mobx store
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { RootStore } from "@/store/root";
|
||||
import { useMobxStore, useUser } from "@/hooks/store";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
// assets
|
||||
import SomethingWentWrongImage from "public/something-went-wrong.svg";
|
||||
|
||||
|
|
@ -20,18 +20,14 @@ export const ProjectDetailsView = observer(() => {
|
|||
const router = useRouter();
|
||||
const { workspace_slug, project_slug, states, labels, priorities, peekId } = router.query;
|
||||
|
||||
const {
|
||||
issue: issueStore,
|
||||
project: projectStore,
|
||||
issueDetails: issueDetailStore,
|
||||
user: userStore,
|
||||
}: RootStore = useMobxStore();
|
||||
const { issue: issueStore, project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore();
|
||||
const { data: currentUser, fetchCurrentUser } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userStore.currentUser) {
|
||||
userStore.fetchCurrentUser();
|
||||
if (!currentUser) {
|
||||
fetchCurrentUser();
|
||||
}
|
||||
}, [userStore]);
|
||||
}, [currentUser, fetchCurrentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace_slug && project_slug) {
|
||||
|
|
|
|||
295
space/helpers/authentication.helper.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export enum EPageTypes {
|
||||
INIT = "INIT",
|
||||
PUBLIC = "PUBLIC",
|
||||
NON_AUTHENTICATED = "NON_AUTHENTICATED",
|
||||
ONBOARDING = "ONBOARDING",
|
||||
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",
|
||||
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
|
||||
INLINE_EMAIL = "INLINE_EMAIL",
|
||||
INLINE_PASSWORD = "INLINE_PASSWORD",
|
||||
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
|
||||
}
|
||||
|
||||
export enum EAuthenticationErrorCodes {
|
||||
// Global
|
||||
INSTANCE_NOT_CONFIGURED = "5000",
|
||||
SIGNUP_DISABLED = "5001",
|
||||
INVALID_PASSWORD = "5002", // Password strength validation
|
||||
SMTP_NOT_CONFIGURED = "5007",
|
||||
// email check
|
||||
INVALID_EMAIL = "5012",
|
||||
EMAIL_REQUIRED = "5013",
|
||||
// Sign Up
|
||||
USER_ALREADY_EXIST = "5003",
|
||||
REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5015",
|
||||
AUTHENTICATION_FAILED_SIGN_UP = "5006",
|
||||
INVALID_EMAIL_SIGN_UP = "5017",
|
||||
MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5023",
|
||||
INVALID_EMAIL_MAGIC_SIGN_UP = "5019",
|
||||
// Sign In
|
||||
USER_DOES_NOT_EXIST = "5004",
|
||||
REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5014",
|
||||
AUTHENTICATION_FAILED_SIGN_IN = "5005",
|
||||
INVALID_EMAIL_SIGN_IN = "5016",
|
||||
MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5022",
|
||||
INVALID_EMAIL_MAGIC_SIGN_IN = "5018",
|
||||
// Both Sign in and Sign up
|
||||
INVALID_MAGIC_CODE = "5008",
|
||||
EXPIRED_MAGIC_CODE = "5009",
|
||||
// Oauth
|
||||
GOOGLE_NOT_CONFIGURED = "5010",
|
||||
GITHUB_NOT_CONFIGURED = "5011",
|
||||
GOOGLE_OAUTH_PROVIDER_ERROR = "5021",
|
||||
GITHUB_OAUTH_PROVIDER_ERROR = "5020",
|
||||
// Reset Password
|
||||
INVALID_PASSWORD_TOKEN = "5024",
|
||||
EXPIRED_PASSWORD_TOKEN = "5025",
|
||||
// Change password
|
||||
INCORRECT_OLD_PASSWORD = "5026",
|
||||
INVALID_NEW_PASSWORD = "5027",
|
||||
// set password
|
||||
PASSWORD_ALREADY_SET = "5028", // used in the onboarding and set password page
|
||||
}
|
||||
|
||||
export type TAuthErrorInfo = {
|
||||
type: EErrorAlertType;
|
||||
code: EAuthenticationErrorCodes;
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
};
|
||||
|
||||
const errorCodeMessages: {
|
||||
[key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
|
||||
} = {
|
||||
// global
|
||||
[EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED]: {
|
||||
title: `Instance not configured`,
|
||||
message: () => `Instance not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.SIGNUP_DISABLED]: {
|
||||
title: `Sign up disabled`,
|
||||
message: () => `Sign up disabled. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.INVALID_PASSWORD]: {
|
||||
title: `Invalid password`,
|
||||
message: () => `Invalid password. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED]: {
|
||||
title: `SMTP not configured`,
|
||||
message: () => `SMTP not configured. Please contact your administrator.`,
|
||||
},
|
||||
|
||||
// email check in both sign up and sign in
|
||||
[EAuthenticationErrorCodes.INVALID_EMAIL]: {
|
||||
title: `Invalid email`,
|
||||
message: () => `Invalid email. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.EMAIL_REQUIRED]: {
|
||||
title: `Email required`,
|
||||
message: () => `Email required. Please try again.`,
|
||||
},
|
||||
|
||||
// sign up
|
||||
[EAuthenticationErrorCodes.USER_ALREADY_EXIST]: {
|
||||
title: `User already exists`,
|
||||
message: (email = undefined) => (
|
||||
<div>
|
||||
Your account is already registered.
|
||||
<Link
|
||||
className="underline underline-offset-4 font-medium hover:font-bold transition-all"
|
||||
href={`/accounts/sign-in${email ? `?email=${email}` : ``}`}
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: {
|
||||
title: `Email and password required`,
|
||||
message: () => `Email and password required. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP]: {
|
||||
title: `Authentication failed`,
|
||||
message: () => `Authentication failed. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP]: {
|
||||
title: `Invalid email`,
|
||||
message: () => `Invalid email. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED]: {
|
||||
title: `Email and code required`,
|
||||
message: () => `Email and code required. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: {
|
||||
title: `Invalid email`,
|
||||
message: () => `Invalid email. Please try again.`,
|
||||
},
|
||||
|
||||
// sign in
|
||||
[EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: {
|
||||
title: `User does not exist`,
|
||||
message: (email = undefined) => (
|
||||
<div>
|
||||
No account found.
|
||||
<Link
|
||||
className="underline underline-offset-4 font-medium hover:font-bold transition-all"
|
||||
href={`/${email ? `?email=${email}` : ``}`}
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
to get started.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN]: {
|
||||
title: `Email and password required`,
|
||||
message: () => `Email and password required. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN]: {
|
||||
title: `Authentication failed`,
|
||||
message: () => `Authentication failed. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN]: {
|
||||
title: `Invalid email`,
|
||||
message: () => `Invalid email. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED]: {
|
||||
title: `Email and code required`,
|
||||
message: () => `Email and code required. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN]: {
|
||||
title: `Invalid email`,
|
||||
message: () => `Invalid email. Please try again.`,
|
||||
},
|
||||
|
||||
// Both Sign in and Sign up
|
||||
[EAuthenticationErrorCodes.INVALID_MAGIC_CODE]: {
|
||||
title: `Authentication failed`,
|
||||
message: () => `Invalid magic code. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE]: {
|
||||
title: `Expired magic code`,
|
||||
message: () => `Expired magic code. Please try again.`,
|
||||
},
|
||||
|
||||
// Oauth
|
||||
[EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: {
|
||||
title: `Google not configured`,
|
||||
message: () => `Google not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED]: {
|
||||
title: `GitHub not configured`,
|
||||
message: () => `GitHub not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: {
|
||||
title: `Google OAuth provider error`,
|
||||
message: () => `Google OAuth provider error. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR]: {
|
||||
title: `GitHub OAuth provider error`,
|
||||
message: () => `GitHub OAuth provider error. Please try again.`,
|
||||
},
|
||||
|
||||
// Reset Password
|
||||
[EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: {
|
||||
title: `Invalid password token`,
|
||||
message: () => `Invalid password token. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN]: {
|
||||
title: `Expired password token`,
|
||||
message: () => `Expired password token. Please try again.`,
|
||||
},
|
||||
|
||||
// Change password
|
||||
[EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD]: {
|
||||
title: `Incorrect old password`,
|
||||
message: () => `Incorrect old password. Please try again.`,
|
||||
},
|
||||
[EAuthenticationErrorCodes.INVALID_NEW_PASSWORD]: {
|
||||
title: `Invalid new password`,
|
||||
message: () => `Invalid new password. Please try again.`,
|
||||
},
|
||||
|
||||
// set password
|
||||
[EAuthenticationErrorCodes.PASSWORD_ALREADY_SET]: {
|
||||
title: `Password already set`,
|
||||
message: () => `Password already set. Please try again.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const authErrorHandler = (
|
||||
errorCode: EAuthenticationErrorCodes,
|
||||
email?: string | undefined
|
||||
): TAuthErrorInfo | undefined => {
|
||||
const toastAlertErrorCodes = [
|
||||
EAuthenticationErrorCodes.SIGNUP_DISABLED,
|
||||
EAuthenticationErrorCodes.INVALID_PASSWORD,
|
||||
EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED,
|
||||
EAuthenticationErrorCodes.INVALID_EMAIL,
|
||||
EAuthenticationErrorCodes.EMAIL_REQUIRED,
|
||||
EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP,
|
||||
EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP,
|
||||
EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED,
|
||||
EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP,
|
||||
EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN,
|
||||
EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN,
|
||||
EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN,
|
||||
EAuthenticationErrorCodes.INVALID_MAGIC_CODE,
|
||||
EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE,
|
||||
EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED,
|
||||
EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED,
|
||||
EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR,
|
||||
EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR,
|
||||
EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN,
|
||||
EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN,
|
||||
EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD,
|
||||
EAuthenticationErrorCodes.INVALID_NEW_PASSWORD,
|
||||
EAuthenticationErrorCodes.PASSWORD_ALREADY_SET,
|
||||
];
|
||||
const bannerAlertErrorCodes = [
|
||||
EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED,
|
||||
EAuthenticationErrorCodes.USER_ALREADY_EXIST,
|
||||
EAuthenticationErrorCodes.USER_DOES_NOT_EXIST,
|
||||
EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP,
|
||||
EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN,
|
||||
EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED,
|
||||
];
|
||||
|
||||
if (toastAlertErrorCodes.includes(errorCode))
|
||||
return {
|
||||
type: EErrorAlertType.TOAST_ALERT,
|
||||
code: errorCode,
|
||||
title: errorCodeMessages[errorCode]?.title || "Error",
|
||||
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
|
||||
};
|
||||
|
||||
if (bannerAlertErrorCodes.includes(errorCode))
|
||||
return {
|
||||
type: EErrorAlertType.BANNER_ALERT,
|
||||
code: errorCode,
|
||||
title: errorCodeMessages[errorCode]?.title || "Error",
|
||||
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
|
||||
};
|
||||
|
||||
return undefined;
|
||||
};
|
||||
16
space/helpers/password.helper.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import zxcvbn from "zxcvbn";
|
||||
|
||||
export const isPasswordCriteriaMet = (password: string) => {
|
||||
const criteria = [password.length >= 8, /[A-Z]/.test(password), /\d/.test(password), /[!@#$%^&*]/.test(password)];
|
||||
|
||||
return criteria.every((criterion) => criterion);
|
||||
};
|
||||
|
||||
export const getPasswordStrength = (password: string) => {
|
||||
if (password.length === 0) return 0;
|
||||
if (password.length < 8) return 1;
|
||||
if (!isPasswordCriteriaMet(password)) return 2;
|
||||
|
||||
const result = zxcvbn(password);
|
||||
return result.score;
|
||||
};
|
||||
4
space/hooks/store/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./user-mobx-provider";
|
||||
|
||||
export * from "./use-instance";
|
||||
export * from "./user";
|
||||
10
space/hooks/store/use-instance.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
import { IInstanceStore } from "@/store/instance.store";
|
||||
|
||||
export const useInstance = (): IInstanceStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useInstance must be used within StoreProvider");
|
||||
return context.instance;
|
||||
};
|
||||
10
space/hooks/store/user-mobx-provider.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
|
||||
export const useMobxStore = (): RootStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useMobxStore must be used within StoreProvider");
|
||||
return context;
|
||||
};
|
||||
2
space/hooks/store/user/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./use-user";
|
||||
export * from "./use-user-profile";
|
||||
10
space/hooks/store/user/use-user-profile.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
import { IProfileStore } from "@/store/user/profile.store";
|
||||
|
||||
export const useUserProfile = (): IProfileStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider");
|
||||
return context.profile;
|
||||
};
|
||||
10
space/hooks/store/user/use-user.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
import { IUserStore } from "@/store/user/index.store";
|
||||
|
||||
export const useUser = (): IUserStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useUser must be used within StoreProvider");
|
||||
return context.user;
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { RootStore } from "@/store/root";
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
|
||||
const useEditorSuggestions = () => {
|
||||
const { mentionsStore }: RootStore = useMobxStore();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useRef, useEffect } from "react";
|
||||
import { UserService } from "services/user.service";
|
||||
import useSWR from "swr";
|
||||
import { IUser } from "@plane/types";
|
||||
import { UserService } from "services/user.service";
|
||||
|
||||
export const useMention = () => {
|
||||
const userService = new UserService();
|
||||
|
|
@ -11,7 +11,6 @@ export const useMention = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (userRef) {
|
||||
// @ts-expect-error mismatch in types
|
||||
userRef.current = user;
|
||||
}
|
||||
}, [user]);
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
// mobx store
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
// types
|
||||
import { IUser } from "types/user";
|
||||
|
||||
type UseSignInRedirectionProps = {
|
||||
error: any | null;
|
||||
isRedirecting: boolean;
|
||||
handleRedirection: () => Promise<void>;
|
||||
};
|
||||
|
||||
const useSignInRedirection = (): UseSignInRedirectionProps => {
|
||||
// states
|
||||
const [isRedirecting, setIsRedirecting] = useState(true);
|
||||
const [error, setError] = useState<any | null>(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { next_path } = router.query;
|
||||
// mobx store
|
||||
const {
|
||||
user: { fetchCurrentUser },
|
||||
} = useMobxStore();
|
||||
|
||||
const handleSignInRedirection = useCallback(
|
||||
async (user: IUser) => {
|
||||
const isOnboard = user.onboarding_step?.profile_complete;
|
||||
|
||||
if (isOnboard) {
|
||||
// if next_path is provided, redirect the user to that url
|
||||
if (next_path) router.push(next_path.toString());
|
||||
else router.push("/");
|
||||
} else {
|
||||
// if the user profile is not complete, redirect them to the onboarding page to complete their profile and then redirect them to the next path
|
||||
if (next_path) router.push(`/onboarding?next_path=${next_path}`);
|
||||
else router.push("/onboarding");
|
||||
}
|
||||
},
|
||||
[router, next_path]
|
||||
);
|
||||
|
||||
const updateUserInfo = useCallback(async () => {
|
||||
setIsRedirecting(true);
|
||||
|
||||
await fetchCurrentUser()
|
||||
.then(async (user) => {
|
||||
if (user)
|
||||
await handleSignInRedirection(user)
|
||||
.catch((err) => setError(err))
|
||||
.finally(() => setIsRedirecting(false));
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err);
|
||||
setIsRedirecting(false);
|
||||
});
|
||||
}, [fetchCurrentUser, handleSignInRedirection]);
|
||||
|
||||
return {
|
||||
error,
|
||||
isRedirecting,
|
||||
handleRedirection: updateUserInfo,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSignInRedirection;
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
// js cookie
|
||||
import Cookie from "js-cookie";
|
||||
// mobx store
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
|
||||
const MobxStoreInit = () => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
useEffect(() => {
|
||||
const authToken = Cookie.get("accessToken") || null;
|
||||
if (authToken) userStore.fetchCurrentUser();
|
||||
}, [userStore]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default MobxStoreInit;
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
// mobx store
|
||||
import { RootStore } from "@/store/root";
|
||||
|
||||
let rootStore: RootStore = new RootStore();
|
||||
|
||||
export const MobxStoreContext = createContext<RootStore>(rootStore);
|
||||
|
||||
const initializeStore = () => {
|
||||
const singletonRootStore: RootStore = rootStore ?? new RootStore();
|
||||
if (typeof window === "undefined") return singletonRootStore;
|
||||
if (!rootStore) rootStore = singletonRootStore;
|
||||
return singletonRootStore;
|
||||
};
|
||||
|
||||
export const MobxStoreProvider = ({ children }: any) => {
|
||||
const store: RootStore = initializeStore();
|
||||
return <MobxStoreContext.Provider value={store}>{children}</MobxStoreContext.Provider>;
|
||||
};
|
||||
|
||||
// hook
|
||||
export const useMobxStore = () => {
|
||||
const context = useContext(MobxStoreContext);
|
||||
if (context === undefined) throw new Error("useMobxStore must be used within MobxStoreProvider");
|
||||
return context;
|
||||
};
|
||||
19
space/lib/store-context.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { ReactElement, createContext } from "react";
|
||||
// mobx store
|
||||
import { RootStore } from "@/store/root.store";
|
||||
|
||||
let rootStore = new RootStore();
|
||||
|
||||
export const StoreContext = createContext<RootStore>(rootStore);
|
||||
|
||||
const initializeStore = () => {
|
||||
const singletonRootStore = rootStore ?? new RootStore();
|
||||
if (typeof window === "undefined") return singletonRootStore;
|
||||
if (!rootStore) rootStore = singletonRootStore;
|
||||
return singletonRootStore;
|
||||
};
|
||||
|
||||
export const StoreProvider = ({ children }: { children: ReactElement }) => {
|
||||
const store = initializeStore();
|
||||
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
||||
};
|
||||
86
space/lib/wrappers/auth-wrapper.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// helpers
|
||||
import { EPageTypes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useUser, useUserProfile } from "@/hooks/store";
|
||||
|
||||
type TAuthWrapper = {
|
||||
children: ReactNode;
|
||||
pageType?: EPageTypes;
|
||||
};
|
||||
|
||||
export const AuthWrapper: FC<TAuthWrapper> = observer((props) => {
|
||||
const router = useRouter();
|
||||
const { children, pageType = EPageTypes.AUTHENTICATED } = props;
|
||||
// hooks
|
||||
const { isLoading, data: currentUser, fetchCurrentUser } = useUser();
|
||||
const { data: currentUserProfile } = useUserProfile();
|
||||
|
||||
console;
|
||||
|
||||
const { isLoading: isSWRLoading } = useSWR("INSTANCE_INFORMATION", () => fetchCurrentUser(), {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
if (isSWRLoading || isLoading)
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (pageType === EPageTypes.PUBLIC) return <>{children}</>;
|
||||
|
||||
if (pageType === EPageTypes.INIT) {
|
||||
if (!currentUser?.id) return <>{children}</>;
|
||||
else {
|
||||
if (currentUserProfile?.id && currentUserProfile?.onboarding_step?.profile_complete) return <>{children}</>;
|
||||
else {
|
||||
router.push(`/onboarding`);
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pageType === EPageTypes.NON_AUTHENTICATED) {
|
||||
if (!currentUser?.id) return <>{children}</>;
|
||||
else {
|
||||
if (currentUserProfile?.id && currentUserProfile?.onboarding_step?.profile_complete) {
|
||||
router.push(`/`);
|
||||
return <></>;
|
||||
} else {
|
||||
router.push(`/onboarding`);
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pageType === EPageTypes.ONBOARDING) {
|
||||
if (!currentUser?.id) {
|
||||
router.push(`/`);
|
||||
return <></>;
|
||||
} else {
|
||||
if (currentUserProfile?.id && currentUserProfile?.onboarding_step?.profile_complete) {
|
||||
router.push(`/`);
|
||||
return <></>;
|
||||
} else return <>{children}</>;
|
||||
}
|
||||
}
|
||||
|
||||
if (pageType === EPageTypes.AUTHENTICATED) {
|
||||
if (!currentUser?.id) return <>{children}</>;
|
||||
else {
|
||||
if (currentUserProfile?.id && currentUserProfile?.onboarding_step?.profile_complete) return <>{children}</>;
|
||||
else {
|
||||
router.push(`/onboarding`);
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
2
space/lib/wrappers/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./instance-wrapper";
|
||||
export * from "./auth-wrapper";
|
||||
34
space/lib/wrappers/instance-wrapper.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { InstanceNotReady } from "@/components/instance";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type TInstanceWrapper = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const InstanceWrapper: FC<TInstanceWrapper> = observer((props) => {
|
||||
const { children } = props;
|
||||
// hooks
|
||||
const { isLoading, instance, fetchInstanceInfo } = useInstance();
|
||||
|
||||
const { isLoading: isSWRLoading } = useSWR("INSTANCE_INFORMATION", () => fetchInstanceInfo(), {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
if (isSWRLoading || isLoading)
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (instance?.instance?.is_setup_done === false) return <InstanceNotReady />;
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ require("dotenv").config({ path: ".env" });
|
|||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
|
||||
const nextConfig = {
|
||||
trailingSlash: true,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
|
@ -12,7 +13,7 @@ const nextConfig = {
|
|||
},
|
||||
];
|
||||
},
|
||||
basePath: process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX === "1" ? "/spaces" : "",
|
||||
basePath: "/spaces",
|
||||
reactStrictMode: false,
|
||||
swcMinify: true,
|
||||
images: {
|
||||
|
|
@ -28,7 +29,8 @@ const nextConfig = {
|
|||
};
|
||||
|
||||
if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0", 10)) {
|
||||
module.exports = withSentryConfig(nextConfig,
|
||||
module.exports = withSentryConfig(
|
||||
nextConfig,
|
||||
{ silent: true, authToken: process.env.SENTRY_AUTH_TOKEN },
|
||||
{ hideSourceMaps: true }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,29 +28,35 @@
|
|||
"dompurify": "^3.0.11",
|
||||
"dotenv": "^16.3.1",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "^0.378.0",
|
||||
"mobx": "^6.10.0",
|
||||
"mobx-react-lite": "^4.0.3",
|
||||
"next": "^14.0.3",
|
||||
"next": "^14.2.3",
|
||||
"next-themes": "^0.2.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.38.0",
|
||||
"react-popper": "^2.3.0",
|
||||
"swr": "^2.2.2",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"typescript": "4.9.5",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.0",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"@types/lodash": "^4.17.1",
|
||||
"@types/node": "18.14.1",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/zxcvbn": "^4.4.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.2",
|
||||
"eslint-config-custom": "*",
|
||||
"tailwind-config-custom": "*",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
/// layouts
|
||||
// components
|
||||
import { ProjectDetailsView } from "@/components/views/project-details";
|
||||
// lib
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import ProjectLayout from "layouts/project-layout";
|
||||
import { ProjectDetailsView } from "@/components/views";
|
||||
// helpers
|
||||
import { EPageTypes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useMobxStore } from "@/hooks/store";
|
||||
// layouts
|
||||
import ProjectLayout from "@/layouts/project-layout";
|
||||
// wrappers
|
||||
import { AuthWrapper } from "@/lib/wrappers";
|
||||
|
||||
const WorkspaceProjectPage = (props: any) => {
|
||||
const SITE_TITLE = props?.project_settings?.project_details?.name || "Plane | Deploy";
|
||||
|
|
@ -31,12 +33,14 @@ const WorkspaceProjectPage = (props: any) => {
|
|||
});
|
||||
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<Head>
|
||||
<title>{SITE_TITLE}</title>
|
||||
</Head>
|
||||
<ProjectDetailsView />
|
||||
</ProjectLayout>
|
||||
<AuthWrapper pageType={EPageTypes.AUTHENTICATED}>
|
||||
<ProjectLayout>
|
||||
<Head>
|
||||
<title>{SITE_TITLE}</title>
|
||||
</Head>
|
||||
<ProjectDetailsView />
|
||||
</ProjectLayout>
|
||||
</AuthWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,16 +7,15 @@ import "@/styles/globals.css";
|
|||
import { SITE_NAME, SITE_DESCRIPTION, SITE_URL, TWITTER_USER_NAME, SITE_KEYWORDS, SITE_TITLE } from "@/constants/seo";
|
||||
import { ToastContextProvider } from "@/contexts/toast.context";
|
||||
// mobx store provider
|
||||
import MobxStoreInit from "@/lib/mobx/store-init";
|
||||
import { MobxStoreProvider } from "@/lib/mobx/store-provider";
|
||||
// constants
|
||||
import { StoreProvider } from "@/lib/store-context";
|
||||
// wrappers
|
||||
import { InstanceWrapper } from "@/lib/wrappers";
|
||||
|
||||
const prefix = parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0") === 0 ? "/" : "/spaces/";
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<MobxStoreProvider>
|
||||
<MobxStoreInit />
|
||||
<>
|
||||
<Head>
|
||||
<title>{SITE_TITLE}</title>
|
||||
<meta property="og:site_name" content={SITE_NAME} />
|
||||
|
|
@ -32,12 +31,16 @@ function MyApp({ Component, pageProps }: AppProps) {
|
|||
<link rel="manifest" href={`${prefix}site.webmanifest.json`} />
|
||||
<link rel="shortcut icon" href={`${prefix}favicon/favicon.ico`} />
|
||||
</Head>
|
||||
<ToastContextProvider>
|
||||
<StoreProvider>
|
||||
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
|
||||
<Component {...pageProps} />
|
||||
<ToastContextProvider>
|
||||
<InstanceWrapper>
|
||||
<Component {...pageProps} />
|
||||
</InstanceWrapper>
|
||||
</ToastContextProvider>
|
||||
</ThemeProvider>
|
||||
</ToastContextProvider>
|
||||
</MobxStoreProvider>
|
||||
</StoreProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
166
space/pages/accounts/forgot-password.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { NextPage } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// icons
|
||||
import { CircleCheck } from "lucide-react";
|
||||
// ui
|
||||
import { Button, Input, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
// helpers
|
||||
import { EPageTypes } from "@/helpers/authentication.helper";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { checkEmailValidity } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import useTimer from "@/hooks/use-timer";
|
||||
// wrappers
|
||||
import { AuthWrapper } from "@/lib/wrappers";
|
||||
// services
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
// 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.png";
|
||||
|
||||
type TForgotPasswordFormValues = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const defaultValues: TForgotPasswordFormValues = {
|
||||
email: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
const ForgotPasswordPage: NextPage = () => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { email } = router.query;
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
// timer
|
||||
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0);
|
||||
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
handleSubmit,
|
||||
} = useForm<TForgotPasswordFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
email: email?.toString() ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleForgotPassword = async (formData: TForgotPasswordFormValues) => {
|
||||
await authService
|
||||
.sendResetPasswordLink({
|
||||
email: formData.email,
|
||||
})
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Email sent",
|
||||
message:
|
||||
"Check your inbox for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder.",
|
||||
});
|
||||
setResendCodeTimer(30);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthWrapper pageType={EPageTypes.NON_AUTHENTICATED}>
|
||||
<div className="relative h-screen w-full overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern}
|
||||
className="w-full h-full object-cover"
|
||||
alt="Plane background pattern"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28">
|
||||
<div className="flex items-center gap-x-2 py-10">
|
||||
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
|
||||
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto h-full">
|
||||
<div className="h-full overflow-auto px-7 pb-56 pt-4 sm:px-0">
|
||||
<div className="mx-auto flex flex-col">
|
||||
<div className="text-center space-y-1 py-4 mx-auto sm:w-96">
|
||||
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
|
||||
Reset your password
|
||||
</h3>
|
||||
<p className="font-medium text-onboarding-text-400">
|
||||
Enter your user account{"'"}s verified email address and we will send you a password reset link.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(handleForgotPassword)} className="mx-auto mt-5 space-y-4 w-5/6 sm:w-96">
|
||||
<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 } }) => (
|
||||
<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 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
disabled={resendTimerCode > 0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{resendTimerCode > 0 && (
|
||||
<p className="flex w-full items-start px-1 gap-1 text-xs font-medium text-green-700">
|
||||
<CircleCheck height={12} width={12} className="mt-0.5" />
|
||||
We sent the reset link to your email address
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting || resendTimerCode > 0}
|
||||
>
|
||||
{resendTimerCode > 0 ? `Resend in ${resendTimerCode} seconds` : "Send reset link"}
|
||||
</Button>
|
||||
<Link href="/" className={cn("w-full", getButtonStyling("link-neutral", "lg"))}>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordPage;
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
import { NextPage } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Lightbulb } from "lucide-react";
|
||||
// services
|
||||
import { Button, Input } from "@plane/ui";
|
||||
import { checkEmailValidity } from "@/helpers/string.helper";
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
// hooks
|
||||
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
// images
|
||||
import latestFeatures from "public/onboarding/onboarding-pages.svg";
|
||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text-new.png";
|
||||
// helpers
|
||||
|
||||
type TResetPasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const defaultValues: TResetPasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
const HomePage: NextPage = () => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { uidb64, token, email } = router.query;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
// sign in redirection hook
|
||||
const { handleRedirection } = useSignInRedirection();
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
handleSubmit,
|
||||
} = useForm<TResetPasswordFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
email: email?.toString() ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleResetPassword = async (formData: TResetPasswordFormValues) => {
|
||||
if (!uidb64 || !token || !email) return;
|
||||
|
||||
const payload = {
|
||||
new_password: formData.password,
|
||||
};
|
||||
|
||||
await authService
|
||||
.resetPassword(uidb64.toString(), token.toString(), payload)
|
||||
.then(() => handleRedirection())
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-onboarding-gradient-100">
|
||||
<div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28 ">
|
||||
<div className="flex items-center gap-x-2 py-10">
|
||||
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
|
||||
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-200 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3 ">
|
||||
<div className="h-full overflow-auto rounded-t-md bg-onboarding-gradient-200 px-7 pb-56 pt-24 sm:px-0">
|
||||
<div className="mx-auto flex flex-col divide-y divide-custom-border-200 sm:w-96">
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||
Let{"'"}s get a new password
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit(handleResetPassword)} className="mx-auto mt-11 space-y-4 sm:w-96">
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: "Email is required",
|
||||
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<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 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Choose password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
minLength={8}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="mt-3 text-xs text-onboarding-text-200">
|
||||
Whatever you choose now will be your account{"'"}s password until you change it.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="xl"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Continue"}
|
||||
</Button>
|
||||
<p className="text-xs text-onboarding-text-200">
|
||||
When you click the button above, you agree with our{" "}
|
||||
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
||||
<span className="font-semibold underline">terms and conditions of service.</span>
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<div className="mx-auto mt-16 flex rounded-[3.5px] border border-onboarding-border-200 bg-onboarding-background-100 py-2 sm:w-96">
|
||||
<Lightbulb className="mx-3 mr-2 h-7 w-7" />
|
||||
<p className="text-left text-sm text-onboarding-text-100">
|
||||
Try the latest features, like Tiptap editor, to write compelling responses.{" "}
|
||||
<Link href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer">
|
||||
<span className="text-sm font-medium underline hover:cursor-pointer">See new features</span>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto mt-8 overflow-hidden rounded-md border border-onboarding-border-200 bg-onboarding-background-100 object-cover sm:h-52 sm:w-96">
|
||||
<div className="h-[90%]">
|
||||
<Image
|
||||
src={latestFeatures}
|
||||
alt="Plane Issues"
|
||||
className={`-mt-2 ml-8 h-full rounded-md ${
|
||||
resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70"
|
||||
} `}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
205
space/pages/accounts/reset-password.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { NextPage } from "next";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
// icons
|
||||
import { useTheme } from "next-themes";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// components
|
||||
import { PasswordStrengthMeter } from "@/components/accounts";
|
||||
// helpers
|
||||
import { EPageTypes } from "@/helpers/authentication.helper";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { getPasswordStrength } from "@/helpers/password.helper";
|
||||
// wrappers
|
||||
import { AuthWrapper } from "@/lib/wrappers";
|
||||
// services
|
||||
import { AuthService } from "@/services/authentication.service";
|
||||
// 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.png";
|
||||
|
||||
type TResetPasswordFormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
confirm_password?: string;
|
||||
};
|
||||
|
||||
const defaultValues: TResetPasswordFormValues = {
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
const ResetPasswordPage: NextPage = () => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { uidb64, token, email } = router.query;
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [resetFormData, setResetFormData] = useState<TResetPasswordFormValues>({
|
||||
...defaultValues,
|
||||
email: email ? email.toString() : "",
|
||||
});
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (email && !resetFormData.email) {
|
||||
setResetFormData((prev) => ({ ...prev, email: email.toString() }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email]);
|
||||
|
||||
const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) =>
|
||||
setResetFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
const isButtonDisabled = useMemo(
|
||||
() =>
|
||||
!!resetFormData.password &&
|
||||
getPasswordStrength(resetFormData.password) >= 3 &&
|
||||
resetFormData.password === resetFormData.confirm_password
|
||||
? false
|
||||
: true,
|
||||
[resetFormData]
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthWrapper pageType={EPageTypes.NON_AUTHENTICATED}>
|
||||
<div className="relative h-screen w-full overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern}
|
||||
className="w-full h-full object-cover"
|
||||
alt="Plane background pattern"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28">
|
||||
<div className="flex items-center gap-x-2 py-10">
|
||||
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
|
||||
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto h-full">
|
||||
<div className="h-full overflow-auto px-7 pb-56 pt-4 sm:px-0">
|
||||
<div className="mx-auto flex flex-col">
|
||||
<div className="text-center space-y-1 py-4 mx-auto sm:w-96">
|
||||
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
|
||||
Set new password
|
||||
</h3>
|
||||
<p className="font-medium text-onboarding-text-400">Secure your account with a strong password</p>
|
||||
</div>
|
||||
<form
|
||||
className="mx-auto mt-5 space-y-4 w-5/6 sm:w-96"
|
||||
method="POST"
|
||||
action={`${API_BASE_URL}/auth/reset-password/${uidb64?.toString()}/${token?.toString()}/`}
|
||||
>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={resetFormData.email}
|
||||
//hasError={Boolean(errors.email)}
|
||||
placeholder="name@company.com"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400 cursor-not-allowed"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={resetFormData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
//hasError={Boolean(errors.password)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
minLength={8}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(false)}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isPasswordInputFocused && <PasswordStrengthMeter password={resetFormData.password} />}
|
||||
</div>
|
||||
{getPasswordStrength(resetFormData.password) >= 3 && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">
|
||||
Confirm password
|
||||
</label>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="confirm_password"
|
||||
value={resetFormData.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"
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(false)}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!resetFormData.confirm_password &&
|
||||
resetFormData.password !== resetFormData.confirm_password && (
|
||||
<span className="text-sm text-red-500">Passwords don{"'"}t match</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
|
||||
Set password
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordPage;
|
||||
|
|
@ -1,28 +1,16 @@
|
|||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// components
|
||||
import { LoginView } from "@/components/views";
|
||||
// store
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import { RootStore } from "@/store/root";
|
||||
import { AuthView } from "@/components/views";
|
||||
// helpers
|
||||
import { EPageTypes } from "@/helpers/authentication.helper";
|
||||
// wrapper
|
||||
import { AuthWrapper } from "@/lib/wrappers";
|
||||
|
||||
const Index: NextPage = observer(() => {
|
||||
const router = useRouter();
|
||||
const { next_path } = router.query;
|
||||
|
||||
const {
|
||||
user: { currentUser },
|
||||
}: RootStore = useMobxStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (next_path && currentUser?.onboarding_step?.profile_complete)
|
||||
router.push(next_path.toString().replace(/[^a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=]/g, ""));
|
||||
}, [router, next_path, currentUser]);
|
||||
|
||||
return <LoginView />;
|
||||
});
|
||||
const Index: NextPage = observer(() => (
|
||||
<AuthWrapper pageType={EPageTypes.INIT}>
|
||||
<AuthView />
|
||||
</AuthWrapper>
|
||||
));
|
||||
|
||||
export default Index;
|
||||
|
|
|
|||
|
|
@ -1,45 +1,131 @@
|
|||
import React, { useEffect } from "react";
|
||||
// mobx
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { OnBoardingForm } from "@/components/accounts/onboarding-form";
|
||||
import { useMobxStore } from "@/lib/mobx/store-provider";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTheme } from "next-themes";
|
||||
// ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// components
|
||||
import { OnBoardingForm } from "@/components/accounts/onboarding-form";
|
||||
// helpers
|
||||
import { EPageTypes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useUser, useUserProfile } from "@/hooks/store";
|
||||
// wrappers
|
||||
import { AuthWrapper } from "@/lib/wrappers";
|
||||
// assets
|
||||
import ProfileSetupDark from "public/onboarding/profile-setup-dark.svg";
|
||||
import ProfileSetup from "public/onboarding/profile-setup.svg";
|
||||
|
||||
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
|
||||
|
||||
const OnBoardingPage = () => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
const OnBoardingPage = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { next_path } = router.query;
|
||||
|
||||
const user = userStore?.currentUser;
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const user = userStore?.currentUser;
|
||||
const { data: user } = useUser();
|
||||
const { data: currentUserProfile, updateUserProfile } = useUserProfile();
|
||||
|
||||
if (!user) {
|
||||
userStore.fetchCurrentUser();
|
||||
}
|
||||
}, [userStore]);
|
||||
if (!user) {
|
||||
router.push("/");
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// complete onboarding
|
||||
const finishOnboarding = async () => {
|
||||
if (!user) return;
|
||||
|
||||
await updateUserProfile({
|
||||
onboarding_step: {
|
||||
...currentUserProfile?.onboarding_step,
|
||||
profile_complete: true,
|
||||
},
|
||||
}).catch(() => {
|
||||
console.log("Failed to update onboarding status");
|
||||
});
|
||||
|
||||
if (next_path) router.replace(next_path.toString());
|
||||
router.replace("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full overflow-hidden bg-custom-background-100">
|
||||
<div className="flex h-full w-full flex-col gap-y-2 overflow-hidden sm:flex-row sm:gap-y-0">
|
||||
<div className="relative h-1/6 flex-shrink-0 sm:w-2/12 md:w-3/12 lg:w-1/5">
|
||||
<div className="absolute left-0 top-1/2 z-10 h-[0.5px] w-full -translate-y-1/2 border-b-[0.5px] border-custom-border-200 sm:left-1/2 sm:top-0 sm:h-screen sm:w-[0.5px] sm:-translate-x-1/2 sm:translate-y-0 sm:border-r-[0.5px] md:left-1/3" />
|
||||
<div className="absolute left-2 top-1/2 z-10 grid -translate-y-1/2 place-items-center bg-custom-background-100 px-3 py-5 sm:left-1/2 sm:top-12 sm:-translate-x-1/2 sm:translate-y-0 sm:px-0 md:left-1/3">
|
||||
<div className="h-[30px] w-[30px]">
|
||||
<img src={`${imagePrefix}/plane-logos/blue-without-text.png`} alt="Plane logo" />
|
||||
<AuthWrapper pageType={EPageTypes.ONBOARDING}>
|
||||
<div className="flex h-full w-full">
|
||||
<div className="w-full h-full overflow-auto px-6 py-10 sm:px-7 sm:py-14 md:px-14 lg:px-28">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex w-full items-center justify-between font-semibold ">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Image
|
||||
src={`${imagePrefix}/plane-logos/blue-without-text.png`}
|
||||
height={30}
|
||||
width={30}
|
||||
alt="Plane Logo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 lg:hidden">
|
||||
<div className="flex w-full shrink-0 justify-end">
|
||||
<div className="flex items-center gap-x-2 pr-4">
|
||||
{user?.avatar && (
|
||||
<Avatar
|
||||
name={user?.first_name ? `${user?.first_name} ${user?.last_name ?? ""}` : user?.email}
|
||||
src={user?.avatar}
|
||||
size={24}
|
||||
shape="square"
|
||||
fallbackBackgroundColor="#FCBE1D"
|
||||
className="!text-base capitalize"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-custom-text-200">
|
||||
{user?.first_name ? `${user?.first_name} ${user?.last_name ?? ""}` : user?.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-4 top-1/4 -translate-y-1/2 text-sm font-medium text-custom-text-100 sm:fixed sm:right-16 sm:top-12 sm:translate-y-0 sm:py-5">
|
||||
{user?.email}
|
||||
<div className="flex flex-col w-full items-center justify-center p-8 mt-14">
|
||||
<div className="text-center space-y-1 py-4 mx-auto">
|
||||
<h3 className="text-3xl font-bold text-onboarding-text-100">Welcome to Plane!</h3>
|
||||
<p className="font-medium text-onboarding-text-400">
|
||||
Let’s setup your profile, tell us a bit about yourself.
|
||||
</p>
|
||||
</div>
|
||||
<OnBoardingForm user={user} finishOnboarding={finishOnboarding} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex h-full justify-center overflow-hidden px-8 pb-0 sm:w-10/12 sm:items-center sm:px-0 sm:py-12 sm:pr-[8.33%] md:w-9/12 lg:w-4/5">
|
||||
<OnBoardingForm user={user} />
|
||||
<div className="hidden lg:block relative w-2/5 h-screen overflow-hidden px-6 py-10 sm:px-7 sm:py-14 md:px-14 lg:px-28">
|
||||
<div className="flex w-full shrink-0 justify-end">
|
||||
<div className="flex items-center gap-x-2 pr-4 z-10">
|
||||
{user?.avatar && (
|
||||
<Avatar
|
||||
name={user?.first_name ? `${user?.first_name} ${user?.last_name ?? ""}` : user?.email}
|
||||
src={user?.avatar}
|
||||
size={24}
|
||||
shape="square"
|
||||
fallbackBackgroundColor="#FCBE1D"
|
||||
className="!text-base capitalize"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-custom-text-200">
|
||||
{user?.first_name ? `${user?.first_name} ${user?.last_name ?? ""}` : user?.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? ProfileSetupDark : ProfileSetup}
|
||||
className="h-screen w-auto float-end object-cover"
|
||||
alt="Profile setup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthWrapper>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default observer(OnBoardingPage);
|
||||
export default OnBoardingPage;
|
||||
|
|
|
|||
|
|
@ -1,32 +1,39 @@
|
|||
// next imports
|
||||
import Image from "next/image";
|
||||
import projectNotPublishedImage from "public/project-not-published.svg";
|
||||
// helpers
|
||||
import { EPageTypes } from "@/helpers/authentication.helper";
|
||||
// wrappers
|
||||
import { AuthWrapper } from "@/lib/wrappers";
|
||||
// images
|
||||
import projectNotPublishedImage from "@/public/project-not-published.svg";
|
||||
|
||||
const CustomProjectNotPublishedError = () => (
|
||||
<div className="relative flex h-full min-h-screen w-screen items-center justify-center py-5">
|
||||
<div className="max-w-[700px] space-y-5">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="relative h-[240px] w-[240px]">
|
||||
<Image src={projectNotPublishedImage} layout="fill" alt="404- Page not found" />
|
||||
<AuthWrapper pageType={EPageTypes.PUBLIC}>
|
||||
<div className="relative flex h-full min-h-screen w-screen items-center justify-center py-5">
|
||||
<div className="max-w-[700px] space-y-5">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="relative h-[240px] w-[240px]">
|
||||
<Image src={projectNotPublishedImage} layout="fill" alt="404- Page not found" />
|
||||
</div>
|
||||
<div className="text-xl font-medium">
|
||||
Oops! The page you{`'`}re looking for isn{`'`}t live at the moment.
|
||||
</div>
|
||||
<div className="text-sm text-custom-text-200">
|
||||
If this is your project, login to your workspace to adjust its visibility settings and make it public.
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl font-medium">
|
||||
Oops! The page you{`'`}re looking for isn{`'`}t live at the moment.
|
||||
</div>
|
||||
<div className="text-sm text-custom-text-200">
|
||||
If this is your project, login to your workspace to adjust its visibility settings and make it public.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<a
|
||||
href={`https://app.plane.so/`}
|
||||
className="cursor-pointer select-none rounded-sm border border-gray-200 bg-gray-50 p-1.5 px-2.5 text-sm font-medium text-gray-700 transition-all hover:scale-105 hover:bg-gray-100 hover:text-gray-800"
|
||||
>
|
||||
Go to your Workspace
|
||||
</a>
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<a
|
||||
href={`https://app.plane.so/`}
|
||||
className="cursor-pointer select-none rounded-sm border border-gray-200 bg-gray-50 p-1.5 px-2.5 text-sm font-medium text-gray-700 transition-all hover:scale-105 hover:bg-gray-100 hover:text-gray-800"
|
||||
>
|
||||
Go to your Workspace
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthWrapper>
|
||||
);
|
||||
|
||||
export default CustomProjectNotPublishedError;
|
||||
|
|
|
|||
68
space/public/auth/background-pattern-dark.svg
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<svg width="1512" height="900" viewBox="0 0 1512 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4817_18724)">
|
||||
<rect width="1512" height="900" fill="#1B1C1E"/>
|
||||
<g opacity="0.09">
|
||||
<line x1="-10.6172" y1="624.328" x2="1500.96" y2="624.328" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="301.59" x2="1500.96" y2="301.59" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="462.958" x2="1500.96" y2="462.958" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="785.696" x2="1500.96" y2="785.696" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="140.22" x2="1500.96" y2="140.22" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="543.642" x2="1500.96" y2="543.642" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="866.381" x2="1500.96" y2="866.381" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="220.904" x2="1500.96" y2="220.904" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="382.272" x2="1500.96" y2="382.272" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="705.012" x2="1500.96" y2="705.013" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="59.534" x2="1500.96" y2="59.534" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="36.3273" y1="-49.8457" x2="36.3273" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="681.808" y1="-49.8457" x2="681.808" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="359.068" y1="-49.8457" x2="359.068" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1004.54" y1="-49.8457" x2="1004.54" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1327.28" y1="-49.8457" x2="1327.28" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="197.698" y1="-49.8457" x2="197.698" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="843.173" y1="-49.8457" x2="843.173" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="520.439" y1="-49.8457" x2="520.439" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1165.92" y1="-49.8457" x2="1165.92" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1488.66" y1="-49.8457" x2="1488.66" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="117.015" y1="-49.8457" x2="117.015" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="762.491" y1="-49.8457" x2="762.491" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="439.751" y1="-49.8457" x2="439.751" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1085.23" y1="-49.8457" x2="1085.23" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1407.97" y1="-49.8457" x2="1407.97" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="278.384" y1="-49.8457" x2="278.384" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="923.861" y1="-49.8457" x2="923.86" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="601.12" y1="-49.8457" x2="601.12" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
<line x1="1246.6" y1="-49.8457" x2="1246.6" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
|
||||
</g>
|
||||
<g opacity="0.5">
|
||||
<rect x="440.141" y="785.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1165.39" y="221.433" width="80.8965" height="80" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="520.367" y="463.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1085.39" y="301.659" width="80.2262" height="80.3408" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1166" y="382" width="80.2262" height="80.08" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1247" y="301" width="80.2262" height="81.08" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="439.994" y="221.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1085.39" y="221.433" width="80" height="80" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="439.994" y="463.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="359.914" y="785.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="359.914" y="865.535" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="198.314" y="705.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1005.16" y="59.835" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="924.059" y="-20.8525" width="80.2262" height="79.5062" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="924.059" y="59.4053" width="80.2262" height="80.0285" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="116.943" y="58.6885" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="36.7168" y="59.835" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="116.943" y="138.915" width="81.3723" height="82.5184" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="440.141" y="-21.5381" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="198.316" y="300.513" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1166.76" y="705.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1246.99" y="785.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="37" y="220" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="-44" y="140" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4817_18724">
|
||||
<rect width="1512" height="900" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6 KiB |
68
space/public/auth/background-pattern.svg
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<svg width="1512" height="900" viewBox="0 0 1512 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4817_18582)">
|
||||
<rect width="1512" height="900" fill="white"/>
|
||||
<g opacity="0.09">
|
||||
<line x1="-10.6172" y1="625.328" x2="1500.96" y2="625.328" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="302.59" x2="1500.96" y2="302.59" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="463.958" x2="1500.96" y2="463.958" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="786.696" x2="1500.96" y2="786.696" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="141.22" x2="1500.96" y2="141.22" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="544.642" x2="1500.96" y2="544.642" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="867.381" x2="1500.96" y2="867.381" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="221.904" x2="1500.96" y2="221.904" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="383.272" x2="1500.96" y2="383.272" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="706.012" x2="1500.96" y2="706.013" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="-10.6172" y1="60.534" x2="1500.96" y2="60.534" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="36.3273" y1="-48.8457" x2="36.3273" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="681.808" y1="-48.8457" x2="681.808" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="359.068" y1="-48.8457" x2="359.068" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1004.54" y1="-48.8457" x2="1004.54" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1327.28" y1="-48.8457" x2="1327.28" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="197.698" y1="-48.8457" x2="197.698" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="843.173" y1="-48.8457" x2="843.173" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="520.439" y1="-48.8457" x2="520.439" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1165.92" y1="-48.8457" x2="1165.92" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1488.66" y1="-48.8457" x2="1488.66" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="117.015" y1="-48.8457" x2="117.015" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="762.491" y1="-48.8457" x2="762.491" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="439.751" y1="-48.8457" x2="439.751" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1085.23" y1="-48.8457" x2="1085.23" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1407.97" y1="-48.8457" x2="1407.97" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="278.384" y1="-48.8457" x2="278.384" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="923.861" y1="-48.8457" x2="923.86" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="601.12" y1="-48.8457" x2="601.12" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<line x1="1246.6" y1="-48.8457" x2="1246.6" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
</g>
|
||||
<g opacity="0.5">
|
||||
<rect x="440.141" y="786.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1166.76" y="222.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="520.367" y="464.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1085.39" y="302.659" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1166" y="383" width="80.2262" height="80.08" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1247" y="302" width="80.2262" height="81.08" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="439.994" y="222.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1085.39" y="222.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="439.994" y="464.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="359.914" y="786.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="359.914" y="866.535" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="198.314" y="706.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1005.16" y="60.835" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="924.059" y="-19.8525" width="80.2262" height="79.5062" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="924.059" y="60.4053" width="80.2262" height="80.0285" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="116.943" y="59.6885" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="36.7168" y="60.835" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="116.943" y="139.915" width="81.3723" height="82.5184" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="440.141" y="-20.5381" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="198.316" y="301.513" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1166.76" y="706.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="1246.99" y="786.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="37" y="221" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
<rect x="-44" y="141" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4817_18582">
|
||||
<rect width="1512" height="900" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
BIN
space/public/instance/plane-instance-not-ready.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
space/public/logos/github-black.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -1,29 +0,0 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M2412 5049 c-452 -23 -877 -163 -1255 -412 -577 -381 -978 -976
|
||||
-1106 -1641 -109 -564 -37 -1123 209 -1621 243 -494 642 -903 1116 -1147 141
|
||||
-73 341 -154 403 -164 58 -9 109 19 133 73 18 40 18 60 12 286 l-7 243 -86
|
||||
-14 c-97 -15 -256 -9 -386 13 -105 19 -211 71 -278 139 -53 53 -67 76 -136
|
||||
229 -63 139 -135 231 -232 297 -66 46 -121 106 -117 128 6 30 48 43 121 38
|
||||
141 -10 288 -113 393 -274 72 -110 143 -179 230 -222 62 -31 79 -35 169 -38
|
||||
103 -4 207 12 291 44 41 16 43 18 58 85 19 86 56 164 106 228 l39 49 -82 11
|
||||
c-264 38 -452 102 -627 215 -229 148 -365 379 -431 731 -20 109 -23 389 -5
|
||||
492 29 167 98 319 200 445 l45 55 -20 62 c-52 168 -42 372 28 574 18 50 22 52
|
||||
103 48 118 -6 371 -108 543 -218 l71 -46 56 11 c30 6 87 18 127 27 271 58 655
|
||||
58 926 0 40 -9 97 -21 127 -27 l55 -10 95 58 c226 137 484 230 575 206 26 -7
|
||||
33 -17 53 -75 43 -125 55 -210 50 -351 -4 -95 -11 -148 -26 -195 l-21 -64 44
|
||||
-54 c89 -109 155 -244 192 -389 22 -89 25 -417 4 -544 -32 -198 -114 -406
|
||||
-210 -532 -165 -217 -464 -366 -843 -418 l-87 -12 39 -49 c47 -60 85 -137 106
|
||||
-221 14 -52 17 -137 20 -503 5 -490 5 -489 72 -521 46 -21 83 -15 229 42 738
|
||||
284 1320 932 1533 1703 55 198 90 474 90 701 0 118 -23 331 -50 473 -129 668
|
||||
-529 1262 -1107 1644 -370 244 -809 390 -1224 408 -68 3 -137 7 -154 8 -16 2
|
||||
-95 0 -173 -4z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
3
space/public/logos/github-dark.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.5 0.890625C8.35093 0.890625 7.21312 1.11695 6.15152 1.55668C5.08992 1.99641 4.12533 2.64093 3.31282 3.45344C1.67187 5.09438 0.75 7.31998 0.75 9.64062C0.75 13.5081 3.26125 16.7894 6.735 17.9531C7.1725 18.0231 7.3125 17.7519 7.3125 17.5156V16.0369C4.88875 16.5619 4.3725 14.8644 4.3725 14.8644C3.97 13.8494 3.40125 13.5781 3.40125 13.5781C2.605 13.0356 3.4625 13.0531 3.4625 13.0531C4.3375 13.1144 4.80125 13.9544 4.80125 13.9544C5.5625 15.2844 6.84875 14.8906 7.3475 14.6806C7.42625 14.1119 7.65375 13.7269 7.89875 13.5081C5.95625 13.2894 3.9175 12.5369 3.9175 9.20312C3.9175 8.23187 4.25 7.45312 4.81875 6.83187C4.73125 6.61312 4.425 5.70312 4.90625 4.52188C4.90625 4.52188 5.64125 4.28562 7.3125 5.41438C8.00375 5.22188 8.75625 5.12563 9.5 5.12563C10.2437 5.12563 10.9963 5.22188 11.6875 5.41438C13.3588 4.28562 14.0938 4.52188 14.0938 4.52188C14.575 5.70312 14.2688 6.61312 14.1813 6.83187C14.75 7.45312 15.0825 8.23187 15.0825 9.20312C15.0825 12.5456 13.035 13.2806 11.0838 13.4994C11.3988 13.7706 11.6875 14.3044 11.6875 15.1181V17.5156C11.6875 17.7519 11.8275 18.0319 12.2738 17.9531C15.7475 16.7806 18.25 13.5081 18.25 9.64062C18.25 8.49156 18.0237 7.35374 17.5839 6.29214C17.1442 5.23055 16.4997 4.26595 15.6872 3.45344C14.8747 2.64093 13.9101 1.99641 12.8485 1.55668C11.7869 1.11695 10.6491 0.890625 9.5 0.890625Z" fill="#B0B4BB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
space/public/logos/google-logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/><path d="M1 1h22v22H1z" fill="none"/></svg>
|
||||
|
After Width: | Height: | Size: 742 B |
220
space/public/onboarding/profile-setup-dark.svg
Normal file
|
After Width: | Height: | Size: 729 KiB |
222
space/public/onboarding/profile-setup.svg
Normal file
|
After Width: | Height: | Size: 730 KiB |
|
|
@ -1,107 +1,52 @@
|
|||
// axios
|
||||
import axios from "axios";
|
||||
// js cookie
|
||||
import Cookies from "js-cookie";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
|
||||
abstract class APIService {
|
||||
protected baseURL: string;
|
||||
protected headers: any = {};
|
||||
private axiosInstance: AxiosInstance;
|
||||
|
||||
constructor(_baseURL: string) {
|
||||
this.baseURL = _baseURL;
|
||||
}
|
||||
|
||||
setRefreshToken(token: string) {
|
||||
Cookies.set("refreshToken", token);
|
||||
}
|
||||
|
||||
getRefreshToken() {
|
||||
return Cookies.get("refreshToken");
|
||||
}
|
||||
|
||||
purgeRefreshToken() {
|
||||
Cookies.remove("refreshToken", { path: "/" });
|
||||
}
|
||||
|
||||
setAccessToken(token: string) {
|
||||
Cookies.set("accessToken", token);
|
||||
}
|
||||
|
||||
getAccessToken() {
|
||||
return Cookies.get("accessToken");
|
||||
}
|
||||
|
||||
purgeAccessToken() {
|
||||
Cookies.remove("accessToken", { path: "/" });
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.getAccessToken()}`,
|
||||
};
|
||||
}
|
||||
|
||||
get(url: string, config = {}): Promise<any> {
|
||||
return axios({
|
||||
method: "get",
|
||||
url: this.baseURL + url,
|
||||
headers: this.getAccessToken() ? this.getHeaders() : {},
|
||||
...config,
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL;
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
post(url: string, data = {}, config = {}): Promise<any> {
|
||||
return axios({
|
||||
method: "post",
|
||||
url: this.baseURL + url,
|
||||
data,
|
||||
headers: this.getAccessToken() ? this.getHeaders() : {},
|
||||
...config,
|
||||
});
|
||||
private setupInterceptors() {
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) window.location.href = "/";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
put(url: string, data = {}, config = {}): Promise<any> {
|
||||
return axios({
|
||||
method: "put",
|
||||
url: this.baseURL + url,
|
||||
data,
|
||||
headers: this.getAccessToken() ? this.getHeaders() : {},
|
||||
...config,
|
||||
});
|
||||
get(url: string, params = {}) {
|
||||
return this.axiosInstance.get(url, { params });
|
||||
}
|
||||
|
||||
patch(url: string, data = {}, config = {}): Promise<any> {
|
||||
return axios({
|
||||
method: "patch",
|
||||
url: this.baseURL + url,
|
||||
data,
|
||||
headers: this.getAccessToken() ? this.getHeaders() : {},
|
||||
...config,
|
||||
});
|
||||
post(url: string, data: any, config = {}) {
|
||||
return this.axiosInstance.post(url, data, config);
|
||||
}
|
||||
|
||||
delete(url: string, data?: any, config = {}): Promise<any> {
|
||||
return axios({
|
||||
method: "delete",
|
||||
url: this.baseURL + url,
|
||||
data: data,
|
||||
headers: this.getAccessToken() ? this.getHeaders() : {},
|
||||
...config,
|
||||
});
|
||||
put(url: string, data: any, config = {}) {
|
||||
return this.axiosInstance.put(url, data, config);
|
||||
}
|
||||
|
||||
mediaUpload(url: string, data = {}, config = {}): Promise<any> {
|
||||
return axios({
|
||||
method: "post",
|
||||
url: this.baseURL + url,
|
||||
data,
|
||||
headers: this.getAccessToken() ? { ...this.getHeaders(), "Content-Type": "multipart/form-data" } : {},
|
||||
...config,
|
||||
});
|
||||
patch(url: string, data: any, config = {}) {
|
||||
return this.axiosInstance.patch(url, data, config);
|
||||
}
|
||||
|
||||
delete(url: string, data?: any, config = {}) {
|
||||
return this.axiosInstance.delete(url, { data, ...config });
|
||||
}
|
||||
|
||||
request(config = {}) {
|
||||
return axios(config);
|
||||
return this.axiosInstance(config);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
// services
|
||||
import APIService from "@/services/api.service";
|
||||
// helper
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// types
|
||||
import { IAppConfig } from "types/app";
|
||||
|
||||
export class AppConfigService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async envConfig(): Promise<IAppConfig> {
|
||||
return this.get("/api/configs/", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,154 +1,46 @@
|
|||
// types
|
||||
import { ICsrfTokenData, IEmailCheckData, IEmailCheckResponse } from "@plane/types";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// services
|
||||
import APIService from "@/services/api.service";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { IEmailCheckData, IEmailCheckResponse, ILoginTokenResponse, IPasswordSignInData } from "types/auth";
|
||||
|
||||
export class AuthService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async requestCSRFToken(): Promise<ICsrfTokenData> {
|
||||
return this.get("/auth/get-csrf-token/")
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async emailCheck(data: IEmailCheckData): Promise<IEmailCheckResponse> {
|
||||
return this.post("/api/email-check/", data, { headers: {} })
|
||||
return this.post("/auth/spaces/email-check/", data, { headers: {} })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async emailLogin(data: any) {
|
||||
return this.post("/api/sign-in/", data, { headers: {} })
|
||||
.then((response) => {
|
||||
this.setAccessToken(response?.data?.access_token);
|
||||
this.setRefreshToken(response?.data?.refresh_token);
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async emailSignUp(data: { email: string; password: string }) {
|
||||
return this.post("/api/sign-up/", data, { headers: {} })
|
||||
.then((response) => {
|
||||
this.setAccessToken(response?.data?.access_token);
|
||||
this.setRefreshToken(response?.data?.refresh_token);
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async socialAuth(data: any): Promise<{
|
||||
access_token: string;
|
||||
refresh_toke: string;
|
||||
user: any;
|
||||
}> {
|
||||
return this.post("/api/social-auth/", data, { headers: {} })
|
||||
.then((response) => {
|
||||
this.setAccessToken(response?.data?.access_token);
|
||||
this.setRefreshToken(response?.data?.refresh_token);
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async passwordSignIn(data: IPasswordSignInData): Promise<ILoginTokenResponse> {
|
||||
return this.post("/api/sign-in/", data, { headers: {} })
|
||||
.then((response) => {
|
||||
this.setAccessToken(response?.data?.access_token);
|
||||
this.setRefreshToken(response?.data?.refresh_token);
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async sendResetPasswordLink(data: { email: string }): Promise<any> {
|
||||
return this.post(`/api/forgot-password/`, data)
|
||||
async sendResetPasswordLink(data: { email: string }): Promise<void> {
|
||||
return this.post(`/auth/forgot-password/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async emailCode(data: any) {
|
||||
return this.post("/api/magic-generate/", data, { headers: {} })
|
||||
async generateUniqueCode(data: { email: string }): Promise<void> {
|
||||
return this.post("/auth/spaces/magic-generate/", data, { headers: {} })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async forgotPassword(data: { email: string }): Promise<any> {
|
||||
return this.post(`/api/forgot-password/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async magicSignIn(data: any) {
|
||||
const response = await this.post("/api/magic-sign-in/", data, { headers: {} });
|
||||
if (response?.status === 200) {
|
||||
this.setAccessToken(response?.data?.access_token);
|
||||
this.setRefreshToken(response?.data?.refresh_token);
|
||||
return response?.data;
|
||||
}
|
||||
throw response.response.data;
|
||||
}
|
||||
|
||||
async generateUniqueCode(data: { email: string }): Promise<any> {
|
||||
return this.post("/api/magic-generate/", data, { headers: {} })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async resetPassword(
|
||||
uidb64: string,
|
||||
token: string,
|
||||
data: {
|
||||
new_password: string;
|
||||
}
|
||||
): Promise<ILoginTokenResponse> {
|
||||
return this.post(`/api/reset-password/${uidb64}/${token}/`, data, { headers: {} })
|
||||
.then((response) => {
|
||||
if (response?.status === 200) {
|
||||
this.setAccessToken(response?.data?.access_token);
|
||||
this.setRefreshToken(response?.data?.refresh_token);
|
||||
return response?.data;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async setPassword(data: { password: string }): Promise<any> {
|
||||
return this.post(`/api/users/me/set-password/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
return this.post("/api/sign-out/", { refresh_token: this.getRefreshToken() })
|
||||
.then((response) => {
|
||||
this.purgeAccessToken();
|
||||
this.purgeRefreshToken();
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.purgeAccessToken();
|
||||
this.purgeRefreshToken();
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
async signOut() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// services
|
||||
import APIService from "@/services/api.service";
|
||||
import axios from "axios";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import axios from "axios";
|
||||
// services
|
||||
import APIService from "@/services/api.service";
|
||||
|
||||
interface UnSplashImage {
|
||||
id: string;
|
||||
|
|
@ -43,7 +43,6 @@ class FileService extends APIService {
|
|||
this.cancelSource = axios.CancelToken.source();
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, {
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
cancelToken: this.cancelSource.token,
|
||||
|
|
@ -117,7 +116,6 @@ class FileService extends APIService {
|
|||
|
||||
async restoreImage(assetUrlWithWorkspaceId: string): Promise<any> {
|
||||
return this.post(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/restore/`, {
|
||||
headers: this.getHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
.then((response) => response?.status)
|
||||
|
|
@ -136,8 +134,13 @@ class FileService extends APIService {
|
|||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async uploadUserFile(file: FormData): Promise<any> {
|
||||
return this.mediaUpload(`/api/users/file-assets/`, file)
|
||||
return this.post(`/api/users/file-assets/`, file, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
|
|
|||
20
space/services/instance.service.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// types
|
||||
import type { IInstance } from "@plane/types";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// services
|
||||
import APIService from "@/services/api.service";
|
||||
|
||||
export class InstanceService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getInstanceInfo(): Promise<IInstance> {
|
||||
return this.get("/api/instances/")
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
// services
|
||||
import APIService from "@/services/api.service";
|
||||
// types
|
||||
import { IUser, TUserProfile } from "@plane/types";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// types
|
||||
import { IUser } from "types/user";
|
||||
// services
|
||||
import APIService from "@/services/api.service";
|
||||
|
||||
export class UserService extends APIService {
|
||||
constructor() {
|
||||
|
|
@ -18,11 +18,26 @@ export class UserService extends APIService {
|
|||
});
|
||||
}
|
||||
|
||||
async updateMe(data: any): Promise<any> {
|
||||
async updateUser(data: Partial<IUser>): Promise<IUser> {
|
||||
return this.patch("/api/users/me/", data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getCurrentUserProfile(): Promise<TUserProfile> {
|
||||
return this.get("/api/users/me/profile/")
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
async updateCurrentUserProfile(data: Partial<TUserProfile>): Promise<TUserProfile> {
|
||||
return this.patch("/api/users/me/profile/", data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
69
space/store/instance.store.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { observable, action, makeObservable, runInAction } from "mobx";
|
||||
// types
|
||||
import { IInstance } from "@plane/types";
|
||||
// services
|
||||
import { InstanceService } from "@/services/instance.service";
|
||||
// store types
|
||||
import { RootStore } from "@/store/root.store";
|
||||
|
||||
type TError = {
|
||||
status: string;
|
||||
message: string;
|
||||
data?: {
|
||||
is_activated: boolean;
|
||||
is_setup_done: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export interface IInstanceStore {
|
||||
// issues
|
||||
isLoading: boolean;
|
||||
instance: IInstance | undefined;
|
||||
error: TError | undefined;
|
||||
// action
|
||||
fetchInstanceInfo: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class InstanceStore implements IInstanceStore {
|
||||
isLoading: boolean = true;
|
||||
instance: IInstance | undefined = undefined;
|
||||
error: TError | undefined = undefined;
|
||||
// services
|
||||
instanceService;
|
||||
|
||||
constructor(private store: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
isLoading: observable.ref,
|
||||
instance: observable,
|
||||
error: observable,
|
||||
// actions
|
||||
fetchInstanceInfo: action,
|
||||
});
|
||||
// services
|
||||
this.instanceService = new InstanceService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description fetching instance information
|
||||
*/
|
||||
fetchInstanceInfo = async () => {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.error = undefined;
|
||||
const instance = await this.instanceService.getInstanceInfo();
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.instance = instance;
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Failed to fetch instance info",
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { observable, action, computed, makeObservable, runInAction } from "mobx"
|
|||
// services
|
||||
import IssueService from "@/services/issue.service";
|
||||
// store
|
||||
import { RootStore } from "./root";
|
||||
import { RootStore } from "./root.store";
|
||||
// types
|
||||
// import { IssueDetailType, TIssueBoardKeys } from "types/issue";
|
||||
import { IIssue, IIssueState, IIssueLabel } from "types/issue";
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { makeObservable, observable, action, runInAction } from "mobx";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// store
|
||||
import { RootStore } from "./root";
|
||||
// services
|
||||
import IssueService from "@/services/issue.service";
|
||||
import { IIssue, IVote } from "types/issue";
|
||||
// store types
|
||||
import { RootStore } from "@/store/root.store";
|
||||
// types
|
||||
import { IIssue, IVote } from "@/types/issue";
|
||||
|
||||
export type IPeekMode = "side" | "modal" | "full";
|
||||
|
||||
|
|
@ -330,7 +331,7 @@ class IssueDetailStore implements IIssueDetailStore {
|
|||
removeIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reactionHex: string) => {
|
||||
try {
|
||||
const newReactions = this.details[issueId].reactions.filter(
|
||||
(_r) => !(_r.reaction === reactionHex && _r.actor_detail.id === this.rootStore.user.currentUser?.id)
|
||||
(_r) => !(_r.reaction === reactionHex && _r.actor_detail.id === this.rootStore.user.data?.id)
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
|
|
@ -361,7 +362,7 @@ class IssueDetailStore implements IIssueDetailStore {
|
|||
|
||||
addIssueVote = async (workspaceSlug: string, projectId: string, issueId: string, data: { vote: 1 | -1 }) => {
|
||||
const newVote: IVote = {
|
||||
actor: this.rootStore.user.currentUser?.id ?? "",
|
||||
actor: this.rootStore.user.data?.id ?? "",
|
||||
actor_detail: this.rootStore.user.currentActor,
|
||||
issue: issueId,
|
||||
project: projectId,
|
||||
|
|
@ -369,7 +370,7 @@ class IssueDetailStore implements IIssueDetailStore {
|
|||
vote: data.vote,
|
||||
};
|
||||
|
||||
const filteredVotes = this.details[issueId].votes.filter((v) => v.actor !== this.rootStore.user.currentUser?.id);
|
||||
const filteredVotes = this.details[issueId].votes.filter((v) => v.actor !== this.rootStore.user.data?.id);
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
|
|
@ -400,7 +401,7 @@ class IssueDetailStore implements IIssueDetailStore {
|
|||
};
|
||||
|
||||
removeIssueVote = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
const newVotes = this.details[issueId].votes.filter((v) => v.actor !== this.rootStore.user.currentUser?.id);
|
||||
const newVotes = this.details[issueId].votes.filter((v) => v.actor !== this.rootStore.user.data?.id);
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// types
|
||||
import { RootStore } from "@/store/root";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
|
||||
export interface IIssueFilterBaseStore {
|
||||
// helper methods
|
||||
|
|
|
|||