diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx deleted file mode 100644 index c071ef4c354..00000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useState } from "react"; -import { observer } from "mobx-react"; -import useSWR from "swr"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// component -import { EmptyStateCompact } from "@plane/propel/empty-state"; -import { APITokenService } from "@plane/services"; -import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal"; -import { ApiTokenListItem } from "@/components/api-token/token-list-item"; -import { PageHead } from "@/components/core/page-title"; -import { SettingsHeading } from "@/components/settings/heading"; -import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token"; -import { API_TOKENS_LIST } from "@/constants/fetch-keys"; -// store hooks -import { useWorkspace } from "@/hooks/store/use-workspace"; - -const apiTokenService = new APITokenService(); - -function ApiTokensPage() { - // states - const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); - // router - // plane hooks - const { t } = useTranslation(); - // store hooks - const { currentWorkspace } = useWorkspace(); - - const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list()); - - const pageTitle = currentWorkspace?.name - ? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}` - : undefined; - - if (!tokens) { - return ; - } - - return ( -
- - setIsCreateTokenModalOpen(false)} /> -
- {tokens.length > 0 ? ( - <> - { - setIsCreateTokenModalOpen(true); - }, - }} - /> -
- {tokens.map((token) => ( - - ))} -
- - ) : ( -
- { - setIsCreateTokenModalOpen(true); - }, - }} - /> - - { - setIsCreateTokenModalOpen(true); - }, - }, - ]} - align="start" - rootClassName="py-20" - /> -
- )} -
-
- ); -} - -export default observer(ApiTokensPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx deleted file mode 100644 index 25d737300b5..00000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { observer } from "mobx-react"; -import { usePathname } from "next/navigation"; -import { Outlet } from "react-router"; -// components -import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; -import { getProfileActivePath } from "@/components/settings/helper"; -import { SettingsMobileNav } from "@/components/settings/mobile"; -// local imports -import { ProfileSidebar } from "./sidebar"; - -function ProfileSettingsLayout() { - // router - const pathname = usePathname(); - - return ( - <> - -
-
- -
-
- - - -
-
- - ); -} - -export default observer(ProfileSettingsLayout); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx deleted file mode 100644 index f3098f675a2..00000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { observer } from "mobx-react"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// components -import { PageHead } from "@/components/core/page-title"; -import { PreferencesList } from "@/components/preferences/list"; -import { LanguageTimezone } from "@/components/profile/preferences/language-timezone"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -import { SettingsHeading } from "@/components/settings/heading"; -// hooks -import { useUserProfile } from "@/hooks/store/user"; - -const ProfileAppearancePage = observer(() => { - const { t } = useTranslation(); - // hooks - const { data: userProfile } = useUserProfile(); - - if (!userProfile) return <>; - return ( - <> - -
-
- - -
-
- - -
-
- - ); -}); - -export default ProfileAppearancePage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx deleted file mode 100644 index cd03a7ca019..00000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { useState } from "react"; -import { observer } from "mobx-react"; -import { Controller, useForm } from "react-hook-form"; -import { Eye, EyeOff } from "lucide-react"; -// plane imports -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 { Input, PasswordStrengthIndicator } from "@plane/ui"; -import { getPasswordStrength } from "@plane/utils"; -// components -import { PageHead } from "@/components/core/page-title"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -// helpers -import { authErrorHandler } from "@/helpers/authentication.helper"; -import type { EAuthenticationErrorCodes } 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, -}; - -function SecurityPage() { - // 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({ 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) { - let errorInfo = undefined; - if (error instanceof Error) { - const err = error as Error & { error_code?: string }; - const code = err.error_code?.toString(); - errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined; - } - - 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 && ( - - ); - - const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; - - return ( - <> - - -
-
- {oldPasswordRequired && ( -
-

{t("auth.common.password.current_password.label")}

-
- ( - - )} - /> - {showPassword?.oldPassword ? ( - handleShowPassword("oldPassword")} - /> - ) : ( - handleShowPassword("oldPassword")} - /> - )} -
- {errors.old_password && ( - {errors.old_password.message} - )} -
- )} -
-

{t("auth.common.password.new_password.label")}

-
- ( - setIsPasswordInputFocused(true)} - onBlur={() => setIsPasswordInputFocused(false)} - /> - )} - /> - {showPassword?.password ? ( - handleShowPassword("password")} - /> - ) : ( - handleShowPassword("password")} - /> - )} -
- {passwordSupport} - {isNewPasswordSameAsOldPassword && !isPasswordInputFocused && ( - - {t("new_password_must_be_different_from_old_password")} - - )} -
-
-

{t("auth.common.password.confirm_password.label")}

-
- ( - setIsRetryPasswordInputFocused(true)} - onBlur={() => setIsRetryPasswordInputFocused(false)} - /> - )} - /> - {showPassword?.confirmPassword ? ( - handleShowPassword("confirmPassword")} - /> - ) : ( - handleShowPassword("confirmPassword")} - /> - )} -
- {!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && ( - {t("auth.common.password.errors.match")} - )} -
-
- -
- -
-
- - ); -} - -export default observer(SecurityPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx deleted file mode 100644 index 4088ec4ab31..00000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; -import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks } from "lucide-react"; -// plane imports -import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants"; -import { LockIcon } from "@plane/propel/icons"; -import { getFileURL } from "@plane/utils"; -// components -import { SettingsSidebar } from "@/components/settings/sidebar"; -// hooks -import { useUser } from "@/hooks/store/user"; - -const ICONS = { - profile: CircleUser, - security: LockIcon, - activity: Activity, - preferences: Settings2, - notifications: Bell, - "api-tokens": KeyRound, - connections: Blocks, -}; - -export function ProjectActionIcons({ type, size, className }: { type: string; size?: number; className?: string }) { - if (type === undefined) return null; - const Icon = ICONS[type as keyof typeof ICONS]; - if (!Icon) return null; - return ; -} - -type TProfileSidebarProps = { - isMobile?: boolean; -}; - -export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSidebarProps) { - const { isMobile = false } = props; - // router - const pathname = usePathname(); - const { workspaceSlug } = useParams(); - // store hooks - const { data: currentUser } = useUser(); - - return ( - pathname === `/${workspaceSlug}${data.href}/`} - customHeader={ -
-
- {!currentUser?.avatar_url || currentUser?.avatar_url === "" ? ( -
- -
- ) : ( -
- {currentUser?.display_name} -
- )} -
-
-
{currentUser?.display_name}
-
{currentUser?.email}
-
-
- } - actionIcons={ProjectActionIcons} - shouldRender - /> - ); -}); diff --git a/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/layout.tsx index 0e489644d4a..854e6bbaf20 100644 --- a/apps/web/app/(all)/[workspaceSlug]/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/layout.tsx @@ -2,14 +2,19 @@ import { Outlet } from "react-router"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper"; import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail"; +import { GlobalModals } from "@/plane-web/components/common/modal/global"; import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; +import type { Route } from "./+types/layout"; + +export default function WorkspaceLayout(props: Route.ComponentProps) { + const { workspaceSlug } = props.params; -export default function WorkspaceLayout() { return ( + diff --git a/apps/web/app/(all)/profile/activity/page.tsx b/apps/web/app/(all)/profile/activity/page.tsx deleted file mode 100644 index e3956258ff0..00000000000 --- a/apps/web/app/(all)/profile/activity/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useState } from "react"; -import { observer } from "mobx-react"; -import { useTheme } from "next-themes"; -// plane imports -import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/propel/button"; -// assets -import darkActivityAsset from "@/app/assets/empty-state/profile/activity-dark.webp?url"; -import lightActivityAsset from "@/app/assets/empty-state/profile/activity-light.webp?url"; -// components -import { PageHead } from "@/components/core/page-title"; -import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; -import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; - -const PER_PAGE = 100; - -function ProfileActivityPage() { - // states - const [pageCount, setPageCount] = useState(1); - const [totalPages, setTotalPages] = useState(0); - const [resultsCount, setResultsCount] = useState(0); - const [isEmpty, setIsEmpty] = useState(false); - // theme hook - const { resolvedTheme } = useTheme(); - // plane hooks - const { t } = useTranslation(); - // derived values - const resolvedPath = resolvedTheme === "light" ? lightActivityAsset : darkActivityAsset; - - 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: React.ReactNode[] = []; - for (let i = 0; i < pageCount; i++) - activityPages.push( - - ); - - const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; - - if (isEmpty) { - return ( - - ); - } - - return ( - <> - - - - {activityPages} - {isLoadMoreVisible && ( -
- -
- )} -
- - ); -} - -export default observer(ProfileActivityPage); diff --git a/apps/web/app/(all)/profile/appearance/page.tsx b/apps/web/app/(all)/profile/appearance/page.tsx deleted file mode 100644 index d0d05588d87..00000000000 --- a/apps/web/app/(all)/profile/appearance/page.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { observer } from "mobx-react"; -import { useTheme } from "next-themes"; -// plane imports -import type { I_THEME_OPTION } from "@plane/constants"; -import { THEME_OPTIONS } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { setPromiseToast } from "@plane/propel/toast"; -import { applyCustomTheme } from "@plane/utils"; -// components -import { LogoSpinner } from "@/components/common/logo-spinner"; -import { PageHead } from "@/components/core/page-title"; -import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; -import { ThemeSwitch } from "@/components/core/theme/theme-switch"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; -// hooks -import { useUserProfile } from "@/hooks/store/user"; - -function ProfileAppearancePage() { - // store hooks - const { data: userProfile, updateUserTheme } = useUserProfile(); - // theme - const { setTheme } = useTheme(); - // translation - const { t } = useTranslation(); - // derived values - const currentTheme = useMemo(() => { - const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme); - return userThemeOption || null; - }, [userProfile?.theme?.theme]); - - const handleThemeChange = useCallback( - async (themeOption: I_THEME_OPTION) => { - setTheme(themeOption.value); - - // If switching to custom theme and user has saved custom colors, apply them immediately - if ( - themeOption.value === "custom" && - userProfile?.theme?.primary && - userProfile?.theme?.background && - userProfile?.theme?.darkPalette !== undefined - ) { - applyCustomTheme( - userProfile.theme.primary, - userProfile.theme.background, - userProfile.theme.darkPalette ? "dark" : "light" - ); - } - - const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value }); - setPromiseToast(updateCurrentUserThemePromise, { - loading: "Updating theme...", - success: { - title: "Theme updated", - message: () => "Reloading to apply changes...", - }, - error: { - title: "Error!", - message: () => "Failed to update theme. Please try again.", - }, - }); - // Wait for the promise to resolve, then reload after showing toast - try { - await updateCurrentUserThemePromise; - window.location.reload(); - } catch (error) { - // Error toast already shown by setPromiseToast - console.error("Error updating theme:", error); - } - }, - [setTheme, updateUserTheme, userProfile] - ); - - return ( - <> - - {userProfile ? ( - - -
-
-

