snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,79 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { APITokenService } from "@plane/services";
// components
import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal";
import { ApiTokenListItem } from "@/components/api-token/token-list-item";
import { ProfileSettingsHeading } from "@/components/settings/profile/heading";
import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token";
// constants
import { API_TOKENS_LIST } from "@plane/constants";
const apiTokenService = new APITokenService();
export const APITokensProfileSettings = observer(function APITokensProfileSettings() {
// states
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
// store hooks
const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list());
// translation
const { t } = useTranslation();
if (!tokens) {
return <APITokenSettingsLoader />;
}
return (
<div className="size-full">
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
<ProfileSettingsHeading
title={t("account_settings.api_tokens.title")}
description={t("account_settings.api_tokens.description")}
control={
<Button variant="primary" size="lg" onClick={() => setIsCreateTokenModalOpen(true)}>
{t("workspace_settings.settings.api_tokens.add_token")}
</Button>
}
/>
<div className="mt-7">
{tokens.length > 0 ? (
<>
<div>
{tokens.map((token) => (
<ApiTokenListItem key={token.id} token={token} />
))}
</div>
</>
) : (
<EmptyStateCompact
assetKey="token"
assetClassName="size-20"
title={t("settings_empty_state.tokens.title")}
description={t("settings_empty_state.tokens.description")}
actions={[
{
label: t("settings_empty_state.tokens.cta_primary"),
onClick: () => {
setIsCreateTokenModalOpen(true);
},
},
]}
align="start"
rootClassName="py-20"
/>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,428 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { UserOutline } from "@makeplane/propel/icons";
// plane imports
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast";
import { EFileAssetType } from "@plane/types";
import type { IUser, TUserProfile } from "@plane/types";
import { getFileURL } from "@plane/utils";
// components
import { DeactivateAccountModal } from "@/components/account/deactivate-account-modal";
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
import { ChangeEmailModal } from "@/components/core/modals/change-email-modal";
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
import { CoverImage } from "@/components/common/cover-image";
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
// helpers
import { handleCoverImageChange } from "@/helpers/cover-image.helper";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useUser, useUserProfile } from "@/hooks/store/user";
// utils
import { validatePersonName, validateDisplayName } from "@plane/utils";
type TUserProfileForm = {
avatar_url: string;
cover_image: string;
cover_image_asset: any;
cover_image_url: string;
first_name: string;
last_name: string;
display_name: string;
email: string;
role: string;
language: string;
user_timezone: string;
};
type Props = {
user: IUser;
profile: TUserProfile;
};
export const GeneralProfileSettingsForm = observer(function GeneralProfileSettingsForm(props: Props) {
const { user, profile } = props;
// states
const [isLoading, setIsLoading] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
const [deactivateAccountModal, setDeactivateAccountModal] = useState(false);
const [isChangeEmailModalOpen, setIsChangeEmailModalOpen] = useState(false);
// language support
const { t } = useTranslation();
// form info
const {
handleSubmit,
watch,
control,
setValue,
formState: { errors },
} = useForm<TUserProfileForm>({
defaultValues: {
avatar_url: user.avatar_url || "",
cover_image_asset: null,
cover_image_url: user.cover_image_url || "",
first_name: user.first_name || "",
last_name: user.last_name || "",
display_name: user.display_name || "",
email: user.email || "",
role: profile.role || "Product / Project Manager",
language: profile.language || "en",
user_timezone: user.user_timezone || "Asia/Kolkata",
},
});
// derived values
const userAvatar = watch("avatar_url");
const userCover = watch("cover_image_url");
// store hooks
const { data: currentUser, updateCurrentUser } = useUser();
const { updateUserProfile } = useUserProfile();
const { config } = useInstance();
const isSMTPConfigured = config?.is_smtp_configured || false;
const handleProfilePictureDelete = async (url: string | null | undefined) => {
if (!url) return;
await updateCurrentUser({
avatar_url: "",
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Profile picture deleted successfully.",
});
setValue("avatar_url", "");
return;
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "There was some error in deleting your profile picture. Please try again.",
});
})
.finally(() => {
setIsImageUploadModalOpen(false);
});
};
const onSubmit = async (formData: TUserProfileForm) => {
setIsLoading(true);
const userPayload: Partial<IUser> = {
first_name: formData.first_name,
last_name: formData.last_name,
avatar_url: formData.avatar_url,
display_name: formData?.display_name,
};
try {
const coverImagePayload = await handleCoverImageChange(user.cover_image_url, formData.cover_image_url, {
entityIdentifier: "",
entityType: EFileAssetType.USER_COVER,
isUserAsset: true,
});
if (coverImagePayload) {
Object.assign(userPayload, coverImagePayload);
}
} catch (error) {
console.error("Error handling cover image:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: error instanceof Error ? error.message : "Failed to process cover image",
});
setIsLoading(false);
return;
}
const profilePayload: Partial<TUserProfile> = {
role: formData.role,
};
const updateCurrentUserDetail = updateCurrentUser(userPayload);
const promises: Promise<IUser | TUserProfile | undefined>[] = [updateCurrentUserDetail];
if (profilePayload.role !== profile.role) {
const updateCurrentUserProfile = updateUserProfile(profilePayload);
promises.push(updateCurrentUserProfile);
}
const updatePromise = Promise.allSettled(promises)
.then((results) => {
const rejectedResult = results.find((result) => result.status === "rejected") as
| PromiseRejectedResult
| undefined;
if (rejectedResult) {
throw rejectedResult.reason ?? new Error("Failed to update profile");
}
const values = results.map(
(result) => (result as PromiseFulfilledResult<IUser | TUserProfile | undefined>).value
);
if (values.some((v) => v === undefined)) {
throw new Error("Failed to update profile");
}
return values;
})
.finally(() => setIsLoading(false));
setPromiseToast(updatePromise, {
loading: "Updating...",
success: {
title: "Success!",
message: () => `Profile updated successfully.`,
},
error: {
title: "Error!",
message: () => `There was some error in updating your profile. Please try again.`,
},
});
};
return (
<>
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
<ChangeEmailModal isOpen={isChangeEmailModalOpen} onClose={() => setIsChangeEmailModalOpen(false)} />
<Controller
control={control}
name="avatar_url"
render={({ field: { onChange, value } }) => (
<UserImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
handleRemove={async () => await handleProfilePictureDelete(currentUser?.avatar_url)}
onSuccess={(url) => {
onChange(url);
handleSubmit(onSubmit)();
setIsImageUploadModalOpen(false);
}}
value={value && value.trim() !== "" ? value : null}
/>
)}
/>
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
<div className="flex w-full flex-col gap-7">
<div className="relative h-44 w-full">
<CoverImage
src={userCover}
className="h-44 w-full rounded-lg"
alt={currentUser?.first_name ?? "Cover image"}
/>
<div className="absolute -bottom-6 left-6 flex items-end justify-between">
<div className="flex gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-surface-2">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{!userAvatar || userAvatar === "" ? (
<div className="h-16 w-16 rounded-md bg-layer-1 p-2">
<UserOutline className="h-full w-full text-secondary" />
</div>
) : (
<div className="relative h-16 w-16 overflow-hidden">
<img
src={getFileURL(userAvatar)}
className="absolute top-0 left-0 h-full w-full rounded-lg object-cover"
onClick={() => setIsImageUploadModalOpen(true)}
alt={currentUser?.display_name}
role="button"
/>
</div>
)}
</button>
</div>
</div>
</div>
<div className="absolute right-3 bottom-3 flex">
<Controller
control={control}
name="cover_image_url"
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={t("change_cover")}
control={control}
onChange={(imageUrl) => onChange(imageUrl)}
value={value}
isProfileCover
/>
)}
/>
</div>
</div>
<div className="item-center mt-6 flex justify-between">
<div className="flex flex-col">
<div className="item-center flex text-16 font-medium text-secondary">
<span>{`${watch("first_name")} ${watch("last_name")}`}</span>
</div>
<span className="text-13 tracking-tight text-tertiary">{watch("email")}</span>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2 xl:grid-cols-3">
<div className="flex flex-col gap-1">
<h4 className="text-13 font-medium text-secondary">
{t("first_name")}&nbsp;
<span className="text-danger-primary">*</span>
</h4>
<Controller
control={control}
name="first_name"
rules={{
required: "Please enter first name",
validate: validatePersonName,
}}
render={({ field: { value, onChange, ref } }) => (
<Field name="first_name" invalid={Boolean(errors.first_name)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="first_name"
name="first_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder="Enter your first name"
maxLength={50}
autoComplete="on"
/>
</InputGroup>
</Field>
)}
/>
{errors.first_name && <span className="text-11 text-danger-primary">{errors.first_name.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 font-medium text-secondary">{t("last_name")}</h4>
<Controller
control={control}
name="last_name"
rules={{
validate: validatePersonName,
}}
render={({ field: { value, onChange, ref } }) => (
<Field name="last_name" invalid={Boolean(errors.last_name)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="last_name"
name="last_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder="Enter your last name"
maxLength={50}
autoComplete="on"
/>
</InputGroup>
</Field>
)}
/>
{errors.last_name && <span className="text-11 text-danger-primary">{errors.last_name.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 font-medium text-secondary">
{t("display_name")}&nbsp;
<span className="text-danger-primary">*</span>
</h4>
<Controller
control={control}
name="display_name"
rules={{
required: "Display name is required.",
validate: validateDisplayName,
}}
render={({ field: { value, onChange, ref } }) => (
<Field name="display_name" invalid={Boolean(errors?.display_name)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="display_name"
name="display_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder="Enter your display name"
maxLength={50}
/>
</InputGroup>
</Field>
)}
/>
{errors?.display_name && (
<span className="text-11 text-danger-primary">{errors?.display_name?.message}</span>
)}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 font-medium text-secondary">
{t("auth.common.email.label")}&nbsp;
<span className="text-danger-primary">*</span>
</h4>
<Controller
control={control}
name="email"
rules={{
required: "Email is required.",
}}
render={({ field: { value, ref } }) => (
<Field name="email" invalid={Boolean(errors.email)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="email"
name="email"
type="email"
value={value}
ref={ref}
placeholder="Enter your email"
autoComplete="on"
disabled
/>
</InputGroup>
</Field>
)}
/>
{isSMTPConfigured && (
<button
type="button"
className="btn w-fit text-11 text-secondary underline"
onClick={() => setIsChangeEmailModalOpen(true)}
>
{t("account_settings.profile.change_email_modal.title")}
</button>
)}
</div>
</div>
</div>
<div>
<Button variant="primary" type="submit" loading={isLoading}>
{isLoading ? t("saving") : t("save_changes")}
</Button>
</div>
</div>
</form>
<div className="mt-10">
<SettingsBoxedControlItem
title={t("deactivate_account")}
description={t("deactivate_account_description")}
control={
<Button variant="error-outline" onClick={() => setDeactivateAccountModal(true)}>
{t("deactivate_account")}
</Button>
}
/>
</div>
</>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// components
import { PageHead } from "@/components/core/page-title";
// hooks
import { useUser } from "@/hooks/store/user";
// local imports
import { GeneralProfileSettingsForm } from "./form";
export const GeneralProfileSettings = observer(function GeneralProfileSettings() {
const { t } = useTranslation();
// store hooks
const { data: currentUser, userProfile } = useUser();
if (!currentUser) return null;
return (
<>
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
<GeneralProfileSettingsForm user={currentUser} profile={userProfile.data} />
</>
);
});

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { lazy } from "react";
// plane imports
import type { TProfileSettingsTabs } from "@plane/types";
export const PROFILE_SETTINGS_PAGES_MAP: Record<TProfileSettingsTabs, React.LazyExoticComponent<React.FC>> = {
general: lazy(() => import("./general").then((m) => ({ default: m.GeneralProfileSettings }))),
preferences: lazy(() => import("./preferences").then((m) => ({ default: m.PreferencesProfileSettings }))),
notifications: lazy(() => import("./notifications").then((m) => ({ default: m.NotificationsProfileSettings }))),
security: lazy(() => import("./security").then((m) => ({ default: m.SecurityProfileSettings }))),
"api-tokens": lazy(() => import("./api-tokens").then((m) => ({ default: m.APITokensProfileSettings }))),
};

View File

@@ -0,0 +1,172 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUserEmailNotificationSettings } from "@plane/types";
import { Switch } from "@makeplane/propel/components/switch";
// components
import { SettingsControlItem } from "@/components/settings/control-item";
// services
import { UserService } from "@/services/user.service";
type Props = {
data: IUserEmailNotificationSettings;
};
// services
const userService = new UserService();
export const NotificationsProfileSettingsForm = observer(function NotificationsProfileSettingsForm(props: Props) {
const { data } = props;
// translation
const { t } = useTranslation();
// form data
const { control, reset } = useForm<IUserEmailNotificationSettings>({
defaultValues: {
...data,
},
});
const handleSettingChange = async (key: keyof IUserEmailNotificationSettings, value: boolean) => {
try {
await userService.updateCurrentUserEmailNotificationSettings({
[key]: value,
});
setToast({
title: t("success"),
type: TOAST_TYPE.SUCCESS,
message: t("email_notification_setting_updated_successfully"),
});
} catch (_error) {
setToast({
title: t("error"),
type: TOAST_TYPE.ERROR,
message: t("failed_to_update_email_notification_setting"),
});
}
};
useEffect(() => {
reset(data);
}, [reset, data]);
return (
<div className="flex flex-col gap-y-1">
<SettingsControlItem
title={t("property_changes")}
description={t("property_changes_description")}
control={
<Controller
control={control}
name="property_change"
render={({ field: { value, onChange } }) => (
<Switch
size="sm"
checked={value}
onCheckedChange={(newValue) => {
onChange(newValue);
void handleSettingChange("property_change", newValue);
}}
aria-label={t("property_changes")}
/>
)}
/>
}
/>
<SettingsControlItem
title={t("state_change")}
description={t("state_change_description")}
control={
<Controller
control={control}
name="state_change"
render={({ field: { value, onChange } }) => (
<Switch
size="sm"
checked={value}
onCheckedChange={(newValue) => {
onChange(newValue);
void handleSettingChange("state_change", newValue);
}}
aria-label={t("state_change")}
/>
)}
/>
}
/>
<div className="border-l-3 border-subtle-1 pl-3">
<SettingsControlItem
title={t("issue_completed")}
description={t("issue_completed_description")}
control={
<Controller
control={control}
name="issue_completed"
render={({ field: { value, onChange } }) => (
<Switch
size="sm"
checked={value}
onCheckedChange={(newValue) => {
onChange(newValue);
void handleSettingChange("issue_completed", newValue);
}}
aria-label={t("issue_completed")}
/>
)}
/>
}
/>
</div>
<SettingsControlItem
title={t("comments")}
description={t("comments_description")}
control={
<Controller
control={control}
name="comment"
render={({ field: { value, onChange } }) => (
<Switch
size="sm"
checked={value}
onCheckedChange={(newValue) => {
onChange(newValue);
void handleSettingChange("comment", newValue);
}}
aria-label={t("comments")}
/>
)}
/>
}
/>
<SettingsControlItem
title={t("mentions")}
description={t("mentions_description")}
control={
<Controller
control={control}
name="mention"
render={({ field: { value, onChange } }) => (
<Switch
size="sm"
checked={value}
onCheckedChange={(newValue) => {
onChange(newValue);
void handleSettingChange("mention", newValue);
}}
aria-label={t("mentions")}
/>
)}
/>
}
/>
</div>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import useSWR from "swr";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { ProfileSettingsHeading } from "@/components/settings/profile/heading";
import { EmailSettingsLoader } from "@/components/ui/loader/settings/email";
// services
import { UserService } from "@/services/user.service";
// local imports
import { NotificationsProfileSettingsForm } from "./email-notification-form";
const userService = new UserService();
export const NotificationsProfileSettings = observer(function NotificationsProfileSettings() {
const { t } = useTranslation();
// fetching user email notification settings
const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
userService.currentUserEmailNotificationSettings()
);
if (!data || isLoading) {
return <EmailSettingsLoader />;
}
return (
<div className="size-full">
<ProfileSettingsHeading
title={t("account_settings.notifications.heading")}
description={t("account_settings.notifications.description")}
/>
<div className="mt-7">
<NotificationsProfileSettingsForm data={data} />
</div>
</div>
);
});

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// components
import { ThemeSwitcher } from "@/components/appearance";
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
return (
<div className="flex flex-col gap-y-1">
<ThemeSwitcher
option={{
id: "theme",
title: "theme",
description: "select_or_customize_your_interface_color_scheme",
}}
/>
</div>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,108 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { SUPPORTED_LANGUAGES, useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { CustomSelect } from "@plane/ui";
// components
import { TimezoneSelect } from "@/components/global";
import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference";
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUser, useUserProfile } from "@/hooks/store/user";
export const ProfileSettingsLanguageAndTimezonePreferencesList = observer(
function ProfileSettingsLanguageAndTimezonePreferencesList() {
// store hooks
const {
data: user,
updateCurrentUser,
userProfile: { data: profile },
} = useUser();
const { updateUserProfile } = useUserProfile();
// translation
const { t } = useTranslation();
const handleTimezoneChange = async (value: string) => {
try {
await updateCurrentUser({ user_timezone: value });
setToast({
title: "Success!",
message: "Timezone updated successfully",
type: TOAST_TYPE.SUCCESS,
});
} catch (_error) {
setToast({
title: "Error!",
message: "Failed to update timezone",
type: TOAST_TYPE.ERROR,
});
}
};
const handleLanguageChange = async (value: string) => {
try {
await updateUserProfile({ language: value });
setToast({
title: "Success!",
message: "Language updated successfully",
type: TOAST_TYPE.SUCCESS,
});
} catch (_error) {
setToast({
title: "Error!",
message: "Failed to update language",
type: TOAST_TYPE.ERROR,
});
}
};
const getLanguageLabel = (value: string) => {
const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value);
if (!selectedLanguage) return value;
return selectedLanguage.label;
};
return (
<div className="flex flex-col gap-y-1">
<SettingsControlItem
title={t("timezone")}
description={t("timezone_setting")}
control={<TimezoneSelect value={user?.user_timezone || "Asia/Kolkata"} onChange={handleTimezoneChange} />}
/>
<SettingsControlItem
title={t("language")}
description={t("language_setting")}
control={
<CustomSelect
value={profile?.language}
label={profile?.language ? getLanguageLabel(profile?.language) : "Select a language"}
onChange={handleLanguageChange}
buttonClassName="border border-subtle-1"
className="rounded-md"
input
placement="bottom-end"
>
{SUPPORTED_LANGUAGES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
}
/>
<StartOfWeekPreference
option={{
title: "First day of the week",
description: "This will change how all calendars in your app look.",
}}
/>
</div>
);
}
);

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { ProfileSettingsHeading } from "@/components/settings/profile/heading";
// hooks
import { useUserProfile } from "@/hooks/store/user";
// local imports
import { ProfileSettingsDefaultPreferencesList } from "./default-list";
import { ProfileSettingsLanguageAndTimezonePreferencesList } from "./language-and-timezone-list";
export const PreferencesProfileSettings = observer(function PreferencesProfileSettings() {
const { t } = useTranslation();
// hooks
const { data: userProfile } = useUserProfile();
if (!userProfile) return null;
return (
<div className="size-full">
<ProfileSettingsHeading
title={t("account_settings.preferences.heading")}
description={t("account_settings.preferences.description")}
/>
<div className="mt-7 flex w-full flex-col gap-6">
<section>
<ProfileSettingsDefaultPreferencesList />
</section>
<section className="flex flex-col gap-y-3">
<div className="text-h6-medium text-primary">{t("language_and_time")}</div>
<ProfileSettingsLanguageAndTimezonePreferencesList />
</section>
</div>
</div>
);
});

View File

@@ -0,0 +1,285 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { HideOutline, ShowOutline } from "@makeplane/propel/icons";
// plane imports
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { PasswordStrengthIndicator } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { ProfileSettingsHeading } from "@/components/settings/profile/heading";
// helpers
import { authErrorHandler, EAuthenticationErrorCodes, passwordErrors } from "@/helpers/authentication.helper";
// hooks
import { useUser } from "@/hooks/store/user";
// services
import { AuthService } from "@/services/auth.service";
export interface FormValues {
old_password: string;
new_password: string;
confirm_password: string;
}
const defaultValues: FormValues = {
old_password: "",
new_password: "",
confirm_password: "",
};
const authService = new AuthService();
const defaultShowPassword = {
oldPassword: false,
password: false,
confirmPassword: false,
};
export const SecurityProfileSettings = observer(function SecurityProfileSettings() {
// store
const { data: currentUser, changePassword } = useUser();
// states
const [showPassword, setShowPassword] = useState(defaultShowPassword);
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
// use form
const {
control,
handleSubmit,
watch,
setError,
formState: { errors, isSubmitting },
reset,
} = useForm<FormValues>({ defaultValues });
// derived values
const oldPassword = watch("old_password");
const password = watch("new_password");
const confirmPassword = watch("confirm_password");
const oldPasswordRequired = !currentUser?.is_password_autoset;
// i18n
const { t } = useTranslation();
const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword;
const handleShowPassword = (key: keyof typeof showPassword) =>
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
const handleChangePassword = async (formData: FormValues) => {
const { old_password, new_password } = formData;
try {
const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token);
if (!csrfToken) throw new Error("csrf token not found");
await changePassword(csrfToken, {
...(oldPasswordRequired && { old_password }),
new_password,
});
reset(defaultValues);
setShowPassword(defaultShowPassword);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("auth.common.password.toast.change_password.success.title"),
message: t("auth.common.password.toast.change_password.success.message"),
});
} catch (error: unknown) {
const err = error as Error & { error_code?: string };
const code = err.error_code?.toString();
const errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined;
setToast({
type: TOAST_TYPE.ERROR,
title: errorInfo?.title ?? t("auth.common.password.toast.change_password.error.title"),
message:
typeof errorInfo?.message === "string"
? errorInfo.message
: t("auth.common.password.toast.change_password.error.message"),
});
if (code && passwordErrors.includes(code as EAuthenticationErrorCodes)) {
setError("new_password", {
type: "manual",
message: errorInfo?.message?.toString() || t("auth.common.password.toast.change_password.error.message"),
});
}
}
};
const isButtonDisabled =
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID ||
(oldPasswordRequired && oldPassword.trim() === "") ||
password.trim() === "" ||
confirmPassword.trim() === "" ||
password !== confirmPassword ||
password === oldPassword;
const passwordSupport = password.length > 0 &&
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
<PasswordStrengthIndicator password={password} isFocused={isPasswordInputFocused} />
);
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
return (
<div className="size-full">
<ProfileSettingsHeading title={t("auth.common.password.change_password.label.default")} />
<form onSubmit={handleSubmit(handleChangePassword)} className="mt-7 flex flex-col gap-8">
<div className="flex flex-col gap-y-7">
{oldPasswordRequired && (
<div className="flex flex-col gap-y-2">
<h4 className="text-13">{t("auth.common.password.current_password.label")}</h4>
<Controller
control={control}
name="old_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Field name="old_password" invalid={Boolean(errors.old_password)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="old_password"
type={showPassword?.oldPassword ? "text" : "password"}
value={value}
onChange={onChange}
placeholder={t("old_password")}
autoComplete="current-password"
/>
<button
type="button"
className="grid size-5 place-items-center"
onClick={() => handleShowPassword("oldPassword")}
aria-label={showPassword?.oldPassword ? "Hide password" : "Show password"}
>
{showPassword?.oldPassword ? (
<HideOutline className="size-5 text-placeholder" />
) : (
<ShowOutline className="size-5 text-placeholder" />
)}
</button>
</InputGroup>
</Field>
)}
/>
{errors.old_password && (
<span className="text-11 text-danger-primary">{errors.old_password.message}</span>
)}
</div>
)}
<div className="grid gap-x-4 gap-y-7 sm:grid-cols-2">
<div className="flex flex-col gap-y-2">
<h4 className="text-13">{t("auth.common.password.new_password.label")}</h4>
<Controller
control={control}
name="new_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Field name="new_password" invalid={Boolean(errors.new_password)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="new_password"
type={showPassword?.password ? "text" : "password"}
value={value}
placeholder={t("auth.common.password.new_password.placeholder")}
onChange={onChange}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="new-password"
/>
<button
type="button"
className="grid size-5 place-items-center"
onClick={() => handleShowPassword("password")}
aria-label={showPassword?.password ? "Hide password" : "Show password"}
>
{showPassword?.password ? (
<HideOutline className="size-5 text-placeholder" />
) : (
<ShowOutline className="size-5 text-placeholder" />
)}
</button>
</InputGroup>
</Field>
)}
/>
{passwordSupport}
{errors.new_password && (
<span className="text-11 text-danger-primary">{errors.new_password.message}</span>
)}
{isNewPasswordSameAsOldPassword && !isPasswordInputFocused && (
<span className="text-11 text-danger-primary">
{t("new_password_must_be_different_from_old_password")}
</span>
)}
</div>
<div className="flex flex-col gap-y-2">
<h4 className="text-13">{t("auth.common.password.confirm_password.label")}</h4>
<Controller
control={control}
name="confirm_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Field name="confirm_password" invalid={Boolean(errors.confirm_password)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="confirm_password"
type={showPassword?.confirmPassword ? "text" : "password"}
placeholder={t("auth.common.password.confirm_password.placeholder")}
value={value}
onChange={onChange}
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
autoComplete="new-password"
/>
<button
type="button"
className="grid size-5 place-items-center"
onClick={() => handleShowPassword("confirmPassword")}
aria-label={showPassword?.confirmPassword ? "Hide password" : "Show password"}
>
{showPassword?.confirmPassword ? (
<HideOutline className="size-5 text-placeholder" />
) : (
<ShowOutline className="size-5 text-placeholder" />
)}
</button>
</InputGroup>
</Field>
)}
/>
{!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && (
<span className="text-13 text-danger-primary">{t("auth.common.password.errors.match")}</span>
)}
</div>
</div>
<div>
<Button variant="primary" size="xl" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
{isSubmitting
? `${t("auth.common.password.change_password.label.submitting")}`
: t("auth.common.password.change_password.label.default")}
</Button>
</div>
</div>
</form>
</div>
);
});

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Suspense } from "react";
import { observer } from "mobx-react";
// plane imports
import { ScrollArea } from "@plane/propel/scrollarea";
import type { TProfileSettingsTabs } from "@plane/types";
import { cn } from "@plane/utils";
// local imports
import { PROFILE_SETTINGS_PAGES_MAP } from "./pages";
type Props = {
activeTab: TProfileSettingsTabs;
className?: string;
};
export const ProfileSettingsContent = observer(function ProfileSettingsContent(props: Props) {
const { activeTab, className } = props;
const PageComponent = PROFILE_SETTINGS_PAGES_MAP[activeTab];
return (
<ScrollArea
className={cn("shrink-0 overflow-y-scroll bg-surface-1", className)}
viewportClassName="px-8 py-9"
scrollType="hover"
orientation="vertical"
size="sm"
>
<Suspense>
<PageComponent />
</Suspense>
</ScrollArea>
);
});

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import { cn } from "@plane/ui";
type Props = {
className?: string;
control?: React.ReactNode;
description?: React.ReactNode;
title?: React.ReactNode;
};
export function ProfileSettingsHeading({ className, control, description, title }: Props) {
return (
<div className={cn("flex flex-col items-start justify-between gap-4 md:flex-row md:items-center", className)}>
<div className="flex flex-col items-start gap-1">
{title && <h6 className="text-h6-medium text-primary">{title}</h6>}
{description && <p className="text-body-xs-regular text-tertiary">{description}</p>}
</div>
{control}
</div>
);
}

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback } from "react";
import { CloseOutline } from "@makeplane/propel/icons";
import { observer } from "mobx-react";
// plane imports
import { IconButton } from "@plane/propel/icon-button";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
// local imports
import { ProfileSettingsContent } from "./content";
import { ProfileSettingsSidebarRoot } from "./sidebar";
export const ProfileSettingsModal = observer(function ProfileSettingsModal() {
// store hooks
const { profileSettingsModal, toggleProfileSettingsModal } = useCommandPalette();
// derived values
const activeTab = profileSettingsModal.activeTab ?? "general";
const handleClose = useCallback(() => {
toggleProfileSettingsModal({
isOpen: false,
});
setTimeout(() => {
toggleProfileSettingsModal({
activeTab: null,
});
}, 300);
}, [toggleProfileSettingsModal]);
return (
<ModalCore
isOpen={profileSettingsModal.isOpen}
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIXL}
className="h-175"
>
<div className="@container relative size-full">
<div className="flex size-full">
<ProfileSettingsSidebarRoot
activeTab={activeTab}
className="w-[250px] rounded-l-xl"
updateActiveTab={(tab) => toggleProfileSettingsModal({ activeTab: tab })}
/>
<ProfileSettingsContent activeTab={activeTab} className="flex-1 rounded-r-xl" />
</div>
<div className="absolute top-3.5 right-3.5">
<IconButton size="base" variant="tertiary" icon={CloseOutline} onClick={handleClose} />
</div>
</div>
</ModalCore>
);
});

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { Avatar } from "@makeplane/propel/components/avatar";
import { getFileURL } from "@plane/utils";
// hooks
import { useUser } from "@/hooks/store/user";
export const ProfileSettingsSidebarHeader = observer(function ProfileSettingsSidebarHeader() {
// store hooks
const { data: currentUser } = useUser();
return (
<div className="flex shrink-0 items-center gap-2">
<div className="shrink-0">
<Avatar
alt={currentUser?.display_name}
fallback={currentUser?.display_name?.[0]?.toUpperCase()}
src={getFileURL(currentUser?.avatar_url ?? "")}
size="lg"
/>
</div>
<div className="truncate">
<p className="truncate text-body-sm-medium">
{currentUser?.first_name} {currentUser?.last_name}
</p>
<p className="truncate text-caption-md-regular">{currentUser?.email}</p>
</div>
</div>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,77 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type React from "react";
import type { LucideIcon } from "lucide-react";
import { KeyOutline, LockOutline, SettingsOutline, SubscribeOutline, UserOutline } from "@makeplane/propel/icons";
import { observer } from "mobx-react";
import { useParams } from "react-router";
// plane imports
import {
GROUPED_PROFILE_SETTINGS,
PROFILE_SETTINGS_CATEGORIES,
PROFILE_SETTINGS_CATEGORY_LABELS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { ISvgIcons } from "@plane/propel/icons";
import type { TProfileSettingsTabs } from "@plane/types";
// local imports
import { SettingsSidebarItem } from "../../sidebar/item";
import { ProfileSettingsSidebarWorkspaceOptions } from "./workspace-options";
const ICONS: Record<TProfileSettingsTabs, LucideIcon | React.FC<ISvgIcons>> = {
general: UserOutline,
security: LockOutline,
preferences: SettingsOutline,
notifications: SubscribeOutline,
"api-tokens": KeyOutline,
};
type Props = {
activeTab: TProfileSettingsTabs;
updateActiveTab: (tab: TProfileSettingsTabs) => void;
};
export const ProfileSettingsSidebarItemCategories = observer(function ProfileSettingsSidebarItemCategories(
props: Props
) {
const { activeTab, updateActiveTab } = props;
// params
const { profileTabId } = useParams();
// translation
const { t } = useTranslation();
return (
<div className="mt-4 flex flex-col gap-y-4">
{PROFILE_SETTINGS_CATEGORIES.map((category) => {
const categoryItems = GROUPED_PROFILE_SETTINGS[category];
if (categoryItems.length === 0) return null;
return (
<div key={category} className="shrink-0">
<div className="p-2 text-caption-md-medium text-tertiary capitalize">
{t(PROFILE_SETTINGS_CATEGORY_LABELS[category])}
</div>
<div className="flex flex-col">
{categoryItems.map((item) => (
<SettingsSidebarItem
key={item.key}
as="button"
onClick={() => updateActiveTab(item.key)}
isActive={activeTab === item.key}
icon={ICONS[item.key]}
label={t(item.i18n_label)}
/>
))}
</div>
</div>
);
})}
{profileTabId && <ProfileSettingsSidebarWorkspaceOptions />}
</div>
);
});

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import { ScrollArea } from "@plane/propel/scrollarea";
import type { TProfileSettingsTabs } from "@plane/types";
import { cn } from "@plane/utils";
// local imports
import { ProfileSettingsSidebarHeader } from "./header";
import { ProfileSettingsSidebarItemCategories } from "./item-categories";
type Props = {
activeTab: TProfileSettingsTabs;
className?: string;
updateActiveTab: (tab: TProfileSettingsTabs) => void;
};
export function ProfileSettingsSidebarRoot(props: Props) {
const { activeTab, className, updateActiveTab } = props;
return (
<ScrollArea
scrollType="hover"
orientation="vertical"
size="sm"
rootClassName={cn("shrink-0 overflow-y-scroll border-r border-r-subtle bg-surface-2 px-3 py-4", className)}
>
<ProfileSettingsSidebarHeader />
<ProfileSettingsSidebarItemCategories activeTab={activeTab} updateActiveTab={updateActiveTab} />
</ScrollArea>
);
}

View File

@@ -0,0 +1,56 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { MailOutline, PlusCircleOutline } from "@makeplane/propel/icons";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
import { WorkspaceLogo } from "@/components/workspace/logo";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
export const ProfileSettingsSidebarWorkspaceOptions = observer(function ProfileSettingsSidebarWorkspaceOptions() {
// store hooks
const { workspaces } = useWorkspace();
// translation
const { t } = useTranslation();
return (
<div className="shrink-0">
<div className="p-2 text-caption-md-medium text-tertiary capitalize">{t("common.workspace")}</div>
<div className="flex flex-col">
{Object.values(workspaces).map((workspace) => (
<SettingsSidebarItem
key={workspace.id}
as="link"
href={`/${workspace.slug}/`}
iconNode={<WorkspaceLogo logo={workspace.logo_url} name={workspace.name} classNames="shrink-0" />}
label={workspace.name}
isActive={false}
/>
))}
<div className="mt-1.5">
<SettingsSidebarItem
as="link"
href="/create-workspace/"
icon={PlusCircleOutline}
label={t("create_workspace")}
isActive={false}
/>
<SettingsSidebarItem
as="link"
href="/invitations/"
icon={MailOutline}
label={t("workspace_invites")}
isActive={false}
/>
</div>
</div>
</div>
);
});