diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx index 49089b5bd..d2d6ef753 100644 --- a/web/components/account/sign-in-forms/root.tsx +++ b/web/components/account/sign-in-forms/root.tsx @@ -31,7 +31,7 @@ export const SignInRoot: React.FC = (props) => { return ( <> -
+
{signInStep === ESignInSteps.EMAIL && ( setSignInStep(step)} diff --git a/web/components/common/index.ts b/web/components/common/index.ts index 57f8b6cca..04aaa8512 100644 --- a/web/components/common/index.ts +++ b/web/components/common/index.ts @@ -1,2 +1,3 @@ export * from "./product-updates-modal"; export * from "./empty-state"; +export * from "./latest-feature-block"; diff --git a/web/components/common/latest-feature-block.tsx b/web/components/common/latest-feature-block.tsx new file mode 100644 index 000000000..46309f6da --- /dev/null +++ b/web/components/common/latest-feature-block.tsx @@ -0,0 +1,35 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// icons +import { Lightbulb } from "lucide-react"; +// images +import signInIssues from "public/onboarding/onboarding-issues.svg"; + +export const LatestFeatureBlock = () => { + const { resolvedTheme } = useTheme(); + + return ( + <> +
+ +

+ Try the latest features, like Tiptap editor, to write compelling responses.{" "} + {}}> + See new features + +

+
+
+ Plane Issues +
+ + ); +}; diff --git a/web/components/instance/index.ts b/web/components/instance/index.ts index 0a8f87052..0d19304bb 100644 --- a/web/components/instance/index.ts +++ b/web/components/instance/index.ts @@ -8,3 +8,7 @@ export * from "./github-config-form"; export * from "./google-config-form"; export * from "./image-config-form"; export * from "./instance-admin-restriction"; +export * from "./not-ready-view"; +export * from "./setup-view"; +export * from "./setup-done-view"; +export * from "./setup-form"; diff --git a/web/components/instance/not-ready-view.tsx b/web/components/instance/not-ready-view.tsx new file mode 100644 index 000000000..501ab4d41 --- /dev/null +++ b/web/components/instance/not-ready-view.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +// image +import instanceNotReady from "public/instance/plane-instance-not-ready.webp"; +import PlaneWhiteLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg"; +import PlaneDarkLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg"; + +export const InstanceNotReady = () => { + const { resolvedTheme } = useTheme(); + + const planeLogo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneDarkLogo; + + return ( +
+
+
+
+
+ image +
+
+ image +
+
+

Your Plane instance isn’t ready yet

+

Ask your Instance Admin to complete set-up first.

+
+
+
+
+
+ ); +}; diff --git a/web/components/instance/setup-done-view.tsx b/web/components/instance/setup-done-view.tsx new file mode 100644 index 000000000..4885e006a --- /dev/null +++ b/web/components/instance/setup-done-view.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import Image from "next/image"; + +// ui +import { Button } from "@plane/ui"; +import { UserCog2 } from "lucide-react"; +// image +import instanceSetupDone from "public/instance-setup-done.svg"; +import PlaneLogo from "public/plane-logos/blue-without-text.png"; + +export const InstanceSetupDone = () => ( +
+
+
+
+
+ image + To the stratosphere now! +
+ +
+ image +
+ +
+ + Your instance is now ready for more security, more controls, and more intelligence. + + + +
+ Use this wisely. Remember, with great power comes great responsibility.🕷️ +
+
+
+
+
+
+); diff --git a/web/components/instance/setup-form/email-code-form.tsx b/web/components/instance/setup-form/email-code-form.tsx new file mode 100644 index 000000000..6264ba20f --- /dev/null +++ b/web/components/instance/setup-form/email-code-form.tsx @@ -0,0 +1,167 @@ +import { FC } from "react"; +import { useForm, Controller } from "react-hook-form"; +// ui +import { Input, Button } from "@plane/ui"; +// icons +import { XCircle } from "lucide-react"; +// services +import { AuthService } from "services/auth.service"; +const authService = new AuthService(); +// hooks +import useToast from "hooks/use-toast"; +import useTimer from "hooks/use-timer"; + +export interface InstanceSetupEmailCodeFormValues { + email: string; + token: string; +} + +export interface IInstanceSetupEmailCodeForm { + email: string; + handleNextStep: () => void; + moveBack: () => void; +} + +export const InstanceSetupEmailCodeForm: FC = (props) => { + const { handleNextStep, email, moveBack } = props; + // form info + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + email, + token: "", + }, + }); + // hooks + const { setToastAlert } = useToast(); + const { timer, setTimer } = useTimer(30); + // computed + const isResendDisabled = timer > 0 || isSubmitting; + + const handleEmailCodeFormSubmit = (formValues: InstanceSetupEmailCodeFormValues) => + authService + .instanceMagicSignIn({ key: `magic_${formValues.email}`, token: formValues.token }) + .then(() => { + reset(); + handleNextStep(); + }) + .catch((err) => { + setToastAlert({ + title: "Oops!", + type: "error", + message: err?.error, + }); + }); + + const resendMagicCode = () => { + setTimer(30); + authService + .instanceAdminEmailCode({ email }) + .then(() => { + // setCodeResending(false); + setTimer(30); + }) + .catch((err) => { + setToastAlert({ + title: "Oops!", + type: "error", + message: err?.error, + }); + }); + }; + + return ( +
+
+