{t("theme")}

-

{t("select_or_customize_your_interface_color_scheme")}

-
-
- -
-
- {userProfile?.theme?.theme === "custom" && } -
- ) : ( -
- -
- )} - - ); -} - -export default observer(ProfileAppearancePage); diff --git a/apps/web/app/(all)/profile/notifications/page.tsx b/apps/web/app/(all)/profile/notifications/page.tsx deleted file mode 100644 index 725117e5c2b..00000000000 --- a/apps/web/app/(all)/profile/notifications/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import useSWR from "swr"; -// components -import { useTranslation } from "@plane/i18n"; -import { PageHead } from "@/components/core/page-title"; -import { EmailNotificationForm } from "@/components/profile/notification/email-notification-form"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; -import { EmailSettingsLoader } from "@/components/ui/loader/settings/email"; -// 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 ; - } - - return ( - <> - - - - - - - ); -} diff --git a/apps/web/app/(all)/profile/page.tsx b/apps/web/app/(all)/profile/page.tsx deleted file mode 100644 index 9b6f8f083ca..00000000000 --- a/apps/web/app/(all)/profile/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { observer } from "mobx-react"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// components -import { LogoSpinner } from "@/components/common/logo-spinner"; -import { PageHead } from "@/components/core/page-title"; -import { ProfileForm } from "@/components/profile/form"; -import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; -// hooks -import { useUser } from "@/hooks/store/user"; - -function ProfileSettingsPage() { - const { t } = useTranslation(); - // store hooks - const { data: currentUser, userProfile } = useUser(); - - if (!currentUser) - return ( -
- -
- ); - - return ( - <> - - - - - - ); -} - -export default observer(ProfileSettingsPage); diff --git a/apps/web/app/(all)/profile/sidebar.tsx b/apps/web/app/(all)/profile/sidebar.tsx deleted file mode 100644 index c4ded1df24c..00000000000 --- a/apps/web/app/(all)/profile/sidebar.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -// icons -import { LogOut, MoveLeft, Activity, Bell, CircleUser, KeyRound, Settings2, CirclePlus, Mails } from "lucide-react"; -// plane imports -import { PROFILE_ACTION_LINKS } from "@plane/constants"; -import { useOutsideClickDetector } from "@plane/hooks"; -import { useTranslation } from "@plane/i18n"; -import { ChevronLeftIcon } from "@plane/propel/icons"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { Tooltip } from "@plane/propel/tooltip"; -import { cn, getFileURL } from "@plane/utils"; -// components -import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation"; -// hooks -import { useAppTheme } from "@/hooks/store/use-app-theme"; -import { useWorkspace } from "@/hooks/store/use-workspace"; -import { useUser, useUserSettings } from "@/hooks/store/user"; -import { usePlatformOS } from "@/hooks/use-platform-os"; - -const WORKSPACE_ACTION_LINKS = [ - { - key: "create_workspace", - Icon: CirclePlus, - i18n_label: "create_workspace", - href: "/create-workspace", - }, - { - key: "invitations", - Icon: Mails, - i18n_label: "workspace_invites", - href: "/invitations", - }, -]; - -function ProjectActionIcons({ type, size, className = "" }: { type: string; size?: number; className?: string }) { - const icons = { - profile: CircleUser, - security: KeyRound, - activity: Activity, - preferences: Settings2, - notifications: Bell, - "api-tokens": KeyRound, - }; - - if (type === undefined) return null; - const Icon = icons[type as keyof typeof icons]; - if (!Icon) return null; - return ; -} - -export const ProfileLayoutSidebar = observer(function ProfileLayoutSidebar() { - // states - const [isSigningOut, setIsSigningOut] = useState(false); - // router - const pathname = usePathname(); - // store hooks - const { sidebarCollapsed, toggleSidebar } = useAppTheme(); - const { data: currentUser, signOut } = useUser(); - const { data: currentUserSettings } = useUserSettings(); - const { workspaces } = useWorkspace(); - const { isMobile } = usePlatformOS(); - const { t } = useTranslation(); - - const workspacesList = Object.values(workspaces ?? {}); - - // redirect url for normal mode - const redirectWorkspaceSlug = - currentUserSettings?.workspace?.last_workspace_slug || - currentUserSettings?.workspace?.fallback_workspace_slug || - ""; - - const ref = useRef(null); - - useOutsideClickDetector(ref, () => { - if (sidebarCollapsed === false) { - if (window.innerWidth < 768) { - toggleSidebar(); - } - } - }); - - useEffect(() => { - const handleResize = () => { - if (window.innerWidth <= 768) { - toggleSidebar(true); - } - }; - handleResize(); - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - }, [toggleSidebar]); - - const handleItemClick = () => { - if (window.innerWidth < 768) { - toggleSidebar(); - } - }; - - const handleSignOut = async () => { - setIsSigningOut(true); - await signOut() - .catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: t("sign_out.toast.error.title"), - message: t("sign_out.toast.error.message"), - }) - ) - .finally(() => setIsSigningOut(false)); - }; - - return ( -
-
- -
- - - - {!sidebarCollapsed && ( -

{t("profile_settings")}

- )} -
- -
- {!sidebarCollapsed && ( -
{t("your_account")}
- )} -
- {PROFILE_ACTION_LINKS.map((link) => { - if (link.key === "change-password" && currentUser?.is_password_autoset) return null; - - return ( - - - -
- - - {!sidebarCollapsed &&

{t(link.i18n_label)}

} -
-
-
- - ); - })} -
-
-
- {!sidebarCollapsed && ( -
{t("workspaces")}
- )} - {workspacesList && workspacesList.length > 0 && ( -
- {workspacesList.map((workspace) => ( - - - - {workspace?.logo_url && workspace.logo_url !== "" ? ( - Workspace Logo - ) : ( - (workspace?.name?.charAt(0) ?? "...") - )} - - {!sidebarCollapsed &&

{workspace.name}

} -
- - ))} -
- )} -
- {WORKSPACE_ACTION_LINKS.map((link) => ( - - -
- {} - {!sidebarCollapsed && t(link.i18n_label)} -
-
- - ))} -
-
-
-
- - - -
-
-
-
- ); -}); diff --git a/apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx b/apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx new file mode 100644 index 00000000000..6b481d8189e --- /dev/null +++ b/apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx @@ -0,0 +1,55 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROFILE_SETTINGS_TABS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { TProfileSettingsTabs } from "@plane/types"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PageHead } from "@/components/core/page-title"; +import { ProfileSettingsContent } from "@/components/settings/profile/content"; +import { ProfileSettingsSidebarRoot } from "@/components/settings/profile/sidebar"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// local imports +import type { Route } from "../+types/layout"; + +function ProfileSettingsPage(props: Route.ComponentProps) { + const { profileTabId } = props.params; + // router + const router = useAppRouter(); + // store hooks + const { data: currentUser } = useUser(); + // translation + const { t } = useTranslation(); + // derived values + const isAValidTab = PROFILE_SETTINGS_TABS.includes(profileTabId as TProfileSettingsTabs); + + if (!currentUser || !isAValidTab) + return ( +
+ +
+ ); + + return ( + <> + +
+
+ router.push(`/settings/profile/${tab}`)} + /> + +
+
+ + ); +} + +export default observer(ProfileSettingsPage); diff --git a/apps/web/app/(all)/profile/layout.tsx b/apps/web/app/(all)/settings/profile/layout.tsx similarity index 56% rename from apps/web/app/(all)/profile/layout.tsx rename to apps/web/app/(all)/settings/profile/layout.tsx index f5aebbfbbbc..38311cb08d3 100644 --- a/apps/web/app/(all)/profile/layout.tsx +++ b/apps/web/app/(all)/settings/profile/layout.tsx @@ -1,20 +1,17 @@ -// components import { Outlet } from "react-router"; -// wrappers +// components import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider"; +// lib import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; -// layout -import { ProfileLayoutSidebar } from "./sidebar"; export default function ProfileSettingsLayout() { return ( <> -
- -
-
+
+
+
diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts index ccb9d78d378..69d583f04d8 100644 --- a/apps/web/app/routes/core.ts +++ b/apps/web/app/routes/core.ts @@ -278,34 +278,6 @@ export const coreRoutes: RouteConfigEntry[] = [ ), ]), - // -------------------------------------------------------------------- - // ACCOUNT SETTINGS - // -------------------------------------------------------------------- - - layout("./(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx", [ - route(":workspaceSlug/settings/account", "./(all)/[workspaceSlug]/(settings)/settings/account/page.tsx"), - route( - ":workspaceSlug/settings/account/activity", - "./(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx" - ), - route( - ":workspaceSlug/settings/account/preferences", - "./(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx" - ), - route( - ":workspaceSlug/settings/account/notifications", - "./(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx" - ), - route( - ":workspaceSlug/settings/account/security", - "./(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx" - ), - route( - ":workspaceSlug/settings/account/api-tokens", - "./(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx" - ), - ]), - // -------------------------------------------------------------------- // PROJECT SETTINGS // -------------------------------------------------------------------- @@ -363,12 +335,8 @@ export const coreRoutes: RouteConfigEntry[] = [ // PROFILE SETTINGS // -------------------------------------------------------------------- - layout("./(all)/profile/layout.tsx", [ - route("profile", "./(all)/profile/page.tsx"), - route("profile/activity", "./(all)/profile/activity/page.tsx"), - route("profile/appearance", "./(all)/profile/appearance/page.tsx"), - route("profile/notifications", "./(all)/profile/notifications/page.tsx"), - route("profile/security", "./(all)/profile/security/page.tsx"), + layout("./(all)/settings/profile/layout.tsx", [ + route("settings/profile/:profileTabId", "./(all)/settings/profile/[profileTabId]/page.tsx"), ]), ]), @@ -389,7 +357,7 @@ export const coreRoutes: RouteConfigEntry[] = [ route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"), // API tokens redirect: /:workspaceSlug/settings/api-tokens - // → /:workspaceSlug/settings/account/api-tokens + // → /settings/profile/api-tokens route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"), // Inbox redirect: /:workspaceSlug/projects/:projectId/inbox @@ -406,4 +374,10 @@ export const coreRoutes: RouteConfigEntry[] = [ // Register redirect route("register", "routes/redirects/core/register.tsx"), + + // Profile settings redirects + route("profile/*", "routes/redirects/core/profile-settings.tsx"), + + // Account settings redirects + route(":workspaceSlug/settings/account/*", "routes/redirects/core/workspace-account-settings.tsx"), ] satisfies RouteConfig; diff --git a/apps/web/app/routes/redirects/core/api-tokens.tsx b/apps/web/app/routes/redirects/core/api-tokens.tsx index 68007aa4165..d97413084bc 100644 --- a/apps/web/app/routes/redirects/core/api-tokens.tsx +++ b/apps/web/app/routes/redirects/core/api-tokens.tsx @@ -1,9 +1,7 @@ import { redirect } from "react-router"; -import type { Route } from "./+types/api-tokens"; -export const clientLoader = ({ params }: Route.ClientLoaderArgs) => { - const { workspaceSlug } = params; - throw redirect(`/${workspaceSlug}/settings/account/api-tokens/`); +export const clientLoader = () => { + throw redirect(`/settings/profile/api-tokens/`); }; export default function ApiTokens() { diff --git a/apps/web/app/routes/redirects/core/index.ts b/apps/web/app/routes/redirects/core/index.ts index efd3ae40f8b..480386bf62d 100644 --- a/apps/web/app/routes/redirects/core/index.ts +++ b/apps/web/app/routes/redirects/core/index.ts @@ -14,7 +14,7 @@ export const coreRedirectRoutes: RouteConfigEntry[] = [ route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"), // API tokens redirect: /:workspaceSlug/settings/api-tokens - // → /:workspaceSlug/settings/account/api-tokens + // → /settings/profile/api-tokens route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"), // Inbox redirect: /:workspaceSlug/projects/:projectId/inbox diff --git a/apps/web/app/routes/redirects/core/profile-settings.tsx b/apps/web/app/routes/redirects/core/profile-settings.tsx new file mode 100644 index 00000000000..7e8a0c15a2e --- /dev/null +++ b/apps/web/app/routes/redirects/core/profile-settings.tsx @@ -0,0 +1,12 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/profile-settings"; + +export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => { + const searchParams = new URL(request.url).searchParams; + const splat = params["*"] || ""; + throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`); +}; + +export default function ProfileSettings() { + return null; +} diff --git a/apps/web/app/routes/redirects/core/workspace-account-settings.tsx b/apps/web/app/routes/redirects/core/workspace-account-settings.tsx new file mode 100644 index 00000000000..10d375e9a16 --- /dev/null +++ b/apps/web/app/routes/redirects/core/workspace-account-settings.tsx @@ -0,0 +1,12 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/workspace-account-settings"; + +export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => { + const searchParams = new URL(request.url).searchParams; + const splat = params["*"] || ""; + throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`); +}; + +export default function WorkspaceAccountSettings() { + return null; +} diff --git a/apps/web/ce/components/common/modal/global.tsx b/apps/web/ce/components/common/modal/global.tsx new file mode 100644 index 00000000000..76b859c3e8d --- /dev/null +++ b/apps/web/ce/components/common/modal/global.tsx @@ -0,0 +1,26 @@ +import { lazy, Suspense } from "react"; +import { observer } from "mobx-react"; + +const ProfileSettingsModal = lazy(() => + import("@/components/settings/profile/modal").then((module) => ({ + default: module.ProfileSettingsModal, + })) +); + +type TGlobalModalsProps = { + workspaceSlug: string; +}; + +/** + * GlobalModals component manages all workspace-level modals across Plane applications. + * + * This includes: + * - Profile settings modal + */ +export const GlobalModals = observer(function GlobalModals(_props: TGlobalModalsProps) { + return ( + + + + ); +}); diff --git a/apps/web/ce/components/navigations/top-navigation-root.tsx b/apps/web/ce/components/navigations/top-navigation-root.tsx index 9035e3147e7..1ee0d9e192a 100644 --- a/apps/web/ce/components/navigations/top-navigation-root.tsx +++ b/apps/web/ce/components/navigations/top-navigation-root.tsx @@ -74,7 +74,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
- +
diff --git a/apps/web/ce/components/preferences/config.ts b/apps/web/ce/components/preferences/config.ts deleted file mode 100644 index 1a67ab7d341..00000000000 --- a/apps/web/ce/components/preferences/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference"; -import { ThemeSwitcher } from "./theme-switcher"; - -export const PREFERENCE_COMPONENTS = { - theme: ThemeSwitcher, - start_of_week: StartOfWeekPreference, -}; diff --git a/apps/web/ce/components/preferences/theme-switcher.tsx b/apps/web/ce/components/preferences/theme-switcher.tsx index d6e6dc252cc..b2c2008b1a6 100644 --- a/apps/web/ce/components/preferences/theme-switcher.tsx +++ b/apps/web/ce/components/preferences/theme-switcher.tsx @@ -10,8 +10,7 @@ import { applyCustomTheme } from "@plane/utils"; // components import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; import { ThemeSwitch } from "@/components/core/theme/theme-switch"; -// helpers -import { PreferencesSection } from "@/components/preferences/section"; +import { SettingsControlItem } from "@/components/settings/control-item"; // hooks import { useUserProfile } from "@/hooks/store/user"; @@ -79,18 +78,16 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: { return ( <> - - { - void handleThemeChange(themeOption); - }} - /> -
+ { + void handleThemeChange(themeOption); + }} + /> } /> {userProfile.theme?.theme === "custom" && } diff --git a/apps/web/core/components/appearance/index.ts b/apps/web/core/components/appearance/index.ts new file mode 100644 index 00000000000..1d07ba69ea3 --- /dev/null +++ b/apps/web/core/components/appearance/index.ts @@ -0,0 +1 @@ +export * from "./theme-switcher"; diff --git a/apps/web/core/components/appearance/theme-switcher.tsx b/apps/web/core/components/appearance/theme-switcher.tsx new file mode 100644 index 00000000000..993a06df582 --- /dev/null +++ b/apps/web/core/components/appearance/theme-switcher.tsx @@ -0,0 +1,70 @@ +import { useCallback, useMemo } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +// plane imports +import type { I_THEME_OPTION } from "@plane/constants"; +import { THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { setPromiseToast } from "@plane/propel/toast"; +// components +import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; +import { ThemeSwitch } from "@/components/core/theme/theme-switch"; +import { SettingsControlItem } from "@/components/settings/control-item"; +// hooks +import { useUserProfile } from "@/hooks/store/user"; + +export const ThemeSwitcher = observer(function ThemeSwitcher(props: { + option: { + id: string; + title: string; + description: string; + }; +}) { + // store hooks + const { data: userProfile, updateUserTheme } = useUserProfile(); + // theme + const { setTheme } = useTheme(); + // translation + const { t } = useTranslation(); + // derived values + const currentTheme = useMemo(() => { + const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme); + return userThemeOption || null; + }, [userProfile?.theme?.theme]); + + const handleThemeChange = useCallback( + (themeOption: I_THEME_OPTION) => { + try { + setTheme(themeOption.value); + const updatePromise = updateUserTheme({ theme: themeOption.value }); + setPromiseToast(updatePromise, { + loading: "Updating theme...", + success: { + title: "Success!", + message: () => "Theme updated successfully!", + }, + error: { + title: "Error!", + message: () => "Failed to update the theme", + }, + }); + } catch (error) { + console.error("Error updating theme:", error); + } + }, + [updateUserTheme] + ); + + if (!userProfile) return null; + + return ( + <> + } + /> + {userProfile.theme?.theme === "custom" && } + + ); +}); diff --git a/apps/web/core/components/core/theme/theme-switch.tsx b/apps/web/core/components/core/theme/theme-switch.tsx index a6cd5cb41f1..d0a0ac3b6d7 100644 --- a/apps/web/core/components/core/theme/theme-switch.tsx +++ b/apps/web/core/components/core/theme/theme-switch.tsx @@ -50,6 +50,7 @@ export function ThemeSwitch(props: Props) { ) } onChange={onChange} + buttonClassName="border border-subtle-1" placement="bottom-end" input > diff --git a/apps/web/core/components/global/timezone-select.tsx b/apps/web/core/components/global/timezone-select.tsx index 59f3285e75b..1a25a022a68 100644 --- a/apps/web/core/components/global/timezone-select.tsx +++ b/apps/web/core/components/global/timezone-select.tsx @@ -1,5 +1,5 @@ -import type { FC } from "react"; import { observer } from "mobx-react"; +// plane imports import { CustomSearchSelect } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks @@ -38,13 +38,14 @@ export const TimezoneSelect = observer(function TimezoneSelect(props: TTimezoneS label={value && selectedValue ? selectedValue(value) : label} options={isDisabled || disabled ? [] : timezones} onChange={onChange} - buttonClassName={cn(buttonClassName, { + buttonClassName={cn(buttonClassName, "border border-subtle-1", { "border-danger-strong": error, })} - className={cn("rounded-md border-[0.5px] !border-subtle", className)} + className={cn("rounded-md", className)} optionsClassName={cn("w-72", optionsClassName)} input disabled={isDisabled || disabled} + placement="bottom-end" /> ); diff --git a/apps/web/core/components/home/widgets/empty-states/no-projects.tsx b/apps/web/core/components/home/widgets/empty-states/no-projects.tsx index fe4384cb7ee..09cb48f4615 100644 --- a/apps/web/core/components/home/widgets/empty-states/no-projects.tsx +++ b/apps/web/core/components/home/widgets/empty-states/no-projects.tsx @@ -108,7 +108,7 @@ export const NoProjectsEmptyState = observer(function NoProjectsEmptyState() { flag: "visited_profile", cta: { text: "home.empty.personalize_account.cta", - link: `/${workspaceSlug}/settings/account`, + link: `/settings/profile/general`, disabled: false, }, }, diff --git a/apps/web/core/components/preferences/list.tsx b/apps/web/core/components/preferences/list.tsx deleted file mode 100644 index 291eebf01e2..00000000000 --- a/apps/web/core/components/preferences/list.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { PREFERENCE_OPTIONS } from "@plane/constants"; -import { PREFERENCE_COMPONENTS } from "@/plane-web/components/preferences/config"; - -export function PreferencesList() { - return ( -
- {PREFERENCE_OPTIONS.map((option) => { - const Component = PREFERENCE_COMPONENTS[option.id as keyof typeof PREFERENCE_COMPONENTS]; - return ; - })} -
- ); -} diff --git a/apps/web/core/components/preferences/section.tsx b/apps/web/core/components/preferences/section.tsx deleted file mode 100644 index f69c600bd20..00000000000 --- a/apps/web/core/components/preferences/section.tsx +++ /dev/null @@ -1,17 +0,0 @@ -interface SettingsSectionProps { - title: string; - description: string; - control: React.ReactNode; -} - -export function PreferencesSection({ title, description, control }: SettingsSectionProps) { - return ( -
-
-

{title}

-

{description}

-
-
{control}
-
- ); -} diff --git a/apps/web/core/components/profile/notification/email-notification-form.tsx b/apps/web/core/components/profile/notification/email-notification-form.tsx deleted file mode 100644 index 8ab4fac8801..00000000000 --- a/apps/web/core/components/profile/notification/email-notification-form.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import type { IUserEmailNotificationSettings } from "@plane/types"; -// ui -import { ToggleSwitch } from "@plane/ui"; -// services -import { UserService } from "@/services/user.service"; -// types -interface IEmailNotificationFormProps { - data: IUserEmailNotificationSettings; -} - -// services -const userService = new UserService(); - -export function EmailNotificationForm(props: IEmailNotificationFormProps) { - const { data } = props; - const { t } = useTranslation(); - // form data - const { control, reset } = useForm({ - 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 ( - <> - {/* Notification Settings */} -
-
-
-
{t("property_changes")}
-
{t("property_changes_description")}
-
-
- ( - { - onChange(newValue); - handleSettingChange("property_change", newValue); - }} - size="sm" - /> - )} - /> -
-
-
-
-
{t("state_change")}
-
{t("state_change_description")}
-
-
- ( - { - onChange(newValue); - handleSettingChange("state_change", newValue); - }} - size="sm" - /> - )} - /> -
-
-
-
-
{t("issue_completed")}
-
{t("issue_completed_description")}
-
-
- ( - { - onChange(newValue); - handleSettingChange("issue_completed", newValue); - }} - size="sm" - /> - )} - /> -
-
-
-
-
{t("comments")}
-
{t("comments_description")}
-
-
- ( - { - onChange(newValue); - handleSettingChange("comment", newValue); - }} - size="sm" - /> - )} - /> -
-
-
-
-
{t("mentions")}
-
{t("mentions_description")}
-
-
- ( - { - onChange(newValue); - handleSettingChange("mention", newValue); - }} - size="sm" - /> - )} - /> -
-
-
- - ); -} diff --git a/apps/web/core/components/profile/preferences/language-timezone.tsx b/apps/web/core/components/profile/preferences/language-timezone.tsx deleted file mode 100644 index ef2de25298f..00000000000 --- a/apps/web/core/components/profile/preferences/language-timezone.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { observer } from "mobx-react"; -import { SUPPORTED_LANGUAGES, useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { CustomSelect } from "@plane/ui"; -import { TimezoneSelect } from "@/components/global"; -import { useUser, useUserProfile } from "@/hooks/store/user"; - -export const LanguageTimezone = observer(function LanguageTimezone() { - // store hooks - const { - data: user, - updateCurrentUser, - userProfile: { data: profile }, - } = useUser(); - const { updateUserProfile } = useUserProfile(); - 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 ( -
-
-
-
-
-

{t("timezone")} 

-

{t("timezone_setting")}

-
-
- -
-
-
-
-
-
-

{t("language")} 

-

{t("language_setting")}

-
-
- - {SUPPORTED_LANGUAGES.map((item) => ( - - {item.label} - - ))} - -
-
-
-
-
- ); -}); diff --git a/apps/web/core/components/profile/profile-setting-content-header.tsx b/apps/web/core/components/profile/profile-setting-content-header.tsx deleted file mode 100644 index 16e233a66f3..00000000000 --- a/apps/web/core/components/profile/profile-setting-content-header.tsx +++ /dev/null @@ -1,14 +0,0 @@ -type Props = { - title: string; - description?: string; -}; - -export function ProfileSettingContentHeader(props: Props) { - const { title, description } = props; - return ( -
-
{title}
- {description &&
{description}
} -
- ); -} diff --git a/apps/web/core/components/profile/sidebar.tsx b/apps/web/core/components/profile/sidebar.tsx index 611fd6e53e6..4d7633051ee 100644 --- a/apps/web/core/components/profile/sidebar.tsx +++ b/apps/web/core/components/profile/sidebar.tsx @@ -1,26 +1,22 @@ import { useEffect, useRef } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; import { useParams } from "next/navigation"; -// icons - -// headless ui import { Disclosure, Transition } from "@headlessui/react"; -// plane helpers +// plane imports import { useOutsideClickDetector } from "@plane/hooks"; -// types import { useTranslation } from "@plane/i18n"; import { Logo } from "@plane/propel/emoji-icon-picker"; +import { IconButton } from "@plane/propel/icon-button"; import { EditIcon, ChevronDownIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import type { IUserProfileProjectSegregation } from "@plane/types"; -// plane ui import { Loader } from "@plane/ui"; import { cn, renderFormattedDate, getFileURL } from "@plane/utils"; // components import { CoverImage } from "@/components/common/cover-image"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useProject } from "@/hooks/store/use-project"; import { useUser } from "@/hooks/store/user"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -37,11 +33,12 @@ export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSi // refs const ref = useRef(null); // router - const { userId, workspaceSlug } = useParams(); + const { userId } = useParams(); // store hooks const { data: currentUser } = useUser(); const { profileSidebarCollapsed, toggleProfileSidebar } = useAppTheme(); const { getProjectById } = useProject(); + const { toggleProfileSettingsModal } = useCommandPalette(); const { isMobile } = usePlatformOS(); const { t } = useTranslation(); // derived values @@ -84,7 +81,7 @@ export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSi return (
{currentUser?.id === userId && ( -
- - - - - +
+ + toggleProfileSettingsModal({ + activeTab: "general", + isOpen: true, + }) + } + />
)} START_OF_THE_WEEK_OPTIONS.find((option) => option.value === startOfWeek)?.label; @@ -27,27 +28,27 @@ export const StartOfWeekPreference = observer(function StartOfWeekPreference(pro }; return ( - - - <> - {START_OF_THE_WEEK_OPTIONS.map((day) => ( - - {day.label} - - ))} - - -
+ + <> + {START_OF_THE_WEEK_OPTIONS.map((day) => ( + + {day.label} + + ))} + + } /> ); diff --git a/apps/web/core/components/settings/control-item.tsx b/apps/web/core/components/settings/control-item.tsx new file mode 100644 index 00000000000..5c9fed7ac61 --- /dev/null +++ b/apps/web/core/components/settings/control-item.tsx @@ -0,0 +1,19 @@ +type Props = { + control: React.ReactNode; + description: string; + title: string; +}; + +export function SettingsControlItem(props: Props) { + const { control, description, title } = props; + + return ( +
+
+

{title}

+

{description}

+
+
{control}
+
+ ); +} diff --git a/apps/web/core/components/settings/heading.tsx b/apps/web/core/components/settings/heading.tsx index 9f8949ee5a9..1777c103104 100644 --- a/apps/web/core/components/settings/heading.tsx +++ b/apps/web/core/components/settings/heading.tsx @@ -24,15 +24,10 @@ export function SettingsHeading({ className, }: Props) { return ( -
+
- {typeof title === "string" ?

{title}

: title} - {description &&
{description}
} + {typeof title === "string" ?
{title}
: title} + {description &&

{description}

}
{showButton && customButton} {button && showButton && ( diff --git a/apps/web/core/components/settings/helper.ts b/apps/web/core/components/settings/helper.ts index e621fcdbd67..4c331f038fb 100644 --- a/apps/web/core/components/settings/helper.ts +++ b/apps/web/core/components/settings/helper.ts @@ -1,4 +1,4 @@ -import { GROUPED_PROFILE_SETTINGS, GROUPED_WORKSPACE_SETTINGS } from "@plane/constants"; +import { GROUPED_WORKSPACE_SETTINGS } from "@plane/constants"; import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; const hrefToLabelMap = (options: Record>) => @@ -14,8 +14,6 @@ const hrefToLabelMap = (options: Record { acc[setting.href] = setting.i18n_label; @@ -39,14 +37,6 @@ export const getWorkspaceActivePath = (pathname: string) => { return workspaceHrefToLabelMap[subPath]; }; -export const getProfileActivePath = (pathname: string) => { - const parts = pathname.split("/").filter(Boolean); - const settingsIndex = parts.indexOf("settings"); - if (settingsIndex === -1) return null; - const subPath = "/" + parts.slice(settingsIndex, settingsIndex + 3).join("/"); - return profiletHrefToLabelMap[subPath]; -}; - export const getProjectActivePath = (pathname: string) => { const parts = pathname.split("/").filter(Boolean); const settingsIndex = parts.indexOf("settings"); diff --git a/apps/web/core/components/settings/profile/content/index.ts b/apps/web/core/components/settings/profile/content/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/settings/profile/content/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/settings/profile/content/pages/activity/activity-list.tsx b/apps/web/core/components/settings/profile/content/pages/activity/activity-list.tsx new file mode 100644 index 00000000000..43798f71cee --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/activity/activity-list.tsx @@ -0,0 +1,188 @@ +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import useSWR from "swr"; +// icons +import { History, MessageSquare } from "lucide-react"; +import { calculateTimeAgo, getFileURL } from "@plane/utils"; +// hooks +import { ActivityIcon, ActivityMessage } from "@/components/core/activity"; +import { RichTextEditor } from "@/components/editor/rich-text"; +import { ActivitySettingsLoader } from "@/components/ui/loader/settings/activity"; +// constants +import { USER_ACTIVITY } from "@/constants/fetch-keys"; +// hooks +import { useUserProfile } from "@/hooks/store/user/user-user-profile"; +// services +import { UserService } from "@/services/user.service"; +const userService = new UserService(); + +type Props = { + cursor: string; + perPage: number; + updateResultsCount: (count: number) => void; + updateTotalPages: (count: number) => void; + updateEmptyState: (state: boolean) => void; +}; + +export const ActivityProfileSettingsList = observer(function ProfileActivityListPage(props: Props) { + const { cursor, perPage, updateResultsCount, updateTotalPages, updateEmptyState } = props; + // store hooks + const { data: currentUser } = useUserProfile(); + + const { data: userProfileActivity } = useSWR( + USER_ACTIVITY({ + cursor, + }), + () => + userService.getUserActivity({ + cursor, + per_page: perPage, + }) + ); + + useEffect(() => { + if (!userProfileActivity) return; + + // if no results found then show empty state + if (userProfileActivity.total_results === 0) updateEmptyState(true); + + updateTotalPages(userProfileActivity.total_pages); + updateResultsCount(userProfileActivity.results.length); + }, [updateResultsCount, updateTotalPages, userProfileActivity, updateEmptyState]); + + // TODO: refactor this component + return ( + <> + {userProfileActivity ? ( +
    + {userProfileActivity.results.map((activityItem: any) => { + if (activityItem.field === "comment") + return ( +
    +
    +
    + {activityItem.field ? ( + activityItem.new_value === "restore" && + ) : activityItem.actor_detail.avatar_url && activityItem.actor_detail.avatar_url !== "" ? ( + {activityItem.actor_detail.display_name} + ) : ( +
    + {activityItem.actor_detail.display_name?.[0]} +
    + )} + + + +
    +
    +
    +
    + {activityItem.actor_detail.is_bot + ? activityItem.actor_detail.first_name + " Bot" + : activityItem.actor_detail.display_name} +
    +

    + Commented {calculateTimeAgo(activityItem.created_at)} +

    +
    +
    + +
    +
    +
    +
    + ); + + const message = ; + + if ("field" in activityItem && activityItem.field !== "updated_by") + return ( +
  • +
    +
    + <> +
    +
    +
    +
    + {activityItem.field ? ( + activityItem.new_value === "restore" ? ( + + ) : ( + + ) + ) : activityItem.actor_detail.avatar_url && + activityItem.actor_detail.avatar_url !== "" ? ( + {activityItem.actor_detail.display_name} + ) : ( +
    + {activityItem.actor_detail.display_name?.[0]} +
    + )} +
    +
    +
    +
    +
    +
    + {activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? ( + Plane + ) : activityItem.actor_detail.is_bot ? ( + {activityItem.actor_detail.first_name} Bot + ) : ( + + + {currentUser?.id === activityItem.actor_detail.id + ? "You" + : activityItem.actor_detail.display_name} + + + )}{" "} +
    + {message}{" "} + + {calculateTimeAgo(activityItem.created_at)} + +
    +
    +
    + +
    +
    +
  • + ); + })} +
+ ) : ( + + )} + + ); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/activity/index.ts b/apps/web/core/components/settings/profile/content/pages/activity/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/activity/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx b/apps/web/core/components/settings/profile/content/pages/activity/root.tsx similarity index 85% rename from apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx rename to apps/web/core/components/settings/profile/content/pages/activity/root.tsx index 00b795dfdc6..064cf801f7c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx +++ b/apps/web/core/components/settings/profile/content/pages/activity/root.tsx @@ -1,23 +1,22 @@ import { useState } from "react"; +import { ChevronDown } from "lucide-react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; +// plane imports import { useTranslation } from "@plane/i18n"; -// ui import { Button } from "@plane/propel/button"; // assets import darkActivityAsset from "@/app/assets/empty-state/profile/activity-dark.webp?url"; import lightActivityAsset from "@/app/assets/empty-state/profile/activity-light.webp?url"; // components -import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; -import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list"; -// hooks import { SettingsHeading } from "@/components/settings/heading"; -import { ChevronDown } from "lucide-react"; +// local imports +import { ActivityProfileSettingsList } from "./activity-list"; const PER_PAGE = 100; -function ProfileActivityPage() { +export const ActivityProfileSettings = observer(function ActivityProfileSettings() { // states const [pageCount, setPageCount] = useState(1); const [totalPages, setTotalPages] = useState(0); @@ -41,7 +40,7 @@ function ProfileActivityPage() { const activityPages: React.ReactNode[] = []; for (let i = 0; i < pageCount; i++) activityPages.push( - +
- +
-
{activityPages}
+
{activityPages}
{isLoadMoreVisible && (
)} - +
); -} - -export default observer(ProfileActivityPage); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/api-tokens.tsx b/apps/web/core/components/settings/profile/content/pages/api-tokens.tsx new file mode 100644 index 00000000000..134da415a9e --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/api-tokens.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane imports +import { useTranslation } from "@plane/i18n"; +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 { SettingsHeading } from "@/components/settings/heading"; +import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token"; +// constants +import { API_TOKENS_LIST } from "@/constants/fetch-keys"; + +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 ; + } + + return ( +
+ setIsCreateTokenModalOpen(false)} /> + { + setIsCreateTokenModalOpen(true); + }, + }} + /> +
+ {tokens.length > 0 ? ( + <> +
+ {tokens.map((token) => ( + + ))} +
+ + ) : ( + { + setIsCreateTokenModalOpen(true); + }, + }, + ]} + align="start" + rootClassName="py-20" + /> + )} +
+
+ ); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/general/form.tsx b/apps/web/core/components/settings/profile/content/pages/general/form.tsx new file mode 100644 index 00000000000..7c452843de5 --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/general/form.tsx @@ -0,0 +1,418 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { CircleUserRound } from "lucide-react"; +import { Disclosure, Transition } from "@headlessui/react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { ChevronDownIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast"; +import { EFileAssetType } from "@plane/types"; +import type { IUser, TUserProfile } from "@plane/types"; +import { Input } from "@plane/ui"; +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"; +// helpers +import { handleCoverImageChange } from "@/helpers/cover-image.helper"; +// hooks +import { useInstance } from "@/hooks/store/use-instance"; +import { useUser, useUserProfile } from "@/hooks/store/user"; + +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({ + 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 = { + 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 = { + role: formData.role, + }; + + const updateCurrentUserDetail = updateCurrentUser(userPayload).finally(() => setIsLoading(false)); + const updateCurrentUserProfile = updateUserProfile(profilePayload).finally(() => setIsLoading(false)); + + const promises = [updateCurrentUserDetail, updateCurrentUserProfile]; + const updateUserAndProfile = Promise.all(promises); + + setPromiseToast(updateUserAndProfile, { + loading: "Updating...", + success: { + title: "Success!", + message: () => `Profile updated successfully.`, + }, + error: { + title: "Error!", + message: () => `There was some error in updating your profile. Please try again.`, + }, + }); + updateUserAndProfile + .then(() => { + return; + }) + .catch(() => {}); + }; + + return ( + <> + setDeactivateAccountModal(false)} /> + setIsChangeEmailModalOpen(false)} /> + ( + setIsImageUploadModalOpen(false)} + handleRemove={async () => await handleProfilePictureDelete(currentUser?.avatar_url)} + onSuccess={(url) => { + onChange(url); + handleSubmit(onSubmit)(); + setIsImageUploadModalOpen(false); + }} + value={value && value.trim() !== "" ? value : null} + /> + )} + /> +
+
+
+ +
+
+
+ +
+
+
+
+ ( + onChange(imageUrl)} + value={value} + isProfileCover + /> + )} + /> +
+
+
+
+
+ {`${watch("first_name")} ${watch("last_name")}`} +
+ {watch("email")} +
+
+
+
+
+

+ {t("first_name")}  + * +

+ ( + + )} + /> + {errors.first_name && {errors.first_name.message}} +
+
+

{t("last_name")}

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

+ {t("display_name")}  + * +

+ { + if (value.trim().length < 1) return "Display name can't be empty."; + if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; + if (value.replace(/\s/g, "").length < 1) return "Display name must be at least 1 character long."; + if (value.replace(/\s/g, "").length > 20) + return "Display name must be less than 20 characters long."; + return true; + }, + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> + {errors?.display_name && ( + {errors?.display_name?.message} + )} +
+
+

+ {t("auth.common.email.label")}  + * +

+ ( + + )} + /> + {isSMTPConfigured && ( + + )} +
+
+
+
+
+ +
+
+
+
+ + {({ open }) => ( + <> + + {t("deactivate_account")} + + + + +
+ {t("deactivate_account_description")} +
+ +
+
+
+
+ + )} +
+ + ); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/general/index.ts b/apps/web/core/components/settings/profile/content/pages/general/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/general/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx b/apps/web/core/components/settings/profile/content/pages/general/root.tsx similarity index 61% rename from apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx rename to apps/web/core/components/settings/profile/content/pages/general/root.tsx index 7e526867781..47673520e79 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx +++ b/apps/web/core/components/settings/profile/content/pages/general/root.tsx @@ -2,22 +2,22 @@ import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; // components import { PageHead } from "@/components/core/page-title"; -import { ProfileForm } from "@/components/profile/form"; // hooks import { useUser } from "@/hooks/store/user"; +// local imports +import { GeneralProfileSettingsForm } from "./form"; -function ProfileSettingsPage() { +export const GeneralProfileSettings = observer(function GeneralProfileSettings() { const { t } = useTranslation(); // store hooks const { data: currentUser, userProfile } = useUser(); - if (!currentUser) return <>; + if (!currentUser) return null; + return ( <> - + ); -} - -export default observer(ProfileSettingsPage); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/index.ts b/apps/web/core/components/settings/profile/content/pages/index.ts new file mode 100644 index 00000000000..030086c2e07 --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/index.ts @@ -0,0 +1,12 @@ +import { lazy } from "react"; +// plane imports +import type { TProfileSettingsTabs } from "@plane/types"; + +export const PROFILE_SETTINGS_PAGES_MAP: Record> = { + 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 }))), + activity: lazy(() => import("./activity").then((m) => ({ default: m.ActivityProfileSettings }))), + "api-tokens": lazy(() => import("./api-tokens").then((m) => ({ default: m.APITokensProfileSettings }))), +}; diff --git a/apps/web/core/components/settings/profile/content/pages/notifications/email-notification-form.tsx b/apps/web/core/components/settings/profile/content/pages/notifications/email-notification-form.tsx new file mode 100644 index 00000000000..d4e46a6330c --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/notifications/email-notification-form.tsx @@ -0,0 +1,161 @@ +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 { ToggleSwitch } from "@plane/ui"; +// 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({ + 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 ( +
+ ( + { + onChange(newValue); + handleSettingChange("property_change", newValue); + }} + size="sm" + /> + )} + /> + } + /> + ( + { + onChange(newValue); + handleSettingChange("state_change", newValue); + }} + size="sm" + /> + )} + /> + } + /> +
+ ( + { + onChange(newValue); + handleSettingChange("issue_completed", newValue); + }} + size="sm" + /> + )} + /> + } + /> +
+ ( + { + onChange(newValue); + handleSettingChange("comment", newValue); + }} + size="sm" + /> + )} + /> + } + /> + ( + { + onChange(newValue); + handleSettingChange("mention", newValue); + }} + size="sm" + /> + )} + /> + } + /> +
+ ); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/notifications/index.ts b/apps/web/core/components/settings/profile/content/pages/notifications/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/notifications/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx b/apps/web/core/components/settings/profile/content/pages/notifications/root.tsx similarity index 68% rename from apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx rename to apps/web/core/components/settings/profile/content/pages/notifications/root.tsx index c4d05e1f888..4469d14ce93 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx +++ b/apps/web/core/components/settings/profile/content/pages/notifications/root.tsx @@ -1,17 +1,18 @@ import useSWR from "swr"; +import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; // components -import { PageHead } from "@/components/core/page-title"; -import { EmailNotificationForm } from "@/components/profile/notification/email-notification-form"; import { SettingsHeading } from "@/components/settings/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 default function ProfileNotificationPage() { +export const NotificationsProfileSettings = observer(function NotificationsProfileSettings() { const { t } = useTranslation(); // fetching user email notification settings const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => @@ -23,14 +24,14 @@ export default function ProfileNotificationPage() { } return ( - <> - - +
- - +
+ +
+
); -} +}); diff --git a/apps/web/core/components/settings/profile/content/pages/preferences/default-list.tsx b/apps/web/core/components/settings/profile/content/pages/preferences/default-list.tsx new file mode 100644 index 00000000000..0952adca931 --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/preferences/default-list.tsx @@ -0,0 +1,17 @@ +import { observer } from "mobx-react"; +// components +import { ThemeSwitcher } from "ce/components/preferences/theme-switcher"; + +export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() { + return ( +
+ +
+ ); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/preferences/index.ts b/apps/web/core/components/settings/profile/content/pages/preferences/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/preferences/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/settings/profile/content/pages/preferences/language-and-timezone-list.tsx b/apps/web/core/components/settings/profile/content/pages/preferences/language-and-timezone-list.tsx new file mode 100644 index 00000000000..1afcff638d1 --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/preferences/language-and-timezone-list.tsx @@ -0,0 +1,102 @@ +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 ( +
+ } + /> + + {SUPPORTED_LANGUAGES.map((item) => ( + + {item.label} + + ))} + + } + /> + +
+ ); + } +); diff --git a/apps/web/core/components/settings/profile/content/pages/preferences/root.tsx b/apps/web/core/components/settings/profile/content/pages/preferences/root.tsx new file mode 100644 index 00000000000..07529ce9f20 --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/preferences/root.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { SettingsHeading } from "@/components/settings/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 ( +
+ +
+
+ +
+
+
{t("language_and_time")}
+ +
+
+
+ ); +}); diff --git a/apps/web/app/(all)/profile/security/page.tsx b/apps/web/core/components/settings/profile/content/pages/security.tsx similarity index 73% rename from apps/web/app/(all)/profile/security/page.tsx rename to apps/web/core/components/settings/profile/content/pages/security.tsx index aad3001fa62..2da7f2d3d12 100644 --- a/apps/web/app/(all)/profile/security/page.tsx +++ b/apps/web/core/components/settings/profile/content/pages/security.tsx @@ -8,11 +8,9 @@ import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Input, PasswordStrengthIndicator } from "@plane/ui"; -// components import { getPasswordStrength } from "@plane/utils"; -import { PageHead } from "@/components/core/page-title"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; +// components +import { SettingsHeading } from "@/components/settings/heading"; // helpers import { authErrorHandler } from "@/helpers/authentication.helper"; import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; @@ -41,7 +39,7 @@ const defaultShowPassword = { confirmPassword: false, }; -function SecurityPage() { +export const SecurityProfileSettings = observer(function SecurityProfileSettings() { // store const { data: currentUser, changePassword } = useUser(); // states @@ -89,9 +87,12 @@ function SecurityPage() { 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; + let errorInfo = undefined; + if (error instanceof Error) { + const code = "error_code" in error ? error.error_code?.toString() : undefined; + errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined; + } + setToast({ type: TOAST_TYPE.ERROR, title: errorInfo?.title ?? t("auth.common.password.toast.error.title"), @@ -117,52 +118,51 @@ function SecurityPage() { const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; return ( - <> - - - -
-
- {oldPasswordRequired && ( -
-

{t("auth.common.password.current_password.label")}

-
- ( - - )} - /> - {showPassword?.oldPassword ? ( - handleShowPassword("oldPassword")} - /> - ) : ( - handleShowPassword("oldPassword")} +
+ + +
+ {oldPasswordRequired && ( +
+

{t("auth.common.password.current_password.label")}

+
+ ( + )} -
- {errors.old_password && ( - {errors.old_password.message} + /> + {showPassword?.oldPassword ? ( + handleShowPassword("oldPassword")} + /> + ) : ( + handleShowPassword("oldPassword")} + /> )}
- )} -
+ {errors.old_password && ( + {errors.old_password.message} + )} +
+ )} +
+

