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>
This commit is contained in:
sriram veeraghanta 2024-05-08 23:01:20 +05:30 committed by GitHub
parent ae43d05714
commit 59335618b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
903 changed files with 25736 additions and 16041 deletions

View file

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

View 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>
);
};

View file

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

View file

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

View 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>
);
};

View 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>
);
});

View 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>
);
};

View file

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

View file

@ -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} />
</>
);
};

View file

@ -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";

View 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>
);
};

View 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>
);
};

View file

@ -0,0 +1,3 @@
export * from "./oauth-options";
export * from "./google-button";
export * from "./github-button";

View 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>
</>
);
});

View file

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

View 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>
);
};

View file

@ -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>
</>
);
};

View file

@ -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>
</>
);
};

View file

@ -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";

View file

@ -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>
</>
);
});

View file

@ -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>
</>
);
};

View file

@ -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>
</>
);
};

View file

@ -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 />
</>
);
});

View file

@ -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>
</>
);
};

View file

@ -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>
</>
);
};

View file

@ -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>
</>
);
};

View 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>
);
};

View 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>
);
});

View file

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

View file

@ -0,0 +1 @@
export * from "./not-ready-view";

View 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>
);
};

View file

@ -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 }) => {

View file

@ -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 }) => {

View file

@ -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(() => {

View file

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

View file

@ -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 }) => {

View file

@ -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(() => {

View file

@ -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(() => {

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

@ -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"
: ""
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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>
)}
</>
)}
</>
);
});

View file

@ -1 +1,2 @@
export * from "./login";
export * from "./auth";
export * from "./project-details";

View file

@ -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>
)}
</>
)}
</>
);
});

View file

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

View 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.&nbsp;
<Link
className="underline underline-offset-4 font-medium hover:font-bold transition-all"
href={`/accounts/sign-in${email ? `?email=${email}` : ``}`}
>
Sign In
</Link>
&nbsp;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.&nbsp;
<Link
className="underline underline-offset-4 font-medium hover:font-bold transition-all"
href={`/${email ? `?email=${email}` : ``}`}
>
Create one
</Link>
&nbsp;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;
};

View 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;
};

View file

@ -0,0 +1,4 @@
export * from "./user-mobx-provider";
export * from "./use-instance";
export * from "./user";

View 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;
};

View 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;
};

View file

@ -0,0 +1,2 @@
export * from "./use-user";
export * from "./use-user-profile";

View 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;
};

View 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;
};

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
};

View 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>;
};

View 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}</>;
});

View file

@ -0,0 +1,2 @@
export * from "./instance-wrapper";
export * from "./auth-wrapper";

View 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}</>;
});

View file

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

View file

@ -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": "*",

View file

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

View file

@ -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>
</>
);
}

View 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;

View file

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

View 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;

View file

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

View file

@ -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">
Lets 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;

View file

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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 729 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 730 KiB

View file

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

View file

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

View file

@ -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() {}
}

View file

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

View 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;
});
}
}

View file

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

View 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",
};
});
}
};
}

View file

@ -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";

View file

@ -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(() => {

View file

@ -1,5 +1,5 @@
// types
import { RootStore } from "@/store/root";
import { RootStore } from "@/store/root.store";
export interface IIssueFilterBaseStore {
// helper methods

Some files were not shown because too many files have changed in this diff Show more