From 32039e310fecfe59b775ac598c90342bc355b27f Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 17 Nov 2023 16:22:30 +0530 Subject: [PATCH] feat: Instance Admin Panel: General Settings --- web/components/instance/general-form.tsx | 121 +++++++++++--- web/components/instance/index.ts | 1 + web/components/instance/sidebar-dropdown.tsx | 148 ++++++++++++++++++ web/components/instance/sidebar-menu.tsx | 2 +- web/components/workspace/sidebar-dropdown.tsx | 15 +- web/layouts/admin-layout/header.tsx | 54 +++++-- web/layouts/admin-layout/sidebar.tsx | 3 +- web/layouts/auth-layout/user-wrapper.tsx | 6 +- web/pages/admin/index.tsx | 2 +- web/services/instance.service.ts | 10 ++ web/services/user.service.ts | 9 ++ web/store/instance/instance.store.ts | 38 +++++ web/store/user.store.ts | 23 +++ web/types/users.d.ts | 4 + 14 files changed, 396 insertions(+), 40 deletions(-) create mode 100644 web/components/instance/sidebar-dropdown.tsx diff --git a/web/components/instance/general-form.tsx b/web/components/instance/general-form.tsx index 10e654cd76a..87a268fd229 100644 --- a/web/components/instance/general-form.tsx +++ b/web/components/instance/general-form.tsx @@ -1,44 +1,125 @@ import { FC } from "react"; -import { useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; // ui -import { Input } from "@plane/ui"; +import { Button, Input, ToggleSwitch } from "@plane/ui"; // types import { IInstance } from "types/instance"; +// hooks +import useToast from "hooks/use-toast"; +import { useMobxStore } from "lib/mobx/store-provider"; export interface IInstanceGeneralForm { - data: IInstance; + instance: IInstance; } export interface GeneralFormValues { instance_name: string; - namespace: string | null; is_telemetry_enabled: boolean; } export const InstanceGeneralForm: FC = (props) => { - const { data } = props; - - const {} = useForm({ + const { instance } = props; + // store + const { instance: instanceStore } = useMobxStore(); + // toast + const { setToastAlert } = useToast(); + // form data + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ defaultValues: { - instance_name: data.instance_name, - namespace: data.namespace, - is_telemetry_enabled: data.is_telemetry_enabled, + instance_name: instance.instance_name, + is_telemetry_enabled: instance.is_telemetry_enabled, }, }); + const onSubmit = async (formData: GeneralFormValues) => { + const payload: Partial = { ...formData }; + + await instanceStore + .updateInstanceInfo(payload) + .then(() => + setToastAlert({ + title: "Success", + type: "success", + message: "Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + return ( -
-
- - +
+
+
+

Name of instance

+ ( + + )} + /> +
+ +
+

Admin Email

+ +
+ +
+

Instance Id

