style: improved profile settings

This commit is contained in:
Aaryan Khandelwal
2026-01-09 14:55:46 +05:30
parent 110dbd9acd
commit 50aa9a6b30
70 changed files with 1817 additions and 1671 deletions

View File

@@ -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 <APITokenSettingsLoader />;
}
return (
<div className="w-full">
<PageHead title={pageTitle} />
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
<section className="w-full">
{tokens.length > 0 ? (
<>
<SettingsHeading
title={t("account_settings.api_tokens.heading")}
description={t("account_settings.api_tokens.description")}
button={{
label: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => {
setIsCreateTokenModalOpen(true);
},
}}
/>
<div>
{tokens.map((token) => (
<ApiTokenListItem key={token.id} token={token} />
))}
</div>
</>
) : (
<div className="flex h-full w-full flex-col py-">
<SettingsHeading
title={t("account_settings.api_tokens.heading")}
description={t("account_settings.api_tokens.description")}
button={{
label: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => {
setIsCreateTokenModalOpen(true);
},
}}
/>
<EmptyStateCompact
assetKey="token"
assetClassName="size-20"
title={t("settings_empty_state.tokens.title")}
description={t("settings_empty_state.tokens.description")}
actions={[
{
label: t("settings_empty_state.tokens.cta_primary"),
onClick: () => {
setIsCreateTokenModalOpen(true);
},
},
]}
align="start"
rootClassName="py-20"
/>
</div>
)}
</section>
</div>
);
}
export default observer(ApiTokensPage);

View File

@@ -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 (
<>
<SettingsMobileNav hamburgerContent={ProfileSidebar} activePath={getProfileActivePath(pathname) || ""} />
<div className="relative flex h-full w-full">
<div className="hidden md:block">
<ProfileSidebar />
</div>
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
<SettingsContentWrapper>
<Outlet />
</SettingsContentWrapper>
</div>
</div>
</>
);
}
export default observer(ProfileSettingsLayout);

View File

@@ -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 (
<>
<PageHead title={`${t("profile.label")} - ${t("preferences")}`} />
<div className="flex flex-col gap-4 w-full">
<div>
<SettingsHeading
title={t("account_settings.preferences.heading")}
description={t("account_settings.preferences.description")}
/>
<PreferencesList />
</div>
<div>
<ProfileSettingContentHeader title={t("language_and_time")} />
<LanguageTimezone />
</div>
</div>
</>
);
});
export default ProfileAppearancePage;

View File

@@ -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<FormValues>({ defaultValues });
// derived values
const oldPassword = watch("old_password");
const password = watch("new_password");
const confirmPassword = watch("confirm_password");
const oldPasswordRequired = !currentUser?.is_password_autoset;
// i18n
const { t } = useTranslation();
const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword;
const handleShowPassword = (key: keyof typeof showPassword) =>
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
const handleChangePassword = async (formData: FormValues) => {
const { old_password, new_password } = formData;
try {
const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token);
if (!csrfToken) throw new Error("csrf token not found");
await changePassword(csrfToken, {
...(oldPasswordRequired && { old_password }),
new_password,
});
reset(defaultValues);
setShowPassword(defaultShowPassword);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("auth.common.password.toast.change_password.success.title"),
message: t("auth.common.password.toast.change_password.success.message"),
});
} catch (error: unknown) {
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 && (
<PasswordStrengthIndicator password={password} isFocused={isPasswordInputFocused} />
);
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
return (
<>
<PageHead title="Profile - Security" />
<ProfileSettingContentHeader title={t("auth.common.password.change_password.label.default")} />
<form onSubmit={handleSubmit(handleChangePassword)} className="flex flex-col gap-8 w-full mt-8">
<div className="flex flex-col gap-10 w-full">
{oldPasswordRequired && (
<div className="space-y-1">
<h4 className="text-13">{t("auth.common.password.current_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="old_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="old_password"
type={showPassword?.oldPassword ? "text" : "password"}
value={value}
onChange={onChange}
placeholder={t("old_password")}
className="w-full"
hasError={Boolean(errors.old_password)}
/>
)}
/>
{showPassword?.oldPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
/>
)}
</div>
{errors.old_password && (
<span className="text-11 text-danger-primary">{errors.old_password.message}</span>
)}
</div>
)}
<div className="space-y-1">
<h4 className="text-13">{t("auth.common.password.new_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="new_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="new_password"
type={showPassword?.password ? "text" : "password"}
value={value}
placeholder={t("auth.common.password.new_password.placeholder")}
onChange={onChange}
className="w-full"
hasError={Boolean(errors.new_password)}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
/>
)}
/>
{showPassword?.password ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
)}
</div>
{passwordSupport}
{isNewPasswordSameAsOldPassword && !isPasswordInputFocused && (
<span className="text-11 text-danger-primary">
{t("new_password_must_be_different_from_old_password")}
</span>
)}
</div>
<div className="space-y-1">
<h4 className="text-13">{t("auth.common.password.confirm_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="confirm_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="confirm_password"
type={showPassword?.confirmPassword ? "text" : "password"}
placeholder={t("auth.common.password.confirm_password.placeholder")}
value={value}
onChange={onChange}
className="w-full"
hasError={Boolean(errors.confirm_password)}
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
/>
)}
/>
{showPassword?.confirmPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("confirmPassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("confirmPassword")}
/>
)}
</div>
{!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && (
<span className="text-13 text-danger-primary">{t("auth.common.password.errors.match")}</span>
)}
</div>
</div>
<div className="flex items-center justify-between py-2">
<Button variant="primary" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
{isSubmitting
? `${t("auth.common.password.change_password.label.submitting")}`
: t("auth.common.password.change_password.label.default")}
</Button>
</div>
</form>
</>
);
}
export default observer(SecurityPage);

View File

