Compare commits

...

11 Commits

Author SHA1 Message Date
gakshita d5780df5af feat: profile settings internal screens upgrade 2025-04-29 14:12:57 +05:30
gakshita b6b7baa9b8 fix: css + build 2025-04-29 12:55:34 +05:30
gakshita 57560c214d fix: handled no project 2025-04-25 15:55:24 +05:30
gakshita c82e35ff0c routes 2025-04-25 14:43:54 +05:30
gakshita b6ef8aa541 chore: project settings + refactoring 2025-04-24 19:11:55 +05:30
gakshita 3493939d9d feat: profile + workspace settings ui 2025-04-24 15:20:26 +05:30
gakshita 71e1de2054 feat: workspce settings + layouting 2025-04-23 20:15:51 +05:30
gakshita 767788bdee feat: workspace settings 2025-04-23 20:15:30 +05:30
gakshita 89a29f75ed fix: backend 2025-04-23 17:05:50 +05:30
sangeethailango 526d7da666 chore: remove unwanted fields 2025-04-23 15:24:56 +05:30
sangeethailango 12bd0b8879 chore: return workspace name and logo in profile settings api 2025-04-23 15:11:07 +05:30
77 changed files with 2028 additions and 280 deletions
+5
View File
@@ -99,11 +99,16 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_member__member=obj.id,
workspace_member__is_active=True,
).first()
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
return {
"last_workspace_id": profile.last_workspace_id,
"last_workspace_slug": (
workspace.slug if workspace is not None else ""
),
"last_workspace_name": (
workspace.name if workspace is not None else ""
),
"last_workspace_logo": (logo_asset_url),
"fallback_workspace_id": profile.last_workspace_id,
"fallback_workspace_slug": (
workspace.slug if workspace is not None else ""
+1
View File
@@ -32,3 +32,4 @@ export * from "./dashboard";
export * from "./page";
export * from "./emoji";
export * from "./subscription";
export * from "./settings";
+44 -30
View File
@@ -1,39 +1,53 @@
export const PROFILE_SETTINGS = {
profile: {
key: "profile",
i18n_label: "profile.actions.profile",
href: `/settings/account`,
highlight: (pathname: string) => pathname === "/settings/account/",
},
security: {
key: "security",
i18n_label: "profile.actions.security",
href: `/settings/account/security`,
highlight: (pathname: string) => pathname === "/settings/account/security/",
},
activity: {
key: "activity",
i18n_label: "profile.actions.activity",
href: `/settings/account/activity`,
highlight: (pathname: string) => pathname === "/settings/account/activity/",
},
preferences: {
key: "preferences",
i18n_label: "profile.actions.preferences",
href: `/settings/account/preferences`,
highlight: (pathname: string) => pathname.includes("/settings/account/preferences"),
},
notifications: {
key: "notifications",
i18n_label: "profile.actions.notifications",
href: `/settings/account/notifications`,
highlight: (pathname: string) => pathname === "/settings/account/notifications/",
},
"api-tokens": {
key: "api-tokens",
i18n_label: "profile.actions.api-tokens",
href: `/settings/account/api-tokens`,
highlight: (pathname: string) => pathname === "/settings/account/api-tokens/",
},
};
export const PROFILE_ACTION_LINKS: {
key: string;
i18n_label: string;
href: string;
highlight: (pathname: string) => boolean;
}[] = [
{
key: "profile",
i18n_label: "profile.actions.profile",
href: `/profile`,
highlight: (pathname: string) => pathname === "/profile/",
},
{
key: "security",
i18n_label: "profile.actions.security",
href: `/profile/security`,
highlight: (pathname: string) => pathname === "/profile/security/",
},
{
key: "activity",
i18n_label: "profile.actions.activity",
href: `/profile/activity`,
highlight: (pathname: string) => pathname === "/profile/activity/",
},
{
key: "appearance",
i18n_label: "profile.actions.appearance",
href: `/profile/appearance`,
highlight: (pathname: string) => pathname.includes("/profile/appearance"),
},
{
key: "notifications",
i18n_label: "profile.actions.notifications",
href: `/profile/notifications`,
highlight: (pathname: string) => pathname === "/profile/notifications/",
},
PROFILE_SETTINGS["profile"],
PROFILE_SETTINGS["security"],
PROFILE_SETTINGS["activity"],
PROFILE_SETTINGS["preferences"],
PROFILE_SETTINGS["notifications"],
PROFILE_SETTINGS["api-tokens"],
];
export const PROFILE_VIEWER_TAB = [
+52
View File
@@ -0,0 +1,52 @@
import { PROFILE_SETTINGS } from ".";
import { WORKSPACE_SETTINGS } from "./workspace";
export enum WORKSPACE_SETTINGS_CATEGORY {
ADMINISTRATION = "administration",
FEATURES = "features",
DEVELOPER = "developer",
}
export enum PROFILE_SETTINGS_CATEGORY {
YOUR_PROFILE = "your profile",
DEVELOPER = "developer",
}
export enum PROJECT_SETTINGS_CATEGORY {
PROJECTS = "projects",
}
export const WORKSPACE_SETTINGS_CATEGORIES = [
WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION,
WORKSPACE_SETTINGS_CATEGORY.FEATURES,
WORKSPACE_SETTINGS_CATEGORY.DEVELOPER,
];
export const PROFILE_SETTINGS_CATEGORIES = [
PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE,
PROFILE_SETTINGS_CATEGORY.DEVELOPER,
];
export const PROJECT_SETTINGS_CATEGORIES = [PROJECT_SETTINGS_CATEGORY.PROJECTS];
export const GROUPED_WORKSPACE_SETTINGS = {
[WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [
WORKSPACE_SETTINGS["general"],
WORKSPACE_SETTINGS["members"],
WORKSPACE_SETTINGS["billing-and-plans"],
WORKSPACE_SETTINGS["export"],
],
[WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [],
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]],
};
export const GROUPED_PROFILE_SETTINGS = {
[PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [
PROFILE_SETTINGS["profile"],
PROFILE_SETTINGS["preferences"],
PROFILE_SETTINGS["notifications"],
PROFILE_SETTINGS["security"],
PROFILE_SETTINGS["activity"],
],
[PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]],
};
-8
View File
@@ -114,13 +114,6 @@ export const WORKSPACE_SETTINGS = {
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
},
"api-tokens": {
key: "api-tokens",
i18n_label: "workspace_settings.settings.api_tokens.title",
href: `/settings/api-tokens`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`,
},
};
export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries(
@@ -139,7 +132,6 @@ export const WORKSPACE_SETTINGS_LINKS: {
WORKSPACE_SETTINGS["billing-and-plans"],
WORKSPACE_SETTINGS["export"],
WORKSPACE_SETTINGS["webhooks"],
WORKSPACE_SETTINGS["api-tokens"],
];
export const ROLE = {
+17 -9
View File
@@ -43,7 +43,8 @@
"your_account": "Your account",
"security": "Security",
"activity": "Activity",
"appearance": "Appearance",
"preferences": "Preferences",
"language_and_time": "Language & Time",
"notifications": "Notifications",
"workspaces": "Workspaces",
"create_workspace": "Create workspace",
@@ -56,6 +57,10 @@
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"load_more": "Load more",
"select_or_customize_your_interface_color_scheme": "Select or customize your interface color scheme.",
"timezone_setting": "Current timezone setting.",
"language_setting": "Choose the language used in the user interface.",
"settings_moved_to_preferences": "Timezone & Language settings have been moved to preferences.",
"go_to_preferences": "Go to preferences",
"theme": "Theme",
"system_preference": "System preference",
"light": "Light",
@@ -334,6 +339,8 @@
"new_password_must_be_different_from_old_password": "New password must be different from old password",
"edited": "edited",
"bot": "Bot",
"settings_description": "Manage your account, workspace, and project preferences all in one place. Switch between tabs to easily configure.",
"back_to_workspace": "Back to workspace",
"project_view": {
"sort_by": {
@@ -1418,29 +1425,29 @@
}
},
"api_tokens": {
"title": "API Tokens",
"add_token": "Add API token",
"title": "Personal Access Tokens",
"add_token": "Add personal access token",
"create_token": "Create token",
"never_expires": "Never expires",
"generate_token": "Generate token",
"generating": "Generating",
"delete": {
"title": "Delete API token",
"title": "Delete personal access token",
"description": "Any application using this token will no longer have the access to Plane data. This action cannot be undone.",
"success": {
"title": "Success!",
"message": "The API token has been successfully deleted"
"message": "The token has been successfully deleted"
},
"error": {
"title": "Error!",
"message": "The API token could not be deleted"
"message": "The token could not be deleted"
}
}
}
},
"empty_state": {
"api_tokens": {
"title": "No API tokens created",
"title": "No personal access tokens created",
"description": "Plane APIs can be used to integrate your data in Plane with any external system. Create a token to get started."
},
"webhooks": {
@@ -1491,8 +1498,9 @@
"profile": "Profile",
"security": "Security",
"activity": "Activity",
"appearance": "Appearance",
"notifications": "Notifications"
"preferences": "Preferences",
"notifications": "Notifications",
"api-tokens": "Personal Access Tokens"
},
"tabs": {
"summary": "Summary",
+3 -8
View File
@@ -76,6 +76,8 @@ export interface IUserSettings {
workspace: {
last_workspace_id: string | undefined;
last_workspace_slug: string | undefined;
last_workspace_name: string | undefined;
last_workspace_logo: string | undefined;
fallback_workspace_id: string | undefined;
fallback_workspace_slug: string | undefined;
invites: number | undefined;
@@ -155,14 +157,7 @@ export interface IUserProfileProjectSegregation {
id: string;
pending_issues: number;
}[];
user_data: Pick<
IUser,
| "avatar_url"
| "cover_image_url"
| "display_name"
| "first_name"
| "last_name"
> & {
user_data: Pick<IUser, "avatar_url" | "cover_image_url" | "display_name" | "first_name" | "last_name"> & {
date_joined: Date;
user_timezone: string;
};
@@ -69,7 +69,7 @@ const ProjectCyclesPage = observer(() => {
primaryButton={{
text: t("disabled_project.empty_state.cycle.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
},
disabled: !hasAdminLevelPermission,
}}
@@ -42,7 +42,7 @@ const ProjectInboxPage = observer(() => {
primaryButton={{
text: t("disabled_project.empty_state.inbox.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}
@@ -61,7 +61,7 @@ const ProjectModulesPage = observer(() => {
primaryButton={{
text: t("disabled_project.empty_state.module.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}
@@ -54,7 +54,7 @@ const ProjectPagesPage = observer(() => {
primaryButton={{
text: t("disabled_project.empty_state.page.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}
@@ -68,7 +68,7 @@ const ProjectViewsPage = observer(() => {
primaryButton={{
text: t("disabled_project.empty_state.view.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}
@@ -1,48 +0,0 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { SidebarNavItem } from "@/components/sidebar";
// hooks
import { useUserPermissions } from "@/hooks/store";
// plane web helpers
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
export const WorkspaceSettingsSidebar = observer(() => {
// router
const { workspaceSlug } = useParams();
const pathname = usePathname();
// mobx store
const { t } = useTranslation();
const { allowPermissions } = useUserPermissions();
return (
<div className="flex w-[280px] flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-custom-sidebar-text-400 uppercase">{t("settings")}</span>
<div className="flex w-full flex-col gap-1">
{WORKSPACE_SETTINGS_LINKS.map(
(link) =>
shouldRenderSettingLink(workspaceSlug.toString(), link.key) &&
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
<SidebarNavItem
key={link.key}
isActive={link.highlight(pathname, `/${workspaceSlug}`)}
className="text-sm font-medium px-4 py-2"
>
{t(link.i18n_label)}
</SidebarNavItem>
</Link>
)
)}
</div>
</div>
</div>
);
});
@@ -1,37 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import { Settings } from "lucide-react";
// ui
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
// hooks
import { useWorkspace } from "@/hooks/store";
export const WorkspaceSettingHeader: FC = observer(() => {
const { currentWorkspace, loader } = useWorkspace();
const { t } = useTranslation();
return (
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${currentWorkspace?.slug}/settings`}
label={currentWorkspace?.name ?? "Workspace"}
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink label={t("settings")} />} />
</Breadcrumbs>
</Header.LeftItem>
</Header>
);
});
@@ -0,0 +1,21 @@
"use client";
import { ContentWrapper } from "@/components/core";
import { SettingsHeader } from "@/components/settings";
import { AuthenticationWrapper } from "@/lib/wrappers";
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<AuthenticationWrapper>
<WorkspaceAuthWrapper>
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-custom-background-100">
{/* Header */}
<SettingsHeader />
{/* Content */}
<ContentWrapper className="px-12 py-page-y flex">{children}</ContentWrapper>
</main>
</WorkspaceAuthWrapper>
</AuthenticationWrapper>
);
}
@@ -3,15 +3,13 @@
import { FC, ReactNode } from "react";
import { observer } from "mobx-react";
// components
import { useParams, usePathname } from "next/navigation";
import { usePathname } from "next/navigation";
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { AppHeader } from "@/components/core";
// hooks
import { NotAuthorizedView } from "@/components/auth-screens";
import { SettingsContentWrapper } from "@/components/settings";
import { useUserPermissions } from "@/hooks/store";
// plane web constants
// local components
import { WorkspaceSettingHeader } from "../header";
import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs";
import { WorkspaceSettingsSidebar } from "./sidebar";
@@ -19,40 +17,41 @@ export interface IWorkspaceSettingLayout {
children: ReactNode;
}
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}` || "" };
};
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
const { children } = props;
// store hooks
const { workspaceUserInfo } = useUserPermissions();
// next hooks
const pathname = usePathname();
const [workspaceSlug, suffix, route] = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes
// derived values
const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname);
const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role;
const isAuthorized =
pathname &&
workspaceSlug &&
userWorkspaceRole &&
WORKSPACE_SETTINGS_ACCESS[route ? `/${suffix}/${route}` : `/${suffix}`]?.includes(
userWorkspaceRole as EUserWorkspaceRoles
);
WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles);
return (
<>
<AppHeader header={<WorkspaceSettingHeader />} />
<MobileWorkspaceSettingsTabs />
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
{workspaceUserInfo && !isAuthorized ? (
<NotAuthorizedView section="settings" />
) : (
<>
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
<WorkspaceSettingsSidebar />
</div>
<div className="flex flex-col relative w-full overflow-hidden">
<div className="w-full h-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
{children}
</div>
<div className="flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
<WorkspaceSettingsSidebar workspaceSlug={workspaceSlug} pathname={pathname} />
</div>
<SettingsContentWrapper>{children}</SettingsContentWrapper>
</>
)}
</div>
@@ -0,0 +1,63 @@
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
import {
EUserPermissionsLevel,
GROUPED_WORKSPACE_SETTINGS,
WORKSPACE_SETTINGS_CATEGORIES,
EUserWorkspaceRoles,
} from "@plane/constants";
import { SettingsSidebar } from "@/components/settings";
import { useUserPermissions } from "@/hooks/store/user";
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
type TWorkspaceSettingsSidebarProps = {
workspaceSlug: string;
pathname: string;
};
export const WorkspaceActionIcons = ({
type,
size,
className,
}: {
type: string;
size?: number;
className?: string;
}) => {
const icons = {
general: Building,
members: Users,
export: ArrowUpToLine,
"billing-and-plans": CreditCard,
webhooks: Webhook,
};
if (type === undefined) return null;
const Icon = icons[type as keyof typeof icons];
if (!Icon) return null;
return <Icon size={size} className={className} strokeWidth={2} />;
};
export const WorkspaceSettingsSidebar = (props: TWorkspaceSettingsSidebarProps) => {
const { workspaceSlug, pathname } = props;
// store hooks
const { allowPermissions } = useUserPermissions();
return (
<SettingsSidebar
categories={WORKSPACE_SETTINGS_CATEGORIES}
groupedSettings={GROUPED_WORKSPACE_SETTINGS}
workspaceSlug={workspaceSlug.toString()}
isActive={(data: { href: string }) =>
data.href === "/settings"
? pathname === `/${workspaceSlug}${data.href}/`
: new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname)
}
shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) =>
data.access
? shouldRenderSettingLink(workspaceSlug.toString(), data.key) &&
allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())
: false
}
actionIcons={WorkspaceActionIcons}
/>
);
};
@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui
import { Button } from "@plane/ui";
// components
import { PageHead } from "@/components/core";
import { DetailedEmptyState } from "@/components/empty-state";
import { ProfileActivityListPage, ProfileSettingContentHeader } from "@/components/profile";
// hooks
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
const PER_PAGE = 100;
const ProfileActivityPage = observer(() => {
// states
const [pageCount, setPageCount] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [resultsCount, setResultsCount] = useState(0);
const [isEmpty, setIsEmpty] = useState(false);
// plane hooks
const { t } = useTranslation();
// derived values
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/profile/activity" });
const updateTotalPages = (count: number) => setTotalPages(count);
const updateResultsCount = (count: number) => setResultsCount(count);
const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty);
const handleLoadMore = () => setPageCount((prev) => prev + 1);
const activityPages: JSX.Element[] = [];
for (let i = 0; i < pageCount; i++)
activityPages.push(
<ProfileActivityListPage
key={i}
cursor={`${PER_PAGE}:${i}:0`}
perPage={PER_PAGE}
updateResultsCount={updateResultsCount}
updateTotalPages={updateTotalPages}
updateEmptyState={updateEmptyState}
/>
);
const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0;
if (isEmpty) {
return (
<DetailedEmptyState
title={t("profile.empty_state.activity.title")}
description={t("profile.empty_state.activity.description")}
assetPath={resolvedPath}
/>
);
}
return (
<>
<PageHead title="Profile - Activity" />
<ProfileSettingContentHeader title={t("activity")} />
<div className="w-full">{activityPages}</div>
{isLoadMoreVisible && (
<div className="flex w-full items-center justify-center text-xs">
<Button variant="accent-primary" size="sm" onClick={handleLoadMore}>
{t("load_more")}
</Button>
</div>
)}
</>
);
});
export default ProfileActivityPage;
@@ -0,0 +1,29 @@
"use client";
import { ReactNode } from "react";
import { useParams, usePathname } from "next/navigation";
// components
import { CommandPalette } from "@/components/command-palette";
import { SettingsContentWrapper } from "@/components/settings";
import { ProfileSidebar } from "./sidebar";
type Props = {
children: ReactNode;
};
export default function ProfileSettingsLayout(props: Props) {
const { children } = props;
// router
const pathname = usePathname();
const { workspaceSlug } = useParams();
return (
<>
<CommandPalette />
<div className="relative flex h-full w-full">
<ProfileSidebar workspaceSlug={workspaceSlug.toString()} pathname={pathname} />
<SettingsContentWrapper>{children}</SettingsContentWrapper>
</div>
</>
);
}
@@ -0,0 +1,36 @@
"use client";
import useSWR from "swr";
// components
import { useTranslation } from "@plane/i18n";
import { PageHead } from "@/components/core";
import { ProfileSettingContentHeader } from "@/components/profile";
import { EmailNotificationForm } from "@/components/profile/notification";
import { EmailSettingsLoader } from "@/components/ui";
// services
import { UserService } from "@/services/user.service";
const userService = new UserService();
export default function ProfileNotificationPage() {
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 (
<>
<PageHead title={`${t("profile.label")} - ${t("notifications")}`} />
<ProfileSettingContentHeader
title={t("email_notifications")}
description={t("stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified")}
/>
<EmailNotificationForm data={data} />
</>
);
}
@@ -0,0 +1,32 @@
"use client";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// components
import { LogoSpinner } from "@/components/common";
import { PageHead } from "@/components/core";
import { ProfileForm } from "@/components/profile";
// hooks
import { useUser } from "@/hooks/store";
const ProfileSettingsPage = observer(() => {
const { t } = useTranslation();
// store hooks
const { data: currentUser, userProfile } = useUser();
if (!currentUser)
return (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
);
return (
<>
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
<ProfileForm user={currentUser} profile={userProfile.data} />
</>
);
});
export default ProfileSettingsPage;
@@ -0,0 +1,98 @@
"use client";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
// plane imports
import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IUserTheme } from "@plane/types";
import { setPromiseToast } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common";
import { CustomThemeSelector, ThemeSwitch, PageHead } from "@/components/core";
import { LanguageTimezone, ProfileSettingContentHeader } from "@/components/profile";
// constants
// helpers
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
// hooks
import { useUserProfile } from "@/hooks/store";
const ProfileAppearancePage = observer(() => {
const { t } = useTranslation();
const { setTheme } = useTheme();
// states
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
// hooks
const { data: userProfile, updateUserTheme } = useUserProfile();
useEffect(() => {
if (userProfile?.theme?.theme) {
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
if (userThemeOption) {
setCurrentTheme(userThemeOption);
}
}
}, [userProfile?.theme?.theme]);
const handleThemeChange = (themeOption: I_THEME_OPTION) => {
applyThemeChange({ theme: themeOption.value });
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
setPromiseToast(updateCurrentUserThemePromise, {
loading: "Updating theme...",
success: {
title: "Success!",
message: () => "Theme updated successfully!",
},
error: {
title: "Error!",
message: () => "Failed to Update the theme",
},
});
};
const applyThemeChange = (theme: Partial<IUserTheme>) => {
setTheme(theme?.theme || "system");
if (theme?.theme === "custom" && theme?.palette) {
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
} else unsetCustomCssVariables();
};
return (
<>
<PageHead title="Profile - Preferences" />
{userProfile ? (
<>
<div className="flex flex-col gap-4 md:min-w-[700px]">
<div>
<ProfileSettingContentHeader title={t("preferences")} />
<div className="flex gap-4 py-6 sm:gap-16 w-full justify-between">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-base font-medium text-custom-text-100">{t("theme")}</h4>
<p className="text-sm text-custom-text-200">{t("select_or_customize_your_interface_color_scheme")}</p>
</div>
<div className="col-span-12 sm:col-span-6 my-auto">
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
{userProfile?.theme?.theme === "custom" && (
<CustomThemeSelector applyThemeChange={applyThemeChange} />
)}
</div>
</div>
</div>
<div>
<ProfileSettingContentHeader title={t("language_and_time")} />
<LanguageTimezone />
</div>
</div>
</>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
)}
</>
);
});
export default ProfileAppearancePage;
@@ -0,0 +1,251 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Eye, EyeOff } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { PasswordStrengthMeter } from "@/components/account";
import { PageHead } from "@/components/core";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
// helpers
import { authErrorHandler } from "@/helpers/authentication.helper";
import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper";
// hooks
import { useUser } from "@/hooks/store";
// 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,
};
const SecurityPage = observer(() => {
// 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,
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 (err: any) {
const errorInfo = authErrorHandler(err.error_code?.toString());
setToast({
type: TOAST_TYPE.ERROR,
title: errorInfo?.title ?? t("auth.common.password.toast.error.title"),
message:
typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.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 && (
<PasswordStrengthMeter password={password} isFocused={isPasswordInputFocused} />
);
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
return (
<>
<PageHead title="Profile - Security" />
<ProfileSettingContentHeader title={t("auth.common.password.change_password.label.default")} />
<form onSubmit={handleSubmit(handleChangePassword)} className="flex flex-col gap-8 w-full mt-8">
<div className="flex flex-col gap-10 w-full">
{oldPasswordRequired && (
<div className="space-y-1">
<h4 className="text-sm">{t("auth.common.password.current_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="old_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="old_password"
type={showPassword?.oldPassword ? "text" : "password"}
value={value}
onChange={onChange}
placeholder={t("old_password")}
className="w-full"
hasError={Boolean(errors.old_password)}
/>
)}
/>
{showPassword?.oldPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
/>
)}
</div>
{errors.old_password && <span className="text-xs text-red-500">{errors.old_password.message}</span>}
</div>
)}
<div className="space-y-1">
<h4 className="text-sm">{t("auth.common.password.new_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="new_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="new_password"
type={showPassword?.password ? "text" : "password"}
value={value}
placeholder={t("auth.common.password.new_password.placeholder")}
onChange={onChange}
className="w-full"
hasError={Boolean(errors.new_password)}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
/>
)}
/>
{showPassword?.password ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
)}
</div>
{passwordSupport}
{isNewPasswordSameAsOldPassword && !isPasswordInputFocused && (
<span className="text-xs text-red-500">{t("new_password_must_be_different_from_old_password")}</span>
)}
</div>
<div className="space-y-1">
<h4 className="text-sm">{t("auth.common.password.confirm_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="confirm_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="confirm_password"
type={showPassword?.confirmPassword ? "text" : "password"}
placeholder={t("auth.common.password.confirm_password.placeholder")}
value={value}
onChange={onChange}
className="w-full"
hasError={Boolean(errors.confirm_password)}
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
/>
)}
/>
{showPassword?.confirmPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("confirmPassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("confirmPassword")}
/>
)}
</div>
{!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && (
<span className="text-sm text-red-500">{t("auth.common.password.errors.match")}</span>
)}
</div>
</div>
<div className="flex items-center justify-between py-2">
<Button variant="primary" 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>
</form>
</>
);
});
export default SecurityPage;
@@ -0,0 +1,67 @@
import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks, Lock } from "lucide-react";
import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants";
import { SettingsSidebar } from "@/components/settings";
import { getFileURL } from "@/helpers/file.helper";
import { useUser } from "@/hooks/store/user";
type TProfileSidebarProps = {
workspaceSlug: string;
pathname: string;
};
export const ProjectActionIcons = ({ type, size, className }: { type: string; size?: number; className?: string }) => {
const icons = {
profile: CircleUser,
security: Lock,
activity: Activity,
preferences: Settings2,
notifications: Bell,
"api-tokens": KeyRound,
connections: Blocks,
};
if (type === undefined) return null;
const Icon = icons[type as keyof typeof icons];
if (!Icon) return null;
return <Icon size={size} className={className} strokeWidth={2} />;
};
export const ProfileSidebar = (props: TProfileSidebarProps) => {
const { workspaceSlug, pathname } = props;
// store hooks
const { data: currentUser } = useUser();
return (
<SettingsSidebar
categories={PROFILE_SETTINGS_CATEGORIES}
groupedSettings={GROUPED_PROFILE_SETTINGS}
workspaceSlug={workspaceSlug.toString()}
isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`}
customHeader={
<div className="flex items-center gap-2">
<div className="flex-shrink-0">
{!currentUser?.avatar_url || currentUser?.avatar_url === "" ? (
<div className="h-8 w-8 rounded-full">
<CircleUserRound className="h-full w-full text-custom-text-200" />
</div>
) : (
<div className="relative h-8 w-8 overflow-hidden">
<img
src={getFileURL(currentUser?.avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
alt={currentUser?.display_name}
/>
</div>
)}
</div>
<div className="w-full overflow-hidden">
<div className="text-base font-medium text-custom-text-200 truncate">{currentUser?.display_name}</div>
<div className="text-sm text-custom-text-300 truncate">{currentUser?.email}</div>
</div>
</div>
}
actionIcons={ProjectActionIcons}
shouldRender
/>
);
};
@@ -0,0 +1,63 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IProject } from "@plane/types";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { NotAuthorizedView } from "@/components/auth-screens";
import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation";
import { PageHead } from "@/components/core";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const AutomationSettingsPage = observer(() => {
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentProjectDetails: projectDetails, updateProject } = useProject();
const { t } = useTranslation();
// derived values
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const handleChange = async (formData: Partial<IProject>) => {
if (!workspaceSlug || !projectId || !projectDetails) return;
await updateProject(workspaceSlug.toString(), projectId.toString(), formData).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
};
// derived values
const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<div className="flex flex-col items-start border-b border-custom-border-100 pb-3.5">
<h3 className="text-xl font-medium leading-normal">{t("project_settings.automations.label")}</h3>
</div>
<AutoArchiveAutomation handleChange={handleChange} />
<AutoCloseAutomation handleChange={handleChange} />
</section>
</>
);
});
export default AutomationSettingsPage;
@@ -0,0 +1,45 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { EstimateRoot } from "@/components/estimates";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const EstimatesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (!workspaceSlug || !projectId) return <></>;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<div
className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}
>
<EstimateRoot
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
isAdmin={canPerformProjectAdminActions}
/>
</div>
</>
);
});
export default EstimatesSettingsPage;
@@ -0,0 +1,43 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectFeaturesList } from "@/components/project";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const FeaturesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentProjectDetails } = useProject();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (!workspaceSlug || !projectId) return null;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<ProjectFeaturesList
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isAdmin={canPerformProjectAdminActions}
/>
</section>
</>
);
});
export default FeaturesSettingsPage;
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useRef } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectSettingsLabelList } from "@/components/labels";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const LabelsSettingsPage = observer(() => {
// store hooks
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined;
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
// derived values
const canPerformProjectMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
// Enable Auto Scroll for Labels list
useEffect(() => {
const element = scrollableContainerRef.current;
if (!element) return;
return combine(
autoScrollForElements({
element,
})
);
}, [scrollableContainerRef?.current]);
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<div ref={scrollableContainerRef} className="h-full w-full gap-10 overflow-y-auto">
<ProjectSettingsLabelList />
</div>
</>
);
});
export default LabelsSettingsPage;
@@ -0,0 +1,40 @@
"use client";
import { observer } from "mobx-react";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const MembersSettingsPage = observer(() => {
// store
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
const isProjectMemberOrAdmin = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin;
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<section className={`w-full overflow-y-auto`}>
<ProjectSettingsMemberDefaults />
<ProjectMemberList />
</section>
</>
);
});
export default MembersSettingsPage;
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { PageHead } from "@/components/core";
import {
ArchiveRestoreProjectModal,
ArchiveProjectSelection,
DeleteProjectModal,
DeleteProjectSection,
ProjectDetailsForm,
ProjectDetailsFormLoader,
} from "@/components/project";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const ProjectSettingsPage = observer(() => {
// states
const [selectProject, setSelectedProject] = useState<string | null>(null);
const [archiveProject, setArchiveProject] = useState<boolean>(false);
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { currentProjectDetails, fetchProjectDetails } = useProject();
const { allowPermissions } = useUserPermissions();
// api call to fetch project details
// TODO: removed this API if not necessary
const { isLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null,
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()) : null
);
// derived values
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId.toString()
);
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined;
return (
<>
<PageHead title={pageTitle} />
{currentProjectDetails && workspaceSlug && projectId && (
<>
<ArchiveRestoreProjectModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isOpen={archiveProject}
onClose={() => setArchiveProject(false)}
archive
/>
<DeleteProjectModal
project={currentProjectDetails}
isOpen={Boolean(selectProject)}
onClose={() => setSelectedProject(null)}
/>
</>
)}
<div className={`w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
{currentProjectDetails && workspaceSlug && projectId && !isLoading ? (
<ProjectDetailsForm
project={currentProjectDetails}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isAdmin={isAdmin}
/>
) : (
<ProjectDetailsFormLoader />
)}
{isAdmin && currentProjectDetails && (
<>
<ArchiveProjectSelection
projectDetails={currentProjectDetails}
handleArchive={() => setArchiveProject(true)}
/>
<DeleteProjectSection
projectDetails={currentProjectDetails}
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)}
/>
</>
)}
</div>
</>
);
});
export default ProjectSettingsPage;
@@ -0,0 +1,73 @@
"use client";
import React from "react";
import range from "lodash/range";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Loader } from "@plane/ui";
// components
import { SidebarNavItem } from "@/components/sidebar";
// hooks
import { useUserPermissions } from "@/hooks/store";
// plane web constants
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
export const ProjectSettingsSidebar = observer(() => {
const { workspaceSlug, projectId } = useParams();
const pathname = usePathname();
// mobx store
const { allowPermissions, projectUserInfo } = useUserPermissions();
const { t } = useTranslation();
// derived values
const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role;
if (!currentProjectRole) {
return (
<div className="flex w-[280px] flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
<Loader className="flex w-full flex-col gap-2">
{range(8).map((index) => (
<Loader.Item key={index} height="34px" />
))}
</Loader>
</div>
</div>
);
}
return (
<div className="flex w-[280px] flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
<div className="flex w-full flex-col gap-1">
{PROJECT_SETTINGS_LINKS.map(
(link) =>
allowPermissions(
link.access,
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId.toString()
) && (
<Link key={link.key} href={`/${workspaceSlug}/projects/${projectId}${link.href}`}>
<SidebarNavItem
key={link.key}
isActive={link.highlight(pathname, `/${workspaceSlug}/projects/${projectId}`)}
className="text-sm font-medium px-4 py-2"
>
{t(link.i18n_label)}
</SidebarNavItem>
</Link>
)
)}
</div>
</div>
</div>
);
});
@@ -0,0 +1,49 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectStateRoot } from "@/components/project-states";
// hook
import { useProject, useUserPermissions } from "@/hooks/store";
const StatesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { t } = useTranslation();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined;
// derived values
const canPerformProjectMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<div className="w-full">
<div className="flex items-center border-b border-custom-border-100">
<h3 className="text-xl font-medium">{t("common.states")}</h3>
</div>
{workspaceSlug && projectId && (
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
</div>
</>
);
});
export default StatesSettingsPage;
@@ -0,0 +1,46 @@
"use client";
import { ReactNode, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
// components
import { CommandPalette } from "@/components/command-palette";
import { SettingsContentWrapper } from "@/components/settings";
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
import { useProject } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
type Props = {
children: ReactNode;
};
const ProjectSettingsLayout = observer((props: Props) => {
const { children } = props;
// router
const router = useAppRouter();
const pathname = usePathname();
const { workspaceSlug, projectId } = useParams();
const { joinedProjectIds } = useProject();
useEffect(() => {
if (projectId) return;
if (joinedProjectIds.length > 0) {
router.push(`/${workspaceSlug}/settings/project/${joinedProjectIds[0]}`);
}
}, [joinedProjectIds, router, workspaceSlug, projectId]);
return (
<>
<CommandPalette />
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
<div className="relative flex h-full w-full">
{projectId && <ProjectSettingsSidebar workspaceSlug={workspaceSlug.toString()} pathname={pathname} />}
<SettingsContentWrapper>{children}</SettingsContentWrapper>
</div>
</ProjectAuthWrapper>
</>
);
});
export default ProjectSettingsLayout;
@@ -0,0 +1,38 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
import { Button, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
import { useCommandPalette } from "@/hooks/store";
const ProjectSettingsPage = () => {
// store hooks
const { resolvedTheme } = useTheme();
const { toggleCreateProjectModal } = useCommandPalette();
// derived values
const resolvedPath =
resolvedTheme === "dark"
? "/empty-state/project-settings/no-projects-dark.png"
: "/empty-state/project-settings/no-projects-light.png";
return (
<div className="flex flex-col gap-4 items-center justify-center h-full max-w-[480px] mx-auto">
<Image src={resolvedPath} alt="No projects yet" width={384} height={250} />
<div className="text-lg font-semibold text-custom-text-350">No projects yet</div>
<div className="text-sm text-custom-text-350 text-center">
Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you
need to get things done.
</div>
<div className="flex gap-2">
<Link href="https://plane.so/" target="_blank" className={cn(getButtonStyling("neutral-primary", "sm"))}>
Learn more about projects
</Link>
<Button size="sm" onClick={() => toggleCreateProjectModal(true)}>
Start your first project
</Button>
</div>
</div>
);
};
export default ProjectSettingsPage;
@@ -44,7 +44,7 @@ export const DeleteWorkspaceSection: FC<TDeleteWorkspace> = observer((props) =>
}
>
<div className="flex flex-col gap-4">
<span className="text-base tracking-tight">
<span className="text-base tracking-tight w-[800px]">
{t("workspace_settings.settings.general.delete_workspace_description")}
</span>
<div>
+13 -6
View File
@@ -4,26 +4,33 @@ import packageJson from "package.json";
// ui
import { Button, Tooltip } from "@plane/ui";
// hooks
import { cn } from "@plane/utils";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local components
import { PaidPlanUpgradeModal } from "../license";
export const WorkspaceEditionBadge = observer(() => {
export const WorkspaceEditionBadge = observer((props: { className?: string; isEditable?: boolean }) => {
const { className, isEditable = true } = props;
const { isMobile } = usePlatformOS();
// states
const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false);
return (
<>
<PaidPlanUpgradeModal
isOpen={isPaidPlanPurchaseModalOpen}
handleClose={() => setIsPaidPlanPurchaseModalOpen(false)}
/>
{isEditable && (
<PaidPlanUpgradeModal
isOpen={isPaidPlanPurchaseModalOpen}
handleClose={() => setIsPaidPlanPurchaseModalOpen(false)}
/>
)}
<Tooltip tooltipContent={`Version: v${packageJson.version}`} isMobile={isMobile}>
<Button
tabIndex={-1}
variant="accent-primary"
className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none"
className={cn(
"w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none",
className
)}
onClick={() => setIsPaidPlanPurchaseModalOpen(true)}
>
Community
+14 -14
View File
@@ -9,57 +9,57 @@ export const PROJECT_SETTINGS = {
general: {
key: "general",
i18n_label: "common.general",
href: `/settings`,
href: ``,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`,
Icon: SettingIcon,
},
members: {
key: "members",
i18n_label: "members",
href: `/settings/members`,
href: `/members`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`,
Icon: SettingIcon,
},
features: {
key: "features",
i18n_label: "common.features",
href: `/settings/features`,
href: `/features`,
access: [EUserPermissions.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/`,
Icon: SettingIcon,
},
states: {
key: "states",
i18n_label: "common.states",
href: `/settings/states`,
href: `/states`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/states/`,
Icon: SettingIcon,
},
labels: {
key: "labels",
i18n_label: "common.labels",
href: `/settings/labels`,
href: `/labels`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/labels/`,
Icon: SettingIcon,
},
estimates: {
key: "estimates",
i18n_label: "common.estimates",
href: `/settings/estimates`,
href: `/estimates`,
access: [EUserPermissions.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/estimates/`,
Icon: SettingIcon,
},
automations: {
key: "automations",
i18n_label: "project_settings.automations.label",
href: `/settings/automations`,
href: `/automations`,
access: [EUserPermissions.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/automations/`,
Icon: SettingIcon,
},
};
@@ -115,7 +115,7 @@ export const NoProjectsEmptyState = observer(() => {
flag: "visited_profile",
cta: {
text: "home.empty.personalize_account.cta",
link: "/profile",
link: `/${workspaceSlug}/settings/account`,
disabled: false,
},
},
@@ -72,7 +72,7 @@ export const ProjectArchivedEmptyState: React.FC = observer(() => {
assetPath={archivedIssuesResolvedPath}
primaryButton={{
text: t("project_issues.empty_state.no_archived_issues.primary_button.text"),
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`),
onClick: () => router.push(`/${workspaceSlug}/settings/project/${projectId}/automations`),
disabled: !canPerformEmptyStateActions,
}}
/>
+18 -66
View File
@@ -1,18 +1,20 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { ChevronDown, CircleUserRound } from "lucide-react";
import { ChevronDown, CircleUserRound, InfoIcon } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import { USER_ROLES } from "@plane/constants";
import { useTranslation, SUPPORTED_LANGUAGES } from "@plane/i18n";
import { useTranslation } from "@plane/i18n";
import type { IUser, TUserProfile } from "@plane/types";
import { Button, CustomSelect, Input, TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
// components
import { getButtonStyling } from "@plane/ui/src/button";
import { cn } from "@plane/utils";
import { DeactivateAccountModal } from "@/components/account";
import { ImagePickerPopover, UserImageUploadModal } from "@/components/core";
import { TimezoneSelect } from "@/components/global";
// constants
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
@@ -39,6 +41,7 @@ export type TProfileFormProps = {
export const ProfileForm = observer((props: TProfileFormProps) => {
const { user, profile } = props;
const { workspaceSlug } = useParams();
// states
const [isLoading, setIsLoading] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
@@ -73,12 +76,6 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
const { data: currentUser, updateCurrentUser } = useUser();
const { updateUserProfile } = useUserProfile();
const getLanguageLabel = (value: string) => {
const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value);
if (!selectedLanguage) return value;
return selectedLanguage.label;
};
const handleProfilePictureDelete = async (url: string | null | undefined) => {
if (!url) return;
await updateCurrentUser({
@@ -111,7 +108,6 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
last_name: formData.last_name,
avatar_url: formData.avatar_url,
display_name: formData?.display_name,
user_timezone: formData.user_timezone,
};
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (formData.cover_image_url?.startsWith("http")) {
@@ -121,7 +117,6 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
const profilePayload: Partial<TUserProfile> = {
role: formData.role,
language: formData.language,
};
const updateCurrentUserDetail = updateCurrentUser(userPayload).finally(() => setIsLoading(false));
@@ -163,7 +158,17 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
/>
)}
/>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="w-full flex text-custom-primary-200 bg-custom-primary-100/10 rounded-md p-2 gap-2 items-center mb-4">
<InfoIcon className="h-4 w-4 flex-shrink-0" />
<div className="text-sm font-medium flex-1">{t("settings_moved_to_preferences")}</div>
<Link
href={`/${workspaceSlug}/settings/account/preferences`}
className={cn(getButtonStyling("neutral-primary", "sm"))}
>
{t("go_to_preferences")}
</Link>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="w-full overflow-y-scroll">
<div className="flex w-full flex-col gap-6">
<div className="relative h-44 w-full">
<img
@@ -368,59 +373,6 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
</div>
</div>
<div className="flex flex-col gap-1">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-4">
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
{t("timezone")}&nbsp;
<span className="text-red-500">*</span>
</h4>
<Controller
name="user_timezone"
control={control}
rules={{ required: "Please select a timezone" }}
render={({ field: { value, onChange } }) => (
<TimezoneSelect
value={value}
onChange={(value: string) => {
onChange(value);
}}
error={Boolean(errors.user_timezone)}
/>
)}
/>
{errors.user_timezone && <span className="text-xs text-red-500">{errors.user_timezone.message}</span>}
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<h4 className="text-sm font-medium text-custom-text-200">{t("language")} </h4>
<div className="w-fit cursor-pointer rounded-2xl text-custom-primary-200 bg-custom-primary-100/20 text-center font-medium outline-none text-xs px-2">
Alpha
</div>
</div>
<Controller
control={control}
name="language"
rules={{ required: "Please select a language" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={value ? getLanguageLabel(value) : "Select a language"}
onChange={onChange}
buttonClassName={errors.language ? "border-red-500" : "border-none"}
className="rounded-md border-[0.5px] !border-custom-border-200"
optionsClassName="w-full"
input
>
{SUPPORTED_LANGUAGES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
<div className="flex items-center justify-between pt-6 pb-8">
<Button variant="primary" type="submit" loading={isLoading}>
{isLoading ? t("saving") : t("save_changes")}
+1
View File
@@ -6,3 +6,4 @@ export * from "./time";
export * from "./profile-setting-content-wrapper";
export * from "./profile-setting-content-header";
export * from "./form";
export * from "./preferences/language-timezone";
@@ -9,7 +9,7 @@ import { ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// services
import { UserService } from "@/services/user.service";
// types
interface IEmailNotificationFormProps {
interface IEmailNotificationFormProps {
data: IUserEmailNotificationSettings;
}
@@ -20,10 +20,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
const { data } = props;
const { t } = useTranslation();
// form data
const {
control,
reset,
} = useForm<IUserEmailNotificationSettings>({
const { control, reset } = useForm<IUserEmailNotificationSettings>({
defaultValues: {
...data,
},
@@ -55,10 +52,9 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
return (
<>
<div className="pt-6 text-lg font-medium text-custom-text-100">{t("notify_me_when")}:</div>
{/* Notification Settings */}
<div className="flex flex-col py-2">
<div className="flex gap-2 items-center pt-6">
<div className="flex gap-2 items-center pt-2">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">{t("property_changes")}</div>
<div className="text-sm font-normal text-custom-text-300">{t("property_changes_description")}</div>
@@ -83,9 +79,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
<div className="flex gap-2 items-center pt-6 pb-2">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">{t("state_change")}</div>
<div className="text-sm font-normal text-custom-text-300">
{t("state_change_description")}
</div>
<div className="text-sm font-normal text-custom-text-300">{t("state_change_description")}</div>
</div>
<div className="shrink-0">
<Controller
@@ -129,9 +123,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">{t("comments")}</div>
<div className="text-sm font-normal text-custom-text-300">
{t("comments_description")}
</div>
<div className="text-sm font-normal text-custom-text-300">{t("comments_description")}</div>
</div>
<div className="shrink-0">
<Controller
@@ -153,9 +145,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">{t("mentions")}</div>
<div className="text-sm font-normal text-custom-text-300">
{t("mentions_description")}
</div>
<div className="text-sm font-normal text-custom-text-300">{t("mentions_description")}</div>
</div>
<div className="shrink-0">
<Controller
@@ -0,0 +1,100 @@
import { observer } from "mobx-react";
import { SUPPORTED_LANGUAGES, useTranslation } from "@plane/i18n";
import { CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
import { TimezoneSelect } from "@/components/global";
import { useUser, useUserProfile } from "@/hooks/store";
export const LanguageTimezone = observer(() => {
// store hooks
const {
data: user,
updateCurrentUser,
userProfile: { data: profile },
} = useUser();
const { updateUserProfile } = useUserProfile();
const { t } = useTranslation();
const handleTimezoneChange = (value: string) => {
updateCurrentUser({ user_timezone: value })
.then(() => {
setToast({
title: "Success!",
message: "Timezone updated successfully",
type: TOAST_TYPE.SUCCESS,
});
})
.catch(() => {
setToast({
title: "Error!",
message: "Failed to update timezone",
type: TOAST_TYPE.ERROR,
});
});
};
const handleLanguageChange = (value: string) => {
updateUserProfile({ language: value })
.then(() => {
setToast({
title: "Success!",
message: "Language updated successfully",
type: TOAST_TYPE.SUCCESS,
});
})
.catch(() => {
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="py-6">
<div className="flex flex-col gap-x-6 gap-y-6">
<div className="flex flex-col gap-1">
<div className="flex gap-4 sm:gap-16 w-full justify-between">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-base font-medium text-custom-text-100"> {t("timezone")}&nbsp;</h4>
<p className="text-sm text-custom-text-200">{t("timezone_setting")}</p>
</div>
<div className="col-span-12 sm:col-span-6 my-auto">
<TimezoneSelect value={user?.user_timezone || "Asia/Kolkata"} onChange={handleTimezoneChange} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-4 sm:gap-16 w-full justify-between">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-base font-medium text-custom-text-100"> {t("language")}&nbsp;</h4>
<p className="text-sm text-custom-text-200">{t("language_setting")}</p>
</div>
<div className="col-span-12 sm:col-span-6 my-auto">
<CustomSelect
value={profile?.language}
label={profile?.language ? getLanguageLabel(profile?.language) : "Select a language"}
onChange={handleLanguageChange}
buttonClassName={"border-none"}
className="rounded-md border !border-custom-border-200"
optionsClassName="w-full"
input
>
{SUPPORTED_LANGUAGES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
</div>
</div>
</div>
</div>
);
});
@@ -9,7 +9,7 @@ type Props = {
export const ProfileSettingContentHeader: FC<Props> = (props) => {
const { title, description } = props;
return (
<div className="flex flex-col gap-1 py-4 border-b border-custom-border-100">
<div className="flex flex-col gap-1 pb-4 border-b border-custom-border-100 w-full">
<div className="text-xl font-medium text-custom-text-100">{title}</div>
{description && <div className="text-sm font-normal text-custom-text-300">{description}</div>}
</div>
+2 -2
View File
@@ -37,7 +37,7 @@ export const ProfileSidebar: FC<TProfileSidebar> = observer((props) => {
// refs
const ref = useRef<HTMLDivElement>(null);
// router
const { userId } = useParams();
const { userId, workspaceSlug } = useParams();
// store hooks
const { data: currentUser } = useUser();
const { profileSidebarCollapsed, toggleProfileSidebar } = useAppTheme();
@@ -94,7 +94,7 @@ export const ProfileSidebar: FC<TProfileSidebar> = observer((props) => {
<div className="relative h-[110px]">
{currentUser?.id === userId && (
<div className="absolute right-3.5 top-3.5 grid h-5 w-5 place-items-center rounded bg-white">
<Link href="/profile">
<Link href={`/${workspaceSlug}/settings/account`}>
<span className="grid place-items-center text-black">
<Pencil className="h-3 w-3" />
</span>
@@ -35,7 +35,7 @@ export const ArchiveProjectSelection: React.FC<IArchiveProject> = (props) => {
>
<Disclosure.Panel>
<div className="flex flex-col gap-8 pt-4">
<span className="text-sm tracking-tight">
<span className="text-sm tracking-tight w-[800px]">
Archiving a project will unlist your project from your side navigation although you will still be able
to access it from your projects page. You can restore the project or delete it whenever you want.
</span>
@@ -36,7 +36,7 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) =>
>
<Disclosure.Panel>
<div className="flex flex-col gap-8 pt-4">
<span className="text-sm tracking-tight">
<span className="text-sm tracking-tight w-[800px]">
When deleting a project, all of the data and resources within that project will be permanently removed
and cannot be recovered.
</span>
@@ -0,0 +1,5 @@
import { ReactNode } from "react";
export const SettingsContentWrapper = ({ children }: { children: ReactNode }) => (
<div className="relative flex flex-col min-w-[60%] items-center mx-auto overflow-y-scroll px-12">{children}</div>
);
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { observer } from "mobx-react";
import Link from "next/link";
import { ChevronLeftIcon } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { getButtonStyling } from "@plane/ui/src/button";
import { cn } from "@plane/utils";
import { useUserSettings } from "@/hooks/store";
import SettingsTabs from "./tabs";
import WorkspaceLogo from "./workspace-logo";
export const SettingsHeader = observer(() => {
// hooks
const { data: currentUserSettings } = useUserSettings();
const { t } = useTranslation();
// redirect url for normal mode
const redirectWorkspaceSlug =
currentUserSettings?.workspace?.last_workspace_slug ||
currentUserSettings?.workspace?.fallback_workspace_slug ||
"";
return (
<div className="bg-custom-background-90 px-12 py-8">
{/* Breadcrumb */}
<Link
href={`/${redirectWorkspaceSlug}`}
className="group flex items-center gap-2 text-custom-text-300 mb-4 border border-transparent hover:bg-custom-background-100 hover:border-custom-border-200 w-fit pr-2 rounded-lg "
>
<button
className={cn(
getButtonStyling("neutral-primary", "sm"),
"rounded-lg p-1 hover:bg-custom-background-100 hover:border-custom-border-200",
"group-hover:bg-custom-background-100 group-hover:border-transparent"
)}
>
<ChevronLeftIcon className="h-4 w-4 my-auto" />
</button>
<div className="text-sm my-auto font-semibold">{t("back_to_workspace")}</div>
{/* Last workspace */}
<div className="flex items-center gap-1">
<WorkspaceLogo
workspace={{
logo_url: currentUserSettings?.workspace?.last_workspace_logo || "",
name: currentUserSettings?.workspace?.last_workspace_name || "",
}}
size="sm"
className="my-auto"
/>
<div className="text-xs my-auto text-custom-text-100 font-semibold">
{currentUserSettings?.workspace?.last_workspace_name}
</div>
</div>
</Link>
<div className="flex flex-col gap-2">
{/* Description */}
<div className="text-custom-text-100 font-semibold text-2xl">{t("settings")}</div>
<div className="text-custom-text-300 text-base">{t("settings_description")}</div>
{/* Actions */}
<SettingsTabs />
</div>
</div>
);
});
+3
View File
@@ -0,0 +1,3 @@
export * from "./header";
export * from "./sidebar";
export * from "./content-wrapper";
@@ -0,0 +1 @@
export * from "./root";
@@ -0,0 +1,73 @@
import { range } from "lodash";
import { observer } from "mobx-react";
import Link from "next/link";
import { usePathname, useParams } from "next/navigation";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Loader } from "@plane/ui";
import { cn } from "@/helpers/common.helper";
import { useProject, useUserPermissions } from "@/hooks/store";
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
export const NavItemChildren = observer((props: { projectId: string }) => {
const { projectId } = props;
const { workspaceSlug } = useParams();
const pathname = usePathname();
// mobx store
const { getProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
// derived values
const currentProject = getProjectById(projectId);
if (!currentProject) {
return (
<div className="flex w-[280px] flex-col gap-6">
<div className="flex flex-col gap-2">
<Loader className="flex w-full flex-col gap-2">
{range(8).map((index) => (
<Loader.Item key={index} height="34px" />
))}
</Loader>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<div className="flex w-full flex-col gap-1">
{PROJECT_SETTINGS_LINKS.map((link) => {
const isActive = link.highlight(pathname, `/${workspaceSlug}/settings/project/${projectId}`);
return (
allowPermissions(
link.access,
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId.toString()
) && (
<Link key={link.key} href={`/${workspaceSlug}/settings/project/${projectId}${link.href}`}>
<div
className={cn(
"cursor-pointer relative group w-full flex items-center justify-between gap-1.5 rounded p-1 px-1.5 outline-none",
{
"text-custom-primary-200 bg-custom-primary-100/10": isActive,
"text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90":
!isActive,
},
"text-sm font-medium"
)}
>
{t(link.i18n_label)}
</div>
</Link>
)
);
})}
</div>
</div>
</div>
);
});
@@ -0,0 +1,47 @@
import { observer } from "mobx-react";
import { PROJECT_SETTINGS_CATEGORIES, PROJECT_SETTINGS_CATEGORY } from "@plane/constants";
import { Logo } from "@/components/common";
import { getUserRole } from "@/helpers/user.helper";
import { useProject } from "@/hooks/store/use-project";
import { SettingsSidebar } from "../..";
import { NavItemChildren } from "./nav-item-children";
type TProjectSettingsSidebarProps = {
workspaceSlug: string;
pathname: string;
};
export const ProjectSettingsSidebar = observer((props: TProjectSettingsSidebarProps) => {
const { workspaceSlug } = props;
// store hooks
const { joinedProjectIds, projectMap } = useProject();
const groupedProject = joinedProjectIds.map((projectId) => ({
key: projectId,
i18n_label: projectMap[projectId].name,
href: `/settings/project/${projectId}`,
icon: <Logo logo={projectMap[projectId].logo_props} />,
}));
return (
<SettingsSidebar
categories={PROJECT_SETTINGS_CATEGORIES}
groupedSettings={{
[PROJECT_SETTINGS_CATEGORY.PROJECTS]: groupedProject,
}}
workspaceSlug={workspaceSlug.toString()}
isActive={false}
appendItemsToTitle={(key: string) => {
const role = projectMap[key].member_role;
return (
<div className="text-xs font-medium text-custom-text-200 capitalize bg-custom-background-90 rounded-md px-1 py-0.5">
{role ? getUserRole(role)?.toLowerCase() : "Guest"}
</div>
);
}}
shouldRender
renderChildren={(key: string) => <NavItemChildren projectId={key} />}
/>
);
});
@@ -0,0 +1,38 @@
import { getUserRole } from "@/helpers/user.helper";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
import WorkspaceLogo from "../workspace-logo";
export const SettingsSidebarHeader = (props: { customHeader?: React.ReactNode }) => {
const { customHeader } = props;
const { currentWorkspace } = useWorkspace();
return customHeader
? customHeader
: currentWorkspace && (
<div className="flex w-full gap-3 items-center justify-between">
<div className="flex w-full gap-3 items-center overflow-hidden">
<WorkspaceLogo
workspace={{
logo_url: currentWorkspace.logo_url || "",
name: currentWorkspace.name,
}}
size="md"
/>
<div className="w-full overflow-hidden">
<div className="text-base font-medium text-custom-text-200 truncate text-ellipsis ">
{currentWorkspace.name}
</div>
<div className="text-sm text-custom-text-300 capitalize">
{getUserRole(currentWorkspace.role)?.toLowerCase() || "guest"}
</div>
</div>
</div>
<div className="flex-shrink-0">
<WorkspaceEditionBadge
isEditable={false}
className="text-xs rounded-md min-w-fit px-1 py-0.5 flex-shrink-0"
/>
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from "./root";
@@ -0,0 +1,88 @@
import React, { useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Disclosure } from "@headlessui/react";
import { EUserWorkspaceRoles } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { cn } from "@/helpers/common.helper";
export type TSettingItem = {
key: string;
i18n_label: string;
href: string;
access?: EUserWorkspaceRoles[];
icon?: React.ReactNode;
};
export type TSettingsSidebarNavItemProps = {
workspaceSlug: string;
setting: TSettingItem;
isActive: boolean | ((data: { href: string }) => boolean);
actionIcons?: (props: { type: string; size?: number; className?: string }) => React.ReactNode;
appendItemsToTitle?: (key: string) => React.ReactNode;
renderChildren?: (key: string) => React.ReactNode;
};
const SettingsSidebarNavItem = (props: TSettingsSidebarNavItemProps) => {
const { workspaceSlug, setting, isActive, actionIcons, appendItemsToTitle, renderChildren } = props;
// router
const { projectId } = useParams();
// i18n
const { t } = useTranslation();
// state
const [isExpanded, setIsExpanded] = useState(projectId === setting.key);
// derived
const buttonClass = cn(
"flex w-full items-center px-2 py-1.5 rounded text-custom-text-200 justify-between",
"hover:bg-custom-primary-100/10",
{
"text-custom-primary-200 bg-custom-primary-100/10": typeof isActive === "function" ? isActive(setting) : isActive,
"hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90":
typeof isActive === "function" ? !isActive(setting) : !isActive,
}
);
const titleElement = (
<>
<div className="flex items-center gap-1.5 overflow-hidden">
{setting.icon ? setting.icon : actionIcons && actionIcons({ type: setting.key, size: 16 })}
<div className="text-sm font-medium truncate">{t(setting.i18n_label)}</div>
</div>
{appendItemsToTitle?.(setting.key)}
</>
);
return (
<Disclosure as="div" className="flex flex-col w-full" defaultOpen={isExpanded} key={setting.key}>
<Disclosure.Button
as="button"
type="button"
className={cn(
"group w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400"
)}
onClick={() => setIsExpanded(!isExpanded)}
>
{renderChildren ? (
<div className={buttonClass}>{titleElement}</div>
) : (
<Link href={`/${workspaceSlug}/${setting.href}`} className={buttonClass}>
{titleElement}
</Link>
)}
</Disclosure.Button>
{/* Nested Navigation */}
{isExpanded && (
<Disclosure.Panel
as="div"
className={cn("flex flex-col gap-0.5", {
"space-y-0 ml-0": isExpanded,
})}
static
>
<div className="ml-4 border-l border-custom-border-200 pl-2 my-0.5">{renderChildren?.(setting.key)}</div>
</Disclosure.Panel>
)}
</Disclosure>
);
};
export default SettingsSidebarNavItem;
@@ -0,0 +1,64 @@
import { useTranslation } from "@plane/i18n";
import { SettingsSidebarHeader } from "./header";
import SettingsSidebarNavItem, { TSettingItem } from "./nav-item";
interface SettingsSidebarProps {
customHeader?: React.ReactNode;
categories: string[];
groupedSettings: {
[key: string]: TSettingItem[];
};
workspaceSlug: string;
isActive: boolean | ((data: { href: string }) => boolean);
shouldRender: boolean | ((setting: TSettingItem) => boolean);
actionIcons?: (props: { type: string; size?: number; className?: string }) => React.ReactNode;
appendItemsToTitle?: (key: string) => React.ReactNode;
renderChildren?: (key: string) => React.ReactNode;
}
export const SettingsSidebar = (props: SettingsSidebarProps) => {
const {
customHeader,
categories,
groupedSettings,
workspaceSlug,
isActive,
shouldRender,
actionIcons,
appendItemsToTitle,
renderChildren,
} = props;
const { t } = useTranslation();
return (
<div className="flex w-[220px] flex-col gap-2 h-full">
{/* Header */}
<SettingsSidebarHeader customHeader={customHeader} />
{/* Navigation */}
<div className="divide-y divide-custom-border-100 overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto vertical-scrollbar">
{categories.map((category) => (
<div key={category} className="py-3">
<span className="text-sm font-semibold text-custom-text-400 capitalize mb-2">{t(category)}</span>
{groupedSettings[category].length > 0 && (
<div className="relative flex flex-col gap-0.5 overflow-y-scroll h-full mt-2">
{groupedSettings[category].map(
(setting) =>
(typeof shouldRender === "function" ? shouldRender(setting) : shouldRender) && (
<SettingsSidebarNavItem
key={setting.key}
setting={setting}
workspaceSlug={workspaceSlug}
isActive={isActive}
appendItemsToTitle={appendItemsToTitle}
renderChildren={renderChildren}
actionIcons={actionIcons}
/>
)
)}
</div>
)}
</div>
))}
</div>
</div>
);
};
+63
View File
@@ -0,0 +1,63 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@plane/utils";
import { useProject } from "@/hooks/store";
const TABS = {
account: {
key: "account",
label: "Account",
href: `/settings/account/`,
},
workspace: {
key: "workspace",
label: "Workspace",
href: `/settings/`,
},
projects: {
key: "projects",
label: "Projects",
href: `/settings/project/`,
},
};
const SettingsTabs = observer(() => {
// router
const pathname = usePathname();
const { workspaceSlug } = useParams();
// store hooks
const { joinedProjectIds } = useProject();
const currentTab = pathname.includes(TABS.projects.href)
? TABS.projects
: pathname.includes(TABS.account.href)
? TABS.account
: TABS.workspace;
return (
<div className="flex w-fit min-w-fit items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80/60 mt-2">
{Object.values(TABS).map((tab) => {
const isActive = currentTab?.key === tab.key;
const href = tab.key === TABS.projects.key ? `${tab.href}${joinedProjectIds[0] || ""}` : tab.href;
return (
<Link
key={tab.key}
href={`/${workspaceSlug}${href}`}
className={cn(
"flex items-center justify-center p-1 min-w-fit w-full font-medium outline-none focus:outline-none cursor-pointer transition-all rounded text-custom-text-400 ",
{
"bg-custom-background-100 text-custom-text-300 shadow-sm": isActive,
"hover:text-custom-text-300 hover:bg-custom-background-80/60": !isActive,
}
)}
>
<div className="text-xs font-semibold p-1">{tab.label}</div>
</Link>
);
})}
</div>
);
});
export default SettingsTabs;
@@ -0,0 +1,40 @@
import { cn } from "@/helpers/common.helper";
import { getFileURL } from "@/helpers/file.helper";
interface IWorkspaceLogoProps {
workspace: {
logo_url: string;
name: string;
};
className?: string;
size?: "sm" | "md" | "lg";
}
const WorkspaceLogo = (props: IWorkspaceLogoProps) => {
const { workspace, className, size = "md" } = props;
const sizeClass = size === "sm" ? "h-4 w-4 text-[9px]" : size === "md" ? "h-8 w-8 text-sm" : "h-10 w-10 text-base";
return (
<div className={cn("flex gap-3 items-center", className)}>
{workspace.logo_url && workspace.logo_url !== "" ? (
<div className={cn("relative my-auto flex rounded", sizeClass)}>
<img
src={getFileURL(workspace.logo_url)}
className={cn("absolute left-0 top-0 h-full w-full rounded-md object-cover", sizeClass)}
alt="Workspace Logo"
/>
</div>
) : (
<div
className={cn(
"relative flex items-center justify-center rounded bg-gray-700 uppercase text-white my-auto",
sizeClass
)}
>
{workspace?.name?.charAt(0) ?? "N"}
</div>
)}
</div>
);
};
export default WorkspaceLogo;
@@ -2,6 +2,7 @@
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { AlertTriangle } from "lucide-react";
// types
@@ -30,6 +31,7 @@ export const DeleteWorkspaceForm: React.FC<Props> = observer((props) => {
const { data, onClose } = props;
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
// store hooks
const { captureWorkspaceEvent } = useEventTracker();
const { deleteWorkspace } = useWorkspace();
@@ -60,7 +62,7 @@ export const DeleteWorkspaceForm: React.FC<Props> = observer((props) => {
await deleteWorkspace(data.slug)
.then(() => {
handleClose();
router.push("/profile");
router.push(`/${workspaceSlug}/settings/account`);
captureWorkspaceEvent({
eventName: WORKSPACE_DELETED,
payload: {
@@ -45,7 +45,7 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
state: "SUCCESS",
element: "Workspace settings members page",
});
router.push("/profile");
router.push(`/${workspaceSlug}/settings/account`);
})
.catch((err: any) =>
setToast({
@@ -3,6 +3,7 @@
import { Fragment, Ref, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { usePopper } from "react-popper";
// icons
import { ChevronDown, CirclePlus, LogOut, Mails, Settings } from "lucide-react";
@@ -26,7 +27,7 @@ import SidebarDropdownItem from "./dropdown-item";
export const SidebarDropdown = observer(() => {
const { t } = useTranslation();
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { data: currentUser } = useUser();
@@ -217,7 +218,7 @@ export const SidebarDropdown = observer(() => {
>
<div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span>
<Link href="/profile">
<Link href={`/${workspaceSlug}/settings/account`}>
<Menu.Item as="div">
<span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
<Settings className="h-4 w-4 stroke-[1.5]" />
@@ -353,7 +353,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/settings`}>
<Link href={`/${workspaceSlug}/settings/project/${project?.id}`}>
<div className="flex items-center justify-start gap-2">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>
@@ -178,7 +178,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
<Button>Go Home</Button>
</Link>
)}
<Link href="/profile">
<Link href={`/${workspaceSlug}/settings/account`}>
<Button variant="neutral-primary">Visit Profile</Button>
</Link>
</div>
+2
View File
@@ -35,6 +35,8 @@ export class UserSettingsStore implements IUserSettingsStore {
workspace: {
last_workspace_id: undefined,
last_workspace_slug: undefined,
last_workspace_name: undefined,
last_workspace_logo: undefined,
fallback_workspace_id: undefined,
fallback_workspace_slug: undefined,
invites: undefined,
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB