snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
34
apps/web/core/components/settings/boxed-control-item.tsx
Normal file
34
apps/web/core/components/settings/boxed-control-item.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
control?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
export function SettingsBoxedControlItem(props: Props) {
|
||||
const { className, control, description, title } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col items-start gap-4 rounded-lg border border-subtle bg-layer-2 px-4 py-3 md:flex-row md:items-center md:justify-between md:gap-8",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h4 className="text-body-sm-medium text-primary">{title}</h4>
|
||||
{description && <p className="text-caption-md-regular text-tertiary">{description}</p>}
|
||||
</div>
|
||||
{control && <div className="shrink-0">{control}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
apps/web/core/components/settings/content-wrapper.tsx
Normal file
41
apps/web/core/components/settings/content-wrapper.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { AppHeader } from "@/components/core/app-header";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
hugging?: boolean;
|
||||
};
|
||||
|
||||
export function SettingsContentWrapper(props: Props) {
|
||||
const { children, header, hugging = false } = props;
|
||||
|
||||
return (
|
||||
<div className="@container flex size-full grow flex-col overflow-hidden">
|
||||
{header && (
|
||||
<div className="w-full shrink-0">
|
||||
<AppHeader header={header} />
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="size-full grow overflow-y-scroll">
|
||||
<div
|
||||
className={cn("py-9", {
|
||||
"w-full px-page-x lg:px-12": hugging,
|
||||
"mx-auto w-full max-w-225 px-page-x @min-[58.95rem]:px-0": !hugging, // 58.95rem = max-width(56.25rem) + padding-x(1.35rem * 2)
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
apps/web/core/components/settings/control-item.tsx
Normal file
25
apps/web/core/components/settings/control-item.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
type Props = {
|
||||
control: React.ReactNode;
|
||||
description: string;
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
export function SettingsControlItem(props: Props) {
|
||||
const { control, description, title } = props;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start gap-4 py-3 md:flex-row md:items-center md:justify-between md:gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-body-sm-medium text-primary">{title}</h4>
|
||||
<p className="text-caption-md-regular text-secondary">{description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">{control}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/web/core/components/settings/heading.tsx
Normal file
38
apps/web/core/components/settings/heading.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { cn } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
control?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
variant?: "h3" | "h4" | "h6";
|
||||
};
|
||||
|
||||
export function SettingsHeading({ className, control, description, title, variant = "h3" }: Props) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-start justify-between gap-4 md:flex-row md:items-center", className)}>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{title && (
|
||||
<h3
|
||||
className={cn("text-h3-medium text-primary", {
|
||||
"text-h3-medium": variant === "h3",
|
||||
"text-h4-medium": variant === "h4",
|
||||
"text-h6-medium": variant === "h6",
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{description && <p className="text-body-xs-regular text-tertiary">{description}</p>}
|
||||
</div>
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
apps/web/core/components/settings/helper.ts
Normal file
51
apps/web/core/components/settings/helper.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { GROUPED_WORKSPACE_SETTINGS, PROJECT_SETTINGS_FLAT_MAP } from "@plane/constants";
|
||||
|
||||
const hrefToLabelMap = (options: Record<string, Array<{ href: string; i18n_label: string; [key: string]: any }>>) =>
|
||||
Object.values(options)
|
||||
.flat()
|
||||
.reduce(
|
||||
(acc, setting) => {
|
||||
acc[setting.href] = setting.i18n_label;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
const workspaceHrefToLabelMap = hrefToLabelMap(GROUPED_WORKSPACE_SETTINGS);
|
||||
|
||||
const projectHrefToLabelMap = PROJECT_SETTINGS_FLAT_MAP.reduce(
|
||||
(acc, setting) => {
|
||||
acc[setting.href] = setting.i18n_label;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
export const pathnameToAccessKey = (pathname: string) => {
|
||||
const pathArray = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes
|
||||
const workspaceSlug = pathArray[0];
|
||||
const accessKey = pathArray.slice(1, 3).join("/");
|
||||
return { workspaceSlug, accessKey: `/${accessKey}` || "" };
|
||||
};
|
||||
|
||||
export const getWorkspaceActivePath = (pathname: string) => {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const settingsIndex = parts.indexOf("settings");
|
||||
if (settingsIndex === -1) return null;
|
||||
const subPath = "/" + parts.slice(settingsIndex, settingsIndex + 2).join("/");
|
||||
return workspaceHrefToLabelMap[subPath];
|
||||
};
|
||||
|
||||
export const getProjectActivePath = (pathname: string) => {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const settingsIndex = parts.indexOf("settings");
|
||||
if (settingsIndex === -1) return null;
|
||||
const subPath = parts.slice(settingsIndex + 3, settingsIndex + 4).join("/");
|
||||
return subPath ? projectHrefToLabelMap["/" + subPath] : projectHrefToLabelMap[subPath];
|
||||
};
|
||||
23
apps/web/core/components/settings/layout.tsx
Normal file
23
apps/web/core/components/settings/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export const SettingsContentLayout = observer(function SettingsContentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// refs
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-full w-full overflow-y-scroll" ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
50
apps/web/core/components/settings/mobile/nav.tsx
Normal file
50
apps/web/core/components/settings/mobile/nav.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ChevronRightOutline } from "@makeplane/propel/icons";
|
||||
import { useUserSettings } from "@/hooks/store/user";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
|
||||
type Props = {
|
||||
hamburgerContent: React.ComponentType<{ className?: string; isMobile?: boolean }>;
|
||||
activePath: string;
|
||||
};
|
||||
|
||||
export const SettingsMobileNav = observer(function SettingsMobileNav(props: Props) {
|
||||
const { hamburgerContent: HamburgerContent, activePath } = props;
|
||||
// refs
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
// store hooks
|
||||
const { sidebarCollapsed, toggleSidebar } = useUserSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useOutsideClickDetector(sidebarRef, () => {
|
||||
if (!sidebarCollapsed) toggleSidebar(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-b border-subtle px-page-x py-3 md:hidden">
|
||||
<div ref={sidebarRef} className="relative z-50 w-fit">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="absolute top-10.5 left-0 z-50">
|
||||
<HamburgerContent className="max-h-100 rounded-lg border border-subtle pb-3" />
|
||||
</div>
|
||||
)}
|
||||
<IconButton variant="secondary" className="group z-50 shrink-0" icon={Menu} onClick={() => toggleSidebar()} />
|
||||
</div>
|
||||
{/* path */}
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRightOutline className="size-4 text-tertiary" />
|
||||
<span className="text-13 font-medium text-secondary">{t(activePath)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
23
apps/web/core/components/settings/page-header.tsx
Normal file
23
apps/web/core/components/settings/page-header.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Header } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
leftItem?: React.ReactNode;
|
||||
rightItem?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function SettingsPageHeader(props: Props) {
|
||||
const { leftItem, rightItem } = props;
|
||||
|
||||
return (
|
||||
<Header>
|
||||
{leftItem && <Header.LeftItem>{leftItem}</Header.LeftItem>}
|
||||
{rightItem && <Header.RightItem>{rightItem}</Header.RightItem>}
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
import { APITokenService } from "@plane/services";
|
||||
// components
|
||||
import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal";
|
||||
import { ApiTokenListItem } from "@/components/api-token/token-list-item";
|
||||
import { ProfileSettingsHeading } from "@/components/settings/profile/heading";
|
||||
import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token";
|
||||
// constants
|
||||
import { API_TOKENS_LIST } from "@plane/constants";
|
||||
|
||||
const apiTokenService = new APITokenService();
|
||||
|
||||
export const APITokensProfileSettings = observer(function APITokensProfileSettings() {
|
||||
// states
|
||||
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list());
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!tokens) {
|
||||
return <APITokenSettingsLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="size-full">
|
||||
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
|
||||
<ProfileSettingsHeading
|
||||
title={t("account_settings.api_tokens.title")}
|
||||
description={t("account_settings.api_tokens.description")}
|
||||
control={
|
||||
<Button variant="primary" size="lg" onClick={() => setIsCreateTokenModalOpen(true)}>
|
||||
{t("workspace_settings.settings.api_tokens.add_token")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="mt-7">
|
||||
{tokens.length > 0 ? (
|
||||
<>
|
||||
<div>
|
||||
{tokens.map((token) => (
|
||||
<ApiTokenListItem key={token.id} token={token} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyStateCompact
|
||||
assetKey="token"
|
||||
assetClassName="size-20"
|
||||
title={t("settings_empty_state.tokens.title")}
|
||||
description={t("settings_empty_state.tokens.description")}
|
||||
actions={[
|
||||
{
|
||||
label: t("settings_empty_state.tokens.cta_primary"),
|
||||
onClick: () => {
|
||||
setIsCreateTokenModalOpen(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
align="start"
|
||||
rootClassName="py-20"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { UserOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { Field } from "@makeplane/propel/components/field";
|
||||
import { Input, InputGroup } from "@makeplane/propel/components/input";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast";
|
||||
import { EFileAssetType } from "@plane/types";
|
||||
import type { IUser, TUserProfile } from "@plane/types";
|
||||
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { DeactivateAccountModal } from "@/components/account/deactivate-account-modal";
|
||||
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
|
||||
import { ChangeEmailModal } from "@/components/core/modals/change-email-modal";
|
||||
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
|
||||
import { CoverImage } from "@/components/common/cover-image";
|
||||
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
|
||||
// helpers
|
||||
import { handleCoverImageChange } from "@/helpers/cover-image.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useUser, useUserProfile } from "@/hooks/store/user";
|
||||
// utils
|
||||
import { validatePersonName, validateDisplayName } from "@plane/utils";
|
||||
|
||||
type TUserProfileForm = {
|
||||
avatar_url: string;
|
||||
cover_image: string;
|
||||
cover_image_asset: any;
|
||||
cover_image_url: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
language: string;
|
||||
user_timezone: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
user: IUser;
|
||||
profile: TUserProfile;
|
||||
};
|
||||
|
||||
export const GeneralProfileSettingsForm = observer(function GeneralProfileSettingsForm(props: Props) {
|
||||
const { user, profile } = props;
|
||||
// states
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
|
||||
const [deactivateAccountModal, setDeactivateAccountModal] = useState(false);
|
||||
const [isChangeEmailModalOpen, setIsChangeEmailModalOpen] = useState(false);
|
||||
// language support
|
||||
const { t } = useTranslation();
|
||||
// form info
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<TUserProfileForm>({
|
||||
defaultValues: {
|
||||
avatar_url: user.avatar_url || "",
|
||||
cover_image_asset: null,
|
||||
cover_image_url: user.cover_image_url || "",
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
display_name: user.display_name || "",
|
||||
email: user.email || "",
|
||||
role: profile.role || "Product / Project Manager",
|
||||
language: profile.language || "en",
|
||||
user_timezone: user.user_timezone || "Asia/Kolkata",
|
||||
},
|
||||
});
|
||||
// derived values
|
||||
const userAvatar = watch("avatar_url");
|
||||
const userCover = watch("cover_image_url");
|
||||
// store hooks
|
||||
const { data: currentUser, updateCurrentUser } = useUser();
|
||||
const { updateUserProfile } = useUserProfile();
|
||||
const { config } = useInstance();
|
||||
|
||||
const isSMTPConfigured = config?.is_smtp_configured || false;
|
||||
|
||||
const handleProfilePictureDelete = async (url: string | null | undefined) => {
|
||||
if (!url) return;
|
||||
await updateCurrentUser({
|
||||
avatar_url: "",
|
||||
})
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Profile picture deleted successfully.",
|
||||
});
|
||||
setValue("avatar_url", "");
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "There was some error in deleting your profile picture. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsImageUploadModalOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: TUserProfileForm) => {
|
||||
setIsLoading(true);
|
||||
const userPayload: Partial<IUser> = {
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
avatar_url: formData.avatar_url,
|
||||
display_name: formData?.display_name,
|
||||
};
|
||||
|
||||
try {
|
||||
const coverImagePayload = await handleCoverImageChange(user.cover_image_url, formData.cover_image_url, {
|
||||
entityIdentifier: "",
|
||||
entityType: EFileAssetType.USER_COVER,
|
||||
isUserAsset: true,
|
||||
});
|
||||
|
||||
if (coverImagePayload) {
|
||||
Object.assign(userPayload, coverImagePayload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling cover image:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: error instanceof Error ? error.message : "Failed to process cover image",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const profilePayload: Partial<TUserProfile> = {
|
||||
role: formData.role,
|
||||
};
|
||||
|
||||
const updateCurrentUserDetail = updateCurrentUser(userPayload);
|
||||
const promises: Promise<IUser | TUserProfile | undefined>[] = [updateCurrentUserDetail];
|
||||
if (profilePayload.role !== profile.role) {
|
||||
const updateCurrentUserProfile = updateUserProfile(profilePayload);
|
||||
promises.push(updateCurrentUserProfile);
|
||||
}
|
||||
|
||||
const updatePromise = Promise.allSettled(promises)
|
||||
.then((results) => {
|
||||
const rejectedResult = results.find((result) => result.status === "rejected") as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
if (rejectedResult) {
|
||||
throw rejectedResult.reason ?? new Error("Failed to update profile");
|
||||
}
|
||||
const values = results.map(
|
||||
(result) => (result as PromiseFulfilledResult<IUser | TUserProfile | undefined>).value
|
||||
);
|
||||
if (values.some((v) => v === undefined)) {
|
||||
throw new Error("Failed to update profile");
|
||||
}
|
||||
return values;
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
|
||||
setPromiseToast(updatePromise, {
|
||||
loading: "Updating...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => `Profile updated successfully.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => `There was some error in updating your profile. Please try again.`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
|
||||
<ChangeEmailModal isOpen={isChangeEmailModalOpen} onClose={() => setIsChangeEmailModalOpen(false)} />
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatar_url"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<UserImageUploadModal
|
||||
isOpen={isImageUploadModalOpen}
|
||||
onClose={() => setIsImageUploadModalOpen(false)}
|
||||
handleRemove={async () => await handleProfilePictureDelete(currentUser?.avatar_url)}
|
||||
onSuccess={(url) => {
|
||||
onChange(url);
|
||||
handleSubmit(onSubmit)();
|
||||
setIsImageUploadModalOpen(false);
|
||||
}}
|
||||
value={value && value.trim() !== "" ? value : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
|
||||
<div className="flex w-full flex-col gap-7">
|
||||
<div className="relative h-44 w-full">
|
||||
<CoverImage
|
||||
src={userCover}
|
||||
className="h-44 w-full rounded-lg"
|
||||
alt={currentUser?.first_name ?? "Cover image"}
|
||||
/>
|
||||
<div className="absolute -bottom-6 left-6 flex items-end justify-between">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-surface-2">
|
||||
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
|
||||
{!userAvatar || userAvatar === "" ? (
|
||||
<div className="h-16 w-16 rounded-md bg-layer-1 p-2">
|
||||
<UserOutline className="h-full w-full text-secondary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-16 w-16 overflow-hidden">
|
||||
<img
|
||||
src={getFileURL(userAvatar)}
|
||||
className="absolute top-0 left-0 h-full w-full rounded-lg object-cover"
|
||||
onClick={() => setIsImageUploadModalOpen(true)}
|
||||
alt={currentUser?.display_name}
|
||||
role="button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-3 bottom-3 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="cover_image_url"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ImagePickerPopover
|
||||
label={t("change_cover")}
|
||||
control={control}
|
||||
onChange={(imageUrl) => onChange(imageUrl)}
|
||||
value={value}
|
||||
isProfileCover
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-center mt-6 flex justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="item-center flex text-16 font-medium text-secondary">
|
||||
<span>{`${watch("first_name")} ${watch("last_name")}`}</span>
|
||||
</div>
|
||||
<span className="text-13 tracking-tight text-tertiary">{watch("email")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 font-medium text-secondary">
|
||||
{t("first_name")}
|
||||
<span className="text-danger-primary">*</span>
|
||||
</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="first_name"
|
||||
rules={{
|
||||
required: "Please enter first name",
|
||||
validate: validatePersonName,
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Field name="first_name" invalid={Boolean(errors.first_name)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
placeholder="Enter your first name"
|
||||
maxLength={50}
|
||||
autoComplete="on"
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{errors.first_name && <span className="text-11 text-danger-primary">{errors.first_name.message}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 font-medium text-secondary">{t("last_name")}</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="last_name"
|
||||
rules={{
|
||||
validate: validatePersonName,
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Field name="last_name" invalid={Boolean(errors.last_name)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
placeholder="Enter your last name"
|
||||
maxLength={50}
|
||||
autoComplete="on"
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{errors.last_name && <span className="text-11 text-danger-primary">{errors.last_name.message}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 font-medium text-secondary">
|
||||
{t("display_name")}
|
||||
<span className="text-danger-primary">*</span>
|
||||
</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="display_name"
|
||||
rules={{
|
||||
required: "Display name is required.",
|
||||
validate: validateDisplayName,
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Field name="display_name" invalid={Boolean(errors?.display_name)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="display_name"
|
||||
name="display_name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
placeholder="Enter your display name"
|
||||
maxLength={50}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{errors?.display_name && (
|
||||
<span className="text-11 text-danger-primary">{errors?.display_name?.message}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 font-medium text-secondary">
|
||||
{t("auth.common.email.label")}
|
||||
<span className="text-danger-primary">*</span>
|
||||
</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: "Email is required.",
|
||||
}}
|
||||
render={({ field: { value, ref } }) => (
|
||||
<Field name="email" invalid={Boolean(errors.email)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={value}
|
||||
ref={ref}
|
||||
placeholder="Enter your email"
|
||||
autoComplete="on"
|
||||
disabled
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{isSMTPConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn w-fit text-11 text-secondary underline"
|
||||
onClick={() => setIsChangeEmailModalOpen(true)}
|
||||
>
|
||||
{t("account_settings.profile.change_email_modal.title")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="primary" type="submit" loading={isLoading}>
|
||||
{isLoading ? t("saving") : t("save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-10">
|
||||
<SettingsBoxedControlItem
|
||||
title={t("deactivate_account")}
|
||||
description={t("deactivate_account_description")}
|
||||
control={
|
||||
<Button variant="error-outline" onClick={() => setDeactivateAccountModal(true)}>
|
||||
{t("deactivate_account")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { GeneralProfileSettingsForm } from "./form";
|
||||
|
||||
export const GeneralProfileSettings = observer(function GeneralProfileSettings() {
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { data: currentUser, userProfile } = useUser();
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
|
||||
<GeneralProfileSettingsForm user={currentUser} profile={userProfile.data} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { lazy } from "react";
|
||||
// plane imports
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
|
||||
export const PROFILE_SETTINGS_PAGES_MAP: Record<TProfileSettingsTabs, React.LazyExoticComponent<React.FC>> = {
|
||||
general: lazy(() => import("./general").then((m) => ({ default: m.GeneralProfileSettings }))),
|
||||
preferences: lazy(() => import("./preferences").then((m) => ({ default: m.PreferencesProfileSettings }))),
|
||||
notifications: lazy(() => import("./notifications").then((m) => ({ default: m.NotificationsProfileSettings }))),
|
||||
security: lazy(() => import("./security").then((m) => ({ default: m.SecurityProfileSettings }))),
|
||||
"api-tokens": lazy(() => import("./api-tokens").then((m) => ({ default: m.APITokensProfileSettings }))),
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IUserEmailNotificationSettings } from "@plane/types";
|
||||
import { Switch } from "@makeplane/propel/components/switch";
|
||||
// components
|
||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
type Props = {
|
||||
data: IUserEmailNotificationSettings;
|
||||
};
|
||||
|
||||
// services
|
||||
const userService = new UserService();
|
||||
|
||||
export const NotificationsProfileSettingsForm = observer(function NotificationsProfileSettingsForm(props: Props) {
|
||||
const { data } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// form data
|
||||
const { control, reset } = useForm<IUserEmailNotificationSettings>({
|
||||
defaultValues: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSettingChange = async (key: keyof IUserEmailNotificationSettings, value: boolean) => {
|
||||
try {
|
||||
await userService.updateCurrentUserEmailNotificationSettings({
|
||||
[key]: value,
|
||||
});
|
||||
setToast({
|
||||
title: t("success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("email_notification_setting_updated_successfully"),
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: t("error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("failed_to_update_email_notification_setting"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset(data);
|
||||
}, [reset, data]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<SettingsControlItem
|
||||
title={t("property_changes")}
|
||||
description={t("property_changes_description")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="property_change"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={value}
|
||||
onCheckedChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
void handleSettingChange("property_change", newValue);
|
||||
}}
|
||||
aria-label={t("property_changes")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsControlItem
|
||||
title={t("state_change")}
|
||||
description={t("state_change_description")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="state_change"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={value}
|
||||
onCheckedChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
void handleSettingChange("state_change", newValue);
|
||||
}}
|
||||
aria-label={t("state_change")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="border-l-3 border-subtle-1 pl-3">
|
||||
<SettingsControlItem
|
||||
title={t("issue_completed")}
|
||||
description={t("issue_completed_description")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="issue_completed"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={value}
|
||||
onCheckedChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
void handleSettingChange("issue_completed", newValue);
|
||||
}}
|
||||
aria-label={t("issue_completed")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SettingsControlItem
|
||||
title={t("comments")}
|
||||
description={t("comments_description")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="comment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={value}
|
||||
onCheckedChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
void handleSettingChange("comment", newValue);
|
||||
}}
|
||||
aria-label={t("comments")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsControlItem
|
||||
title={t("mentions")}
|
||||
description={t("mentions_description")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="mention"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={value}
|
||||
onCheckedChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
void handleSettingChange("mention", newValue);
|
||||
}}
|
||||
aria-label={t("mentions")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { ProfileSettingsHeading } from "@/components/settings/profile/heading";
|
||||
import { EmailSettingsLoader } from "@/components/ui/loader/settings/email";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
// local imports
|
||||
import { NotificationsProfileSettingsForm } from "./email-notification-form";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
export const NotificationsProfileSettings = observer(function NotificationsProfileSettings() {
|
||||
const { t } = useTranslation();
|
||||
// fetching user email notification settings
|
||||
const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
|
||||
userService.currentUserEmailNotificationSettings()
|
||||
);
|
||||
|
||||
if (!data || isLoading) {
|
||||
return <EmailSettingsLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="size-full">
|
||||
<ProfileSettingsHeading
|
||||
title={t("account_settings.notifications.heading")}
|
||||
description={t("account_settings.notifications.description")}
|
||||
/>
|
||||
<div className="mt-7">
|
||||
<NotificationsProfileSettingsForm data={data} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { ThemeSwitcher } from "@/components/appearance";
|
||||
|
||||
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<ThemeSwitcher
|
||||
option={{
|
||||
id: "theme",
|
||||
title: "theme",
|
||||
description: "select_or_customize_your_interface_color_scheme",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
// components
|
||||
import { TimezoneSelect } from "@/components/global";
|
||||
import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference";
|
||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||
// hooks
|
||||
import { useUser, useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
export const ProfileSettingsLanguageAndTimezonePreferencesList = observer(
|
||||
function ProfileSettingsLanguageAndTimezonePreferencesList() {
|
||||
// store hooks
|
||||
const {
|
||||
data: user,
|
||||
updateCurrentUser,
|
||||
userProfile: { data: profile },
|
||||
} = useUser();
|
||||
const { updateUserProfile } = useUserProfile();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleTimezoneChange = async (value: string) => {
|
||||
try {
|
||||
await updateCurrentUser({ user_timezone: value });
|
||||
setToast({
|
||||
title: "Success!",
|
||||
message: "Timezone updated successfully",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
message: "Failed to update timezone",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
try {
|
||||
await updateUserProfile({ language: value });
|
||||
setToast({
|
||||
title: "Success!",
|
||||
message: "Language updated successfully",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
message: "Failed to update language",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getLanguageLabel = (value: string) => {
|
||||
const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value);
|
||||
if (!selectedLanguage) return value;
|
||||
return selectedLanguage.label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<SettingsControlItem
|
||||
title={t("timezone")}
|
||||
description={t("timezone_setting")}
|
||||
control={<TimezoneSelect value={user?.user_timezone || "Asia/Kolkata"} onChange={handleTimezoneChange} />}
|
||||
/>
|
||||
<SettingsControlItem
|
||||
title={t("language")}
|
||||
description={t("language_setting")}
|
||||
control={
|
||||
<CustomSelect
|
||||
value={profile?.language}
|
||||
label={profile?.language ? getLanguageLabel(profile?.language) : "Select a language"}
|
||||
onChange={handleLanguageChange}
|
||||
buttonClassName="border border-subtle-1"
|
||||
className="rounded-md"
|
||||
input
|
||||
placement="bottom-end"
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
}
|
||||
/>
|
||||
<StartOfWeekPreference
|
||||
option={{
|
||||
title: "First day of the week",
|
||||
description: "This will change how all calendars in your app look.",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { ProfileSettingsHeading } from "@/components/settings/profile/heading";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { ProfileSettingsDefaultPreferencesList } from "./default-list";
|
||||
import { ProfileSettingsLanguageAndTimezonePreferencesList } from "./language-and-timezone-list";
|
||||
|
||||
export const PreferencesProfileSettings = observer(function PreferencesProfileSettings() {
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { data: userProfile } = useUserProfile();
|
||||
|
||||
if (!userProfile) return null;
|
||||
|
||||
return (
|
||||
<div className="size-full">
|
||||
<ProfileSettingsHeading
|
||||
title={t("account_settings.preferences.heading")}
|
||||
description={t("account_settings.preferences.description")}
|
||||
/>
|
||||
<div className="mt-7 flex w-full flex-col gap-6">
|
||||
<section>
|
||||
<ProfileSettingsDefaultPreferencesList />
|
||||
</section>
|
||||
<section className="flex flex-col gap-y-3">
|
||||
<div className="text-h6-medium text-primary">{t("language_and_time")}</div>
|
||||
<ProfileSettingsLanguageAndTimezonePreferencesList />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { HideOutline, ShowOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { Field } from "@makeplane/propel/components/field";
|
||||
import { Input, InputGroup } from "@makeplane/propel/components/input";
|
||||
import { E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { PasswordStrengthIndicator } from "@plane/ui";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// components
|
||||
import { ProfileSettingsHeading } from "@/components/settings/profile/heading";
|
||||
// helpers
|
||||
import { authErrorHandler, EAuthenticationErrorCodes, passwordErrors } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
export interface FormValues {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
}
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
old_password: "",
|
||||
new_password: "",
|
||||
confirm_password: "",
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
const defaultShowPassword = {
|
||||
oldPassword: false,
|
||||
password: false,
|
||||
confirmPassword: false,
|
||||
};
|
||||
|
||||
export const SecurityProfileSettings = observer(function SecurityProfileSettings() {
|
||||
// store
|
||||
const { data: currentUser, changePassword } = useUser();
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState(defaultShowPassword);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
|
||||
|
||||
// use form
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<FormValues>({ defaultValues });
|
||||
// derived values
|
||||
const oldPassword = watch("old_password");
|
||||
const password = watch("new_password");
|
||||
const confirmPassword = watch("confirm_password");
|
||||
const oldPasswordRequired = !currentUser?.is_password_autoset;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword;
|
||||
|
||||
const handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleChangePassword = async (formData: FormValues) => {
|
||||
const { old_password, new_password } = formData;
|
||||
try {
|
||||
const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token);
|
||||
if (!csrfToken) throw new Error("csrf token not found");
|
||||
|
||||
await changePassword(csrfToken, {
|
||||
...(oldPasswordRequired && { old_password }),
|
||||
new_password,
|
||||
});
|
||||
|
||||
reset(defaultValues);
|
||||
setShowPassword(defaultShowPassword);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("auth.common.password.toast.change_password.success.title"),
|
||||
message: t("auth.common.password.toast.change_password.success.message"),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error & { error_code?: string };
|
||||
const code = err.error_code?.toString();
|
||||
const errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined;
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: errorInfo?.title ?? t("auth.common.password.toast.change_password.error.title"),
|
||||
message:
|
||||
typeof errorInfo?.message === "string"
|
||||
? errorInfo.message
|
||||
: t("auth.common.password.toast.change_password.error.message"),
|
||||
});
|
||||
|
||||
if (code && passwordErrors.includes(code as EAuthenticationErrorCodes)) {
|
||||
setError("new_password", {
|
||||
type: "manual",
|
||||
message: errorInfo?.message?.toString() || t("auth.common.password.toast.change_password.error.message"),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled =
|
||||
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID ||
|
||||
(oldPasswordRequired && oldPassword.trim() === "") ||
|
||||
password.trim() === "" ||
|
||||
confirmPassword.trim() === "" ||
|
||||
password !== confirmPassword ||
|
||||
password === oldPassword;
|
||||
|
||||
const passwordSupport = password.length > 0 &&
|
||||
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
|
||||
<PasswordStrengthIndicator password={password} isFocused={isPasswordInputFocused} />
|
||||
);
|
||||
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<div className="size-full">
|
||||
<ProfileSettingsHeading title={t("auth.common.password.change_password.label.default")} />
|
||||
<form onSubmit={handleSubmit(handleChangePassword)} className="mt-7 flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-y-7">
|
||||
{oldPasswordRequired && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="text-13">{t("auth.common.password.current_password.label")}</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="old_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Field name="old_password" invalid={Boolean(errors.old_password)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="old_password"
|
||||
type={showPassword?.oldPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={t("old_password")}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-5 place-items-center"
|
||||
onClick={() => handleShowPassword("oldPassword")}
|
||||
aria-label={showPassword?.oldPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword?.oldPassword ? (
|
||||
<HideOutline className="size-5 text-placeholder" />
|
||||
) : (
|
||||
<ShowOutline className="size-5 text-placeholder" />
|
||||
)}
|
||||
</button>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{errors.old_password && (
|
||||
<span className="text-11 text-danger-primary">{errors.old_password.message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-x-4 gap-y-7 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="text-13">{t("auth.common.password.new_password.label")}</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="new_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Field name="new_password" invalid={Boolean(errors.new_password)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="new_password"
|
||||
type={showPassword?.password ? "text" : "password"}
|
||||
value={value}
|
||||
placeholder={t("auth.common.password.new_password.placeholder")}
|
||||
onChange={onChange}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-5 place-items-center"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
aria-label={showPassword?.password ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword?.password ? (
|
||||
<HideOutline className="size-5 text-placeholder" />
|
||||
) : (
|
||||
<ShowOutline className="size-5 text-placeholder" />
|
||||
)}
|
||||
</button>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{passwordSupport}
|
||||
{errors.new_password && (
|
||||
<span className="text-11 text-danger-primary">{errors.new_password.message}</span>
|
||||
)}
|
||||
{isNewPasswordSameAsOldPassword && !isPasswordInputFocused && (
|
||||
<span className="text-11 text-danger-primary">
|
||||
{t("new_password_must_be_different_from_old_password")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="text-13">{t("auth.common.password.confirm_password.label")}</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirm_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Field name="confirm_password" invalid={Boolean(errors.confirm_password)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="confirm_password"
|
||||
type={showPassword?.confirmPassword ? "text" : "password"}
|
||||
placeholder={t("auth.common.password.confirm_password.placeholder")}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-5 place-items-center"
|
||||
onClick={() => handleShowPassword("confirmPassword")}
|
||||
aria-label={showPassword?.confirmPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword?.confirmPassword ? (
|
||||
<HideOutline className="size-5 text-placeholder" />
|
||||
) : (
|
||||
<ShowOutline className="size-5 text-placeholder" />
|
||||
)}
|
||||
</button>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && (
|
||||
<span className="text-13 text-danger-primary">{t("auth.common.password.errors.match")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="primary" size="xl" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
|
||||
{isSubmitting
|
||||
? `${t("auth.common.password.change_password.label.submitting")}`
|
||||
: t("auth.common.password.change_password.label.default")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
38
apps/web/core/components/settings/profile/content/root.tsx
Normal file
38
apps/web/core/components/settings/profile/content/root.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { PROFILE_SETTINGS_PAGES_MAP } from "./pages";
|
||||
|
||||
type Props = {
|
||||
activeTab: TProfileSettingsTabs;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ProfileSettingsContent = observer(function ProfileSettingsContent(props: Props) {
|
||||
const { activeTab, className } = props;
|
||||
const PageComponent = PROFILE_SETTINGS_PAGES_MAP[activeTab];
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className={cn("shrink-0 overflow-y-scroll bg-surface-1", className)}
|
||||
viewportClassName="px-8 py-9"
|
||||
scrollType="hover"
|
||||
orientation="vertical"
|
||||
size="sm"
|
||||
>
|
||||
<Suspense>
|
||||
<PageComponent />
|
||||
</Suspense>
|
||||
</ScrollArea>
|
||||
);
|
||||
});
|
||||
27
apps/web/core/components/settings/profile/heading.tsx
Normal file
27
apps/web/core/components/settings/profile/heading.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { cn } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
control?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ProfileSettingsHeading({ className, control, description, title }: Props) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-start justify-between gap-4 md:flex-row md:items-center", className)}>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{title && <h6 className="text-h6-medium text-primary">{title}</h6>}
|
||||
{description && <p className="text-body-xs-regular text-tertiary">{description}</p>}
|
||||
</div>
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
apps/web/core/components/settings/profile/modal.tsx
Normal file
59
apps/web/core/components/settings/profile/modal.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { CloseOutline } from "@makeplane/propel/icons";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
// local imports
|
||||
import { ProfileSettingsContent } from "./content";
|
||||
import { ProfileSettingsSidebarRoot } from "./sidebar";
|
||||
|
||||
export const ProfileSettingsModal = observer(function ProfileSettingsModal() {
|
||||
// store hooks
|
||||
const { profileSettingsModal, toggleProfileSettingsModal } = useCommandPalette();
|
||||
// derived values
|
||||
const activeTab = profileSettingsModal.activeTab ?? "general";
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
toggleProfileSettingsModal({
|
||||
isOpen: false,
|
||||
});
|
||||
setTimeout(() => {
|
||||
toggleProfileSettingsModal({
|
||||
activeTab: null,
|
||||
});
|
||||
}, 300);
|
||||
}, [toggleProfileSettingsModal]);
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={profileSettingsModal.isOpen}
|
||||
handleClose={handleClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.VIXL}
|
||||
className="h-175"
|
||||
>
|
||||
<div className="@container relative size-full">
|
||||
<div className="flex size-full">
|
||||
<ProfileSettingsSidebarRoot
|
||||
activeTab={activeTab}
|
||||
className="w-[250px] rounded-l-xl"
|
||||
updateActiveTab={(tab) => toggleProfileSettingsModal({ activeTab: tab })}
|
||||
/>
|
||||
<ProfileSettingsContent activeTab={activeTab} className="flex-1 rounded-r-xl" />
|
||||
</div>
|
||||
<div className="absolute top-3.5 right-3.5">
|
||||
<IconButton size="base" variant="tertiary" icon={CloseOutline} onClick={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
36
apps/web/core/components/settings/profile/sidebar/header.tsx
Normal file
36
apps/web/core/components/settings/profile/sidebar/header.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { Avatar } from "@makeplane/propel/components/avatar";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export const ProfileSettingsSidebarHeader = observer(function ProfileSettingsSidebarHeader() {
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0">
|
||||
<Avatar
|
||||
alt={currentUser?.display_name}
|
||||
fallback={currentUser?.display_name?.[0]?.toUpperCase()}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<p className="truncate text-body-sm-medium">
|
||||
{currentUser?.first_name} {currentUser?.last_name}
|
||||
</p>
|
||||
<p className="truncate text-caption-md-regular">{currentUser?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { KeyOutline, LockOutline, SettingsOutline, SubscribeOutline, UserOutline } from "@makeplane/propel/icons";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "react-router";
|
||||
// plane imports
|
||||
import {
|
||||
GROUPED_PROFILE_SETTINGS,
|
||||
PROFILE_SETTINGS_CATEGORIES,
|
||||
PROFILE_SETTINGS_CATEGORY_LABELS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
// local imports
|
||||
import { SettingsSidebarItem } from "../../sidebar/item";
|
||||
import { ProfileSettingsSidebarWorkspaceOptions } from "./workspace-options";
|
||||
|
||||
const ICONS: Record<TProfileSettingsTabs, LucideIcon | React.FC<ISvgIcons>> = {
|
||||
general: UserOutline,
|
||||
security: LockOutline,
|
||||
preferences: SettingsOutline,
|
||||
notifications: SubscribeOutline,
|
||||
"api-tokens": KeyOutline,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
activeTab: TProfileSettingsTabs;
|
||||
updateActiveTab: (tab: TProfileSettingsTabs) => void;
|
||||
};
|
||||
|
||||
export const ProfileSettingsSidebarItemCategories = observer(function ProfileSettingsSidebarItemCategories(
|
||||
props: Props
|
||||
) {
|
||||
const { activeTab, updateActiveTab } = props;
|
||||
// params
|
||||
const { profileTabId } = useParams();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-y-4">
|
||||
{PROFILE_SETTINGS_CATEGORIES.map((category) => {
|
||||
const categoryItems = GROUPED_PROFILE_SETTINGS[category];
|
||||
|
||||
if (categoryItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={category} className="shrink-0">
|
||||
<div className="p-2 text-caption-md-medium text-tertiary capitalize">
|
||||
{t(PROFILE_SETTINGS_CATEGORY_LABELS[category])}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{categoryItems.map((item) => (
|
||||
<SettingsSidebarItem
|
||||
key={item.key}
|
||||
as="button"
|
||||
onClick={() => updateActiveTab(item.key)}
|
||||
isActive={activeTab === item.key}
|
||||
icon={ICONS[item.key]}
|
||||
label={t(item.i18n_label)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{profileTabId && <ProfileSettingsSidebarWorkspaceOptions />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
35
apps/web/core/components/settings/profile/sidebar/root.tsx
Normal file
35
apps/web/core/components/settings/profile/sidebar/root.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { ProfileSettingsSidebarHeader } from "./header";
|
||||
import { ProfileSettingsSidebarItemCategories } from "./item-categories";
|
||||
|
||||
type Props = {
|
||||
activeTab: TProfileSettingsTabs;
|
||||
className?: string;
|
||||
updateActiveTab: (tab: TProfileSettingsTabs) => void;
|
||||
};
|
||||
|
||||
export function ProfileSettingsSidebarRoot(props: Props) {
|
||||
const { activeTab, className, updateActiveTab } = props;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
scrollType="hover"
|
||||
orientation="vertical"
|
||||
size="sm"
|
||||
rootClassName={cn("shrink-0 overflow-y-scroll border-r border-r-subtle bg-surface-2 px-3 py-4", className)}
|
||||
>
|
||||
<ProfileSettingsSidebarHeader />
|
||||
<ProfileSettingsSidebarItemCategories activeTab={activeTab} updateActiveTab={updateActiveTab} />
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { MailOutline, PlusCircleOutline } from "@makeplane/propel/icons";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
|
||||
import { WorkspaceLogo } from "@/components/workspace/logo";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
export const ProfileSettingsSidebarWorkspaceOptions = observer(function ProfileSettingsSidebarWorkspaceOptions() {
|
||||
// store hooks
|
||||
const { workspaces } = useWorkspace();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div className="p-2 text-caption-md-medium text-tertiary capitalize">{t("common.workspace")}</div>
|
||||
<div className="flex flex-col">
|
||||
{Object.values(workspaces).map((workspace) => (
|
||||
<SettingsSidebarItem
|
||||
key={workspace.id}
|
||||
as="link"
|
||||
href={`/${workspace.slug}/`}
|
||||
iconNode={<WorkspaceLogo logo={workspace.logo_url} name={workspace.name} classNames="shrink-0" />}
|
||||
label={workspace.name}
|
||||
isActive={false}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1.5">
|
||||
<SettingsSidebarItem
|
||||
as="link"
|
||||
href="/create-workspace/"
|
||||
icon={PlusCircleOutline}
|
||||
label={t("create_workspace")}
|
||||
isActive={false}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
as="link"
|
||||
href="/invitations/"
|
||||
icon={MailOutline}
|
||||
label={t("workspace_invites")}
|
||||
isActive={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { IProject } from "@plane/types";
|
||||
import { Switch } from "@makeplane/propel/components/switch";
|
||||
// components
|
||||
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
|
||||
type Props = {
|
||||
description?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
projectId: string;
|
||||
featureProperty: keyof IProject;
|
||||
title: React.ReactNode;
|
||||
value: boolean;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const ProjectSettingsFeatureControlItem = observer(function ProjectSettingsFeatureControlItem(props: Props) {
|
||||
const { description, disabled, featureProperty, projectId, title, value, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById, updateProject } = useProject();
|
||||
// derived values
|
||||
const currentProjectDetails = getProjectById(projectId);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!workspaceSlug || !projectId || !currentProjectDetails) return;
|
||||
|
||||
// making the request to update the project feature
|
||||
const settingsPayload = {
|
||||
[featureProperty]: !currentProjectDetails?.[featureProperty],
|
||||
};
|
||||
const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload);
|
||||
|
||||
setPromiseToast(updateProjectPromise, {
|
||||
loading: "Updating project feature...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Project feature updated successfully.",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Something went wrong while updating project feature. Please try again.",
|
||||
},
|
||||
});
|
||||
void updateProjectPromise.then(() => {
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsBoxedControlItem
|
||||
title={title}
|
||||
description={description}
|
||||
control={
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={value}
|
||||
onCheckedChange={handleSubmit}
|
||||
disabled={disabled}
|
||||
aria-label={typeof title === "string" ? title : "Toggle project feature"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
64
apps/web/core/components/settings/project/sidebar/header.tsx
Normal file
64
apps/web/core/components/settings/project/sidebar/header.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ArrowNarrowLeftOutline } from "@makeplane/propel/icons";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ROLE_DETAILS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const ProjectSettingsSidebarHeader = observer(function ProjectSettingsSidebarHeader(props: Props) {
|
||||
const { projectId } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { getPartialProjectById } = useProject();
|
||||
// derived values
|
||||
const projectDetails = getPartialProjectById(projectId);
|
||||
const currentProjectRole = currentWorkspace?.slug
|
||||
? getProjectRoleByWorkspaceSlugAndProjectId(currentWorkspace.slug, projectId)
|
||||
: undefined;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!currentProjectRole) return null;
|
||||
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center gap-1 py-3 pr-5 pl-4 text-body-md-medium">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="base"
|
||||
icon={ArrowNarrowLeftOutline}
|
||||
onClick={() => router.push(`/${currentWorkspace?.slug}/projects/${projectId}/issues/`)}
|
||||
/>
|
||||
<p>Project settings</p>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-2 truncate px-5 py-0.5">
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded bg-layer-2">
|
||||
<Logo logo={projectDetails?.logo_props} size={20} />
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<p className="truncate text-body-sm-medium">{projectDetails?.name}</p>
|
||||
<p className="truncate text-caption-md-regular">{t(ROLE_DETAILS[currentProjectRole].i18n_title)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useParams } from "react-router";
|
||||
// plane imports
|
||||
import {
|
||||
EUserPermissionsLevel,
|
||||
GROUPED_PROJECT_SETTINGS,
|
||||
PROJECT_SETTINGS_CATEGORIES,
|
||||
PROJECT_SETTINGS_CATEGORY_LABELS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { PROJECT_SETTINGS_ICONS } from "./item-icon";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const ProjectSettingsSidebarItemCategories = observer(function ProjectSettingsSidebarItemCategories(
|
||||
props: Props
|
||||
) {
|
||||
const { projectId } = props;
|
||||
// params
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col divide-y divide-subtle px-3">
|
||||
{PROJECT_SETTINGS_CATEGORIES.map((category) => {
|
||||
const categoryItems = GROUPED_PROJECT_SETTINGS[category];
|
||||
const accessibleItems = categoryItems.filter((item) =>
|
||||
allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, projectId)
|
||||
);
|
||||
|
||||
if (accessibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={category} className="shrink-0 py-3 first:pt-0 last:pb-0">
|
||||
<div className="p-2 text-caption-md-medium text-tertiary capitalize">
|
||||
{t(PROJECT_SETTINGS_CATEGORY_LABELS[category])}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{accessibleItems.map((item) => {
|
||||
const isItemActive =
|
||||
item.href === ""
|
||||
? pathname === `/${workspaceSlug}/settings/projects/${projectId}${item.href}/`
|
||||
: new RegExp(`^/${workspaceSlug}/settings/projects/${projectId}${item.href}/`).test(pathname);
|
||||
|
||||
return (
|
||||
<SettingsSidebarItem
|
||||
key={item.key}
|
||||
as="link"
|
||||
href={`/${workspaceSlug}/settings/projects/${projectId}${item.href}/`}
|
||||
isActive={isItemActive}
|
||||
icon={PROJECT_SETTINGS_ICONS[item.key]}
|
||||
label={t(item.i18n_label)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
CyclesOutline,
|
||||
EstimateOutline,
|
||||
IntakeOutline,
|
||||
LabelsOutline,
|
||||
MembersOutline,
|
||||
ModuleOutline,
|
||||
PagesOutline,
|
||||
StateOutline,
|
||||
TriggerOutline,
|
||||
ViewsOutline,
|
||||
} from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import type { TProjectSettingsTabs } from "@plane/types";
|
||||
// components
|
||||
import { SettingIcon } from "@/components/icons/attachment";
|
||||
|
||||
export const PROJECT_SETTINGS_ICONS: Record<TProjectSettingsTabs, LucideIcon | React.FC<ISvgIcons>> = {
|
||||
general: SettingIcon,
|
||||
members: MembersOutline,
|
||||
features_cycles: CyclesOutline,
|
||||
features_modules: ModuleOutline,
|
||||
features_views: ViewsOutline,
|
||||
features_pages: PagesOutline,
|
||||
features_intake: IntakeOutline,
|
||||
states: StateOutline,
|
||||
labels: LabelsOutline,
|
||||
estimates: EstimateOutline,
|
||||
automations: TriggerOutline,
|
||||
};
|
||||
32
apps/web/core/components/settings/project/sidebar/root.tsx
Normal file
32
apps/web/core/components/settings/project/sidebar/root.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||
// local imports
|
||||
import { ProjectSettingsSidebarHeader } from "./header";
|
||||
import { ProjectSettingsSidebarItemCategories } from "./item-categories";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export function ProjectSettingsSidebarRoot(props: Props) {
|
||||
const { projectId } = props;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
scrollType="hover"
|
||||
orientation="vertical"
|
||||
size="sm"
|
||||
rootClassName="shrink-0 animate-fade-in h-full w-[250px] bg-surface-1 border-r border-r-subtle overflow-y-scroll"
|
||||
viewportClassName="pb-5"
|
||||
>
|
||||
<ProjectSettingsSidebarHeader projectId={projectId} />
|
||||
<ProjectSettingsSidebarItemCategories projectId={projectId} />
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
60
apps/web/core/components/settings/sidebar/item.tsx
Normal file
60
apps/web/core/components/settings/sidebar/item.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
|
||||
type Props = {
|
||||
isActive: boolean;
|
||||
label: string;
|
||||
} & ({ as: "button"; onClick: () => void } | { as: "link"; href: string }) &
|
||||
(
|
||||
| {
|
||||
icon: LucideIcon | React.FC<ISvgIcons>;
|
||||
}
|
||||
| { iconNode: React.ReactElement }
|
||||
);
|
||||
|
||||
export function SettingsSidebarItem(props: Props) {
|
||||
const { as, isActive, label } = props;
|
||||
// common class
|
||||
const className = cn(
|
||||
"flex items-center gap-2 rounded-lg px-2 py-1.5 text-left text-body-sm-medium text-secondary transition-colors",
|
||||
{
|
||||
"bg-layer-transparent-selected text-primary": isActive,
|
||||
"hover:bg-layer-transparent-hover": !isActive,
|
||||
}
|
||||
);
|
||||
// common content
|
||||
const content = (
|
||||
<>
|
||||
{"icon" in props ? (
|
||||
<span className="grid size-4 shrink-0 place-items-center">{<props.icon className="size-3.5" />}</span>
|
||||
) : (
|
||||
props.iconNode
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (as === "button") {
|
||||
return (
|
||||
<button type="button" className={className} onClick={props.onClick}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className={className} href={props.href}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ArrowNarrowLeftOutline } from "@makeplane/propel/icons";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ROLE_DETAILS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
// components
|
||||
import { WorkspaceLogo } from "@/components/workspace/logo";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
export const WorkspaceSettingsSidebarHeader = observer(function WorkspaceSettingsSidebarHeader() {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { getWorkspaceRoleByWorkspaceSlug } = useUserPermissions();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
// derived values
|
||||
const currentWorkspaceRole = currentWorkspace?.slug
|
||||
? getWorkspaceRoleByWorkspaceSlug(currentWorkspace.slug)
|
||||
: undefined;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!currentWorkspaceRole) return null;
|
||||
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center gap-1 py-3 pr-5 pl-4 text-body-md-medium">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="base"
|
||||
icon={ArrowNarrowLeftOutline}
|
||||
onClick={() => router.push(`/${currentWorkspace?.slug}/`)}
|
||||
/>
|
||||
<p>Workspace settings</p>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2 px-5 py-0.5">
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<WorkspaceLogo
|
||||
logo={currentWorkspace?.logo_url}
|
||||
name={currentWorkspace?.name}
|
||||
classNames="shrink-0 size-8 border border-subtle"
|
||||
/>
|
||||
<div className="truncate">
|
||||
<p className="truncate text-body-sm-medium">{currentWorkspace?.name}</p>
|
||||
<p className="truncate text-caption-md-regular">{t(ROLE_DETAILS[currentWorkspaceRole].i18n_title)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useParams } from "react-router";
|
||||
// plane imports
|
||||
import {
|
||||
EUserPermissionsLevel,
|
||||
GROUPED_WORKSPACE_SETTINGS,
|
||||
WORKSPACE_SETTINGS_CATEGORIES,
|
||||
WORKSPACE_SETTINGS_CATEGORY_LABELS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
// components
|
||||
import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { WORKSPACE_SETTINGS_ICONS } from "./item-icon";
|
||||
|
||||
export const WorkspaceSettingsSidebarItemCategories = observer(function WorkspaceSettingsSidebarItemCategories() {
|
||||
// params
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col divide-y divide-subtle px-3">
|
||||
{WORKSPACE_SETTINGS_CATEGORIES.map((category) => {
|
||||
const categoryItems = GROUPED_WORKSPACE_SETTINGS[category];
|
||||
const accessibleItems = categoryItems.filter((item) =>
|
||||
allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug)
|
||||
);
|
||||
|
||||
if (accessibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={category} className="shrink-0 py-3 first:pt-0 last:pb-0">
|
||||
<div className="p-2 text-caption-md-medium text-tertiary capitalize">
|
||||
{t(WORKSPACE_SETTINGS_CATEGORY_LABELS[category])}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{accessibleItems.map((item) => {
|
||||
const isItemActive =
|
||||
item.href === "/settings"
|
||||
? pathname === `/${workspaceSlug}${item.href}/`
|
||||
: new RegExp(`^/${workspaceSlug}${item.href}/`).test(pathname);
|
||||
|
||||
return (
|
||||
<SettingsSidebarItem
|
||||
key={item.key}
|
||||
as="link"
|
||||
href={joinUrlPath(workspaceSlug ?? "", item.href)}
|
||||
isActive={isItemActive}
|
||||
icon={WORKSPACE_SETTINGS_ICONS[item.key]}
|
||||
label={t(item.i18n_label)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
BillingsOutline,
|
||||
BuildingOutline,
|
||||
ExportOutline,
|
||||
MembersOutline,
|
||||
WebhooksOutline,
|
||||
} from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import type { TWorkspaceSettingsTabs } from "@plane/types";
|
||||
|
||||
export const WORKSPACE_SETTINGS_ICONS: Record<TWorkspaceSettingsTabs, LucideIcon | React.FC<ISvgIcons>> = {
|
||||
general: BuildingOutline,
|
||||
members: MembersOutline,
|
||||
export: ExportOutline,
|
||||
"billing-and-plans": BillingsOutline,
|
||||
webhooks: WebhooksOutline,
|
||||
};
|
||||
35
apps/web/core/components/settings/workspace/sidebar/root.tsx
Normal file
35
apps/web/core/components/settings/workspace/sidebar/root.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { WorkspaceSettingsSidebarHeader } from "./header";
|
||||
import { WorkspaceSettingsSidebarItemCategories } from "./item-categories";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function WorkspaceSettingsSidebarRoot(props: Props) {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
scrollType="hover"
|
||||
orientation="vertical"
|
||||
size="sm"
|
||||
rootClassName={cn(
|
||||
"h-full w-[250px] shrink-0 animate-fade-in overflow-y-scroll border-r border-r-subtle bg-surface-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<WorkspaceSettingsSidebarHeader />
|
||||
<WorkspaceSettingsSidebarItemCategories />
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user