@@ -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 <Icon size={size} className={className} strokeWidth={2} />;
}
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 (
<SettingsSidebar
isMobile={isMobile}
categories={PROFILE_SETTINGS_CATEGORIES}
groupedSettings={GROUPED_PROFILE_SETTINGS}
workspaceSlug={workspaceSlug?.toString() ?? ""}
isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`}
customHeader={
<div className="flex items-center gap-2">
<div className="flex-shrink-0">
{!currentUser?.avatar_url || currentUser?.avatar_url === "" ? (
<div className="h-8 w-8 rounded-full">
<CircleUserRound className="h-full w-full text-secondary" />
</div>
) : (
<div className="relative h-8 w-8 overflow-hidden">
<img
src={getFileURL(currentUser?.avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
alt={currentUser?.display_name}
/>
</div>
)}
</div>
<div className="w-full overflow-hidden">
<div className="text-14 font-medium text-secondary truncate">{currentUser?.display_name}</div>
<div className="text-13 text-tertiary truncate">{currentUser?.email}</div>
</div>
</div>
}
actionIcons={ProjectActionIcons}
shouldRender
/>
);
});

View File

@@ -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 (
<AuthenticationWrapper>
<WorkspaceAuthWrapper>
<AppRailVisibilityProvider>
<WorkspaceContentWrapper>
<GlobalModals workspaceSlug={workspaceSlug} />
<Outlet />
</WorkspaceContentWrapper>
</AppRailVisibilityProvider>

View File

@@ -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(
<ProfileActivityListPage
key={i}
cursor={`${PER_PAGE}:${i}:0`}
perPage={PER_PAGE}
updateResultsCount={updateResultsCount}
updateTotalPages={updateTotalPages}
updateEmptyState={updateEmptyState}
/>
);
const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0;
if (isEmpty) {
return (
<DetailedEmptyState
title={t("profile.empty_state.activity.title")}
description={t("profile.empty_state.activity.description")}
assetPath={resolvedPath}
/>
);
}
return (
<>
<PageHead title="Profile - Activity" />
<ProfileSettingContentWrapper>
<ProfileSettingContentHeader title={t("activity")} />
{activityPages}
{isLoadMoreVisible && (
<div className="flex w-full items-center justify-center text-11">
<Button variant="secondary" onClick={handleLoadMore}>
{t("load_more")}
</Button>
</div>
)}
</ProfileSettingContentWrapper>
</>
);
}
export default observer(ProfileActivityPage);

View File

@@ -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 (
<>
<PageHead title="Profile - Appearance" />
{userProfile ? (
<ProfileSettingContentWrapper>
<ProfileSettingContentHeader title={t("appearance")} />
<div className="grid grid-cols-12 gap-4 py-6 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-16 font-semibold text-primary">{t("theme")}</h4>
<p className="text-13 text-secondary">{t("select_or_customize_your_interface_color_scheme")}</p>
</div>
<div className="col-span-12 sm:col-span-6">
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
</div>
</div>
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector />}
</ProfileSettingContentWrapper>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
)}
</>
);
}
export default observer(ProfileAppearancePage);

View File

@@ -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 <EmailSettingsLoader />;
}
return (
<>
<PageHead title={`${t("profile.label")} - ${t("notifications")}`} />
<ProfileSettingContentWrapper>
<ProfileSettingContentHeader
title={t("email_notifications")}
description={t("stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified")}
/>
<EmailNotificationForm data={data} />
</ProfileSettingContentWrapper>
</>
);
}

View File

@@ -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 (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
);
return (
<>
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
<ProfileSettingContentWrapper>
<ProfileForm user={currentUser} profile={userProfile.data} />
</ProfileSettingContentWrapper>
</>
);
}
export default observer(ProfileSettingsPage);

View File

@@ -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 <Icon size={size} className={className} />;
}
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<HTMLDivElement>(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 (
<div
className={`fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative
${sidebarCollapsed ? "-ml-[250px]" : ""}
sm:${sidebarCollapsed ? "-ml-[250px]" : ""}
md:ml-0 ${sidebarCollapsed ? "w-[70px]" : "w-[250px]"}
`}
>
<div ref={ref} className="flex h-full w-full flex-col gap-y-4">
<Link href={`/${redirectWorkspaceSlug}`} onClick={handleItemClick}>
<div
className={`flex flex-shrink-0 items-center gap-2 truncate px-4 pt-4 ${
sidebarCollapsed ? "justify-center" : ""
}`}
>
<span className="grid h-5 w-5 flex-shrink-0 place-items-center">
<ChevronLeftIcon className="h-5 w-5" strokeWidth={1} />
</span>
{!sidebarCollapsed && (
<h4 className="truncate text-16 font-semibold text-secondary">{t("profile_settings")}</h4>
)}
</div>
</Link>
<div className="flex flex-shrink-0 flex-col overflow-x-hidden">
{!sidebarCollapsed && (
<h6 className="rounded-sm px-6 text-13 font-semibold text-placeholder">{t("your_account")}</h6>
)}
<div className="vertical-scrollbar scrollbar-sm mt-2 px-4 h-full space-y-1 overflow-y-auto">
{PROFILE_ACTION_LINKS.map((link) => {
if (link.key === "change-password" && currentUser?.is_password_autoset) return null;
return (
<Link key={link.key} href={link.href} className="block w-full" onClick={handleItemClick}>
<Tooltip
tooltipContent={t(link.key)}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<SidebarNavItem
key={link.key}
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={link.highlight(pathname)}
>
<div className="flex items-center gap-1.5 py-[1px]">
<ProjectActionIcons type={link.key} size={16} />
{!sidebarCollapsed && <p className="text-13 leading-5 font-medium">{t(link.i18n_label)}</p>}
</div>
</SidebarNavItem>
</Tooltip>
</Link>
);
})}
</div>
</div>
<div className="flex flex-col overflow-x-hidden">
{!sidebarCollapsed && (
<h6 className="rounded-sm px-6 text-13 font-semibold text-placeholder">{t("workspaces")}</h6>
)}
{workspacesList && workspacesList.length > 0 && (
<div
className={cn("vertical-scrollbar scrollbar-xs mt-2 px-4 h-full space-y-1.5 overflow-y-auto", {
"scrollbar-sm": !sidebarCollapsed,
"ml-2.5 px-1": sidebarCollapsed,
})}
>
{workspacesList.map((workspace) => (
<Link
key={workspace.id}
href={`/${workspace.slug}`}
className={`flex flex-grow cursor-pointer select-none items-center truncate text-left text-13 font-medium ${
sidebarCollapsed ? "justify-center" : `justify-between`
}`}
onClick={handleItemClick}
>
<span
className={`flex w-full flex-grow items-center gap-x-2 truncate rounded-md px-3 py-1 hover:bg-layer-1 ${
sidebarCollapsed ? "justify-center" : ""
}`}
>
<span
className={`relative flex h-6 w-6 flex-shrink-0 items-center justify-center p-2 text-11 uppercase ${
!workspace?.logo_url && "rounded-sm bg-accent-primary text-on-color"
}`}
>
{workspace?.logo_url && workspace.logo_url !== "" ? (
<img
src={getFileURL(workspace.logo_url)}
className="absolute left-0 top-0 h-full w-full rounded-sm object-cover"
alt="Workspace Logo"
/>
) : (
(workspace?.name?.charAt(0) ?? "...")
)}
</span>
{!sidebarCollapsed && <p className="truncate text-13 text-secondary">{workspace.name}</p>}
</span>
</Link>
))}
</div>
)}
<div className="mt-1.5 px-4">
{WORKSPACE_ACTION_LINKS.map((link) => (
<Link className="block w-full" key={link.key} href={link.href} onClick={handleItemClick}>
<Tooltip
tooltipContent={t(link.key)}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<div
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-13 font-medium text-secondary outline-none hover:bg-layer-1 focus:bg-layer-1 ${
sidebarCollapsed ? "justify-center" : ""
}`}
>
{<link.Icon className="flex-shrink-0 size-4" />}
{!sidebarCollapsed && t(link.i18n_label)}
</div>
</Tooltip>
</Link>
))}
</div>
</div>
<div className="flex flex-shrink-0 flex-grow items-end px-6 py-2">
<div
className={`flex w-full ${
sidebarCollapsed ? "flex-col justify-center gap-2" : "items-center justify-between gap-2"
}`}
>
<button
type="button"
onClick={handleSignOut}
className="flex items-center justify-center gap-2 text-13 font-medium text-danger-primary"
disabled={isSigningOut}
>
<LogOut className="h-3.5 w-3.5" />
{!sidebarCollapsed && <span>{isSigningOut ? `${t("signing_out")}...` : t("sign_out")}</span>}
</button>
<button
type="button"
className="grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-surface-2 hover:text-primary md:hidden"
onClick={() => toggleSidebar()}
>
<MoveLeft className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={`ml-auto hidden place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-surface-2 hover:text-primary md:grid ${
sidebarCollapsed ? "w-full" : ""
}`}
onClick={() => toggleSidebar()}
>
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${sidebarCollapsed ? "rotate-180" : ""}`} />
</button>
</div>
</div>
</div>
</div>
);
});

View File

@@ -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 (
<div className="size-full grid place-items-center px-4">
<LogoSpinner />
</div>
);
return (
<>
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
<div className="relative size-full">
<div className="size-full flex">
<ProfileSettingsSidebarRoot
activeTab={profileTabId as TProfileSettingsTabs}
className="w-[250px]"
updateActiveTab={(tab) => router.push(`/settings/profile/${tab}`)}
/>
<ProfileSettingsContent
activeTab={profileTabId as TProfileSettingsTabs}
className="grow py-20 px-page-x mx-auto w-fit max-w-225"
/>
</div>
</div>
</>
);
}
export default observer(ProfileSettingsPage);

View File

@@ -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 (
<>
<ProjectsAppPowerKProvider />
<AuthenticationWrapper>
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-subtle">
<ProfileLayoutSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
<div className="h-full w-full overflow-hidden">
<div className="relative flex size-full overflow-hidden bg-canvas p-2">
<main className="relative flex flex-col size-full overflow-hidden bg-surface-1 rounded-lg border border-subtle">
<div className="size-full overflow-hidden">
<Outlet />
</div>
</main>

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 (
<Suspense fallback={null}>
<ProfileSettingsModal />
</Suspense>
);
});

View File

@@ -74,7 +74,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
<HelpMenuRoot />
<StarUsOnGitHubLink />
<div className="flex items-center justify-center size-8 hover:bg-layer-1-hover rounded-md">
<UserMenuRoot size="xs" />
<UserMenuRoot />
</div>
</div>
</div>

View File

@@ -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,
};

View File

@@ -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 (
<>
<PreferencesSection
<SettingsControlItem
title={t(props.option.title)}
description={t(props.option.description)}
control={
<div>
<ThemeSwitch
value={currentTheme}
onChange={(themeOption) => {
void handleThemeChange(themeOption);
}}
/>
</div>
<ThemeSwitch
value={currentTheme}
onChange={(themeOption) => {
void handleThemeChange(themeOption);
}}
/>
}
/>
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}

View File

@@ -0,0 +1 @@
export * from "./theme-switcher";

View File

@@ -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 (
<>
<SettingsControlItem
title={t(props.option.title)}
description={t(props.option.description)}
control={<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />}
/>
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
</>
);
});

View File

@@ -50,6 +50,7 @@ export function ThemeSwitch(props: Props) {
)
}
onChange={onChange}
buttonClassName="border border-subtle-1"
placement="bottom-end"
input
>

View File

@@ -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"
/>
</div>
);

View File

@@ -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,
},
},

View File

@@ -1,13 +0,0 @@
import { PREFERENCE_OPTIONS } from "@plane/constants";
import { PREFERENCE_COMPONENTS } from "@/plane-web/components/preferences/config";
export function PreferencesList() {
return (
<div className="py-6 space-y-6">
{PREFERENCE_OPTIONS.map((option) => {
const Component = PREFERENCE_COMPONENTS[option.id as keyof typeof PREFERENCE_COMPONENTS];
return <Component key={option.id} option={option} />;
})}
</div>
);
}

View File

@@ -1,17 +0,0 @@
interface SettingsSectionProps {
title: string;
description: string;
control: React.ReactNode;
}
export function PreferencesSection({ title, description, control }: SettingsSectionProps) {
return (
<div className="flex w-full justify-between gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-14 font-medium text-primary">{title}</h4>
<p className="text-13 text-secondary">{description}</p>
</div>
<div className="col-span-12 sm:col-span-6 my-auto">{control}</div>
</div>
);
}

View File

@@ -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<IUserEmailNotificationSettings>({
defaultValues: {
...data,
},
});
const handleSettingChange = async (key: keyof IUserEmailNotificationSettings, value: boolean) => {
try {
await userService.updateCurrentUserEmailNotificationSettings({
[key]: value,
});
setToast({
title: t("success"),
type: TOAST_TYPE.SUCCESS,
message: t("email_notification_setting_updated_successfully"),
});
} catch (_error) {
setToast({
title: t("error"),
type: TOAST_TYPE.ERROR,
message: t("failed_to_update_email_notification_setting"),
});
}
};
useEffect(() => {
reset(data);
}, [reset, data]);
return (
<>
{/* Notification Settings */}
<div className="flex flex-col py-2 w-full">
<div className="flex gap-2 items-center pt-2">
<div className="grow">
<div className="pb-1 text-14 font-medium text-primary">{t("property_changes")}</div>
<div className="text-13 font-regular text-tertiary">{t("property_changes_description")}</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="property_change"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("property_change", newValue);
}}
size="sm"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center pt-6 pb-2">
<div className="grow">
<div className="pb-1 text-14 font-medium text-primary">{t("state_change")}</div>
<div className="text-13 font-regular text-tertiary">{t("state_change_description")}</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="state_change"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("state_change", newValue);
}}
size="sm"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center border-0 border-l-[3px] border-strong pl-3">
<div className="grow">
<div className="pb-1 text-14 font-medium text-primary">{t("issue_completed")}</div>
<div className="text-13 font-regular text-tertiary">{t("issue_completed_description")}</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="issue_completed"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("issue_completed", newValue);
}}
size="sm"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-14 font-medium text-primary">{t("comments")}</div>
<div className="text-13 font-regular text-tertiary">{t("comments_description")}</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="comment"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("comment", newValue);
}}
size="sm"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-14 font-medium text-primary">{t("mentions")}</div>
<div className="text-13 font-regular text-tertiary">{t("mentions_description")}</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="mention"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("mention", newValue);
}}
size="sm"
/>
)}
/>
</div>
</div>
</div>
</>
);
}

View File

@@ -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 (
<div className="py-6">
<div className="flex flex-col gap-x-6 gap-y-6">
<div className="flex flex-col gap-1">
<div className="flex gap-4 sm:gap-16 w-full justify-between">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-14 font-medium text-primary"> {t("timezone")}&nbsp;</h4>
<p className="text-13 text-secondary">{t("timezone_setting")}</p>
</div>
<div className="col-span-12 sm:col-span-6 my-auto">
<TimezoneSelect value={user?.user_timezone || "Asia/Kolkata"} onChange={handleTimezoneChange} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-4 sm:gap-16 w-full justify-between">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-14 font-medium text-primary"> {t("language")}&nbsp;</h4>
<p className="text-13 text-secondary">{t("language_setting")}</p>
</div>
<div className="col-span-12 sm:col-span-6 my-auto">
<CustomSelect
value={profile?.language}
label={profile?.language ? getLanguageLabel(profile?.language) : "Select a language"}
onChange={handleLanguageChange}
buttonClassName={"border-none"}
className="rounded-md border !border-subtle"
input
>
{SUPPORTED_LANGUAGES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
</div>
</div>
</div>
</div>
);
});

View File

@@ -1,14 +0,0 @@
type Props = {
title: string;
description?: string;
};
export function ProfileSettingContentHeader(props: Props) {
const { title, description } = props;
return (
<div className="flex flex-col gap-1 pb-4 border-b border-subtle w-full">
<div className="text-18 font-medium text-primary">{title}</div>
{description && <div className="text-13 font-regular text-tertiary">{description}</div>}
</div>
);
}

View File

@@ -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<HTMLDivElement>(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 (
<div
className={cn(
`vertical-scrollbar scrollbar-md fixed z-5 h-full w-full flex-shrink-0 overflow-hidden overflow-y-auto border-l border-subtle bg-surface-1 transition-all md:relative md:w-[300px] shadow-raised-200`,
`vertical-scrollbar scrollbar-md fixed z-5 h-full w-full shrink-0 overflow-hidden overflow-y-auto border-l border-subtle bg-surface-1 transition-all md:relative md:w-[300px] shadow-raised-200`,
className
)}
style={profileSidebarCollapsed ? { marginLeft: `${window?.innerWidth || 0}px` } : {}}
@@ -93,12 +90,17 @@ export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSi
<>
<div className="relative h-[110px]">
{currentUser?.id === userId && (
<div className="absolute right-3.5 top-3.5 grid h-5 w-5 place-items-center rounded-sm bg-white">
<Link href={`/${workspaceSlug}/settings/account`}>
<span className="grid place-items-center text-black">
<EditIcon className="h-3 w-3" />
</span>
</Link>
<div className="absolute right-3.5 top-3.5">
<IconButton
variant="secondary"
icon={EditIcon}
onClick={() =>
toggleProfileSettingsModal({
activeTab: "general",
isOpen: true,
})
}
/>
</div>
)}
<CoverImage

View File

@@ -4,9 +4,10 @@ import { START_OF_THE_WEEK_OPTIONS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { EStartOfTheWeek } from "@plane/types";
import { CustomSelect } from "@plane/ui";
// components
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUserProfile } from "@/hooks/store/user";
import { PreferencesSection } from "../preferences/section";
const getStartOfWeekLabel = (startOfWeek: EStartOfTheWeek) =>
START_OF_THE_WEEK_OPTIONS.find((option) => option.value === startOfWeek)?.label;
@@ -27,27 +28,27 @@ export const StartOfWeekPreference = observer(function StartOfWeekPreference(pro
};
return (
<PreferencesSection
<SettingsControlItem
title={props.option.title}
description={props.option.description}
control={
<div className="">
<CustomSelect
value={userProfile.start_of_the_week}
label={getStartOfWeekLabel(userProfile.start_of_the_week)}
onChange={handleStartOfWeekChange}
input
maxHeight="lg"
>
<>
{START_OF_THE_WEEK_OPTIONS.map((day) => (
<CustomSelect.Option key={day.value} value={day.value}>
{day.label}
</CustomSelect.Option>
))}
</>
</CustomSelect>
</div>
<CustomSelect
value={userProfile.start_of_the_week}
label={getStartOfWeekLabel(userProfile.start_of_the_week)}
onChange={handleStartOfWeekChange}
buttonClassName="border border-subtle-1"
input
maxHeight="lg"
placement="bottom-end"
>
<>
{START_OF_THE_WEEK_OPTIONS.map((day) => (
<CustomSelect.Option key={day.value} value={day.value}>
{day.label}
</CustomSelect.Option>
))}
</>
</CustomSelect>
}
/>
);

View File

@@ -0,0 +1,19 @@
type Props = {
control: React.ReactNode;
description: string;
title: string;
};
export function SettingsControlItem(props: Props) {
const { control, description, title } = props;
return (
<div className="w-full py-3 flex flex-col md:flex-row items-start md:items-center md:justify-between gap-4 md:gap-8">
<div className="flex flex-col gap-1">
<h4 className="text-body-sm-medium text-primary">{title}</h4>
<p className="text-caption-md-regular text-secondary">{description}</p>
</div>
<div className="shrink-0">{control}</div>
</div>
);
}

View File

@@ -24,15 +24,10 @@ export function SettingsHeading({
className,
}: Props) {
return (
<div
className={cn(
"flex flex-col md:flex-row gap-2 items-start md:items-center justify-between border-b border-subtle pb-3.5",
className
)}
>
<div className={cn("flex flex-col md:flex-row gap-2 items-start md:items-center justify-between", className)}>
<div className="flex flex-col items-start gap-1">
{typeof title === "string" ? <h3 className="text-18 font-medium">{title}</h3> : title}
{description && <div className="text-13 text-tertiary">{description}</div>}
{typeof title === "string" ? <h6 className="text-h6-medium text-primary">{title}</h6> : title}
{description && <p className="text-body-xs-regular text-tertiary">{description}</p>}
</div>
{showButton && customButton}
{button && showButton && (

View File

@@ -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<string, Array<{ href: string; i18n_label: string; [key: string]: any }>>) =>
@@ -14,8 +14,6 @@ const hrefToLabelMap = (options: Record<string, Array<{ href: string; i18n_label
const workspaceHrefToLabelMap = hrefToLabelMap(GROUPED_WORKSPACE_SETTINGS);
const profiletHrefToLabelMap = hrefToLabelMap(GROUPED_PROFILE_SETTINGS);
const projectHrefToLabelMap = PROJECT_SETTINGS_LINKS.reduce(
(acc, setting) => {
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");

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -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 ? (
<ul>
{userProfileActivity.results.map((activityItem: any) => {
if (activityItem.field === "comment")
return (
<div key={activityItem.id} className="mt-2">
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{activityItem.field ? (
activityItem.new_value === "restore" && <History className="h-3.5 w-3.5 text-secondary" />
) : activityItem.actor_detail.avatar_url && activityItem.actor_detail.avatar_url !== "" ? (
<img
src={getFileURL(activityItem.actor_detail.avatar_url)}
alt={activityItem.actor_detail.display_name}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-subtle-1 bg-layer-3"
/>
) : (
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-subtle-1 bg-layer-3 capitalize">
{activityItem.actor_detail.display_name?.[0]}
</div>
)}
<span className="flex h-6 w-6 p-2 items-center justify-center rounded-full bg-layer-3 text-secondary">
<MessageSquare className="!text-20 text-secondary" aria-hidden="true" />
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-11">
{activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name + " Bot"
: activityItem.actor_detail.display_name}
</div>
<p className="mt-0.5 text-11 text-secondary">
Commented {calculateTimeAgo(activityItem.created_at)}
</p>
</div>
<div className="issue-comments-section p-0">
<RichTextEditor
editable={false}
id={activityItem.id}
initialValue={
activityItem?.new_value !== "" ? activityItem.new_value : activityItem.old_value
}
containerClassName="text-11 bg-surface-1"
workspaceId={activityItem?.workspace_detail?.id?.toString() ?? ""}
workspaceSlug={activityItem?.workspace_detail?.slug?.toString() ?? ""}
projectId={activityItem.project ?? ""}
/>
</div>
</div>
</div>
</div>
);
const message = <ActivityMessage activity={activityItem} showIssue />;
if ("field" in activityItem && activityItem.field !== "updated_by")
return (
<li key={activityItem.id}>
<div className="relative pb-1">
<div className="relative flex items-start space-x-2">
<>
<div>
<div className="relative px-1.5 mt-4">
<div className="mt-1.5">
<div className="flex h-6 w-6 items-center justify-center border border-subtle rounded-lg shadow-raised-100">
{activityItem.field ? (
activityItem.new_value === "restore" ? (
<History className="h-5 w-5 text-secondary" />
) : (
<ActivityIcon activity={activityItem} />
)
) : activityItem.actor_detail.avatar_url &&
activityItem.actor_detail.avatar_url !== "" ? (
<img
src={getFileURL(activityItem.actor_detail.avatar_url)}
alt={activityItem.actor_detail.display_name}
height={24}
width={24}
className="h-full w-full rounded-full object-cover"
/>
) : (
<div className="grid h-6 w-6 place-items-center rounded-full border-2 border-subtle-1 bg-layer-3 text-11 capitalize">
{activityItem.actor_detail.display_name?.[0]}
</div>
)}
</div>
</div>
</div>
</div>
<div className="min-w-0 flex-1 border-b border-subtle py-4">
<div className="break-words text-caption-md-regular text-secondary">
{activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? (
<span className="text-gray font-medium">Plane</span>
) : activityItem.actor_detail.is_bot ? (
<span className="text-gray font-medium">{activityItem.actor_detail.first_name} Bot</span>
) : (
<Link
href={`/${activityItem.workspace_detail.slug}/profile/${activityItem.actor_detail.id}`}
className="inline"
>
<span className="text-gray font-medium">
{currentUser?.id === activityItem.actor_detail.id
? "You"
: activityItem.actor_detail.display_name}
</span>
</Link>
)}{" "}
<div className="inline gap-1">
{message}{" "}
<span className="flex-shrink-0 whitespace-nowrap">
{calculateTimeAgo(activityItem.created_at)}
</span>
</div>
</div>
</div>
</>
</div>
</div>
</li>
);
})}
</ul>
) : (
<ActivitySettingsLoader />
)}
</>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -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(
<ProfileActivityListPage
<ActivityProfileSettingsList
key={i}
cursor={`${PER_PAGE}:${i}:0`}
perPage={PER_PAGE}
@@ -55,7 +54,7 @@ function ProfileActivityPage() {
if (isEmpty) {
return (
<div className="flex h-full w-full flex-col">
<div className="size-full flex flex-col gap-y-7">
<SettingsHeading
title={t("account_settings.activity.heading")}
description={t("account_settings.activity.description")}
@@ -72,13 +71,12 @@ function ProfileActivityPage() {
}
return (
<>
<PageHead title="Profile - Activity" />
<div className="size-full">
<SettingsHeading
title={t("account_settings.activity.heading")}
description={t("account_settings.activity.description")}
/>
<div className="w-full">{activityPages}</div>
<div className="mt-7 w-full">{activityPages}</div>
{isLoadMoreVisible && (
<div className="flex w-full items-center justify-center mt-4">
<Button variant="ghost" onClick={handleLoadMore} appendIcon={<ChevronDown />}>
@@ -86,8 +84,6 @@ function ProfileActivityPage() {
</Button>
</div>
)}
</>
</div>
);
}
export default observer(ProfileActivityPage);
});

View File

@@ -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 <APITokenSettingsLoader />;
}
return (
<div className="size-full">
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
<SettingsHeading
title={t("account_settings.api_tokens.heading")}
description={t("account_settings.api_tokens.description")}
button={{
label: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => {
setIsCreateTokenModalOpen(true);
},
}}
/>
<div className="mt-7">
{tokens.length > 0 ? (
<>
<div>
{tokens.map((token) => (
<ApiTokenListItem key={token.id} token={token} />
))}
</div>
</>
) : (
<EmptyStateCompact
assetKey="token"
assetClassName="size-20"
title={t("settings_empty_state.tokens.title")}
description={t("settings_empty_state.tokens.description")}
actions={[
{
label: t("settings_empty_state.tokens.cta_primary"),
onClick: () => {
setIsCreateTokenModalOpen(true);
},
},
]}
align="start"
rootClassName="py-20"
/>
)}
</div>
</div>
);
});

View File

@@ -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<TUserProfileForm>({
defaultValues: {
avatar_url: user.avatar_url || "",
cover_image_asset: null,
cover_image_url: user.cover_image_url || "",
first_name: user.first_name || "",
last_name: user.last_name || "",
display_name: user.display_name || "",
email: user.email || "",
role: profile.role || "Product / Project Manager",
language: profile.language || "en",
user_timezone: user.user_timezone || "Asia/Kolkata",
},
});
// derived values
const userAvatar = watch("avatar_url");
const userCover = watch("cover_image_url");
// store hooks
const { data: currentUser, updateCurrentUser } = useUser();
const { updateUserProfile } = useUserProfile();
const { config } = useInstance();
const isSMTPConfigured = config?.is_smtp_configured || false;
const handleProfilePictureDelete = async (url: string | null | undefined) => {
if (!url) return;
await updateCurrentUser({
avatar_url: "",
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Profile picture deleted successfully.",
});
setValue("avatar_url", "");
return;
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "There was some error in deleting your profile picture. Please try again.",
});
})
.finally(() => {
setIsImageUploadModalOpen(false);
});
};
const onSubmit = async (formData: TUserProfileForm) => {
setIsLoading(true);
const userPayload: Partial<IUser> = {
first_name: formData.first_name,
last_name: formData.last_name,
avatar_url: formData.avatar_url,
display_name: formData?.display_name,
};
try {
const coverImagePayload = await handleCoverImageChange(user.cover_image_url, formData.cover_image_url, {
entityIdentifier: "",
entityType: EFileAssetType.USER_COVER,
isUserAsset: true,
});
if (coverImagePayload) {
Object.assign(userPayload, coverImagePayload);
}
} catch (error) {
console.error("Error handling cover image:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: error instanceof Error ? error.message : "Failed to process cover image",
});
setIsLoading(false);
return;
}
const profilePayload: Partial<TUserProfile> = {
role: formData.role,
};
const updateCurrentUserDetail = updateCurrentUser(userPayload).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 (
<>
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
<ChangeEmailModal isOpen={isChangeEmailModalOpen} onClose={() => setIsChangeEmailModalOpen(false)} />
<Controller
control={control}
name="avatar_url"
render={({ field: { onChange, value } }) => (
<UserImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
handleRemove={async () => await handleProfilePictureDelete(currentUser?.avatar_url)}
onSuccess={(url) => {
onChange(url);
handleSubmit(onSubmit)();
setIsImageUploadModalOpen(false);
}}
value={value && value.trim() !== "" ? value : null}
/>
)}
/>
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
<div className="flex w-full flex-col gap-6">
<div className="relative h-44 w-full">
<CoverImage
src={userCover}
className="h-44 w-full rounded-lg"
alt={currentUser?.first_name ?? "Cover image"}
/>
<div className="absolute -bottom-6 left-6 flex items-end justify-between">
<div className="flex gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-surface-2">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{!userAvatar || userAvatar === "" ? (
<div className="h-16 w-16 rounded-md bg-layer-1 p-2">
<CircleUserRound className="h-full w-full text-secondary" />
</div>
) : (
<div className="relative h-16 w-16 overflow-hidden">
<img
src={getFileURL(userAvatar)}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
onClick={() => setIsImageUploadModalOpen(true)}
alt={currentUser?.display_name}
role="button"
/>
</div>
)}
</button>
</div>
</div>
</div>
<div className="absolute bottom-3 right-3 flex">
<Controller
control={control}
name="cover_image_url"
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={t("change_cover")}
control={control}
onChange={(imageUrl) => onChange(imageUrl)}
value={value}
isProfileCover
/>
)}
/>
</div>
</div>
<div className="item-center mt-6 flex justify-between">
<div className="flex flex-col">
<div className="item-center flex text-16 font-medium text-secondary">
<span>{`${watch("first_name")} ${watch("last_name")}`}</span>
</div>
<span className="text-13 text-tertiary tracking-tight">{watch("email")}</span>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-4">
<div className="flex flex-col gap-1">
<h4 className="text-13 font-medium text-secondary">
{t("first_name")}&nbsp;
<span className="text-danger-primary">*</span>
</h4>
<Controller
control={control}
name="first_name"
rules={{
required: "Please enter first name",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="first_name"
name="first_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.first_name)}
placeholder="Enter your first name"
className={`w-full rounded-md ${errors.first_name ? "border-danger-strong" : ""}`}
maxLength={24}
autoComplete="on"
/>
)}
/>
{errors.first_name && <span className="text-11 text-danger-primary">{errors.first_name.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 font-medium text-secondary">{t("last_name")}</h4>
<Controller
control={control}
name="last_name"
render={({ field: { value, onChange, ref } }) => (
<Input
id="last_name"
name="last_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.last_name)}
placeholder="Enter your last name"
className="w-full rounded-md"
maxLength={24}
autoComplete="on"
/>
)}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 font-medium text-secondary">
{t("display_name")}&nbsp;
<span className="text-danger-primary">*</span>
</h4>
<Controller
control={control}
name="display_name"
rules={{
required: "Display name is required.",
validate: (value) => {
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 } }) => (
<Input
id="display_name"
name="display_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors?.display_name)}
placeholder="Enter your display name"
className={`w-full ${errors?.display_name ? "border-danger-strong" : ""}`}
maxLength={24}
/>
)}
/>
{errors?.display_name && (
<span className="text-11 text-danger-primary">{errors?.display_name?.message}</span>
)}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 font-medium text-secondary">
{t("auth.common.email.label")}&nbsp;
<span className="text-danger-primary">*</span>
</h4>
<Controller
control={control}
name="email"
rules={{
required: "Email is required.",
}}
render={({ field: { value, ref } }) => (
<Input
id="email"
name="email"
type="email"
value={value}
ref={ref}
hasError={Boolean(errors.email)}
placeholder="Enter your email"
className={`w-full cursor-not-allowed rounded-md !bg-surface-2 ${
errors.email ? "border-danger-strong" : ""
}`}
autoComplete="on"
disabled
/>
)}
/>
{isSMTPConfigured && (
<button
type="button"
className="text-11 underline btn w-fit text-secondary"
onClick={() => setIsChangeEmailModalOpen(true)}
>
{t("account_settings.profile.change_email_modal.title")}
</button>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between pt-6 pb-8">
<Button variant="primary" type="submit" loading={isLoading}>
{isLoading ? t("saving") : t("save_changes")}
</Button>
</div>
</div>
</div>
</form>
<Disclosure as="div" className="border-t border-subtle w-full">
{({ open }) => (
<>
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4">
<span className="text-16 font-medium tracking-tight">{t("deactivate_account")}</span>
<ChevronDownIcon className={`h-5 w-5 transition-all ${open ? "rotate-180" : ""}`} />
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8">
<span className="text-13 tracking-tight">{t("deactivate_account_description")}</span>
<div>
<Button variant="error-fill" onClick={() => setDeactivateAccountModal(true)}>
{t("deactivate_account")}
</Button>
</div>
</div>
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
</>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -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 (
<>
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
<ProfileForm user={currentUser} profile={userProfile.data} />
<GeneralProfileSettingsForm user={currentUser} profile={userProfile.data} />
</>
);
}
export default observer(ProfileSettingsPage);
});

View File

@@ -0,0 +1,12 @@
import { lazy } from "react";
// plane imports
import type { TProfileSettingsTabs } from "@plane/types";
export const PROFILE_SETTINGS_PAGES_MAP: Record<TProfileSettingsTabs, React.LazyExoticComponent<React.FC>> = {
general: lazy(() => import("./general").then((m) => ({ default: m.GeneralProfileSettings }))),
preferences: lazy(() => import("./preferences").then((m) => ({ default: m.PreferencesProfileSettings }))),
notifications: lazy(() => import("./notifications").then((m) => ({ default: m.NotificationsProfileSettings }))),
security: lazy(() => import("./security").then((m) => ({ default: m.SecurityProfileSettings }))),
activity: lazy(() => import("./activity").then((m) => ({ default: m.ActivityProfileSettings }))),
"api-tokens": lazy(() => import("./api-tokens").then((m) => ({ default: m.APITokensProfileSettings }))),
};

View File

@@ -0,0 +1,158 @@
import { useEffect } from "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";
interface IEmailNotificationFormProps {
data: IUserEmailNotificationSettings;
}
// services
const userService = new UserService();
export function NotificationsProfileSettingsForm(props: IEmailNotificationFormProps) {
const { data } = props;
const { t } = useTranslation();
// form data
const { control, reset } = useForm<IUserEmailNotificationSettings>({
defaultValues: {
...data,
},
});
const handleSettingChange = async (key: keyof IUserEmailNotificationSettings, value: boolean) => {
try {
await userService.updateCurrentUserEmailNotificationSettings({
[key]: value,
});
setToast({
title: t("success"),
type: TOAST_TYPE.SUCCESS,
message: t("email_notification_setting_updated_successfully"),
});
} catch (_error) {
setToast({
title: t("error"),
type: TOAST_TYPE.ERROR,
message: t("failed_to_update_email_notification_setting"),
});
}
};
useEffect(() => {
reset(data);
}, [reset, data]);
return (
<div className="flex flex-col gap-y-1">
<SettingsControlItem
title={t("property_changes")}
description={t("property_changes_description")}
control={
<Controller
control={control}
name="property_change"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("property_change", newValue);
}}
size="sm"
/>
)}
/>
}
/>
<SettingsControlItem
title={t("state_change")}
description={t("state_change_description")}
control={
<Controller
control={control}
name="state_change"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("state_change", newValue);
}}
size="sm"
/>
)}
/>
}
/>
<div className="border-l-3 border-subtle-1 pl-3">
<SettingsControlItem
title={t("issue_completed")}
description={t("issue_completed_description")}
control={
<Controller
control={control}
name="issue_completed"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("issue_completed", newValue);
}}
size="sm"
/>
)}
/>
}
/>
</div>
<SettingsControlItem
title={t("comments")}
description={t("comments_description")}
control={
<Controller
control={control}
name="comment"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("comment", newValue);
}}
size="sm"
/>
)}
/>
}
/>
<SettingsControlItem
title={t("mentions")}
description={t("mentions_description")}
control={
<Controller
control={control}
name="mention"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("mention", newValue);
}}
size="sm"
/>
)}
/>
}
/>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -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 (
<>
<PageHead title={`${t("profile.label")} - ${t("notifications")}`} />
<div className="size-full">
<SettingsHeading
title={t("account_settings.notifications.heading")}
description={t("account_settings.notifications.description")}
/>
<EmailNotificationForm data={data} />
</>
<div className="mt-7">
<NotificationsProfileSettingsForm data={data} />
</div>
</div>
);
}
});

View File

@@ -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 (
<div className="flex flex-col gap-y-1">
<ThemeSwitcher
option={{
id: "theme",
title: "theme",
description: "select_or_customize_your_interface_color_scheme",
}}
/>
</div>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -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 (
<div className="flex flex-col gap-y-1">
<SettingsControlItem
title={t("timezone")}
description={t("timezone_setting")}
control={<TimezoneSelect value={user?.user_timezone || "Asia/Kolkata"} onChange={handleTimezoneChange} />}
/>
<SettingsControlItem
title={t("language")}
description={t("language_setting")}
control={
<CustomSelect
value={profile?.language}
label={profile?.language ? getLanguageLabel(profile?.language) : "Select a language"}
onChange={handleLanguageChange}
buttonClassName="border border-subtle-1"
className="rounded-md"
input
placement="bottom-end"
>
{SUPPORTED_LANGUAGES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
}
/>
<StartOfWeekPreference
option={{
title: "First day of the week",
description: "This will change how all calendars in your app look.",
}}
/>
</div>
);
}
);

View File

@@ -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 (
<div className="size-full">
<SettingsHeading
title={t("account_settings.preferences.heading")}
description={t("account_settings.preferences.description")}
/>
<div className="mt-7 flex flex-col gap-6 w-full">
<section>
<ProfileSettingsDefaultPreferencesList />
</section>
<section className="flex flex-col gap-y-3">
<div className="text-h6-medium text-primary">{t("language_and_time")}</div>
<ProfileSettingsLanguageAndTimezonePreferencesList />
</section>
</div>
</div>
);
});

View File

@@ -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,13 @@ 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 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"),
@@ -117,52 +119,51 @@ function SecurityPage() {
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
return (
<>
<PageHead title="Profile - Security" />
<ProfileSettingContentWrapper>
<ProfileSettingContentHeader title={t("auth.common.password.change_password.label.default")} />
<form onSubmit={handleSubmit(handleChangePassword)} className="flex flex-col gap-8 py-6">
<div className="flex flex-col gap-10 w-full max-w-96">
{oldPasswordRequired && (
<div className="space-y-1">
<h4 className="text-13">{t("auth.common.password.current_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="old_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="old_password"
type={showPassword?.oldPassword ? "text" : "password"}
value={value}
onChange={onChange}
placeholder={t("old_password")}
className="w-full"
hasError={Boolean(errors.old_password)}
/>
)}
/>
{showPassword?.oldPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
<div className="size-full">
<SettingsHeading title={t("auth.common.password.change_password.label.default")} />
<form onSubmit={handleSubmit(handleChangePassword)} className="mt-7 flex flex-col gap-8">
<div className="flex flex-col gap-y-7">
{oldPasswordRequired && (
<div className="flex flex-col gap-y-2">
<h4 className="text-13">{t("auth.common.password.current_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="old_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="old_password"
type={showPassword?.oldPassword ? "text" : "password"}
value={value}
onChange={onChange}
placeholder={t("old_password")}
className="w-full"
hasError={Boolean(errors.old_password)}
/>
)}
</div>
{errors.old_password && (
<span className="text-11 text-danger-primary">{errors.old_password.message}</span>
/>
{showPassword?.oldPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
/>
)}
</div>
)}
<div className="space-y-1">
{errors.old_password && (
<span className="text-11 text-danger-primary">{errors.old_password.message}</span>
)}
</div>
)}
<div className="grid sm:grid-cols-2 gap-y-7 gap-x-4">
<div className="flex flex-col gap-y-2">
<h4 className="text-13">{t("auth.common.password.new_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
@@ -204,7 +205,7 @@ function SecurityPage() {
</span>
)}
</div>
<div className="space-y-1">
<div className="flex flex-col gap-y-2">
<h4 className="text-13">{t("auth.common.password.confirm_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
@@ -244,18 +245,15 @@ function SecurityPage() {
)}
</div>
</div>
<div className="flex items-center justify-between py-2">
<Button variant="primary" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
<div>
<Button variant="primary" size="xl" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
{isSubmitting
? `${t("auth.common.password.change_password.label.submitting")}`
: t("auth.common.password.change_password.label.default")}
</Button>
</div>
</form>
</ProfileSettingContentWrapper>
</>
</div>
</form>
</div>
);
}
export default observer(SecurityPage);
});

View File

@@ -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 (
<ScrollArea
rootClassName={cn("shrink-0 bg-surface-1 overflow-y-scroll", className)}
scrollType="hover"
orientation="vertical"
size="sm"
>
<Suspense>
<PageComponent />
</Suspense>
</ScrollArea>
);
});

View File

@@ -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 (
<ModalCore
isOpen={profileSettingsModal.isOpen}
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIXL}
className="h-175"
>
<div className="@container relative size-full">
<div className="flex size-full">
<ProfileSettingsSidebarRoot
activeTab={activeTab}
className="w-[250px] rounded-l-xl"
updateActiveTab={(tab) => toggleProfileSettingsModal({ activeTab: tab })}
/>
<ProfileSettingsContent activeTab={activeTab} className="grow px-8 py-9 w-fit rounded-r-xl" />
</div>
<div className="absolute top-3.5 right-3.5">
<IconButton size="base" variant="tertiary" icon={X} onClick={handleClose} />
</div>
</div>
</ModalCore>
);
});

View File

@@ -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 (
<div className="shrink-0 flex items-center gap-2">
<div className="shrink-0">
<Avatar
src={getFileURL(currentUser?.avatar_url ?? "")}
name={currentUser?.display_name}
size={32}
shape="circle"
className="text-16"
/>
</div>
<div className="truncate">
<p className="text-body-sm-medium truncate">
{currentUser?.first_name} {currentUser?.last_name}
</p>
<p className="text-caption-md-regular truncate">{currentUser?.email}</p>
</div>
</div>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -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<TProfileSettingsTabs, LucideIcon | React.FC<ISvgIcons>> = {
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 (
<div className="mt-4 flex flex-col gap-y-4">
{PROFILE_SETTINGS_CATEGORIES.map((category) => {
const categoryItems = GROUPED_PROFILE_SETTINGS[category];
if (categoryItems.length === 0) return null;
return (
<div key={category} className="shrink-0">
<div className="p-2 text-caption-md-medium text-tertiary capitalize">{t(category)}</div>
<div className="flex flex-col">
{categoryItems.map((item) => (
<SettingsSidebarItem
key={item.key}
as="button"
onClick={() => updateActiveTab(item.key)}
isActive={activeTab === item.key}
icon={ICONS[item.key]}
label={t(item.i18n_label)}
/>
))}
</div>
</div>
);
})}
{profileTabId && <ProfileSettingsSidebarWorkspaceOptions />}
</div>
);
});

View File

@@ -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 (
<ScrollArea
scrollType="hover"
orientation="vertical"
size="sm"
rootClassName={cn("shrink-0 py-4 px-3 bg-surface-2 border-r border-r-subtle overflow-y-scroll", className)}
>
<ProfileSettingsSidebarHeader />
<ProfileSettingsSidebarItemCategories activeTab={activeTab} updateActiveTab={updateActiveTab} />
</ScrollArea>
);
}

View File

@@ -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 (
<div className="shrink-0">
<div className="p-2 text-caption-md-medium text-tertiary capitalize">{t("workspace")}</div>
<div className="flex flex-col">
{Object.values(workspaces).map((workspace) => (
<SettingsSidebarItem
key={workspace.id}
as="link"
href={`/${workspace.slug}/`}
iconNode={<WorkspaceLogo logo={workspace.logo_url} name={workspace.name} classNames="shrink-0" />}
label={workspace.name}
isActive={false}
/>
))}
<div className="mt-1.5">
<SettingsSidebarItem
as="link"
href="/create-workspace/"
icon={CirclePlus}
label={t("create_workspace")}
isActive={false}
/>
<SettingsSidebarItem
as="link"
href="/invitations/"
icon={Mails}
label={t("workspace_invites")}
isActive={false}
/>
</div>
</div>
</div>
);
});

View File

@@ -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<ISvgIcons>;
}
| { 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 ? (
<span className="shrink-0 size-4 grid place-items-center">{<props.icon className="size-3.5" />}</span>
) : (
props.iconNode
)}
<span className="truncate">{label}</span>
</>
);
if (as === "button") {
return (
<button type="button" className={className} onClick={props.onClick}>
{content}
</button>
);
}
return (
<Link className={className} href={props.href}>
{content}
</Link>
);
}

View File

@@ -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 (
<div className="flex w-fit min-w-fit items-center justify-between gap-1.5 rounded-md text-13 p-0.5 bg-layer-2">

View File

@@ -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 (
<CustomMenu
@@ -61,7 +58,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) {
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={size === "xs" ? 20 : size === "sm" ? 24 : 28}
size={20}
shape="circle"
/>
),
@@ -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
>
<div className="flex flex-col gap-2">
<span className="px-2 text-secondary truncate">{currentUser?.email}</span>
<CustomMenu.MenuItem onClick={() => router.push(`/${workspaceSlug}/settings/account`)}>
<div className="flex w-full items-center gap-2 rounded-sm text-11">
<Settings className="h-4 w-4 stroke-[1.5]" />
<span>{t("settings")}</span>
<div className="relative h-29 w-full rounded-lg">
<CoverImage
src={currentUser?.cover_image_url ?? undefined}
alt={currentUser?.display_name}
className="h-29 w-full rounded-lg"
showDefaultWhenEmpty
/>
<div className="absolute inset-0 bg-layer-1/50" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="flex flex-col items-center gap-y-2">
<div>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={40}
shape="circle"
className="text-18 font-medium"
/>
</div>
<div className="text-center">
<p className="text-body-sm-medium">
{currentUser?.first_name} {currentUser?.last_name}
</p>
<p className="text-caption-md-regular">{currentUser?.email}</p>
</div>
</div>
</div>
</div>
<div>
<CustomMenu.MenuItem
onClick={() =>
toggleProfileSettingsModal({
activeTab: "general",
isOpen: true,
})
}
className="flex items-center gap-2"
>
<Settings className="shrink-0 size-3.5" />
{t("settings")}
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => router.push(`/${workspaceSlug}/settings/account/preferences`)}>
<div className="flex w-full items-center gap-2 rounded-sm text-11">
<Settings2 className="h-4 w-4 stroke-[1.5]" />
<span>Preferences</span>
</div>
</CustomMenu.MenuItem>
</div>
<div className="my-1 border-t border-subtle" />
<div className={`${isUserInstanceAdmin ? "pb-2" : ""}`}>
<CustomMenu.MenuItem>
<button
type="button"
className="flex w-full items-center gap-2 rounded-sm text-11 hover:bg-layer-1"
onClick={handleSignOut}
>
<LogOut className="size-4 stroke-[1.5]" />
{t("sign_out")}
</button>
<CustomMenu.MenuItem
onClick={() =>
toggleProfileSettingsModal({
activeTab: "preferences",
isOpen: true,
})
}
className="flex items-center gap-2"
>
<Settings2 className="shrink-0 size-3.5" />
Preferences
</CustomMenu.MenuItem>
</div>
<CustomMenu.MenuItem onClick={handleSignOut} className="flex items-center gap-2">
<LogOut className="shrink-0 size-3.5" />
{t("sign_out")}
</CustomMenu.MenuItem>
{isUserInstanceAdmin && (
<>
<div className="my-1 border-t border-subtle" />
<div className="px-1">
<CustomMenu.MenuItem onClick={() => router.push(GOD_MODE_URL)}>
<div className="flex w-full items-center justify-center rounded-sm bg-accent-primary/20 px-2 py-1 text-11 font-medium text-accent-primary hover:bg-accent-primary/30 hover:text-accent-secondary">
{t("enter_god_mode")}
</div>
</CustomMenu.MenuItem>
</div>
</>
<CustomMenu.MenuItem
onClick={() => 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")}
</CustomMenu.MenuItem>
)}
</CustomMenu>
);

View File

@@ -178,15 +178,12 @@ export const WorkspaceAuthWrapper = observer(function WorkspaceAuthWrapper(props
</Link>
)}
{allWorkspaces?.length > 0 && (
<Link
href={`/${allWorkspaces[0].slug}/settings/account`}
className={cn(getButtonStyling("secondary", "base"))}
>
<Link href="/settings/profile/general/" className={cn(getButtonStyling("secondary", "base"))}>
Visit Profile
</Link>
)}
{allWorkspaces && allWorkspaces.length === 0 && (
<Link href={`/`} className={cn(getButtonStyling("secondary", "base"))}>
<Link href="/create-workspace/" className={cn(getButtonStyling("secondary", "base"))}>
Create new workspace
</Link>
)}

View File

@@ -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<string, boolean>;
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<string, boolean> = {};
@@ -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;
});
};
}

View File

@@ -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.",
},
];
/**

View File

@@ -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"],

View File

@@ -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";

View File

@@ -0,0 +1 @@
export type TProfileSettingsTabs = "general" | "preferences" | "activity" | "notifications" | "security" | "api-tokens";