feat: changemod space

This commit is contained in:
sriram veeraghanta 2024-05-12 19:15:10 +05:30
parent 243680132e
commit 9eac78ae83
35 changed files with 442 additions and 799 deletions

View File

@ -13,7 +13,7 @@ module.exports = {
plugins: ["react", "@typescript-eslint", "import"], plugins: ["react", "@typescript-eslint", "import"],
settings: { settings: {
next: { next: {
rootDir: ["web/", "space/", "packages/*/"], rootDir: ["web/", "space/", "admin/", "packages/*/"],
}, },
}, },
rules: { rules: {

View File

@ -0,0 +1,30 @@
import { observer } from "mobx-react-lite";
import Image from "next/image";
// components
import IssueNavbar from "@/components/issues/navbar";
// assets
import planeLogo from "public/plane-logo.svg";
const ProjectLayout = ({ children }: { children: React.ReactNode }) => (
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
<IssueNavbar />
</div>
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
<a
href="https://plane.so"
className="fixed bottom-2.5 right-5 !z-[999999] flex items-center gap-1 rounded border border-custom-border-200 bg-custom-background-100 px-2 py-1 shadow-custom-shadow-2xs"
target="_blank"
rel="noreferrer noopener"
>
<div className="relative grid h-6 w-6 place-items-center">
<Image src={planeLogo} alt="Plane logo" className="h-6 w-6" height="24" width="24" />
</div>
<div className="text-xs">
Powered by <span className="font-semibold">Plane Deploy</span>
</div>
</a>
</div>
);
export default observer(ProjectLayout);

View File

@ -35,9 +35,6 @@ const WorkspaceProjectPage = (props: any) => {
return ( return (
<AuthWrapper pageType={EPageTypes.AUTHENTICATED}> <AuthWrapper pageType={EPageTypes.AUTHENTICATED}>
<ProjectLayout> <ProjectLayout>
<Head>
<title>{SITE_TITLE}</title>
</Head>
<ProjectDetailsView /> <ProjectDetailsView />
</ProjectLayout> </ProjectLayout>
</AuthWrapper> </AuthWrapper>

5
space/app/error.tsx Normal file
View File

@ -0,0 +1,5 @@
"use client";
export default function InstanceError() {
return <div>Instance Error</div>;
}

49
space/app/layout.tsx Normal file
View File

@ -0,0 +1,49 @@
import { Metadata } from "next";
// styles
import "@/styles/globals.css";
// components
import { InstanceNotReady } from "@/components/instance";
// lib
import { AppProvider } from "@/lib/app-providers";
// services
import { InstanceService } from "@/services/instance.service";
const instanceService = new InstanceService();
export const metadata: Metadata = {
title: "Plane Deploy | Make your Plane boards public with one-click",
description: "Plane Deploy is a customer feedback management tool built on top of plane.so",
openGraph: {
title: "Plane Deploy | Make your Plane boards public with one-click",
description: "Plane Deploy is a customer feedback management tool built on top of plane.so",
url: "https://sites.plane.so/",
},
keywords:
"software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration",
twitter: {
site: "@planepowers",
},
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const instanceDetails = await instanceService.getInstanceInfo();
return (
<html lang="en">
<head>
{/* <link rel="apple-touch-icon" sizes="180x180" href={`${prefix}favicon/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${prefix}favicon/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${prefix}favicon/favicon-16x16.png`} />
<link rel="manifest" href={`${prefix}site.webmanifest.json`} />
<link rel="shortcut icon" href={`${prefix}favicon/favicon.ico`} /> */}
</head>
<body>
{!instanceDetails?.instance?.is_setup_done ? (
<InstanceNotReady />
) : (
<AppProvider initialState={{ instance: instanceDetails.instance }}>{children}</AppProvider>
)}
</body>
</html>
);
}

20
space/app/page.tsx Normal file
View File

@ -0,0 +1,20 @@
"use client";
// components
import { AuthView } from "@/components/views";
// helpers
import { EPageTypes } from "@/helpers/authentication.helper";
import { useInstance } from "@/hooks/store";
// wrapper
import { AuthWrapper } from "@/lib/wrappers";
export default function HomePage() {
const { data } = useInstance();
console.log("data", data);
return (
<AuthWrapper pageType={EPageTypes.INIT}>
<AuthView />
</AuthWrapper>
);
}

View File

@ -1,40 +1,33 @@
"use client";
import { FC } from "react"; import { FC } from "react";
import Image from "next/image"; import Image from "next/image";
import { useTheme } from "next-themes";
// icons
import { UserCog2 } from "lucide-react";
// ui // ui
import { getButtonStyling } from "@plane/ui"; import { Button } from "@plane/ui";
// helper
import { ADMIN_BASE_URL, ADMIN_BASE_PATH } from "@/helpers/common.helper";
// images // images
import instanceNotReady from "public/instance/plane-instance-not-ready.webp"; import PlaneTakeOffImage from "@/public/instance/plane-takeoff.png";
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 = () => { export const InstanceNotReady: FC = () => {
const { resolvedTheme } = useTheme(); const GOD_MODE_URL = encodeURI(ADMIN_BASE_URL + ADMIN_BASE_PATH);
const planeLogo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo;
return ( return (
<div className="h-screen w-full overflow-y-auto bg-onboarding-gradient-100"> <div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center pt-12">
<div className="h-full w-full pt-24"> <div className="w-auto max-w-2xl relative space-y-8 py-10">
<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 flex flex-col justify-center items-center space-y-4">
<div className="relative h-full rounded-t-md bg-onboarding-gradient-200 px-7 sm:px-0"> <h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
<div className="flex items-center justify-center py-10"> <Image src={PlaneTakeOffImage} alt="Plane Logo" />
<Image src={planeLogo} className="h-[44px] w-full" alt="Plane logo" /> <p className="font-medium text-base text-onboarding-text-400">
</div> Get started by setting up your instance and workspace
<div className="mt-20"> </p>
<Image src={instanceNotReady} className="w-full" alt="Instance not ready" /> </div>
</div> <div>
<div className="flex w-full flex-col items-center gap-5 py-12 pb-20"> <a href={GOD_MODE_URL}>
<h3 className="text-2xl font-medium">Your Plane instance isn{"'"}t ready yet</h3> <Button size="lg" className="w-full">
<p className="text-sm">Ask your Instance Admin to complete set-up first.</p> Get started
<a href="/god-mode" className={`${getButtonStyling("primary", "md")} mt-4`}> </Button>
<UserCog2 className="h-3.5 w-3.5" /> </a>
Get started
</a>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,3 +1,5 @@
"use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Link from "next/link"; import Link from "next/link";

View File

@ -1,3 +1,5 @@
"use client";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Image from "next/image"; import Image from "next/image";
// ui // ui
@ -17,7 +19,7 @@ export const AuthView = observer(() => {
// hooks // hooks
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
// store // store
const { data: currentUser, fetchCurrentUser, isLoading } = useUser(); const { fetchCurrentUser, isLoading, isAuthenticated } = useUser();
// fetching user information // fetching user information
const { isLoading: isSWRLoading } = useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), { const { isLoading: isSWRLoading } = useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), {
@ -33,7 +35,7 @@ export const AuthView = observer(() => {
</div> </div>
) : ( ) : (
<> <>
{currentUser ? ( {isAuthenticated ? (
<UserLoggedIn /> <UserLoggedIn />
) : ( ) : (
<div className="relative w-screen h-screen overflow-hidden"> <div className="relative w-screen h-screen overflow-hidden">

View File

@ -3,4 +3,9 @@ import { twMerge } from "tailwind-merge";
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL ?? "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH ?? "";
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL ?? "";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));

View File

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

View File

@ -1,6 +1,6 @@
import { useContext } from "react"; import { useContext } from "react";
// store // store
import { StoreContext } from "@/lib/store-context"; import { StoreContext } from "@/lib/app-providers";
import { IInstanceStore } from "@/store/instance.store"; import { IInstanceStore } from "@/store/instance.store";
export const useInstance = (): IInstanceStore => { export const useInstance = (): IInstanceStore => {

View File

@ -1,10 +0,0 @@
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

@ -1,10 +1,10 @@
import { useContext } from "react"; import { useContext } from "react";
// store // store
import { StoreContext } from "@/lib/store-context"; import { StoreContext } from "@/lib/app-providers";
import { IProfileStore } from "@/store/user/profile.store"; import { IProfileStore } from "@/store/user/profile.store";
export const useUserProfile = (): IProfileStore => { export const useUserProfile = (): IProfileStore => {
const context = useContext(StoreContext); const context = useContext(StoreContext);
if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider");
return context.profile; return context.user.profile;
}; };

View File

@ -1,7 +1,8 @@
import { useContext } from "react"; import { useContext } from "react";
// lib
import { StoreContext } from "@/lib/app-providers";
// store // store
import { StoreContext } from "@/lib/store-context"; import { IUserStore } from "@/store/user.store";
import { IUserStore } from "@/store/user";
export const useUser = (): IUserStore => { export const useUser = (): IUserStore => {
const context = useContext(StoreContext); const context = useContext(StoreContext);

View File

@ -0,0 +1,38 @@
"use client";
import { ReactNode, createContext } from "react";
import { ThemeProvider } from "next-themes";
// store
import { RootStore } from "@/store/root.store";
let rootStore = new RootStore();
export const StoreContext = createContext(rootStore);
function initializeStore(initialData = {}) {
const singletonRootStore = rootStore ?? new RootStore();
// If your page has Next.js data fetching methods that use a Mobx store, it will
// get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details
if (initialData) {
singletonRootStore.hydrate(initialData);
}
// For SSG and SSR always create a new store
if (typeof window === "undefined") return singletonRootStore;
// Create the store once in the client
if (!rootStore) rootStore = singletonRootStore;
return singletonRootStore;
}
export type AppProviderProps = {
children: ReactNode;
initialState: any;
};
export const AppProvider = ({ children, initialState = {} }: AppProviderProps) => {
const store = initializeStore(initialState);
return (
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<StoreContext.Provider value={store}>{children}</StoreContext.Provider>
</ThemeProvider>
);
};

View File

@ -1 +0,0 @@
export const init = {};

View File

@ -1,19 +0,0 @@
import { ReactElement, createContext } from "react";
// mobx store
import { RootStore } from "@/store/root.store";
export 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

@ -1,6 +1,6 @@
import { FC, ReactNode } from "react"; import { FC, ReactNode } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
import { Spinner } from "@plane/ui"; import { Spinner } from "@plane/ui";
// helpers // helpers

View File

@ -4,9 +4,9 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo run develop", "dev": "turbo run develop",
"develop": "next dev -p 4000", "develop": "next dev -p 3002",
"build": "next build", "build": "next build",
"start": "next start -p 4000", "start": "next start",
"lint": "next lint", "lint": "next lint",
"export": "next export" "export": "next export"
}, },

View File

@ -1,42 +0,0 @@
// next imports
import { observer } from "mobx-react-lite";
import Image from "next/image";
// hooks
import { useInstance } from "@/hooks/store";
// images
import notFoundImage from "public/404.svg";
const Custom404Error = observer(() => {
// hooks
const { instance } = useInstance();
const redirectionUrl = instance?.config?.app_base_url || "/";
return (
<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={notFoundImage} layout="fill" alt="404- Page not found" />
</div>
<div className="text-xl font-medium">Oops! Something went wrong.</div>
<div className="text-sm text-custom-text-200">
Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is
temporarily unavailable.
</div>
</div>
<div className="flex items-center justify-center text-center">
<a
href={redirectionUrl}
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>
);
});
export default Custom404Error;

View File

@ -1,5 +0,0 @@
const WorkspaceProjectPage = () => (
<div className="relative flex h-screen w-screen items-center justify-center text-5xl">Plane Workspace Space</div>
);
export default WorkspaceProjectPage;

View File

@ -1,47 +0,0 @@
import type { AppProps } from "next/app";
import Head from "next/head";
import { ThemeProvider } from "next-themes";
// styles
import "@/styles/globals.css";
// contexts
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 { StoreProvider } from "@/lib/store-context";
// wrappers
import { InstanceWrapper } from "@/lib/wrappers";
const prefix = "/spaces/";
function MyApp({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<title>{SITE_TITLE}</title>
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:title" content={SITE_TITLE} />
<meta property="og:url" content={SITE_URL} />
<meta name="description" content={SITE_DESCRIPTION} />
<meta property="og:description" content={SITE_DESCRIPTION} />
<meta name="keywords" content={SITE_KEYWORDS} />
<meta name="twitter:site" content={`@${TWITTER_USER_NAME}`} />
<link rel="apple-touch-icon" sizes="180x180" href={`${prefix}favicon/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${prefix}favicon/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${prefix}favicon/favicon-16x16.png`} />
<link rel="manifest" href={`${prefix}site.webmanifest.json`} />
<link rel="shortcut icon" href={`${prefix}favicon/favicon.ico`} />
</Head>
<StoreProvider>
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<ToastContextProvider>
<InstanceWrapper>
<Component {...pageProps} />
</InstanceWrapper>
</ToastContextProvider>
</ThemeProvider>
</StoreProvider>
</>
);
}
export default MyApp;

View File

@ -1,18 +0,0 @@
import Document, { Html, Head, Main, NextScript } from "next/document";
class MyDocument extends Document {
render() {
return (
<Html>
<Head />
<body className="w-100 bg-custom-background-100 antialiased">
<div id="context-menu-portal" />
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;

View File

@ -1,166 +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";
// 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,205 +0,0 @@
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,16 +0,0 @@
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
// components
import { AuthView } from "@/components/views";
// helpers
import { EPageTypes } from "@/helpers/authentication.helper";
// wrapper
import { AuthWrapper } from "@/lib/wrappers";
const Index: NextPage = observer(() => (
<AuthWrapper pageType={EPageTypes.INIT}>
<AuthView />
</AuthWrapper>
));
export default Index;

View File

@ -1,131 +0,0 @@
import React from "react";
import { observer } from "mobx-react-lite";
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 = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
const OnBoardingPage = observer(() => {
// router
const router = useRouter();
const { next_path } = router.query;
// hooks
const { resolvedTheme } = useTheme();
const { data: user } = useUser();
const { data: currentUserProfile, updateUserProfile } = useUserProfile();
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 (
<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="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="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>
</AuthWrapper>
);
});
export default OnBoardingPage;

View File

@ -1,49 +0,0 @@
// next imports
import { observer } from "mobx-react-lite";
import Image from "next/image";
// helpers
import { EPageTypes } from "@/helpers/authentication.helper";
// hooks
import { useInstance } from "@/hooks/store";
// wrappers
import { AuthWrapper } from "@/lib/wrappers";
// images
import projectNotPublishedImage from "@/public/project-not-published.svg";
const CustomProjectNotPublishedError = observer(() => {
// hooks
const { instance } = useInstance();
const redirectionUrl = instance?.config?.app_base_url || "/";
return (
<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="flex items-center justify-center text-center">
<a
href={redirectionUrl}
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>
</AuthWrapper>
);
});
export default CustomProjectNotPublishedError;

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
// store // store
import { rootStore } from "@/lib/store-context"; // import { rootStore } from "@/lib/store-context";
abstract class APIService { abstract class APIService {
protected baseURL: string; protected baseURL: string;
@ -18,14 +18,14 @@ abstract class APIService {
} }
private setupInterceptors() { private setupInterceptors() {
this.axiosInstance.interceptors.response.use( // this.axiosInstance.interceptors.response.use(
(response) => response, // (response) => response,
(error) => { // (error) => {
const store = rootStore; // const store = rootStore;
if (error.response && error.response.status === 401 && store.user.data) store.user.reset(); // if (error.response && error.response.status === 401 && store.user.data) store.user.reset();
return Promise.reject(error); // return Promise.reject(error);
} // }
); // );
} }
get(url: string, params = {}) { get(url: string, params = {}) {

View File

@ -18,15 +18,18 @@ type TError = {
export interface IInstanceStore { export interface IInstanceStore {
// issues // issues
isLoading: boolean; isLoading: boolean;
instance: IInstance | undefined; data: IInstance | NonNullable<unknown>;
config: Record<string, unknown>;
error: TError | undefined; error: TError | undefined;
// action // action
fetchInstanceInfo: () => Promise<void>; fetchInstanceInfo: () => Promise<void>;
hydrate: (data: Record<string, unknown>, config: Record<string, unknown>) => void;
} }
export class InstanceStore implements IInstanceStore { export class InstanceStore implements IInstanceStore {
isLoading: boolean = true; isLoading: boolean = true;
instance: IInstance | undefined = undefined; data: IInstance | Record<string, unknown> = {};
config: Record<string, unknown> = {};
error: TError | undefined = undefined; error: TError | undefined = undefined;
// services // services
instanceService; instanceService;
@ -35,15 +38,22 @@ export class InstanceStore implements IInstanceStore {
makeObservable(this, { makeObservable(this, {
// observable // observable
isLoading: observable.ref, isLoading: observable.ref,
instance: observable, data: observable,
config: observable,
error: observable, error: observable,
// actions // actions
fetchInstanceInfo: action, fetchInstanceInfo: action,
hydrate: action,
}); });
// services // services
this.instanceService = new InstanceService(); this.instanceService = new InstanceService();
} }
hydrate = (data: Record<string, unknown>, config: Record<string, unknown>) => {
this.data = { ...this.data, ...data };
this.config = { ...this.config, ...config };
};
/** /**
* @description fetching instance information * @description fetching instance information
*/ */
@ -51,10 +61,11 @@ export class InstanceStore implements IInstanceStore {
try { try {
this.isLoading = true; this.isLoading = true;
this.error = undefined; this.error = undefined;
const instance = await this.instanceService.getInstanceInfo(); const instanceDetails = await this.instanceService.getInstanceInfo();
runInAction(() => { runInAction(() => {
this.isLoading = false; this.isLoading = false;
this.instance = instance; this.data = instanceDetails.instance;
this.config = instanceDetails.config;
}); });
} catch (error) { } catch (error) {
runInAction(() => { runInAction(() => {

View File

@ -1,52 +1,60 @@
// mobx lite
import { enableStaticRendering } from "mobx-react-lite"; import { enableStaticRendering } from "mobx-react-lite";
// store imports // store imports
import { IInstanceStore, InstanceStore } from "@/store/instance.store"; import { IInstanceStore, InstanceStore } from "@/store/instance.store";
import { IProjectStore, ProjectStore } from "@/store/project"; import { IUserStore, UserStore } from "@/store/user.store";
import { IUserStore, UserStore } from "@/store/user";
import { IProfileStore, ProfileStore } from "@/store/user/profile.store";
import IssueStore, { IIssueStore } from "./issue"; // import { IProjectStore, ProjectStore } from "@/store/project";
import IssueDetailStore, { IIssueDetailStore } from "./issue_details"; // import { IProfileStore, ProfileStore } from "@/store/user/profile.store";
import { IIssuesFilterStore, IssuesFilterStore } from "./issues/issue-filters.store";
import { IMentionsStore, MentionsStore } from "./mentions.store"; // import IssueStore, { IIssueStore } from "./issue";
// import IssueDetailStore, { IIssueDetailStore } from "./issue_details";
// import { IIssuesFilterStore, IssuesFilterStore } from "./issues/issue-filters.store";
// import { IMentionsStore, MentionsStore } from "./mentions.store";
enableStaticRendering(typeof window === "undefined"); enableStaticRendering(typeof window === "undefined");
export class RootStore { export class RootStore {
instance: IInstanceStore; instance: IInstanceStore;
user: IUserStore; user: IUserStore;
profile: IProfileStore; // profile: IProfileStore;
project: IProjectStore; // project: IProjectStore;
issue: IIssueStore; // issue: IIssueStore;
issueDetails: IIssueDetailStore; // issueDetails: IIssueDetailStore;
mentionsStore: IMentionsStore; // mentionsStore: IMentionsStore;
issuesFilter: IIssuesFilterStore; // issuesFilter: IIssuesFilterStore;
constructor() { constructor() {
// makeObservable(this, {
// instance: observable,
// });
this.instance = new InstanceStore(this); this.instance = new InstanceStore(this);
this.user = new UserStore(this); this.user = new UserStore(this);
this.profile = new ProfileStore(this); // this.profile = new ProfileStore(this);
this.project = new ProjectStore(this); // this.project = new ProjectStore(this);
this.issue = new IssueStore(this); // this.issue = new IssueStore(this);
this.issueDetails = new IssueDetailStore(this); // this.issueDetails = new IssueDetailStore(this);
this.mentionsStore = new MentionsStore(this); // this.mentionsStore = new MentionsStore(this);
this.issuesFilter = new IssuesFilterStore(this); // this.issuesFilter = new IssuesFilterStore(this);
} }
resetOnSignOut = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
localStorage.setItem("theme", "system"); hydrate = (data: any) => {
if (!data) return;
this.instance.hydrate(data?.instance || {}, data?.config || {});
this.user.hydrate(data?.user || {});
};
reset = () => {
localStorage.setItem("theme", "system");
this.instance = new InstanceStore(this); this.instance = new InstanceStore(this);
this.user = new UserStore(this); this.user = new UserStore(this);
this.profile = new ProfileStore(this); // this.profile = new ProfileStore(this);
this.project = new ProjectStore(this); // this.project = new ProjectStore(this);
// this.issue = new IssueStore(this);
this.issue = new IssueStore(this); // this.issueDetails = new IssueDetailStore(this);
this.issueDetails = new IssueDetailStore(this); // this.mentionsStore = new MentionsStore(this);
this.mentionsStore = new MentionsStore(this); // this.issuesFilter = new IssuesFilterStore(this);
this.issuesFilter = new IssuesFilterStore(this);
}; };
} }

183
space/store/user.store.ts Normal file
View File

@ -0,0 +1,183 @@
import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
// types
import { IUser } from "@plane/types";
// services
import { AuthService } from "@/services/authentication.service";
import { UserService } from "@/services/user.service";
// stores
import { RootStore } from "@/store/root.store";
import { ProfileStore, IProfileStore } from "@/store/user/profile.store";
import { ActorDetail } from "@/types/issue";
type TUserErrorStatus = {
status: string;
message: string;
};
export interface IUserStore {
// observables
isAuthenticated: boolean;
isLoading: boolean;
error: TUserErrorStatus | undefined;
data: IUser | undefined;
// store observables
profile: IProfileStore;
// computed
currentActor: ActorDetail;
// actions
fetchCurrentUser: () => Promise<IUser | undefined>;
updateCurrentUser: (data: Partial<IUser>) => Promise<IUser | undefined>;
hydrate: (data: IUser) => void;
reset: () => void;
signOut: () => Promise<void>;
}
export class UserStore implements IUserStore {
// observables
isAuthenticated: boolean = false;
isLoading: boolean = false;
error: TUserErrorStatus | undefined = undefined;
data: IUser | undefined = undefined;
// store observables
profile: IProfileStore;
// service
userService: UserService;
authService: AuthService;
constructor(private store: RootStore) {
// stores
this.profile = new ProfileStore(store);
// service
this.userService = new UserService();
this.authService = new AuthService();
// observables
makeObservable(this, {
// observables
isAuthenticated: observable.ref,
isLoading: observable.ref,
error: observable,
// model observables
data: observable,
profile: observable,
// computed
currentActor: computed,
// actions
fetchCurrentUser: action,
updateCurrentUser: action,
reset: action,
signOut: action,
});
}
// computed
get currentActor(): ActorDetail {
return {
id: this.data?.id,
first_name: this.data?.first_name,
last_name: this.data?.last_name,
display_name: this.data?.display_name,
avatar: this.data?.avatar || undefined,
is_bot: false,
};
}
// actions
/**
* @description fetches the current user
* @returns {Promise<IUser>}
*/
fetchCurrentUser = async (): Promise<IUser> => {
try {
runInAction(() => {
this.isLoading = true;
this.error = undefined;
});
const user = await this.userService.currentUser();
if (user && user?.id) {
await this.profile.fetchUserProfile();
runInAction(() => {
this.data = user;
this.isLoading = false;
this.isAuthenticated = true;
});
} else
runInAction(() => {
this.data = user;
this.isLoading = false;
this.isAuthenticated = false;
});
return user;
} catch (error) {
runInAction(() => {
this.isLoading = false;
this.isAuthenticated = false;
this.error = {
status: "user-fetch-error",
message: "Failed to fetch current user",
};
});
throw error;
}
};
/**
* @description updates the current user
* @param data
* @returns {Promise<IUser>}
*/
updateCurrentUser = async (data: Partial<IUser>): Promise<IUser> => {
const currentUserData = this.data;
try {
if (currentUserData) {
Object.keys(data).forEach((key: string) => {
const userKey: keyof IUser = key as keyof IUser;
if (this.data) set(this.data, userKey, data[userKey]);
});
}
const user = await this.userService.updateUser(data);
return user;
} catch (error) {
if (currentUserData) {
Object.keys(currentUserData).forEach((key: string) => {
const userKey: keyof IUser = key as keyof IUser;
if (this.data) set(this.data, userKey, currentUserData[userKey]);
});
}
runInAction(() => {
this.error = {
status: "user-update-error",
message: "Failed to update current user",
};
});
throw error;
}
};
hydrate = (data: IUser): void => {
this.data = { ...this.data, ...data };
};
/**
* @description resets the user store
* @returns {void}
*/
reset = (): void => {
runInAction(() => {
this.isAuthenticated = false;
this.isLoading = false;
this.error = undefined;
this.data = undefined;
this.profile = new ProfileStore(this.store);
});
};
/**
* @description signs out the current user
* @returns {Promise<void>}
*/
signOut = async (): Promise<void> => {
// await this.authService.signOut(API_BASE_URL);
// this.store.resetOnSignOut();
};
}

View File

@ -1,12 +1,22 @@
{ {
"extends": "tsconfig/nextjs.json", "extends": "tsconfig/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "additional.d.ts"], "plugins": [
{
"name": "next"
}
],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "additional.d.ts", ".next/types/**/*.ts"],
"exclude": ["node_modules"], "exclude": ["node_modules"],
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"jsx": "preserve", "jsx": "preserve",
"paths": { "paths": {
"@/*": ["*"] "@/*": ["*"]
} },
"plugins": [
{
"name": "next"
}
]
} }
} }