{t("auth.common.password.new_password.label")}

)}
-
+

{t("auth.common.password.confirm_password.label")}

- -
-
- - - +
+ +
); -} - -export default observer(SecurityPage); +}); diff --git a/apps/web/core/components/settings/profile/content/root.tsx b/apps/web/core/components/settings/profile/content/root.tsx new file mode 100644 index 00000000000..d93350f0940 --- /dev/null +++ b/apps/web/core/components/settings/profile/content/root.tsx @@ -0,0 +1,31 @@ +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 ( + + + + + + ); +}); diff --git a/apps/web/core/components/settings/profile/modal.tsx b/apps/web/core/components/settings/profile/modal.tsx new file mode 100644 index 00000000000..6600ddfb9a2 --- /dev/null +++ b/apps/web/core/components/settings/profile/modal.tsx @@ -0,0 +1,53 @@ +import { useCallback } from "react"; +import { X } from "lucide-react"; +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 ( + +
+
+ toggleProfileSettingsModal({ activeTab: tab })} + /> + +
+
+ +
+
+
+ ); +}); diff --git a/apps/web/core/components/settings/profile/sidebar/header.tsx b/apps/web/core/components/settings/profile/sidebar/header.tsx new file mode 100644 index 00000000000..1bd4f1b1c72 --- /dev/null +++ b/apps/web/core/components/settings/profile/sidebar/header.tsx @@ -0,0 +1,31 @@ +import { observer } from "mobx-react"; +// plane imports +import { Avatar } from "@plane/ui"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { getFileURL } from "@plane/utils"; + +export const ProfileSettingsSidebarHeader = observer(function ProfileSettingsSidebarHeader() { + // store hooks + const { data: currentUser } = useUser(); + + return ( +
+
+ +
+
+