+ Let’s secure your instance +

+
+

Paste the code you got at

+ {email} + below. +
+ +
+ + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + value + ) || "Email address is not valid", + }} + render={({ field: { value, onChange } }) => ( +
+ + moveBack()} + /> +
+ )} + /> +
+ {timer > 0 ? ( + Request new code in {timer}s + ) : isSubmitting ? ( + "Sending new code..." + ) : ( +
+ +
+ )} +
+ ( +
+ +
+ )} + /> + + +
+
+
+ ); +}; diff --git a/web/components/instance/setup-form/email-form.tsx b/web/components/instance/setup-form/email-form.tsx new file mode 100644 index 000000000..b2c336695 --- /dev/null +++ b/web/components/instance/setup-form/email-form.tsx @@ -0,0 +1,105 @@ +import { FC } from "react"; +import { useForm, Controller } from "react-hook-form"; +// ui +import { Input, Button } from "@plane/ui"; +// icons +import { XCircle } from "lucide-react"; +// services +import { AuthService } from "services/auth.service"; +const authService = new AuthService(); +// hooks +import useToast from "hooks/use-toast"; + +export interface InstanceSetupEmailFormValues { + email: string; +} + +export interface IInstanceSetupEmailForm { + handleNextStep: (email: string) => void; +} + +export const InstanceSetupEmailForm: FC = (props) => { + const { handleNextStep } = props; + // form info + const { + control, + handleSubmit, + setValue, + reset, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + email: "", + }, + }); + // hooks + const { setToastAlert } = useToast(); + + const handleEmailFormSubmit = (formValues: InstanceSetupEmailFormValues) => + authService + .instanceAdminEmailCode({ email: formValues.email }) + .then(() => { + reset(); + handleNextStep(formValues.email); + }) + .catch((err) => { + setToastAlert({ + title: "Oops!", + type: "error", + message: err?.error, + }); + }); + + return ( +
+
+

+ Let’s secure your instance +

+

+ Explore privacy options. Get AI features. Secure access.
Takes 2 minutes. +

+ +
+ + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + value + ) || "Email address is not valid", + }} + render={({ field: { value, onChange } }) => ( +
+ + {value.length > 0 && ( + setValue("email", "")} + /> + )} +
+ )} + /> +

+ Use your email address if you are the instance admin.
Use your admin’s e-mail if you are not. +

+ + +
+
+
+ ); +}; diff --git a/web/components/instance/setup-form/index.ts b/web/components/instance/setup-form/index.ts new file mode 100644 index 000000000..f7a18d5b6 --- /dev/null +++ b/web/components/instance/setup-form/index.ts @@ -0,0 +1,3 @@ +export * from "./email-code-form"; +export * from "./email-form"; +export * from "./root"; diff --git a/web/components/instance/setup-form/password-form.tsx b/web/components/instance/setup-form/password-form.tsx new file mode 100644 index 000000000..f963b960e --- /dev/null +++ b/web/components/instance/setup-form/password-form.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { useForm, Controller } from "react-hook-form"; +// ui +import { Input, Button } from "@plane/ui"; +// icons +import { XCircle } from "lucide-react"; +// services +import { AuthService } from "services/auth.service"; +const authService = new AuthService(); + +export interface InstanceSetupPasswordFormValues { + email: string; + password: string; +} + +export interface IInstanceSetupPasswordForm { + email: string; + onNextStep: () => void; + resetSteps: () => void; +} + +export const InstanceSetupPasswordForm: React.FC = (props) => { + const { onNextStep, email, resetSteps } = props; + // form info + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + email, + password: "", + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handlePasswordSubmit = (formData: InstanceSetupPasswordFormValues) => + authService.setInstanceAdminPassword({ password: formData.password }).then(() => { + onNextStep(); + }); + + return ( +
+
+

+ Moving to the runway +

+

+ {"Let's set a password so you can do away with codes."} +

+ +
+ + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + value + ) || "Email address is not valid", + }} + render={({ field: { value, onChange } }) => ( +
+ + resetSteps()} + /> +
+ )} + /> + +
+ ( +
+ +
+ )} + /> +
+

+ {"Whatever you choose now will be your account's password"} +

+ +
+
+
+ ); +}; diff --git a/web/components/instance/setup-form/root.tsx b/web/components/instance/setup-form/root.tsx new file mode 100644 index 000000000..ebc6c5649 --- /dev/null +++ b/web/components/instance/setup-form/root.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +// components +import { InstanceSetupEmailCodeForm } from "./email-code-form"; +import { InstanceSetupEmailForm } from "./email-form"; +import { InstanceSetupPasswordForm } from "./password-form"; +import { LatestFeatureBlock } from "components/common"; +import { InstanceSetupDone } from "components/instance"; + +export enum EInstanceSetupSteps { + EMAIL = "EMAIL", + VERIFY_CODE = "VERIFY_CODE", + PASSWORD = "PASSWORD", + DONE = "DONE", +} + +export const InstanceSetupFormRoot = () => { + // states + const [setupStep, setSetupStep] = useState(EInstanceSetupSteps.EMAIL); + const [email, setEmail] = useState(""); + + return ( + <> + {setupStep === EInstanceSetupSteps.DONE ? ( +
+ +
+ ) : ( +
+
+
+ {setupStep === EInstanceSetupSteps.EMAIL && ( + { + setEmail(email); + setSetupStep(EInstanceSetupSteps.VERIFY_CODE); + }} + /> + )} + + {setupStep === EInstanceSetupSteps.VERIFY_CODE && ( + { + setSetupStep(EInstanceSetupSteps.PASSWORD); + }} + moveBack={() => { + setSetupStep(EInstanceSetupSteps.EMAIL); + }} + /> + )} + + {setupStep === EInstanceSetupSteps.PASSWORD && ( + { + setSetupStep(EInstanceSetupSteps.DONE); + }} + resetSteps={() => { + setSetupStep(EInstanceSetupSteps.EMAIL); + }} + /> + )} +
+ +
+
+ )} + + ); +}; diff --git a/web/components/instance/setup-view.tsx b/web/components/instance/setup-view.tsx new file mode 100644 index 000000000..07d4455dd --- /dev/null +++ b/web/components/instance/setup-view.tsx @@ -0,0 +1,38 @@ +import { useEffect, useCallback } from "react"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +// components +import { InstanceSetupFormRoot } from "components/instance"; +// hooks +import { useMobxStore } from "lib/mobx/store-provider"; +// images +import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; + +export const InstanceSetupView = observer(() => { + // store + const { + user: { fetchCurrentUser }, + } = useMobxStore(); + + const mutateUserInfo = useCallback(() => { + fetchCurrentUser(); + }, [fetchCurrentUser]); + + useEffect(() => { + mutateUserInfo(); + }, [mutateUserInfo]); + + return ( + <> +
+
+
+ Plane Logo + Plane +
+
+ +
+ + ); +}); diff --git a/web/layouts/admin-layout/layout.tsx b/web/layouts/admin-layout/layout.tsx index cfe178e97..1a5c188eb 100644 --- a/web/layouts/admin-layout/layout.tsx +++ b/web/layouts/admin-layout/layout.tsx @@ -4,6 +4,9 @@ import { AdminAuthWrapper, UserAuthWrapper } from "layouts/auth-layout"; // components import { InstanceAdminSidebar } from "./sidebar"; import { InstanceAdminHeader } from "./header"; +import { InstanceSetupView } from "components/instance"; +// store +import { useMobxStore } from "lib/mobx/store-provider"; export interface IInstanceAdminLayout { children: ReactNode; @@ -11,6 +14,18 @@ export interface IInstanceAdminLayout { export const InstanceAdminLayout: FC = (props) => { const { children } = props; + // store + const { + instance: { instance }, + user: { currentUser }, + } = useMobxStore(); + // fetch + + console.log("instance", instance); + + if (instance?.is_setup_done === false) { + return ; + } return ( <> diff --git a/web/layouts/instance-layout/index.tsx b/web/layouts/instance-layout/index.tsx new file mode 100644 index 000000000..75aa374c1 --- /dev/null +++ b/web/layouts/instance-layout/index.tsx @@ -0,0 +1,52 @@ +import { FC, ReactNode, useEffect } from "react"; + +import useSWR from "swr"; + +// route +import { useRouter } from "next/router"; +// store +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { Spinner } from "@plane/ui"; +import { InstanceNotReady } from "components/instance"; + +type Props = { + children: ReactNode; +}; + +const InstanceLayout: FC = observer(({ children }) => { + // store + const { + instance: { fetchInstanceInfo, instance, createInstance }, + } = useMobxStore(); + + const router = useRouter(); + const isGodMode = router.pathname.includes("god-mode"); + + useSWR("INSTANCE_INFO", () => fetchInstanceInfo()); + + useEffect(() => { + if (instance?.is_activated === false) { + createInstance(); + } + }, [instance?.is_activated, createInstance]); + + return ( +
+ {instance ? ( + !instance.is_setup_done && !isGodMode ? ( + + ) : ( + children + ) + ) : ( +
+ +
+ )} +
+ ); +}); + +export default InstanceLayout; diff --git a/web/layouts/settings-layout/profile/sidebar.tsx b/web/layouts/settings-layout/profile/sidebar.tsx index 513bf72b8..487c53078 100644 --- a/web/layouts/settings-layout/profile/sidebar.tsx +++ b/web/layouts/settings-layout/profile/sidebar.tsx @@ -102,21 +102,20 @@ export const ProfileLayoutSidebar = observer(() => { } ${sidebarCollapsed ? "left-0" : "-left-full md:left-0"}`} >
-
- - + + + - - - - {!sidebarCollapsed && ( -

Profile settings

- )} -
+ + {!sidebarCollapsed && ( +

Profile settings

+ )} + +
{!sidebarCollapsed && (
Your account
diff --git a/web/lib/app-provider.tsx b/web/lib/app-provider.tsx index 3a822401c..ba882f174 100644 --- a/web/lib/app-provider.tsx +++ b/web/lib/app-provider.tsx @@ -3,8 +3,15 @@ import dynamic from "next/dynamic"; import Router from "next/router"; import NProgress from "nprogress"; import { observer } from "mobx-react-lite"; +import { ThemeProvider } from "next-themes"; // mobx store provider import { useMobxStore } from "lib/mobx/store-provider"; +// constants +import { THEMES } from "constants/themes"; +// layouts +import InstanceLayout from "layouts/instance-layout"; +// contexts +import { ToastContextProvider } from "contexts/toast.context"; // dynamic imports const StoreWrapper = dynamic(() => import("lib/wrappers/store-wrapper"), { ssr: false }); const PosthogWrapper = dynamic(() => import("lib/wrappers/posthog-wrapper"), { ssr: false }); @@ -29,18 +36,24 @@ export const AppProvider: FC = observer((props) => { } = useMobxStore(); return ( - - - - {children} - - - + + + + + + + {children} + + + + + + ); }); diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index 9f2cf995b..c6fb89772 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -1,7 +1,6 @@ import { ReactElement } from "react"; import Head from "next/head"; import { AppProps } from "next/app"; -import { ThemeProvider } from "next-themes"; // styles import "styles/globals.css"; import "styles/editor.css"; @@ -9,10 +8,7 @@ import "styles/table.css"; import "styles/command-pallette.css"; import "styles/nprogress.css"; import "styles/react-datepicker.css"; -// contexts -import { ToastContextProvider } from "contexts/toast.context"; // constants -import { THEMES } from "constants/themes"; import { SITE_TITLE } from "constants/seo-variables"; // mobx store provider import { MobxStoreProvider } from "lib/mobx/store-provider"; @@ -34,11 +30,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { {SITE_TITLE} - - - {getLayout()} - - + {getLayout()} ); diff --git a/web/public/instance-not-ready.svg b/web/public/instance-not-ready.svg new file mode 100644 index 000000000..393187bdb --- /dev/null +++ b/web/public/instance-not-ready.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/instance-setup-done.svg b/web/public/instance-setup-done.svg new file mode 100644 index 000000000..3474f7f38 --- /dev/null +++ b/web/public/instance-setup-done.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/instance/plane-instance-not-ready.webp b/web/public/instance/plane-instance-not-ready.webp new file mode 100644 index 000000000..a0efca52c Binary files /dev/null and b/web/public/instance/plane-instance-not-ready.webp differ diff --git a/web/services/auth.service.ts b/web/services/auth.service.ts index 76d2cedc8..43511d33d 100644 --- a/web/services/auth.service.ts +++ b/web/services/auth.service.ts @@ -80,6 +80,18 @@ export class AuthService extends APIService { }); } + async setInstanceAdminPassword(data: any): Promise { + return this.post("/api/licenses/instances/admins/set-password/", data) + .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 { return this.post("/api/social-auth/", data, { headers: {} }) .then((response) => { @@ -100,6 +112,14 @@ export class AuthService extends APIService { }); } + async instanceAdminEmailCode(data: any): Promise { + return this.post("/api/licenses/instances/admins/magic-generate/", data, { headers: {} }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async magicSignIn(data: IMagicSignInData): Promise { return await this.post("/api/magic-sign-in/", data, { headers: {} }) .then((response) => { @@ -114,6 +134,16 @@ export class AuthService extends APIService { }); } + async instanceMagicSignIn(data: any): Promise { + const response = await this.post("/api/licenses/instances/admins/magic-sign-in/", data); + if (response?.status === 200) { + this.setAccessToken(response?.data?.access_token); + this.setRefreshToken(response?.data?.refresh_token); + return response?.data; + } + throw response.response.data; + } + async signOut(): Promise { return this.post("/api/sign-out/", { refresh_token: this.getRefreshToken() }) .then((response) => { diff --git a/web/services/instance.service.ts b/web/services/instance.service.ts index 6c1eb3556..4be5311bc 100644 --- a/web/services/instance.service.ts +++ b/web/services/instance.service.ts @@ -15,7 +15,15 @@ export class InstanceService extends APIService { } async getInstanceInfo(): Promise { - return this.get("/api/licenses/instances/") + return this.get("/api/licenses/instances/", { headers: {} }) + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + async createInstance(): Promise { + return this.post("/api/licenses/instances/", {}, { headers: {} }) .then((response) => response.data) .catch((error) => { throw error; diff --git a/web/store/instance/instance.store.ts b/web/store/instance/instance.store.ts index 644128e68..81db85ae8 100644 --- a/web/store/instance/instance.store.ts +++ b/web/store/instance/instance.store.ts @@ -17,6 +17,7 @@ export interface IInstanceStore { formattedConfig: IFormattedInstanceConfiguration | null; // action fetchInstanceInfo: () => Promise; + createInstance: () => Promise; fetchInstanceAdmins: () => Promise; updateInstanceInfo: (data: Partial) => Promise; fetchInstanceConfigurations: () => Promise; @@ -45,6 +46,7 @@ export class InstanceStore implements IInstanceStore { formattedConfig: computed, // actions fetchInstanceInfo: action, + createInstance: action, fetchInstanceAdmins: action, updateInstanceInfo: action, fetchInstanceConfigurations: action, @@ -84,6 +86,22 @@ export class InstanceStore implements IInstanceStore { } }; + /** + * Creating new Instance In case of no instance found + */ + createInstance = async () => { + try { + const instance = await this.instanceService.createInstance(); + runInAction(() => { + this.instance = instance; + }); + return instance; + } catch (error) { + console.log("Error while creating the instance"); + throw error; + } + }; + /** * fetch instance admins from API */ diff --git a/web/types/instance.d.ts b/web/types/instance.d.ts index b97f9cd7e..e11b6add8 100644 --- a/web/types/instance.d.ts +++ b/web/types/instance.d.ts @@ -16,6 +16,8 @@ export interface IInstance { is_support_required: boolean; created_by: string | null; updated_by: string | null; + is_activated: boolean; + is_setup_done: boolean; } export interface IInstanceConfiguration { diff --git a/web/types/users.d.ts b/web/types/users.d.ts index ba5527528..0d81ba2a7 100644 --- a/web/types/users.d.ts +++ b/web/types/users.d.ts @@ -16,6 +16,7 @@ export interface IUser { is_onboarded: boolean; is_password_autoset: boolean; is_tour_completed: boolean; + is_password_autoset: boolean; mobile_number: string | null; role: string | null; onboarding_step: {