+ +
-
- - + +
+
+
Share anonymous usage instance
+
+ Help us understand how you use Plane so we can build better for you. +
+
+
+ } + /> +
-
- - + +
+
); diff --git a/web/components/instance/index.ts b/web/components/instance/index.ts index 52f87906294..c4840736a19 100644 --- a/web/components/instance/index.ts +++ b/web/components/instance/index.ts @@ -1,3 +1,4 @@ export * from "./help-section"; export * from "./sidebar-menu"; +export * from "./sidebar-dropdown"; export * from "./general-form"; diff --git a/web/components/instance/sidebar-dropdown.tsx b/web/components/instance/sidebar-dropdown.tsx new file mode 100644 index 00000000000..923dd8d2191 --- /dev/null +++ b/web/components/instance/sidebar-dropdown.tsx @@ -0,0 +1,148 @@ +import { Fragment } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +import { Menu, Transition } from "@headlessui/react"; +import { LogOut, Settings, Shield, UserCircle2 } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import useToast from "hooks/use-toast"; +// services +import { AuthService } from "services/auth.service"; +// ui +import { Avatar } from "@plane/ui"; + +// Static Data +const profileLinks = (workspaceSlug: string, userId: string) => [ + { + name: "View profile", + icon: UserCircle2, + link: `/${workspaceSlug}/profile/${userId}`, + }, + { + name: "Settings", + icon: Settings, + link: `/${workspaceSlug}/me/profile`, + }, +]; + +const authService = new AuthService(); + +export const InstanceSidebarDropdown = observer(() => { + const router = useRouter(); + // store + const { + theme: { sidebarCollapsed }, + workspace: { workspaceSlug }, + user: { currentUser, currentUserSettings }, + } = useMobxStore(); + // hooks + const { setToastAlert } = useToast(); + + // redirect url for normal mode + const redirectWorkspaceSlug = + workspaceSlug || + currentUserSettings?.workspace?.last_workspace_slug || + currentUserSettings?.workspace?.fallback_workspace_slug || + ""; + + const handleSignOut = async () => { + await authService + .signOut() + .then(() => { + router.push("/"); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Failed to sign out. Please try again.", + }) + ); + }; + + return ( +
+
+
+
+ +
+ + {!sidebarCollapsed && ( +

Instance Admin Settings

+ )} +
+
+ + {!sidebarCollapsed && ( + + + + + + + +
+ {currentUser?.email} + {profileLinks(workspaceSlug?.toString() ?? "", currentUser?.id ?? "").map((link, index) => ( + + + + + {link.name} + + + + ))} +
+
+ + + Sign out + +
+ +
+ + + + Normal Mode + + + +
+
+
+
+ )} +
+ ); +}); diff --git a/web/components/instance/sidebar-menu.tsx b/web/components/instance/sidebar-menu.tsx index 0eeaa76765e..dbb697efbf4 100644 --- a/web/components/instance/sidebar-menu.tsx +++ b/web/components/instance/sidebar-menu.tsx @@ -37,7 +37,7 @@ export const InstanceAdminSidebarMenu = () => { const router = useRouter(); return ( -
+
{INSTANCE_ADMIN_LINKS.map((item, index) => { const isActive = item.name === "Settings" ? router.asPath.includes(item.href) : router.asPath === item.href; diff --git a/web/components/workspace/sidebar-dropdown.tsx b/web/components/workspace/sidebar-dropdown.tsx index 58879e96815..6fa950a8453 100644 --- a/web/components/workspace/sidebar-dropdown.tsx +++ b/web/components/workspace/sidebar-dropdown.tsx @@ -53,7 +53,7 @@ export const WorkspaceSidebarDropdown = observer(() => { const { theme: { sidebarCollapsed }, workspace: { workspaces, currentWorkspace: activeWorkspace }, - user: { currentUser, updateCurrentUser }, + user: { currentUser, updateCurrentUser, isUserInstanceAdmin }, } = useMobxStore(); // hooks const { setToastAlert } = useToast(); @@ -286,7 +286,7 @@ export const WorkspaceSidebarDropdown = observer(() => { ))}
-
+
{ Sign out
+ {isUserInstanceAdmin && ( +
+ + + + God Mode + + + +
+ )} diff --git a/web/layouts/admin-layout/header.tsx b/web/layouts/admin-layout/header.tsx index f2a3e226637..a111222f378 100644 --- a/web/layouts/admin-layout/header.tsx +++ b/web/layouts/admin-layout/header.tsx @@ -1,21 +1,47 @@ import { FC } from "react"; +// next +import Link from "next/link"; +// mobx +import { observer } from "mobx-react-lite"; // ui import { Breadcrumbs } from "@plane/ui"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // icons -import { Settings } from "lucide-react"; +import { ArrowLeftToLine, Settings } from "lucide-react"; -export const InstanceAdminHeader: FC = () => ( -
-
-
- - } - label="General" - /> - +export const InstanceAdminHeader: FC = observer(() => { + const { + workspace: { workspaceSlug }, + user: { currentUserSettings }, + } = useMobxStore(); + + const redirectWorkspaceSlug = + workspaceSlug || + currentUserSettings?.workspace?.last_workspace_slug || + currentUserSettings?.workspace?.fallback_workspace_slug || + ""; + + return ( +
+
+
+ + } + label="General" + /> + +
+
+
+ + + + +
-
-); + ); +}); diff --git a/web/layouts/admin-layout/sidebar.tsx b/web/layouts/admin-layout/sidebar.tsx index c067354fca1..d3a9ecfa12e 100644 --- a/web/layouts/admin-layout/sidebar.tsx +++ b/web/layouts/admin-layout/sidebar.tsx @@ -1,7 +1,7 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; // components -import { InstanceAdminSidebarMenu, InstanceHelpSection } from "components/instance"; +import { InstanceAdminSidebarMenu, InstanceHelpSection, InstanceSidebarDropdown } from "components/instance"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; @@ -19,6 +19,7 @@ export const InstanceAdminSidebar: FC = observer(() => { } ${themStore?.sidebarCollapsed ? "left-0" : "-left-full md:left-0"}`} >
+
diff --git a/web/layouts/auth-layout/user-wrapper.tsx b/web/layouts/auth-layout/user-wrapper.tsx index 6072f167343..6b64099fa92 100644 --- a/web/layouts/auth-layout/user-wrapper.tsx +++ b/web/layouts/auth-layout/user-wrapper.tsx @@ -14,7 +14,7 @@ export const UserAuthWrapper: FC = (props) => { const { children } = props; // store const { - user: { fetchCurrentUser, fetchCurrentUserSettings }, + user: { fetchCurrentUser, fetchCurrentUserInstanceAdminStatus, fetchCurrentUserSettings }, workspace: { fetchWorkspaces }, } = useMobxStore(); // router @@ -23,6 +23,10 @@ export const UserAuthWrapper: FC = (props) => { const { data: currentUser, error } = useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), { shouldRetryOnError: false, }); + // fetching current user instance admin status + useSWR("CURRENT_USER_INSTANCE_ADMIN_STATUS", () => fetchCurrentUserInstanceAdminStatus(), { + shouldRetryOnError: false, + }); // fetching user settings useSWR("CURRENT_USER_SETTINGS", () => fetchCurrentUserSettings(), { shouldRetryOnError: false, diff --git a/web/pages/admin/index.tsx b/web/pages/admin/index.tsx index 96d1091ef5f..70ffd0cc1c2 100644 --- a/web/pages/admin/index.tsx +++ b/web/pages/admin/index.tsx @@ -18,7 +18,7 @@ const InstanceAdminPage: NextPageWithLayout = observer(() => { useSWR("INSTANCE_INFO", () => fetchInstanceInfo()); - return
{instance && }
; + return
{instance && }
; }); InstanceAdminPage.getLayout = function getLayout(page: ReactElement) { diff --git a/web/services/instance.service.ts b/web/services/instance.service.ts index 281069bf42a..74c32aa5f06 100644 --- a/web/services/instance.service.ts +++ b/web/services/instance.service.ts @@ -17,6 +17,16 @@ export class InstanceService extends APIService { }); } + async updateInstanceInfo( + data: Partial + ): Promise { + return this.patch("/api/licenses/instances/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }) + } + async getInstanceConfigurations() { return this.get("/api/licenses/instances/configurations/") .then((response) => response.data) diff --git a/web/services/user.service.ts b/web/services/user.service.ts index f5c4ac17e6e..a2cd7469768 100644 --- a/web/services/user.service.ts +++ b/web/services/user.service.ts @@ -6,6 +6,7 @@ import type { IIssue, IUser, IUserActivityResponse, + IInstanceAdminStatus, IUserProfileData, IUserProfileProjectSegregation, IUserSettings, @@ -54,6 +55,14 @@ export class UserService extends APIService { }); } + async currentUserInstanceAdminStatus(): Promise { + return this.get("/api/users/me/instance-admin/") + .then((respone) => respone?.data) + .catch((error) => { + throw error?.response; + }); + } + async currentUserSettings(): Promise { return this.get("/api/users/me/settings/") .then((response) => response?.data) diff --git a/web/store/instance/instance.store.ts b/web/store/instance/instance.store.ts index 78740c11e2d..bd37110a154 100644 --- a/web/store/instance/instance.store.ts +++ b/web/store/instance/instance.store.ts @@ -15,6 +15,7 @@ export interface IInstanceStore { // computed // action fetchInstanceInfo: () => Promise; + updateInstanceInfo: (data: Partial) => Promise; fetchInstanceConfigurations: () => Promise; } @@ -38,6 +39,7 @@ export class InstanceStore implements IInstanceStore { // getIssueType: computed, // actions fetchInstanceInfo: action, + updateInstanceInfo: action, fetchInstanceConfigurations: action, }); @@ -45,6 +47,9 @@ export class InstanceStore implements IInstanceStore { this.instanceService = new InstanceService(); } + /** + * fetch instace info from API + */ fetchInstanceInfo = async () => { try { const instance = await this.instanceService.getInstanceInfo(); @@ -58,6 +63,39 @@ export class InstanceStore implements IInstanceStore { } }; + /** + * update instance info + * @param data + */ + updateInstanceInfo = async (data: Partial) => { + try { + runInAction(() => { + this.loader = true; + this.error = null; + }); + + const response = await this.instanceService.updateInstanceInfo(data); + + runInAction(() => { + this.loader = false; + this.error = null; + this.instance = response; + }); + + return response; + } catch (error) { + runInAction(() => { + this.loader = false; + this.error = error; + }); + + throw error; + } + }; + + /** + * fetch instace configurations from API + */ fetchInstanceConfigurations = async () => { try { const configurations = await this.instanceService.getInstanceConfigurations(); diff --git a/web/store/user.store.ts b/web/store/user.store.ts index c1d91904f29..6b7e4154899 100644 --- a/web/store/user.store.ts +++ b/web/store/user.store.ts @@ -14,6 +14,7 @@ export interface IUserStore { isUserLoggedIn: boolean | null; currentUser: IUser | null; + isUserInstanceAdmin: boolean | null; currentUserSettings: IUserSettings | null; dashboardInfo: any; @@ -41,6 +42,7 @@ export interface IUserStore { hasPermissionToCurrentProject: boolean | undefined; fetchCurrentUser: () => Promise; + fetchCurrentUserInstanceAdminStatus: () => Promise; fetchCurrentUserSettings: () => Promise; fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise; @@ -58,6 +60,7 @@ class UserStore implements IUserStore { isUserLoggedIn: boolean | null = null; currentUser: IUser | null = null; + isUserInstanceAdmin: boolean | null = null; currentUserSettings: IUserSettings | null = null; dashboardInfo: any = null; @@ -87,7 +90,9 @@ class UserStore implements IUserStore { makeObservable(this, { // observable loader: observable.ref, + isUserLoggedIn: observable.ref, currentUser: observable.ref, + isUserInstanceAdmin: observable.ref, currentUserSettings: observable.ref, dashboardInfo: observable.ref, workspaceMemberInfo: observable.ref, @@ -96,6 +101,7 @@ class UserStore implements IUserStore { hasPermissionToProject: observable.ref, // action fetchCurrentUser: action, + fetchCurrentUserInstanceAdminStatus: action, fetchCurrentUserSettings: action, fetchUserDashboardInfo: action, fetchUserWorkspaceInfo: action, @@ -167,6 +173,23 @@ class UserStore implements IUserStore { } }; + fetchCurrentUserInstanceAdminStatus = async () => { + try { + const response = await this.userService.currentUserInstanceAdminStatus(); + if (response) { + runInAction(() => { + this.isUserInstanceAdmin = response.is_instance_admin; + }) + } + return response.is_instance_admin; + } catch (error) { + runInAction(() => { + this.isUserInstanceAdmin = false; + }); + throw error; + } + }; + fetchCurrentUserSettings = async () => { try { const response = await this.userService.currentUserSettings(); diff --git a/web/types/users.d.ts b/web/types/users.d.ts index 2c93ff764c0..c9dbd6cbdec 100644 --- a/web/types/users.d.ts +++ b/web/types/users.d.ts @@ -29,6 +29,10 @@ export interface IUser { theme: IUserTheme; } +export interface IInstanceAdminStatus { + is_instance_admin: boolean; +} + export interface IUserSettings { id: string; email: string;