+ {currentUser?.first_name} {currentUser?.last_name} +

+

{currentUser?.email}

+
+
+ ); +}); diff --git a/apps/web/core/components/settings/profile/sidebar/index.ts b/apps/web/core/components/settings/profile/sidebar/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/core/components/settings/profile/sidebar/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/settings/profile/sidebar/item-categories.tsx b/apps/web/core/components/settings/profile/sidebar/item-categories.tsx new file mode 100644 index 00000000000..b204e4110d9 --- /dev/null +++ b/apps/web/core/components/settings/profile/sidebar/item-categories.tsx @@ -0,0 +1,66 @@ +import type React from "react"; +import type { LucideIcon } from "lucide-react"; +import { Activity, Bell, CircleUser, KeyRound, LockIcon, Settings2 } from "lucide-react"; +import { observer } from "mobx-react"; +import { useParams } from "react-router"; +// plane imports +import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } 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> = { + general: CircleUser, + security: LockIcon, + activity: Activity, + preferences: Settings2, + notifications: Bell, + "api-tokens": KeyRound, +}; + +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 ( +
+ {PROFILE_SETTINGS_CATEGORIES.map((category) => { + const categoryItems = GROUPED_PROFILE_SETTINGS[category]; + + if (categoryItems.length === 0) return null; + + return ( +
+
{t(category)}
+
+ {categoryItems.map((item) => ( + updateActiveTab(item.key)} + isActive={activeTab === item.key} + icon={ICONS[item.key]} + label={t(item.i18n_label)} + /> + ))} +
+
+ ); + })} + {profileTabId && } +
+ ); +}); diff --git a/apps/web/core/components/settings/profile/sidebar/root.tsx b/apps/web/core/components/settings/profile/sidebar/root.tsx new file mode 100644 index 00000000000..703218e04f0 --- /dev/null +++ b/apps/web/core/components/settings/profile/sidebar/root.tsx @@ -0,0 +1,29 @@ +// 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 ( + + + + + ); +} diff --git a/apps/web/core/components/settings/profile/sidebar/workspace-options.tsx b/apps/web/core/components/settings/profile/sidebar/workspace-options.tsx new file mode 100644 index 00000000000..a695437ad3b --- /dev/null +++ b/apps/web/core/components/settings/profile/sidebar/workspace-options.tsx @@ -0,0 +1,50 @@ +import { CirclePlus, Mails } from "lucide-react"; +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 ( +
+
{t("workspace")}
+
+ {Object.values(workspaces).map((workspace) => ( + } + label={workspace.name} + isActive={false} + /> + ))} +
+ + +
+
+
+ ); +}); diff --git a/apps/web/core/components/settings/sidebar/item.tsx b/apps/web/core/components/settings/sidebar/item.tsx new file mode 100644 index 00000000000..2fdd8044542 --- /dev/null +++ b/apps/web/core/components/settings/sidebar/item.tsx @@ -0,0 +1,54 @@ +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; + } + | { iconNode: React.ReactElement } + ); + +export function SettingsSidebarItem(props: Props) { + const { as, isActive, label } = props; + // common class + const className = cn( + "flex items-center gap-2 py-1.5 px-2 rounded-lg text-body-sm-medium text-secondary text-left transition-colors", + { + "bg-layer-transparent-selected text-primary": isActive, + "hover:bg-layer-transparent-hover": !isActive, + } + ); + // common content + const content = ( + <> + {"icon" in props ? ( + {} + ) : ( + props.iconNode + )} + {label} + + ); + + if (as === "button") { + return ( + + ); + } + + return ( + + {content} + + ); +} diff --git a/apps/web/core/components/settings/tabs.tsx b/apps/web/core/components/settings/tabs.tsx index eac70d34af5..c8566f438f7 100644 --- a/apps/web/core/components/settings/tabs.tsx +++ b/apps/web/core/components/settings/tabs.tsx @@ -5,11 +5,6 @@ import { cn } from "@plane/utils"; import { useProject } from "@/hooks/store/use-project"; const TABS = { - account: { - key: "account", - label: "Account", - href: `/settings/account/`, - }, workspace: { key: "workspace", label: "Workspace", @@ -29,11 +24,7 @@ const SettingsTabs = observer(function SettingsTabs() { // store hooks const { joinedProjectIds } = useProject(); - const currentTab = pathname.includes(TABS.projects.href) - ? TABS.projects - : pathname.includes(TABS.account.href) - ? TABS.account - : TABS.workspace; + const currentTab = pathname.includes(TABS.projects.href) ? TABS.projects : TABS.workspace; return (
diff --git a/apps/web/core/components/workspace/sidebar/user-menu-root.tsx b/apps/web/core/components/workspace/sidebar/user-menu-root.tsx index 2183492c532..8fbd13a2d5d 100644 --- a/apps/web/core/components/workspace/sidebar/user-menu-root.tsx +++ b/apps/web/core/components/workspace/sidebar/user-menu-root.tsx @@ -1,7 +1,6 @@ import { useState, useEffect } from "react"; import { observer } from "mobx-react"; -import { useParams, useRouter } from "next/navigation"; -// icons +import { useRouter } from "next/navigation"; import { LogOut, Settings, Settings2 } from "lucide-react"; // plane imports import { GOD_MODE_URL } from "@plane/constants"; @@ -9,33 +8,31 @@ import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Avatar, CustomMenu } from "@plane/ui"; import { getFileURL } from "@plane/utils"; -// hooks +// components +import { CoverImage } from "@/components/common/cover-image"; import { AppSidebarItem } from "@/components/sidebar/sidebar-item"; +// hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useUser } from "@/hooks/store/user"; -type Props = { - size?: "xs" | "sm" | "md"; -}; - -export const UserMenuRoot = observer(function UserMenuRoot(props: Props) { - const { size = "sm" } = props; - const { workspaceSlug } = useParams(); +export const UserMenuRoot = observer(function UserMenuRoot() { + // states + const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); // router const router = useRouter(); // store hooks const { toggleAnySidebarDropdown } = useAppTheme(); const { data: currentUser } = useUser(); const { signOut } = useUser(); + const { toggleProfileSettingsModal } = useCommandPalette(); // derived values const isUserInstanceAdmin = false; // translation const { t } = useTranslation(); - // local state - const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); - const handleSignOut = async () => { - await signOut().catch(() => + const handleSignOut = () => { + signOut().catch(() => setToast({ type: TOAST_TYPE.ERROR, title: t("sign_out.toast.error.title"), @@ -48,7 +45,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) { useEffect(() => { if (isUserMenuOpen) toggleAnySidebarDropdown(true); else toggleAnySidebarDropdown(false); - }, [isUserMenuOpen]); + }, [isUserMenuOpen, toggleAnySidebarDropdown]); return ( ), @@ -72,48 +69,75 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) { menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)} onMenuClose={() => setIsUserMenuOpen(false)} placement="bottom-end" - maxHeight="lg" + maxHeight="2xl" + optionsClassName="w-72 p-3 flex flex-col gap-y-3" closeOnSelect > -
- {currentUser?.email} - router.push(`/${workspaceSlug}/settings/account`)}> -
- - {t("settings")} -
-
- router.push(`/${workspaceSlug}/settings/account/preferences`)}> -
- - Preferences +
+ +
+
+
+
+ +
+
+

+ {currentUser?.first_name} {currentUser?.last_name} +

+

{currentUser?.email}

+
- +
-
-
- - +
+ + toggleProfileSettingsModal({ + activeTab: "general", + isOpen: true, + }) + } + className="flex items-center gap-2" + > + + {t("settings")} + + + toggleProfileSettingsModal({ + activeTab: "preferences", + isOpen: true, + }) + } + className="flex items-center gap-2" + > + + {t("preferences")}
+ + + {t("sign_out")} + {isUserInstanceAdmin && ( - <> -
-
- router.push(GOD_MODE_URL)}> -
- {t("enter_god_mode")} -
-
-
- + router.push(GOD_MODE_URL)} + className="bg-accent-primary/20 text-accent-primary hover:bg-accent-primary/30 hover:text-accent-secondary" + > + {t("enter_god_mode")} + )} ); diff --git a/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx b/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx index 256afec7df2..394fdabf38a 100644 --- a/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx +++ b/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx @@ -178,15 +178,12 @@ export const WorkspaceAuthWrapper = observer(function WorkspaceAuthWrapper(props )} {allWorkspaces?.length > 0 && ( - + Visit Profile )} {allWorkspaces && allWorkspaces.length === 0 && ( - + Create new workspace )} diff --git a/apps/web/core/store/base-command-palette.store.ts b/apps/web/core/store/base-command-palette.store.ts index d48769f7e29..e459295303e 100644 --- a/apps/web/core/store/base-command-palette.store.ts +++ b/apps/web/core/store/base-command-palette.store.ts @@ -1,8 +1,11 @@ -import { observable, action, makeObservable } from "mobx"; +import { observable, action, makeObservable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; +// plane imports import type { TCreateModalStoreTypes, TCreatePageModal } from "@plane/constants"; import { DEFAULT_CREATE_PAGE_MODAL_DATA, EPageAccess } from "@plane/constants"; +import type { TProfileSettingsTabs } from "@plane/types"; import { EIssuesStoreType } from "@plane/types"; +// lib import { store } from "@/lib/store-context"; export interface ModalData { @@ -22,6 +25,10 @@ export interface IBaseCommandPaletteStore { isBulkDeleteIssueModalOpen: boolean; createIssueStoreType: TCreateModalStoreTypes; createWorkItemAllowedProjectIds: string[] | undefined; + profileSettingsModal: { + activeTab: TProfileSettingsTabs | null; + isOpen: boolean; + }; allStickiesModal: boolean; projectListOpenMap: Record; getIsProjectListOpen: (projectId: string) => boolean; @@ -36,6 +43,7 @@ export interface IBaseCommandPaletteStore { toggleBulkDeleteIssueModal: (value?: boolean) => void; toggleAllStickiesModal: (value?: boolean) => void; toggleProjectListOpen: (projectId: string, value?: boolean) => void; + toggleProfileSettingsModal: (value: { activeTab?: TProfileSettingsTabs | null; isOpen?: boolean }) => void; } export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStore { @@ -50,6 +58,10 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor createPageModal: TCreatePageModal = DEFAULT_CREATE_PAGE_MODAL_DATA; createIssueStoreType: TCreateModalStoreTypes = EIssuesStoreType.PROJECT; createWorkItemAllowedProjectIds: IBaseCommandPaletteStore["createWorkItemAllowedProjectIds"] = undefined; + profileSettingsModal: IBaseCommandPaletteStore["profileSettingsModal"] = { + activeTab: "general", + isOpen: false, + }; allStickiesModal: boolean = false; projectListOpenMap: Record = {}; @@ -66,6 +78,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor createPageModal: observable, createIssueStoreType: observable, createWorkItemAllowedProjectIds: observable, + profileSettingsModal: observable, allStickiesModal: observable, projectListOpenMap: observable, // toggle actions @@ -79,6 +92,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor toggleBulkDeleteIssueModal: action, toggleAllStickiesModal: action, toggleProjectListOpen: action, + toggleProfileSettingsModal: action, }); } @@ -240,4 +254,20 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor this.allStickiesModal = !this.allStickiesModal; } }; + + /** + * Toggles the profile settings modal + * @param value + * @returns + */ + toggleProfileSettingsModal: IBaseCommandPaletteStore["toggleProfileSettingsModal"] = (payload) => { + const updatedSettings: IBaseCommandPaletteStore["profileSettingsModal"] = { + ...this.profileSettingsModal, + ...payload, + }; + + runInAction(() => { + this.profileSettingsModal = updatedSettings; + }); + }; } diff --git a/packages/constants/src/profile.ts b/packages/constants/src/profile.ts index cb90d62d3b3..5fa54ba3f82 100644 --- a/packages/constants/src/profile.ts +++ b/packages/constants/src/profile.ts @@ -1,56 +1,41 @@ +// plane imports +import type { TProfileSettingsTabs } from "@plane/types"; import { EStartOfTheWeek } from "@plane/types"; -export const PROFILE_SETTINGS = { - profile: { - key: "profile", +export const PROFILE_SETTINGS: Record< + TProfileSettingsTabs, + { + key: TProfileSettingsTabs; + i18n_label: string; + } +> = { + general: { + key: "general", 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 === "/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; -}[] = [ - PROFILE_SETTINGS["profile"], - PROFILE_SETTINGS["security"], - PROFILE_SETTINGS["activity"], - PROFILE_SETTINGS["preferences"], - PROFILE_SETTINGS["notifications"], - PROFILE_SETTINGS["api-tokens"], -]; + +export const PROFILE_SETTINGS_TABS: TProfileSettingsTabs[] = Object.keys(PROFILE_SETTINGS) as TProfileSettingsTabs[]; export const PROFILE_VIEWER_TAB = [ { @@ -98,11 +83,6 @@ export const PREFERENCE_OPTIONS: { title: "theme", description: "select_or_customize_your_interface_color_scheme", }, - { - id: "start_of_week", - title: "First day of the week", - description: "This will change how all calendars in your app look.", - }, ]; /** diff --git a/packages/constants/src/settings.ts b/packages/constants/src/settings.ts index 2c55a6a2dd7..6fdde932097 100644 --- a/packages/constants/src/settings.ts +++ b/packages/constants/src/settings.ts @@ -1,3 +1,6 @@ +// plane imports +import type { TProfileSettingsTabs } from "@plane/types"; +// local imports import { PROFILE_SETTINGS } from "./profile"; import { WORKSPACE_SETTINGS } from "./workspace"; @@ -22,7 +25,7 @@ export const WORKSPACE_SETTINGS_CATEGORIES = [ WORKSPACE_SETTINGS_CATEGORY.DEVELOPER, ]; -export const PROFILE_SETTINGS_CATEGORIES = [ +export const PROFILE_SETTINGS_CATEGORIES: PROFILE_SETTINGS_CATEGORY[] = [ PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE, PROFILE_SETTINGS_CATEGORY.DEVELOPER, ]; @@ -40,9 +43,12 @@ export const GROUPED_WORKSPACE_SETTINGS = { [WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]], }; -export const GROUPED_PROFILE_SETTINGS = { +export const GROUPED_PROFILE_SETTINGS: Record< + PROFILE_SETTINGS_CATEGORY, + { key: TProfileSettingsTabs; i18n_label: string }[] +> = { [PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [ - PROFILE_SETTINGS["profile"], + PROFILE_SETTINGS["general"], PROFILE_SETTINGS["preferences"], PROFILE_SETTINGS["notifications"], PROFILE_SETTINGS["security"], diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9d3103c83b8..7b0df5b6e86 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -36,6 +36,7 @@ export * from "./reaction"; export * from "./intake"; export * from "./rich-filters"; export * from "./search"; +export * from "./settings"; export * from "./state"; export * from "./stickies"; export * from "./timezone"; diff --git a/packages/types/src/settings.ts b/packages/types/src/settings.ts new file mode 100644 index 00000000000..c5b75090aee --- /dev/null +++ b/packages/types/src/settings.ts @@ -0,0 +1 @@ +export type TProfileSettingsTabs = "general" | "preferences" | "activity" | "notifications" | "security" | "api-tokens";