From 41c2aefad4c5f1c59da86edce05308b0af42d1c0 Mon Sep 17 00:00:00 2001 From: Sangeetha Date: Fri, 30 May 2025 18:47:33 +0530 Subject: [PATCH] [WEB-3998] feat: settings page revamp (#6959) * chore: return workspace name and logo in profile settings api * chore: remove unwanted fields * fix: backend * feat: workspace settings * feat: workspce settings + layouting * feat: profile + workspace settings ui * chore: project settings + refactoring * routes * fix: handled no project * fix: css + build * feat: profile settings internal screens upgrade * fix: workspace settings internal screens * fix: external scrolling allowed * fix: css * fix: css * fix: css * fix: preferences settings * fix: css * fix: mobile interface * fix: profile redirections * fix: dark theme * fix: css * fix: css * feat: scroll * fix: refactor * fix: bug fixes * fix: refactor * fix: css * fix: routes * fix: first day of the week * fix: scrolling * fix: refactoring * fix: project -> projects * fix: refactoring * fix: refactor * fix: no authorized view consistency * fix: folder structure * fix: revert * fix: handled redirections * fix: scroll * fix: deleted old routes * fix: empty states * fix: headings * fix: settings description * fix: build --------- Co-authored-by: gakshita Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com> --- apiserver/plane/app/serializers/user.py | 5 + apiserver/plane/app/views/workspace/base.py | 13 + packages/constants/src/index.ts | 1 + packages/constants/src/profile.ts | 91 ++++--- packages/constants/src/settings.ts | 52 ++++ packages/constants/src/workspace.ts | 8 - .../i18n/src/locales/en/translations.json | 66 ++++- packages/types/src/users.d.ts | 2 + .../[projectId]/cycles/(list)/page.tsx | 2 +- .../(detail)/[projectId]/intake/page.tsx | 2 +- .../[projectId]/modules/(list)/page.tsx | 2 +- .../[projectId]/pages/(list)/page.tsx | 2 +- .../settings/(with-sidebar)/layout.tsx | 33 --- .../settings/(with-sidebar)/sidebar.tsx | 73 ----- .../(detail)/[projectId]/settings/header.tsx | 79 ------ .../[projectId]/views/(list)/page.tsx | 2 +- .../settings/(with-sidebar)/layout.tsx | 63 ----- .../settings/(with-sidebar)/sidebar.tsx | 48 ---- .../(projects)/settings/header.tsx | 37 --- .../[workspaceSlug]/(settings)/layout.tsx | 25 ++ .../settings/(workspace)}/billing/page.tsx | 7 +- .../settings/(workspace)}/exports/page.tsx | 16 +- .../settings/(workspace)}/imports/page.tsx | 24 +- .../(workspace)}/integrations/page.tsx | 19 +- .../settings/(workspace)/layout.tsx | 58 ++++ .../settings/(workspace)}/members/page.tsx | 9 +- .../(workspace)}/mobile-header-tabs.tsx | 0 .../settings/(workspace)}/page.tsx | 5 +- .../settings/(workspace)/sidebar.tsx | 73 +++++ .../webhooks/[webhookId]/page.tsx | 5 +- .../settings/(workspace)}/webhooks/page.tsx | 36 +-- .../settings/account/activity/page.tsx | 89 +++++++ .../settings/account}/api-tokens/page.tsx | 44 +-- .../(settings)/settings/account/layout.tsx | 33 +++ .../settings/account/notifications/page.tsx | 37 +++ .../(settings)/settings/account/page.tsx | 32 +++ .../settings/account/preferences/page.tsx | 46 ++++ .../settings/account/security/page.tsx | 251 ++++++++++++++++++ .../(settings)/settings/account/sidebar.tsx | 82 ++++++ .../[projectId]}/automations/page.tsx | 16 +- .../projects/[projectId]}/estimates/page.tsx | 11 +- .../projects/[projectId]}/features/page.tsx | 9 +- .../projects/[projectId]}/labels/page.tsx | 11 +- .../projects/[projectId]}/members/page.tsx | 9 +- .../settings/projects/[projectId]}/page.tsx | 12 +- .../projects/[projectId]}/states/page.tsx | 20 +- .../(settings)/settings/projects/layout.tsx | 46 ++++ .../(settings)/settings/projects/page.tsx | 38 +++ web/app/(all)/profile/appearance/page.tsx | 3 +- web/ce/components/preferences/config.ts | 7 + .../components/preferences/theme-switcher.tsx | 105 ++++++++ web/ce/components/workspace/billing/root.tsx | 12 +- web/ce/constants/project/settings/tabs.ts | 28 +- .../auth-screens/not-authorized-view.tsx | 5 +- .../modals/workspace-image-upload-modal.tsx | 10 +- .../components/estimates/empty-screen.tsx | 38 +-- web/core/components/estimates/root.tsx | 11 +- web/core/components/exporter/column.tsx | 112 ++++++++ web/core/components/exporter/export-form.tsx | 172 ++++++++++++ web/core/components/exporter/guide.tsx | 215 ++------------- web/core/components/exporter/prev-exports.tsx | 137 ++++++++++ .../home/widgets/empty-states/no-projects.tsx | 2 +- .../empty-states/archived-issues.tsx | 2 +- .../labels/project-setting-label-list.tsx | 20 +- web/core/components/preferences/list.tsx | 11 + web/core/components/preferences/section.tsx | 15 ++ .../activity/profile-activity-list.tsx | 4 +- web/core/components/profile/form.tsx | 87 ++---- web/core/components/profile/index.ts | 1 + .../notification/email-notification-form.tsx | 24 +- .../profile/preferences/language-timezone.tsx | 100 +++++++ .../profile-setting-content-header.tsx | 2 +- web/core/components/profile/sidebar.tsx | 4 +- .../profile/start-of-week-preference.tsx | 71 ++--- web/core/components/project/card.tsx | 4 +- .../components/project/member-list-item.tsx | 4 +- web/core/components/project/member-list.tsx | 53 ++-- .../project/settings/features-list.tsx | 8 +- .../project/settings/member-columns.tsx | 6 +- .../components/settings/content-wrapper.tsx | 22 ++ web/core/components/settings/header.tsx | 84 ++++++ web/core/components/settings/heading.tsx | 29 ++ web/core/components/settings/helper.ts | 56 ++++ web/core/components/settings/index.ts | 6 + web/core/components/settings/layout.tsx | 46 ++++ web/core/components/settings/mobile/index.ts | 1 + web/core/components/settings/mobile/nav.tsx | 46 ++++ .../settings/project/sidebar/index.ts | 1 + .../project/sidebar/nav-item-children.tsx | 78 ++++++ .../settings/project/sidebar/root.tsx | 49 ++++ .../components/settings/sidebar/header.tsx | 33 +++ web/core/components/settings/sidebar/index.ts | 1 + .../components/settings/sidebar/nav-item.tsx | 93 +++++++ web/core/components/settings/sidebar/root.tsx | 75 ++++++ web/core/components/settings/tabs.tsx | 63 +++++ .../ui/loader/settings/api-token.tsx | 41 +-- .../workspace/delete-workspace-form.tsx | 9 +- .../workspace/settings/member-columns.tsx | 6 +- .../workspace/settings/members-list-item.tsx | 13 +- .../workspace/settings/workspace-details.tsx | 2 +- .../components/workspace/sidebar/dropdown.tsx | 4 +- .../workspace/sidebar/projects-list-item.tsx | 2 +- .../layouts/auth-layout/workspace-wrapper.tsx | 25 +- web/core/layouts/default-layout/index.tsx | 8 +- .../lib/wrappers/authentication-wrapper.tsx | 2 +- web/core/store/user/settings.store.ts | 21 ++ web/core/store/workspace/index.ts | 21 ++ web/next.config.js | 10 + .../project-settings/estimates-dark.png | Bin 0 -> 1385116 bytes .../project-settings/estimates-light.png | Bin 0 -> 852149 bytes .../project-settings/no-projects-dark.png | Bin 0 -> 299445 bytes .../project-settings/no-projects-light.png | Bin 0 -> 261332 bytes 112 files changed, 2789 insertions(+), 975 deletions(-) create mode 100644 packages/constants/src/settings.ts delete mode 100644 web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx delete mode 100644 web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx delete mode 100644 web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx delete mode 100644 web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx delete mode 100644 web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx delete mode 100644 web/app/(all)/[workspaceSlug]/(projects)/settings/header.tsx create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/layout.tsx rename web/app/(all)/[workspaceSlug]/{(projects)/settings/(with-sidebar) => (settings)/settings/(workspace)}/billing/page.tsx (83%) rename web/app/(all)/[workspaceSlug]/{(projects)/settings/(with-sidebar) => (settings)/settings/(workspace)}/exports/page.tsx (75%) rename web/app/(all)/[workspaceSlug]/{(projects)/settings/(with-sidebar) => (settings)/settings/(workspace)}/imports/page.tsx (61%) rename web/app/(all)/[workspaceSlug]/{(projects)/settings/(with-sidebar) => (settings)/settings/(workspace)}/integrations/page.tsx (86%) create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx rename web/app/(all)/[workspaceSlug]/{(projects)/settings/(with-sidebar) => (settings)/settings/(workspace)}/members/page.tsx (95%) rename web/app/(all)/[workspaceSlug]/{(projects)/settings/(with-sidebar) => (settings)/settings/(workspace)}/mobile-header-tabs.tsx (100%) rename web/app/(all)/[workspaceSlug]/{(projects)/settings/(with-sidebar) => (settings)/settings/(workspace)}/page.tsx (85%) create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx rename web/app/(all)/[workspaceSlug]/{(projects)/settings/(with-sidebar) => (settings)/settings/(workspace)}/webhooks/[webhookId]/page.tsx (96%) rename web/app/(all)/[workspaceSlug]/{(projects)/settings/(with-sidebar) => (settings)/settings/(workspace)}/webhooks/page.tsx (75%) create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx rename web/app/(all)/[workspaceSlug]/{(projects)/settings/(with-sidebar) => (settings)/settings/account}/api-tokens/page.tsx (71%) create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx rename web/app/(all)/[workspaceSlug]/{(projects)/projects/(detail)/[projectId]/settings/(with-sidebar) => (settings)/settings/projects/[projectId]}/automations/page.tsx (79%) rename web/app/(all)/[workspaceSlug]/{(projects)/projects/(detail)/[projectId]/settings/(with-sidebar) => (settings)/settings/projects/[projectId]}/estimates/page.tsx (80%) rename web/app/(all)/[workspaceSlug]/{(projects)/projects/(detail)/[projectId]/settings/(with-sidebar) => (settings)/settings/projects/[projectId]}/features/page.tsx (81%) rename web/app/(all)/[workspaceSlug]/{(projects)/projects/(detail)/[projectId]/settings/(with-sidebar) => (settings)/settings/projects/[projectId]}/labels/page.tsx (87%) rename web/app/(all)/[workspaceSlug]/{(projects)/projects/(detail)/[projectId]/settings/(with-sidebar) => (settings)/settings/projects/[projectId]}/members/page.tsx (83%) rename web/app/(all)/[workspaceSlug]/{(projects)/projects/(detail)/[projectId]/settings/(with-sidebar) => (settings)/settings/projects/[projectId]}/page.tsx (91%) rename web/app/(all)/[workspaceSlug]/{(projects)/projects/(detail)/[projectId]/settings/(with-sidebar) => (settings)/settings/projects/[projectId]}/states/page.tsx (68%) create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx create mode 100644 web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx create mode 100644 web/ce/components/preferences/config.ts create mode 100644 web/ce/components/preferences/theme-switcher.tsx create mode 100644 web/core/components/exporter/column.tsx create mode 100644 web/core/components/exporter/export-form.tsx create mode 100644 web/core/components/exporter/prev-exports.tsx create mode 100644 web/core/components/preferences/list.tsx create mode 100644 web/core/components/preferences/section.tsx create mode 100644 web/core/components/profile/preferences/language-timezone.tsx create mode 100644 web/core/components/settings/content-wrapper.tsx create mode 100644 web/core/components/settings/header.tsx create mode 100644 web/core/components/settings/heading.tsx create mode 100644 web/core/components/settings/helper.ts create mode 100644 web/core/components/settings/index.ts create mode 100644 web/core/components/settings/layout.tsx create mode 100644 web/core/components/settings/mobile/index.ts create mode 100644 web/core/components/settings/mobile/nav.tsx create mode 100644 web/core/components/settings/project/sidebar/index.ts create mode 100644 web/core/components/settings/project/sidebar/nav-item-children.tsx create mode 100644 web/core/components/settings/project/sidebar/root.tsx create mode 100644 web/core/components/settings/sidebar/header.tsx create mode 100644 web/core/components/settings/sidebar/index.ts create mode 100644 web/core/components/settings/sidebar/nav-item.tsx create mode 100644 web/core/components/settings/sidebar/root.tsx create mode 100644 web/core/components/settings/tabs.tsx create mode 100644 web/public/empty-state/project-settings/estimates-dark.png create mode 100644 web/public/empty-state/project-settings/estimates-light.png create mode 100644 web/public/empty-state/project-settings/no-projects-dark.png create mode 100644 web/public/empty-state/project-settings/no-projects-light.png diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index c0e1061784..c5a3d35df0 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -110,11 +110,16 @@ class UserMeSettingsSerializer(BaseSerializer): workspace_member__member=obj.id, workspace_member__is_active=True, ).first() + logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else "" return { "last_workspace_id": profile.last_workspace_id, "last_workspace_slug": ( workspace.slug if workspace is not None else "" ), + "last_workspace_name": ( + workspace.name if workspace is not None else "" + ), + "last_workspace_logo": (logo_asset_url), "fallback_workspace_id": profile.last_workspace_id, "fallback_workspace_slug": ( workspace.slug if workspace is not None else "" diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index 8ca29526d6..803e380670 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -3,6 +3,7 @@ import csv import io import os from datetime import date +import uuid from dateutil.relativedelta import relativedelta from django.db import IntegrityError @@ -35,6 +36,7 @@ from plane.db.models import ( Workspace, WorkspaceMember, WorkspaceTheme, + Profile ) from plane.app.permissions import ROLE, allow_permission from django.utils.decorators import method_decorator @@ -157,8 +159,19 @@ class WorkSpaceViewSet(BaseViewSet): def partial_update(self, request, *args, **kwargs): return super().partial_update(request, *args, **kwargs) + + def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None: + """ + Remove the last workspace id from the user settings + """ + Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None) + return + @allow_permission([ROLE.ADMIN], level="WORKSPACE") def destroy(self, request, *args, **kwargs): + # Get the workspace + workspace = self.get_object() + self.remove_last_workspace_ids_from_user_settings(workspace.id) return super().destroy(request, *args, **kwargs) diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 49e10c3d19..58b51ed723 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -32,5 +32,6 @@ export * from "./dashboard"; export * from "./page"; export * from "./emoji"; export * from "./subscription"; +export * from "./settings"; export * from "./icon"; export * from "./analytics-v2"; diff --git a/packages/constants/src/profile.ts b/packages/constants/src/profile.ts index 032e4526a8..983a787d47 100644 --- a/packages/constants/src/profile.ts +++ b/packages/constants/src/profile.ts @@ -1,39 +1,53 @@ +export const PROFILE_SETTINGS = { + profile: { + key: "profile", + i18n_label: "profile.actions.profile", + href: `/settings/account`, + highlight: (pathname: string) => pathname === "/settings/account/", + }, + security: { + key: "security", + i18n_label: "profile.actions.security", + href: `/settings/account/security`, + highlight: (pathname: string) => pathname === "/settings/account/security/", + }, + activity: { + key: "activity", + i18n_label: "profile.actions.activity", + href: `/settings/account/activity`, + highlight: (pathname: string) => pathname === "/settings/account/activity/", + }, + preferences: { + key: "preferences", + i18n_label: "profile.actions.preferences", + href: `/settings/account/preferences`, + highlight: (pathname: string) => pathname === "/settings/account/preferences", + }, + notifications: { + key: "notifications", + i18n_label: "profile.actions.notifications", + href: `/settings/account/notifications`, + highlight: (pathname: string) => pathname === "/settings/account/notifications/", + }, + "api-tokens": { + key: "api-tokens", + i18n_label: "profile.actions.api-tokens", + href: `/settings/account/api-tokens`, + highlight: (pathname: string) => pathname === "/settings/account/api-tokens/", + }, +}; export const PROFILE_ACTION_LINKS: { key: string; i18n_label: string; href: string; highlight: (pathname: string) => boolean; }[] = [ - { - key: "profile", - i18n_label: "profile.actions.profile", - href: `/profile`, - highlight: (pathname: string) => pathname === "/profile/", - }, - { - key: "security", - i18n_label: "profile.actions.security", - href: `/profile/security`, - highlight: (pathname: string) => pathname === "/profile/security/", - }, - { - key: "activity", - i18n_label: "profile.actions.activity", - href: `/profile/activity`, - highlight: (pathname: string) => pathname === "/profile/activity/", - }, - { - key: "appearance", - i18n_label: "profile.actions.appearance", - href: `/profile/appearance`, - highlight: (pathname: string) => pathname.includes("/profile/appearance"), - }, - { - key: "notifications", - i18n_label: "profile.actions.notifications", - href: `/profile/notifications`, - highlight: (pathname: string) => pathname === "/profile/notifications/", - }, + PROFILE_SETTINGS["profile"], + PROFILE_SETTINGS["security"], + PROFILE_SETTINGS["activity"], + PROFILE_SETTINGS["preferences"], + PROFILE_SETTINGS["notifications"], + PROFILE_SETTINGS["api-tokens"], ]; export const PROFILE_VIEWER_TAB = [ @@ -72,6 +86,23 @@ export const PROFILE_ADMINS_TAB = [ }, ]; +export const PREFERENCE_OPTIONS: { + id: string; + title: string; + description: string; +}[] = [ + { + id: "theme", + 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.", + }, +]; + /** * @description The start of the week for the user * @enum {number} diff --git a/packages/constants/src/settings.ts b/packages/constants/src/settings.ts new file mode 100644 index 0000000000..f42374dc73 --- /dev/null +++ b/packages/constants/src/settings.ts @@ -0,0 +1,52 @@ +import { PROFILE_SETTINGS } from "."; +import { WORKSPACE_SETTINGS } from "./workspace"; + +export enum WORKSPACE_SETTINGS_CATEGORY { + ADMINISTRATION = "administration", + FEATURES = "features", + DEVELOPER = "developer", +} + +export enum PROFILE_SETTINGS_CATEGORY { + YOUR_PROFILE = "your profile", + DEVELOPER = "developer", +} + +export enum PROJECT_SETTINGS_CATEGORY { + PROJECTS = "projects", +} + +export const WORKSPACE_SETTINGS_CATEGORIES = [ + WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION, + WORKSPACE_SETTINGS_CATEGORY.FEATURES, + WORKSPACE_SETTINGS_CATEGORY.DEVELOPER, +]; + +export const PROFILE_SETTINGS_CATEGORIES = [ + PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE, + PROFILE_SETTINGS_CATEGORY.DEVELOPER, +]; + +export const PROJECT_SETTINGS_CATEGORIES = [PROJECT_SETTINGS_CATEGORY.PROJECTS]; + +export const GROUPED_WORKSPACE_SETTINGS = { + [WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [ + WORKSPACE_SETTINGS["general"], + WORKSPACE_SETTINGS["members"], + WORKSPACE_SETTINGS["billing-and-plans"], + WORKSPACE_SETTINGS["export"], + ], + [WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [], + [WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]], +}; + +export const GROUPED_PROFILE_SETTINGS = { + [PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [ + PROFILE_SETTINGS["profile"], + PROFILE_SETTINGS["preferences"], + PROFILE_SETTINGS["notifications"], + PROFILE_SETTINGS["security"], + PROFILE_SETTINGS["activity"], + ], + [PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]], +}; diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index c1c60f392a..25c942dfc6 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -114,13 +114,6 @@ export const WORKSPACE_SETTINGS = { access: [EUserWorkspaceRoles.ADMIN], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`, }, - "api-tokens": { - key: "api-tokens", - i18n_label: "workspace_settings.settings.api_tokens.title", - href: `/settings/api-tokens`, - access: [EUserWorkspaceRoles.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`, - }, }; export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries( @@ -139,7 +132,6 @@ export const WORKSPACE_SETTINGS_LINKS: { WORKSPACE_SETTINGS["billing-and-plans"], WORKSPACE_SETTINGS["export"], WORKSPACE_SETTINGS["webhooks"], - WORKSPACE_SETTINGS["api-tokens"], ]; export const ROLE = { diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index ead40fd1e5..60e79121bc 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -43,7 +43,8 @@ "your_account": "Your account", "security": "Security", "activity": "Activity", - "appearance": "Appearance", + "preferences": "Preferences", + "language_and_time": "Language & Time", "notifications": "Notifications", "workspaces": "Workspaces", "create_workspace": "Create workspace", @@ -56,6 +57,10 @@ "something_went_wrong_please_try_again": "Something went wrong. Please try again.", "load_more": "Load more", "select_or_customize_your_interface_color_scheme": "Select or customize your interface color scheme.", + "timezone_setting": "Current timezone setting.", + "language_setting": "Choose the language used in the user interface.", + "settings_moved_to_preferences": "Timezone & Language settings have been moved to preferences.", + "go_to_preferences": "Go to preferences", "theme": "Theme", "system_preference": "System preference", "light": "Light", @@ -334,6 +339,8 @@ "new_password_must_be_different_from_old_password": "New password must be different from old password", "edited": "edited", "bot": "Bot", + "settings_description": "Manage your account, workspace, and project preferences all in one place. Switch between tabs to easily configure.", + "back_to_workspace": "Back to workspace", "project_view": { "sort_by": { "created_at": "Created at", @@ -1301,6 +1308,28 @@ } } }, + "account_settings": { + "profile":{}, + "preferences":{ + "heading": "Preferences", + "description": "Customize your app experience the way you work" + }, + "notifications":{ + "heading": "Email notifications", + "description": "Stay in the loop on Work items you are subscribed to. Enable this to get notified." + }, + "security":{ + "heading": "Security" + }, + "api_tokens":{ + "heading": "Personal Access Tokens", + "description": "Generate secure API tokens to integrate your data with external systems and applications." + }, + "activity":{ + "heading": "Activity", + "description": "Track your recent actions and changes across all projects and work items." + } + }, "workspace_settings": { "label": "Workspace settings", "page_label": "{workspace} - General settings", @@ -1367,16 +1396,22 @@ } }, "billing_and_plans": { + "heading": "Billing & Plans", + "description":"Choose your plan, manage subscriptions, and easily upgrade as your needs grow.", "title": "Billing & Plans", "current_plan": "Current plan", "free_plan": "You are currently using the free plan", "view_plans": "View plans" }, "exports": { + "heading": "Exports", + "description": "Export your project data in various formats and access your export history with download links.", "title": "Exports", "exporting": "Exporting", "previous_exports": "Previous exports", "export_separate_files": "Export the data into separate files", + "exporting_projects": "Exporting project", + "format": "Format", "modal": { "title": "Export to", "toasts": { @@ -1392,6 +1427,8 @@ } }, "webhooks": { + "heading": "Webhooks", + "description": "Automate notifications to external services when project events occur.", "title": "Webhooks", "add_webhook": "Add webhook", "modal": { @@ -1443,29 +1480,29 @@ } }, "api_tokens": { - "title": "API Tokens", - "add_token": "Add API token", + "title": "Personal Access Tokens", + "add_token": "Add personal access token", "create_token": "Create token", "never_expires": "Never expires", "generate_token": "Generate token", "generating": "Generating", "delete": { - "title": "Delete API token", + "title": "Delete personal access token", "description": "Any application using this token will no longer have the access to Plane data. This action cannot be undone.", "success": { "title": "Success!", - "message": "The API token has been successfully deleted" + "message": "The token has been successfully deleted" }, "error": { "title": "Error!", - "message": "The API token could not be deleted" + "message": "The token could not be deleted" } } } }, "empty_state": { "api_tokens": { - "title": "No API tokens created", + "title": "No personal access tokens created", "description": "Plane APIs can be used to integrate your data in Plane with any external system. Create a token to get started." }, "webhooks": { @@ -1515,8 +1552,9 @@ "profile": "Profile", "security": "Security", "activity": "Activity", - "appearance": "Appearance", - "notifications": "Notifications" + "preferences": "Preferences", + "notifications": "Notifications", + "api-tokens": "Personal Access Tokens" }, "tabs": { "summary": "Summary", @@ -1578,6 +1616,8 @@ } }, "states": { + "heading": "States", + "description": "Define and customize workflow states to track the progress of your work items.", "describe_this_state_for_your_members": "Describe this state for your members.", "empty_state": { "title": "No states available for the {groupKey} group", @@ -1585,6 +1625,8 @@ } }, "labels": { + "heading": "Labels", + "description": "Create custom labels to categorize and organize your work items", "label_title": "Label title", "label_title_is_required": "Label title is required", "label_max_char": "Label name should not exceed 255 characters", @@ -1593,9 +1635,11 @@ } }, "estimates": { + "heading": "Estimates", + "description": "Set up estimation systems to track and communicate the effort required for each work item.", "label": "Estimates", "title": "Enable estimates for my project", - "description": "They help you in communicating complexity and workload of the team.", + "enable_description": "They help you in communicating complexity and workload of the team.", "no_estimate": "No estimate", "new": "New estimate system", "create": { @@ -1677,6 +1721,8 @@ }, "automations": { "label": "Automations", + "heading": "Automations", + "description": "Configure automated actions to streamline your project management workflow and reduce manual tasks.", "auto-archive": { "title": "Auto-archive closed work items", "description": "Plane will auto archive work items that have been completed or canceled.", diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index eda9a022af..7694c24064 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -79,6 +79,8 @@ export interface IUserSettings { workspace: { last_workspace_id: string | undefined; last_workspace_slug: string | undefined; + last_workspace_name: string | undefined; + last_workspace_logo: string | undefined; fallback_workspace_id: string | undefined; fallback_workspace_slug: string | undefined; invites: number | undefined; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx index 5dfcd25f9d..e23396f4df 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -69,7 +69,7 @@ const ProjectCyclesPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.cycle.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !hasAdminLevelPermission, }} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx index 5cb8509e0a..5b6ad8b5f1 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx @@ -42,7 +42,7 @@ const ProjectInboxPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.inbox.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx index 572cb3862f..7c62a4d51a 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx @@ -61,7 +61,7 @@ const ProjectModulesPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.module.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index 56d89d7f45..270faf985d 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -54,7 +54,7 @@ const ProjectPagesPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.page.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx deleted file mode 100644 index 221ecf4428..0000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { FC, ReactNode } from "react"; -// components -import { AppHeader } from "@/components/core"; -// local components -import { ProjectSettingHeader } from "../header"; -import { ProjectSettingsSidebar } from "./sidebar"; - -export interface IProjectSettingLayout { - children: ReactNode; -} - -const ProjectSettingLayout: FC = (props) => { - const { children } = props; - return ( - <> - } /> -
-
- -
-
-
- {children} -
-
-
- - ); -}; - -export default ProjectSettingLayout; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx deleted file mode 100644 index 7bb1984c89..0000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import React from "react"; -import range from "lodash/range"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams, usePathname } from "next/navigation"; -import { EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -// ui -import { Loader } from "@plane/ui"; -// components -import { SidebarNavItem } from "@/components/sidebar"; -// hooks -import { useUserPermissions } from "@/hooks/store"; -// plane web constants -import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; - -export const ProjectSettingsSidebar = observer(() => { - const { workspaceSlug, projectId } = useParams(); - const pathname = usePathname(); - // mobx store - const { allowPermissions, projectUserInfo } = useUserPermissions(); - - const { t } = useTranslation(); - - // derived values - const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role; - - if (!currentProjectRole) { - return ( -
-
- SETTINGS - - {range(8).map((index) => ( - - ))} - -
-
- ); - } - - return ( -
-
- SETTINGS -
- {PROJECT_SETTINGS_LINKS.map( - (link) => - allowPermissions( - link.access, - EUserPermissionsLevel.PROJECT, - workspaceSlug.toString(), - projectId.toString() - ) && ( - - - {t(link.i18n_label)} - - - ) - )} -
-
-
- ); -}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx deleted file mode 100644 index 6fa36db34e..0000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// ui -import { Settings } from "lucide-react"; -import { EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, CustomMenu, Header } from "@plane/ui"; -// components -import { BreadcrumbLink } from "@/components/common"; -// hooks -import { useProject, useUserPermissions } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; -// plane web -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; -import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; - -export const ProjectSettingHeader: FC = observer(() => { - // router - const router = useAppRouter(); - const { workspaceSlug, projectId } = useParams(); - // store hooks - const { allowPermissions } = useUserPermissions(); - const { loader } = useProject(); - - const { t } = useTranslation(); - - return ( -
- -
-
- - -
- } /> - } - /> -
-
-
-
- - Settings - - } - placement="bottom-start" - closeOnSelect - > - {PROJECT_SETTINGS_LINKS.map( - (item) => - allowPermissions( - item.access, - EUserPermissionsLevel.PROJECT, - workspaceSlug.toString(), - projectId.toString() - ) && ( - router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)} - > - {t(item.i18n_label)} - - ) - )} - -
-
- ); -}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx index d4a3051ec9..4e21defc93 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -68,7 +68,7 @@ const ProjectViewsPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.view.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx deleted file mode 100644 index e51106bfe5..0000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { FC, ReactNode } from "react"; -import { observer } from "mobx-react"; -// components -import { useParams, usePathname } from "next/navigation"; -import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; -import { NotAuthorizedView } from "@/components/auth-screens"; -import { AppHeader } from "@/components/core"; -// hooks -import { useUserPermissions } from "@/hooks/store"; -// plane web constants -// local components -import { WorkspaceSettingHeader } from "../header"; -import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs"; -import { WorkspaceSettingsSidebar } from "./sidebar"; - -export interface IWorkspaceSettingLayout { - children: ReactNode; -} - -const WorkspaceSettingLayout: FC = observer((props) => { - const { children } = props; - - const { workspaceUserInfo } = useUserPermissions(); - const pathname = usePathname(); - const [workspaceSlug, suffix, route] = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes - - // derived values - const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role; - const isAuthorized = - pathname && - workspaceSlug && - userWorkspaceRole && - WORKSPACE_SETTINGS_ACCESS[route ? `/${suffix}/${route}` : `/${suffix}`]?.includes( - userWorkspaceRole as EUserWorkspaceRoles - ); - - return ( - <> - } /> - -
- {workspaceUserInfo && !isAuthorized ? ( - - ) : ( - <> -
- -
-
-
- {children} -
-
- - )} -
- - ); -}); - -export default WorkspaceSettingLayout; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx deleted file mode 100644 index 95cb20c6cd..0000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import React from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams, usePathname } from "next/navigation"; -import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -// components -import { SidebarNavItem } from "@/components/sidebar"; -// hooks -import { useUserPermissions } from "@/hooks/store"; -// plane web helpers -import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; - -export const WorkspaceSettingsSidebar = observer(() => { - // router - const { workspaceSlug } = useParams(); - const pathname = usePathname(); - // mobx store - const { t } = useTranslation(); - const { allowPermissions } = useUserPermissions(); - - return ( -
-
- {t("settings")} -
- {WORKSPACE_SETTINGS_LINKS.map( - (link) => - shouldRenderSettingLink(workspaceSlug.toString(), link.key) && - allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && ( - - - {t(link.i18n_label)} - - - ) - )} -
-
-
- ); -}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/header.tsx deleted file mode 100644 index 003e727431..0000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/header.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react"; -import { Settings } from "lucide-react"; -// ui -import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, Header } from "@plane/ui"; -// components -import { BreadcrumbLink } from "@/components/common"; -// hooks -import { useWorkspace } from "@/hooks/store"; - -export const WorkspaceSettingHeader: FC = observer(() => { - const { currentWorkspace, loader } = useWorkspace(); - const { t } = useTranslation(); - - return ( -
- - - } - /> - } - /> - } /> - - -
- ); -}); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx new file mode 100644 index 0000000000..d05da40583 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { CommandPalette } from "@/components/command-palette"; +import { ContentWrapper } from "@/components/core"; +import { SettingsContentLayout, SettingsHeader } from "@/components/settings"; +import { AuthenticationWrapper } from "@/lib/wrappers"; +import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; + +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
+ {/* Header */} + + {/* Content */} + + {children} + +
+
+
+ ); +} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx similarity index 83% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx index 801b04b378..0286476694 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx @@ -6,6 +6,7 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useUserPermissions, useWorkspace } from "@/hooks/store"; // plane web components import { BillingRoot } from "@/plane-web/components/workspace"; @@ -19,14 +20,14 @@ const BillingSettingsPage = observer(() => { const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined; if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { - return ; + return ; } return ( - <> + - + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx similarity index 75% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx index 1b63406de3..9f08259c6e 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx @@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import ExportGuide from "@/components/exporter/guide"; // helpers +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { cn } from "@/helpers/common.helper"; // hooks import { useUserPermissions, useWorkspace } from "@/hooks/store"; @@ -29,23 +30,24 @@ const ExportsPage = observer(() => { // if user is not authorized to view this page if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { - return ; + return ; } return ( - <> +
-
-

{t("workspace_settings.settings.exports.title")}

-
+
- +
); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx similarity index 61% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx index 718742804a..10d1a76e66 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx @@ -3,40 +3,32 @@ import { observer } from "mobx-react"; // components import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import IntegrationGuide from "@/components/integration/guide"; // hooks +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { useUserPermissions, useWorkspace } from "@/hooks/store"; const ImportsPage = observer(() => { + // router // store hooks const { currentWorkspace } = useWorkspace(); const { allowPermissions } = useUserPermissions(); - // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined; - if (!isAdmin) - return ( - <> - -
-

You are not authorized to access this page.

-
- - ); + if (!isAdmin) return ; return ( - <> + -
-
-

Imports

-
+
+
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx similarity index 86% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx index ef31bd82fe..335631a2ed 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx @@ -4,8 +4,10 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; // components import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { SingleIntegrationCard } from "@/components/integration"; +import { SettingsContentWrapper } from "@/components/settings"; import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui"; // constants import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; @@ -26,23 +28,14 @@ const WorkspaceIntegrationsPage = observer(() => { // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined; - - if (!isAdmin) - return ( - <> - -
-

You are not authorized to access this page.

-
- - ); - const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () => workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null ); + if (!isAdmin) return ; + return ( - <> +
@@ -56,7 +49,7 @@ const WorkspaceIntegrationsPage = observer(() => { )}
- +
); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx new file mode 100644 index 0000000000..fb96779947 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { observer } from "mobx-react"; +// components +import { usePathname } from "next/navigation"; +import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; +// hooks +import { NotAuthorizedView } from "@/components/auth-screens"; +import { CommandPalette } from "@/components/command-palette"; +import { SettingsMobileNav } from "@/components/settings"; +import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper"; +import { useUserPermissions } from "@/hooks/store"; +// local components +import { WorkspaceSettingsSidebar } from "./sidebar"; + +export interface IWorkspaceSettingLayout { + children: ReactNode; +} + +const WorkspaceSettingLayout: FC = observer((props) => { + const { children } = props; + // store hooks + const { workspaceUserInfo } = useUserPermissions(); + // next hooks + const pathname = usePathname(); + // derived values + const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname); + const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role; + + const isAuthorized: boolean | string = + pathname && + workspaceSlug && + userWorkspaceRole && + WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles); + + return ( + <> + + +
+ {workspaceUserInfo && !isAuthorized ? ( + + ) : ( +
+
{}
+ {children} +
+ )} +
+ + ); +}); + +export default WorkspaceSettingLayout; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx similarity index 95% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx index 8be7a9d22f..250b5bc02f 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -14,6 +14,7 @@ import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { NotAuthorizedView } from "@/components/auth-screens"; import { CountChip } from "@/components/common"; import { PageHead } from "@/components/core"; +import { SettingsContentWrapper } from "@/components/settings"; import { WorkspaceMembersList } from "@/components/workspace"; // helpers import { cn } from "@/helpers/common.helper"; @@ -95,11 +96,11 @@ const WorkspaceMembersSettingsPage = observer(() => { // if user is not authorized to view this page if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { - return ; + return ; } return ( - <> + { onSubmit={handleWorkspaceInvite} />
@@ -137,7 +138,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
- +
); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx similarity index 85% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx index 6088cf0a50..736c348106 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx @@ -4,6 +4,7 @@ import { observer } from "mobx-react"; // components import { useTranslation } from "@plane/i18n"; import { PageHead } from "@/components/core"; +import { SettingsContentWrapper } from "@/components/settings"; import { WorkspaceDetails } from "@/components/workspace"; // hooks import { useWorkspace } from "@/hooks/store"; @@ -18,10 +19,10 @@ const WorkspaceSettingsPage = observer(() => { : undefined; return ( - <> + - + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx new file mode 100644 index 0000000000..8a97c8b050 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -0,0 +1,73 @@ +import { useParams, usePathname } from "next/navigation"; +import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react"; +import { + EUserPermissionsLevel, + GROUPED_WORKSPACE_SETTINGS, + WORKSPACE_SETTINGS_CATEGORIES, + EUserWorkspaceRoles, + EUserPermissions, + WORKSPACE_SETTINGS_CATEGORY, +} from "@plane/constants"; +import { SettingsSidebar } from "@/components/settings"; +import { useUserPermissions } from "@/hooks/store/user"; +import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; + +const ICONS = { + general: Building, + members: Users, + export: ArrowUpToLine, + "billing-and-plans": CreditCard, + webhooks: Webhook, +}; + +export const WorkspaceActionIcons = ({ + type, + size, + className, +}: { + type: string; + size?: number; + className?: string; +}) => { + if (type === undefined) return null; + const Icon = ICONS[type as keyof typeof ICONS]; + if (!Icon) return null; + return ; +}; + +type TWorkspaceSettingsSidebarProps = { + isMobile?: boolean; +}; + +export const WorkspaceSettingsSidebar = (props: TWorkspaceSettingsSidebarProps) => { + const { isMobile = false } = props; + // router + const pathname = usePathname(); + const { workspaceSlug } = useParams(); // store hooks + const { allowPermissions } = useUserPermissions(); + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + return ( + + isAdmin || ![WORKSPACE_SETTINGS_CATEGORY.FEATURES, WORKSPACE_SETTINGS_CATEGORY.DEVELOPER].includes(category) + )} + groupedSettings={GROUPED_WORKSPACE_SETTINGS} + workspaceSlug={workspaceSlug.toString()} + isActive={(data: { href: string }) => + data.href === "/settings" + ? pathname === `/${workspaceSlug}${data.href}/` + : new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname) + } + shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) => + data.access + ? shouldRenderSettingLink(workspaceSlug.toString(), data.key) && + allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) + : false + } + actionIcons={WorkspaceActionIcons} + /> + ); +}; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx similarity index 96% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx index 5edc914e90..a775ff3b1f 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx @@ -11,6 +11,7 @@ import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; +import { SettingsContentWrapper } from "@/components/settings"; import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks"; // hooks import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store"; @@ -87,7 +88,7 @@ const WebhookDetailsPage = observer(() => { ); return ( - <> + setDeleteWebhookModal(false)} />
@@ -96,7 +97,7 @@ const WebhookDetailsPage = observer(() => {
{currentWebhook && setDeleteWebhookModal(true)} />} - +
); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx similarity index 75% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx index 2623660da8..d1692168ed 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx @@ -7,11 +7,11 @@ import useSWR from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/ui"; // components import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { DetailedEmptyState } from "@/components/empty-state"; +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { WebhookSettingsLoader } from "@/components/ui"; import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; // hooks @@ -48,15 +48,15 @@ const WebhooksListPage = observer(() => { }, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]); if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { - return ; + return ; } if (!webhooks) return ; return ( - <> + -
+
{ setShowCreateWebhookModal(false); }} /> + setShowCreateWebhookModal(true), + }} + /> {Object.keys(webhooks).length > 0 ? (
-
-
{t("workspace_settings.settings.webhooks.title")}
- -
) : (
-
-
{t("workspace_settings.settings.webhooks.title")}
- -
setShowCreateWebhookModal(true), + }} />
)}
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx new file mode 100644 index 0000000000..05777e6489 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button } from "@plane/ui"; +// components +import { PageHead } from "@/components/core"; +import { DetailedEmptyState } from "@/components/empty-state"; +import { ProfileActivityListPage } from "@/components/profile"; +// hooks +import { SettingsHeading } from "@/components/settings"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const PER_PAGE = 100; + +const ProfileActivityPage = observer(() => { + // states + const [pageCount, setPageCount] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [resultsCount, setResultsCount] = useState(0); + const [isEmpty, setIsEmpty] = useState(false); + // plane hooks + const { t } = useTranslation(); + // derived values + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/profile/activity" }); + + const updateTotalPages = (count: number) => setTotalPages(count); + + const updateResultsCount = (count: number) => setResultsCount(count); + + const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty); + + const handleLoadMore = () => setPageCount((prev) => prev + 1); + + const activityPages: JSX.Element[] = []; + for (let i = 0; i < pageCount; i++) + activityPages.push( + + ); + + const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; + + if (isEmpty) { + return ( +
+ + +
+ ); + } + + return ( + <> + + +
{activityPages}
+ {isLoadMoreVisible && ( +
+ +
+ )} + + ); +}); + +export default ProfileActivityPage; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx similarity index 71% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx index 21334ff23c..10461db071 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx @@ -7,12 +7,12 @@ import useSWR from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/ui"; // component import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { DetailedEmptyState } from "@/components/empty-state"; +import { SettingsHeading } from "@/components/settings"; import { APITokenSettingsLoader } from "@/components/ui"; import { API_TOKENS_LIST } from "@/constants/fetch-keys"; // store hooks @@ -48,7 +48,7 @@ const ApiTokensPage = observer(() => { : undefined; if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { - return ; + return ; } if (!tokens) { @@ -56,18 +56,20 @@ const ApiTokensPage = observer(() => { } return ( - <> +
setIsCreateTokenModalOpen(false)} /> -
+
{tokens.length > 0 ? ( <> -
-

{t("workspace_settings.settings.api_tokens.title")}

- -
+ setIsCreateTokenModalOpen(true), + }} + />
{tokens.map((token) => ( @@ -76,23 +78,31 @@ const ApiTokensPage = observer(() => { ) : (
-
-

{t("workspace_settings.settings.api_tokens.title")}

- -
+ setIsCreateTokenModalOpen(true), + }} + />
setIsCreateTokenModalOpen(true), + }} />
)}
- +
); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx new file mode 100644 index 0000000000..9dcffd57cf --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +import { SettingsContentWrapper, SettingsMobileNav } from "@/components/settings"; +import { getProfileActivePath } from "@/components/settings/helper"; +import { ProfileSidebar } from "./sidebar"; + +type Props = { + children: ReactNode; +}; + +const ProfileSettingsLayout = observer((props: Props) => { + const { children } = props; + // router + const pathname = usePathname(); + + return ( + <> + +
+
+ +
+ {children} +
+ + ); +}); + +export default ProfileSettingsLayout; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx new file mode 100644 index 0000000000..cc71877af0 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import useSWR from "swr"; +// components +import { useTranslation } from "@plane/i18n"; +import { PageHead } from "@/components/core"; +import { EmailNotificationForm } from "@/components/profile/notification"; +import { SettingsHeading } from "@/components/settings"; +import { EmailSettingsLoader } from "@/components/ui"; +// services +import { UserService } from "@/services/user.service"; + +const userService = new UserService(); + +export default function ProfileNotificationPage() { + const { t } = useTranslation(); + // fetching user email notification settings + const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => + userService.currentUserEmailNotificationSettings() + ); + + if (!data || isLoading) { + return ; + } + + return ( + <> + + + + + + ); +} diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx new file mode 100644 index 0000000000..f37178c2a0 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// components +import { LogoSpinner } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { ProfileForm } from "@/components/profile"; +// hooks +import { useUser } from "@/hooks/store"; + +const ProfileSettingsPage = observer(() => { + const { t } = useTranslation(); + // store hooks + const { data: currentUser, userProfile } = useUser(); + + if (!currentUser) + return ( +
+ +
+ ); + + return ( + <> + + + + ); +}); + +export default ProfileSettingsPage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx new file mode 100644 index 0000000000..81c37ae4c4 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// components +import { LogoSpinner } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { PreferencesList } from "@/components/preferences/list"; +import { LanguageTimezone, ProfileSettingContentHeader } from "@/components/profile"; +// hooks +import { SettingsHeading } from "@/components/settings"; +import { useUserProfile } from "@/hooks/store"; +const ProfileAppearancePage = observer(() => { + const { t } = useTranslation(); + // hooks + const { data: userProfile } = useUserProfile(); + + return ( + <> + + {userProfile ? ( + <> +
+
+ + +
+
+ + +
+
+ + ) : ( +
+ +
+ )} + + ); +}); + +export default ProfileAppearancePage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx new file mode 100644 index 0000000000..b9cdf9d268 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { Eye, EyeOff } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { PasswordStrengthMeter } from "@/components/account"; +import { PageHead } from "@/components/core"; +import { ProfileSettingContentHeader } from "@/components/profile"; +// helpers +import { authErrorHandler } from "@/helpers/authentication.helper"; +import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; +// hooks +import { useUser } from "@/hooks/store"; +// services +import { AuthService } from "@/services/auth.service"; + +export interface FormValues { + old_password: string; + new_password: string; + confirm_password: string; +} + +const defaultValues: FormValues = { + old_password: "", + new_password: "", + confirm_password: "", +}; + +const authService = new AuthService(); + +const defaultShowPassword = { + oldPassword: false, + password: false, + confirmPassword: false, +}; + +const SecurityPage = observer(() => { + // store + const { data: currentUser, changePassword } = useUser(); + // states + const [showPassword, setShowPassword] = useState(defaultShowPassword); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + + // use form + const { + control, + handleSubmit, + watch, + formState: { errors, isSubmitting }, + reset, + } = useForm({ defaultValues }); + // derived values + const oldPassword = watch("old_password"); + const password = watch("new_password"); + const confirmPassword = watch("confirm_password"); + const oldPasswordRequired = !currentUser?.is_password_autoset; + // i18n + const { t } = useTranslation(); + + const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword; + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleChangePassword = async (formData: FormValues) => { + const { old_password, new_password } = formData; + try { + const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token); + if (!csrfToken) throw new Error("csrf token not found"); + + await changePassword(csrfToken, { + ...(oldPasswordRequired && { old_password }), + new_password, + }); + + reset(defaultValues); + setShowPassword(defaultShowPassword); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("auth.common.password.toast.change_password.success.title"), + message: t("auth.common.password.toast.change_password.success.message"), + }); + } catch (err: any) { + const errorInfo = authErrorHandler(err.error_code?.toString()); + setToast({ + type: TOAST_TYPE.ERROR, + title: errorInfo?.title ?? t("auth.common.password.toast.error.title"), + message: + typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"), + }); + } + }; + + const isButtonDisabled = + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID || + (oldPasswordRequired && oldPassword.trim() === "") || + password.trim() === "" || + confirmPassword.trim() === "" || + password !== confirmPassword || + password === oldPassword; + + const passwordSupport = password.length > 0 && + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( + + ); + + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + return ( + <> + + +
+
+ {oldPasswordRequired && ( +
+

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

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

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

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

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

+
+ ( + setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + )} + /> + {showPassword?.confirmPassword ? ( + handleShowPassword("confirmPassword")} + /> + ) : ( + handleShowPassword("confirmPassword")} + /> + )} +
+ {!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )} +
+
+ +
+ +
+
+ + ); +}); + +export default SecurityPage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx new file mode 100644 index 0000000000..6e495daff1 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx @@ -0,0 +1,82 @@ +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks, Lock } from "lucide-react"; +import { + EUserPermissions, + EUserPermissionsLevel, + GROUPED_PROFILE_SETTINGS, + PROFILE_SETTINGS_CATEGORIES, + PROFILE_SETTINGS_CATEGORY, +} from "@plane/constants"; +import { SettingsSidebar } from "@/components/settings"; +import { getFileURL } from "@/helpers/file.helper"; +import { useUser, useUserPermissions } from "@/hooks/store/user"; + +const ICONS = { + profile: CircleUser, + security: Lock, + activity: Activity, + preferences: Settings2, + notifications: Bell, + "api-tokens": KeyRound, + connections: Blocks, +}; + +export const ProjectActionIcons = ({ type, size, className }: { type: string; size?: number; className?: string }) => { + if (type === undefined) return null; + const Icon = ICONS[type as keyof typeof ICONS]; + if (!Icon) return null; + return ; +}; + +type TProfileSidebarProps = { + isMobile?: boolean; +}; + +export const ProfileSidebar = observer((props: TProfileSidebarProps) => { + const { isMobile = false } = props; + // router + const pathname = usePathname(); + const { workspaceSlug } = useParams(); + // store hooks + const { data: currentUser } = useUser(); + + const { allowPermissions } = useUserPermissions(); + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + return ( + isAdmin || category !== PROFILE_SETTINGS_CATEGORY.DEVELOPER + )} + groupedSettings={GROUPED_PROFILE_SETTINGS} + workspaceSlug={workspaceSlug?.toString() ?? ""} + isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`} + customHeader={ +
+
+ {!currentUser?.avatar_url || currentUser?.avatar_url === "" ? ( +
+ +
+ ) : ( +
+ {currentUser?.display_name} +
+ )} +
+
+
{currentUser?.display_name}
+
{currentUser?.email}
+
+
+ } + actionIcons={ProjectActionIcons} + shouldRender + /> + ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx similarity index 79% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx index 5fc536d91b..c7542b4f06 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx @@ -13,6 +13,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; import { PageHead } from "@/components/core"; // hooks +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const AutomationSettingsPage = observer(() => { @@ -43,20 +44,21 @@ const AutomationSettingsPage = observer(() => { const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined; if (workspaceUserInfo && !canPerformProjectAdminActions) { - return ; + return ; } return ( - <> + -
-
-

{t("project_settings.automations.label")}

-
+
+
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx similarity index 80% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx index 0a19713e88..db9d17e89b 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx @@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { EstimateRoot } from "@/components/estimates"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const EstimatesSettingsPage = observer(() => { @@ -23,22 +24,20 @@ const EstimatesSettingsPage = observer(() => { if (!workspaceSlug || !projectId) return <>; if (workspaceUserInfo && !canPerformProjectAdminActions) { - return ; + return ; } return ( - <> + -
+
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx similarity index 81% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx index 23aa8ad45c..d84ba10c4a 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx @@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectFeaturesList } from "@/components/project"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const FeaturesSettingsPage = observer(() => { @@ -23,20 +24,20 @@ const FeaturesSettingsPage = observer(() => { if (!workspaceSlug || !projectId) return null; if (workspaceUserInfo && !canPerformProjectAdminActions) { - return ; + return ; } return ( - <> + -
+
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx similarity index 87% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx index 17a466a801..317e769298 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx @@ -10,6 +10,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectSettingsLabelList } from "@/components/labels"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const LabelsSettingsPage = observer(() => { @@ -38,19 +39,19 @@ const LabelsSettingsPage = observer(() => { element, }) ); - }, [scrollableContainerRef?.current]); + }, []); if (workspaceUserInfo && !canPerformProjectMemberActions) { - return ; + return ; } return ( - <> + -
+
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx similarity index 83% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx index 9deaef126a..06990217fe 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx @@ -7,6 +7,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const MembersSettingsPage = observer(() => { @@ -23,17 +24,17 @@ const MembersSettingsPage = observer(() => { const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin; if (workspaceUserInfo && !canPerformProjectMemberActions) { - return ; + return ; } return ( - <> + -
+
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx similarity index 91% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx index 96ff1bcc3f..cf79fa127d 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx @@ -16,9 +16,9 @@ import { ProjectDetailsFormLoader, } from "@/components/project"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; - -const GeneralSettingsPage = observer(() => { +const ProjectSettingsPage = observer(() => { // states const [selectProject, setSelectedProject] = useState(null); const [archiveProject, setArchiveProject] = useState(false); @@ -45,7 +45,7 @@ const GeneralSettingsPage = observer(() => { const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined; return ( - <> + {currentProjectDetails && workspaceSlug && projectId && ( <> @@ -64,7 +64,7 @@ const GeneralSettingsPage = observer(() => { )} -
+
{currentProjectDetails && workspaceSlug && projectId && !isLoading ? ( { )}
- + ); }); -export default GeneralSettingsPage; +export default ProjectSettingsPage; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx similarity index 68% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx index 54fca1c082..30f6c3da63 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx @@ -9,6 +9,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectStateRoot } from "@/components/project-states"; // hook +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const StatesSettingsPage = observer(() => { @@ -28,19 +29,22 @@ const StatesSettingsPage = observer(() => { ); if (workspaceUserInfo && !canPerformProjectMemberActions) { - return ; + return ; } return ( - <> + -
-

{t("common.states")}

+
+ + {workspaceSlug && projectId && ( + + )}
- {workspaceSlug && projectId && ( - - )} - + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx new file mode 100644 index 0000000000..4701775b46 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { ReactNode, useEffect } from "react"; +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +// components +import { SettingsMobileNav } from "@/components/settings"; +import { getProjectActivePath } from "@/components/settings/helper"; +import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar"; +import { useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; + +type Props = { + children: ReactNode; +}; + +const ProjectSettingsLayout = observer((props: Props) => { + const { children } = props; + // router + const router = useAppRouter(); + const pathname = usePathname(); + const { workspaceSlug, projectId } = useParams(); + const { joinedProjectIds } = useProject(); + + useEffect(() => { + if (projectId) return; + if (joinedProjectIds.length > 0) { + router.push(`/${workspaceSlug}/settings/projects/${joinedProjectIds[0]}`); + } + }, [joinedProjectIds, router, workspaceSlug, projectId]); + + return ( + <> + + +
+
{projectId && }
+ {children} +
+
+ + ); +}); + +export default ProjectSettingsLayout; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx new file mode 100644 index 0000000000..65ea627015 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx @@ -0,0 +1,38 @@ +"use client"; +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import { Button, getButtonStyling } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { useCommandPalette } from "@/hooks/store"; + +const ProjectSettingsPage = () => { + // store hooks + const { resolvedTheme } = useTheme(); + const { toggleCreateProjectModal } = useCommandPalette(); + // derived values + const resolvedPath = + resolvedTheme === "dark" + ? "/empty-state/project-settings/no-projects-dark.png" + : "/empty-state/project-settings/no-projects-light.png"; + return ( +
+ No projects yet +
No projects yet
+
+ Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you + need to get things done. +
+
+ + Learn more about projects + + +
+
+ ); +}; + +export default ProjectSettingsPage; diff --git a/web/app/(all)/profile/appearance/page.tsx b/web/app/(all)/profile/appearance/page.tsx index db367e49a7..ac5beec376 100644 --- a/web/app/(all)/profile/appearance/page.tsx +++ b/web/app/(all)/profile/appearance/page.tsx @@ -11,7 +11,7 @@ import { setPromiseToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; import { ThemeSwitch, PageHead, CustomThemeSelector } from "@/components/core"; -import { ProfileSettingContentHeader, ProfileSettingContentWrapper, StartOfWeekPreference } from "@/components/profile"; +import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile"; // helpers import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; // hooks @@ -75,7 +75,6 @@ const ProfileAppearancePage = observer(() => {
{userProfile?.theme?.theme === "custom" && } - ) : (
diff --git a/web/ce/components/preferences/config.ts b/web/ce/components/preferences/config.ts new file mode 100644 index 0000000000..1a67ab7d34 --- /dev/null +++ b/web/ce/components/preferences/config.ts @@ -0,0 +1,7 @@ +import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference"; +import { ThemeSwitcher } from "./theme-switcher"; + +export const PREFERENCE_COMPONENTS = { + theme: ThemeSwitcher, + start_of_week: StartOfWeekPreference, +}; diff --git a/web/ce/components/preferences/theme-switcher.tsx b/web/ce/components/preferences/theme-switcher.tsx new file mode 100644 index 0000000000..6fd3972310 --- /dev/null +++ b/web/ce/components/preferences/theme-switcher.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { IUserTheme } from "@plane/types"; +import { setPromiseToast } from "@plane/ui"; + +// components +import { CustomThemeSelector, ThemeSwitch } from "@/components/core"; +// helpers +import { PreferencesSection } from "@/components/preferences/section"; +import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; +// hooks +import { useUserProfile } from "@/hooks/store"; + +export const ThemeSwitcher = observer( + (props: { + option: { + id: string; + title: string; + description: string; + }; + }) => { + // hooks + const { setTheme } = useTheme(); + const { data: userProfile, updateUserTheme } = useUserProfile(); + + // states + const [currentTheme, setCurrentTheme] = useState(null); + + const { t } = useTranslation(); + + // initialize theme + useEffect(() => { + if (!userProfile?.theme?.theme) return; + + const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile.theme.theme); + + if (userThemeOption) { + setCurrentTheme(userThemeOption); + } + }, [userProfile?.theme?.theme]); + + // handlers + const applyThemeChange = useCallback( + (theme: Partial) => { + const themeValue = theme?.theme || "system"; + setTheme(themeValue); + + if (theme?.theme === "custom" && theme?.palette) { + const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5"; + const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette; + applyTheme(palette, false); + } else { + unsetCustomCssVariables(); + } + }, + [setTheme] + ); + + const handleThemeChange = useCallback( + async (themeOption: I_THEME_OPTION) => { + try { + applyThemeChange({ theme: 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); + } + }, + [applyThemeChange, updateUserTheme] + ); + + if (!userProfile) return null; + + return ( + <> + + +
+ } + /> + {userProfile.theme?.theme === "custom" && } + + ); + } +); diff --git a/web/ce/components/workspace/billing/root.tsx b/web/ce/components/workspace/billing/root.tsx index f760525841..6dd4521340 100644 --- a/web/ce/components/workspace/billing/root.tsx +++ b/web/ce/components/workspace/billing/root.tsx @@ -6,9 +6,11 @@ import { EProductSubscriptionEnum, SUBSCRIPTION_WITH_BILLING_FREQUENCY, } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { TBillingFrequency, TProductBillingFrequency } from "@plane/types"; import { cn } from "@plane/utils"; // components +import { SettingsHeading } from "@/components/settings"; import { getSubscriptionTextColor } from "@/components/workspace/billing/subscription"; // local imports import { PlansComparison } from "./comparison/root"; @@ -20,6 +22,7 @@ export const BillingRoot = observer(() => { const [productBillingFrequency, setProductBillingFrequency] = useState( DEFAULT_PRODUCT_BILLING_FREQUENCY ); + const { t } = useTranslation(); /** * Retrieves the billing frequency for a given subscription type @@ -56,11 +59,10 @@ export const BillingRoot = observer(() => { return (
-
-
-

Billing and plans

-
-
+
pathname === `${baseUrl}/settings/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`, Icon: SettingIcon, }, members: { key: "members", i18n_label: "members", - href: `/settings/members`, + href: `/members`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`, Icon: SettingIcon, }, features: { key: "features", i18n_label: "common.features", - href: `/settings/features`, + href: `/features`, access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/`, Icon: SettingIcon, }, states: { key: "states", i18n_label: "common.states", - href: `/settings/states`, + href: `/states`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/states/`, Icon: SettingIcon, }, labels: { key: "labels", i18n_label: "common.labels", - href: `/settings/labels`, + href: `/labels`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/labels/`, Icon: SettingIcon, }, estimates: { key: "estimates", i18n_label: "common.estimates", - href: `/settings/estimates`, + href: `/estimates`, access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/estimates/`, Icon: SettingIcon, }, automations: { key: "automations", i18n_label: "project_settings.automations.label", - href: `/settings/automations`, + href: `/automations`, access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/automations/`, Icon: SettingIcon, }, }; diff --git a/web/core/components/auth-screens/not-authorized-view.tsx b/web/core/components/auth-screens/not-authorized-view.tsx index fe344f468f..58265a41fb 100644 --- a/web/core/components/auth-screens/not-authorized-view.tsx +++ b/web/core/components/auth-screens/not-authorized-view.tsx @@ -12,17 +12,18 @@ type Props = { actionButton?: React.ReactNode; section?: "settings" | "general"; isProjectView?: boolean; + className?: string; }; export const NotAuthorizedView: React.FC = observer((props) => { - const { actionButton, section = "general", isProjectView = false } = props; + const { actionButton, section = "general", isProjectView = false, className } = props; // assets const settingAsset = isProjectView ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg; const asset = section === "settings" ? settingAsset : Unauthorized; return ( - +
ProjectSettingImg diff --git a/web/core/components/core/modals/workspace-image-upload-modal.tsx b/web/core/components/core/modals/workspace-image-upload-modal.tsx index 6b4bf36099..163e7ff297 100644 --- a/web/core/components/core/modals/workspace-image-upload-modal.tsx +++ b/web/core/components/core/modals/workspace-image-upload-modal.tsx @@ -8,7 +8,7 @@ import { Transition, Dialog } from "@headlessui/react"; // plane imports import { ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, MAX_FILE_SIZE } from "@plane/constants"; import { EFileAssetType } from "@plane/types/src/enums"; -import { Button } from "@plane/ui"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // helpers import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper"; import { checkURLValidity } from "@/helpers/string.helper"; @@ -71,9 +71,13 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { ); updateWorkspaceLogo(workspaceSlug.toString(), asset_url); onSuccess(asset_url); - } catch (error) { + } catch (error: any) { console.log("error", error); - throw new Error("Error in uploading file."); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error", + message: error.error || "Something went wrong", + }); } finally { setIsImageUploading(false); } diff --git a/web/core/components/estimates/empty-screen.tsx b/web/core/components/estimates/empty-screen.tsx index 0155e25196..73acce27f8 100644 --- a/web/core/components/estimates/empty-screen.tsx +++ b/web/core/components/estimates/empty-screen.tsx @@ -1,13 +1,10 @@ "use client"; import { FC } from "react"; -import Image from "next/image"; import { useTheme } from "next-themes"; import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/ui"; // public images -import EstimateEmptyDarkImage from "@/public/empty-state/estimates/dark.svg"; -import EstimateEmptyLightImage from "@/public/empty-state/estimates/light.svg"; +import { DetailedEmptyState } from "../empty-state"; type TEstimateEmptyScreen = { onButtonClick: () => void; @@ -20,28 +17,17 @@ export const EstimateEmptyScreen: FC = (props) => { const { t } = useTranslation(); - const emptyScreenImage = resolvedTheme === "light" ? EstimateEmptyLightImage : EstimateEmptyDarkImage; - + const resolvedPath = `/empty-state/project-settings/estimates-${resolvedTheme === "light" ? "light" : "dark"}.png`; return ( -
-
- Empty estimate image -
-
-

- {t("project_settings.empty_state.estimates.title")} -

-

{t("project_settings.empty_state.estimates.description")}

-
-
- -
-
+ ); }; diff --git a/web/core/components/estimates/root.tsx b/web/core/components/estimates/root.tsx index 56a8f5efa8..5d0294f721 100644 --- a/web/core/components/estimates/root.tsx +++ b/web/core/components/estimates/root.tsx @@ -15,6 +15,7 @@ import { import { useProject, useProjectEstimates } from "@/hooks/store"; // plane web components import { UpdateEstimateModal } from "@/plane-web/components/estimates"; +import { SettingsHeading } from "../settings"; type TEstimateRoot = { workspaceSlug: string; @@ -46,9 +47,11 @@ export const EstimateRoot: FC = observer((props) => { ) : (
{/* header */} -
-

{t("common.estimates")}

-
+ + {/* current active estimate section */} {currentActiveEstimateId ? ( @@ -57,7 +60,7 @@ export const EstimateRoot: FC = observer((props) => {

{t("project_settings.estimates.title")}

-

{t("project_settings.estimates.description")}

+

{t("project_settings.estimates.enable_description")}

diff --git a/web/core/components/exporter/column.tsx b/web/core/components/exporter/column.tsx new file mode 100644 index 0000000000..113478c9db --- /dev/null +++ b/web/core/components/exporter/column.tsx @@ -0,0 +1,112 @@ +import { Download } from "lucide-react"; +import { IExportData } from "@plane/types"; +import { getDate, getFileURL, renderFormattedDate } from "@plane/utils"; + +type RowData = IExportData; +const checkExpiry = (inputDateString: string) => { + const currentDate = new Date(); + const expiryDate = getDate(inputDateString); + if (!expiryDate) return false; + expiryDate.setDate(expiryDate.getDate() + 7); + return expiryDate > currentDate; +}; +export const useExportColumns = () => { + const columns = [ + { + key: "Exported By", + content: "Exported By", + tdRender: (rowData: RowData) => { + const { avatar_url, display_name, email } = rowData.initiated_by_detail; + return ( +
+
+ {avatar_url && avatar_url.trim() !== "" ? ( + + {display_name + + ) : ( + + {(email ?? display_name ?? "?")[0]} + + )} +
+
{display_name}
+
+ ); + }, + }, + { + key: "Exported On", + content: "Exported On", + tdRender: (rowData: RowData) => {renderFormattedDate(rowData.created_at)}, + }, + + { + key: "Exported projects", + content: "Exported projects", + tdRender: (rowData: RowData) =>
{rowData.project.length} project(s)
, + }, + { + key: "Format", + content: "Format", + tdRender: (rowData: RowData) => ( + + {rowData.provider === "csv" + ? "CSV" + : rowData.provider === "xlsx" + ? "Excel" + : rowData.provider === "json" + ? "JSON" + : ""} + + ), + }, + { + key: "Status", + content: "Status", + tdRender: (rowData: RowData) => ( + + {rowData.status} + + ), + }, + { + key: "Download", + content: "Download", + tdRender: (rowData: RowData) => + checkExpiry(rowData.created_at) ? ( + <> + {rowData.status == "completed" ? ( + + + + ) : ( + "-" + )} + + ) : ( +
Expired
+ ), + }, + ]; + return columns; +}; diff --git a/web/core/components/exporter/export-form.tsx b/web/core/components/exporter/export-form.tsx new file mode 100644 index 0000000000..b108d2e4c5 --- /dev/null +++ b/web/core/components/exporter/export-form.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { intersection } from "lodash"; +import { Controller, useForm } from "react-hook-form"; +import { EUserPermissions, EUserPermissionsLevel, EXPORTERS_LIST } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button, CustomSearchSelect, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; +import { useProject, useUser, useUserPermissions } from "@/hooks/store"; +import { ProjectExportService } from "@/services/project/project-export.service"; + +type Props = { + workspaceSlug: string; + provider: string | null; + mutateServices: () => void; +}; +type FormData = { + provider: (typeof EXPORTERS_LIST)[0]; + project: string[]; + multiple: boolean; +}; +const projectExportService = new ProjectExportService(); + +export const ExportForm = (props: Props) => { + // props + const { workspaceSlug, mutateServices } = props; + // states + const [exportLoading, setExportLoading] = useState(false); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { data: user, canPerformAnyCreateAction, projectsWithCreatePermissions } = useUser(); + const { workspaceProjectIds, getProjectById } = useProject(); + const { t } = useTranslation(); + // form + const { handleSubmit, control } = useForm({ + defaultValues: { + provider: EXPORTERS_LIST[0], + project: [], + multiple: false, + }, + }); + // derived values + const hasProjects = workspaceProjectIds && workspaceProjectIds.length > 0; + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const wsProjectIdsWithCreatePermisisons = projectsWithCreatePermissions + ? intersection(workspaceProjectIds, Object.keys(projectsWithCreatePermissions)) + : []; + const options = wsProjectIdsWithCreatePermisisons?.map((projectId) => { + const projectDetails = getProjectById(projectId); + + return { + value: projectDetails?.id, + query: `${projectDetails?.name} ${projectDetails?.identifier}`, + content: ( +
+ {projectDetails?.identifier} + {projectDetails?.name} +
+ ), + }; + }); + + // handlers + const ExportCSVToMail = async (formData: FormData) => { + console.log(formData); + setExportLoading(true); + if (workspaceSlug && user) { + const payload = { + provider: formData.provider.provider, + project: formData.project, + multiple: formData.project.length > 1, + }; + await projectExportService + .csvExport(workspaceSlug as string, payload) + .then(() => { + mutateServices(); + setExportLoading(false); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("workspace_settings.settings.exports.modal.toasts.success.title"), + message: t("workspace_settings.settings.exports.modal.toasts.success.message", { + entity: + formData.provider.provider === "csv" + ? "CSV" + : formData.provider.provider === "xlsx" + ? "Excel" + : formData.provider.provider === "json" + ? "JSON" + : "", + }), + }); + }) + .catch(() => { + setExportLoading(false); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("workspace_settings.settings.exports.modal.toasts.error.message"), + }); + }); + } + }; + return ( +
+
+ {/* Project Selector */} +
+
+ {t("workspace_settings.settings.exports.exporting_projects")} +
+ ( + onChange(val)} + options={options} + input + label={ + value && value.length > 0 + ? value + .map((projectId) => { + const projectDetails = getProjectById(projectId); + + return projectDetails?.identifier; + }) + .join(", ") + : "All projects" + } + optionsClassName="max-w-48 sm:max-w-[532px]" + placement="bottom-end" + multiple + /> + )} + /> +
+ {/* Format Selector */} +
+
+ {t("workspace_settings.settings.exports.format")} +
+ ( + + {EXPORTERS_LIST.map((service) => ( + + {t(service.i18n_title)} + + ))} + + )} + /> +
+
+
+ +
+
+ ); +}; diff --git a/web/core/components/exporter/guide.tsx b/web/core/components/exporter/guide.tsx index 1dfd900107..024cd059eb 100644 --- a/web/core/components/exporter/guide.tsx +++ b/web/core/components/exporter/guide.tsx @@ -1,221 +1,38 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; -import Image from "next/image"; -import Link from "next/link"; import { useParams, useSearchParams } from "next/navigation"; -import useSWR, { mutate } from "swr"; -// icons -import { MoveLeft, MoveRight, RefreshCw } from "lucide-react"; -// plane imports -import { EXPORTERS_LIST, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/ui"; -// components -import { DetailedEmptyState } from "@/components/empty-state"; -import { Exporter, SingleExport } from "@/components/exporter"; -import { ImportExportSettingsLoader } from "@/components/ui"; -// constants +import { mutate } from "swr"; import { EXPORT_SERVICES_LIST } from "@/constants/fetch-keys"; -// hooks -import { useProject, useUser, useUserPermissions } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; -// services images -import CSVLogo from "@/public/services/csv.svg"; -import ExcelLogo from "@/public/services/excel.svg"; -import JSONLogo from "@/public/services/json.svg"; -// services -import { IntegrationService } from "@/services/integrations"; - -const integrationService = new IntegrationService(); - -const getExporterLogo = (provider: string) => { - switch (provider) { - case "csv": - return CSVLogo; - case "excel": - return ExcelLogo; - case "xlsx": - return ExcelLogo; - case "json": - return JSONLogo; - default: - return ""; - } -}; +import { ExportForm } from "./export-form"; +import { PrevExports } from "./prev-exports"; const IntegrationGuide = observer(() => { - // states - const [refreshing, setRefreshing] = useState(false); - const per_page = 10; - const [cursor, setCursor] = useState(`10:0:0`); // router - const router = useAppRouter(); const { workspaceSlug } = useParams(); const searchParams = useSearchParams(); const provider = searchParams.get("provider"); - // plane hooks - const { t } = useTranslation(); - // store hooks - const { data: currentUser, canPerformAnyCreateAction } = useUser(); - const { allowPermissions } = useUserPermissions(); - const { workspaceProjectIds } = useProject(); - // derived values - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/exports" }); - - const { data: exporterServices } = useSWR( - workspaceSlug && cursor ? EXPORT_SERVICES_LIST(workspaceSlug as string, cursor, `${per_page}`) : null, - workspaceSlug && cursor - ? () => integrationService.getExportsServicesList(workspaceSlug as string, cursor, per_page) - : null - ); - - const handleRefresh = () => { - setRefreshing(true); - mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`)).then(() => setRefreshing(false)); - }; - - const handleCsvClose = () => { - router.replace(`/${workspaceSlug?.toString()}/settings/exports`); - }; - - const hasProjects = workspaceProjectIds && workspaceProjectIds.length > 0; - const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); - - useEffect(() => { - const interval = setInterval(() => { - if (exporterServices?.results?.some((service) => service.status === "processing")) { - handleRefresh(); - } else { - clearInterval(interval); - } - }, 3000); - - return () => clearInterval(interval); - }, [exporterServices]); + // state + const per_page = 10; + const [cursor, setCursor] = useState(`10:0:0`); return ( <>
<> -
- {EXPORTERS_LIST.map((service) => ( -
-
-
-
- {`${t(service.i18n_title)} -
-
-

{t(service.i18n_title)}

-

{t(service.i18n_description)}

-
-
-
- - - - - -
-
-
- ))} -
-
-
-
-

- {t("workspace_settings.settings.exports.previous_exports")} -

- - -
-
- - -
-
-
- {exporterServices && exporterServices?.results ? ( - exporterServices?.results?.length > 0 ? ( -
-
- {exporterServices?.results.map((service) => ( - - ))} -
-
- ) : ( -
- -
- ) - ) : ( - - )} -
-
- - {provider && ( - handleCsvClose()} - data={null} - user={currentUser || null} + mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`))} /> - )} + +
); diff --git a/web/core/components/exporter/prev-exports.tsx b/web/core/components/exporter/prev-exports.tsx new file mode 100644 index 0000000000..b07d580b3c --- /dev/null +++ b/web/core/components/exporter/prev-exports.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import useSWR, { mutate } from "swr"; +import { MoveLeft, MoveRight, RefreshCw } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +import { IExportData } from "@plane/types"; +import { Table } from "@plane/ui"; +import { EXPORT_SERVICES_LIST } from "@/constants/fetch-keys"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import { IntegrationService } from "@/services/integrations"; +import { DetailedEmptyState } from "../empty-state"; +import { ImportExportSettingsLoader } from "../ui"; +import { useExportColumns } from "./column"; + +const integrationService = new IntegrationService(); + +type Props = { + workspaceSlug: string; + cursor: string | undefined; + per_page: number; + setCursor: (cursor: string) => void; +}; +type RowData = IExportData; +export const PrevExports = observer((props: Props) => { + // props + const { workspaceSlug, cursor, per_page, setCursor } = props; + // state + const [refreshing, setRefreshing] = useState(false); + // hooks + const { t } = useTranslation(); + const columns = useExportColumns(); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/exports" }); + + const { data: exporterServices } = useSWR( + workspaceSlug && cursor ? EXPORT_SERVICES_LIST(workspaceSlug as string, cursor, `${per_page}`) : null, + workspaceSlug && cursor + ? () => integrationService.getExportsServicesList(workspaceSlug as string, cursor, per_page) + : null + ); + + const handleRefresh = () => { + setRefreshing(true); + mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`)).then(() => setRefreshing(false)); + }; + + useEffect(() => { + const interval = setInterval(() => { + if (exporterServices?.results?.some((service) => service.status === "processing")) { + handleRefresh(); + } else { + clearInterval(interval); + } + }, 3000); + + return () => clearInterval(interval); + }, [exporterServices]); + + return ( +
+
+
+

+ {t("workspace_settings.settings.exports.previous_exports")} +

+ + +
+
+ + +
+
+ +
+ {exporterServices && exporterServices?.results ? ( + exporterServices?.results?.length > 0 ? ( +
+
+ rowData?.id ?? ""} + tHeadClassName="border-b border-custom-border-100" + thClassName="text-left font-medium divide-x-0 text-custom-text-400" + tBodyClassName="divide-y-0" + tBodyTrClassName="divide-x-0 p-4 h-[40px] text-custom-text-200" + tHeadTrClassName="divide-x-0" + /> + + + ) : ( +
+ +
+ ) + ) : ( + + )} + + + ); +}); diff --git a/web/core/components/home/widgets/empty-states/no-projects.tsx b/web/core/components/home/widgets/empty-states/no-projects.tsx index 44496aa81c..f3f35d5a11 100644 --- a/web/core/components/home/widgets/empty-states/no-projects.tsx +++ b/web/core/components/home/widgets/empty-states/no-projects.tsx @@ -115,7 +115,7 @@ export const NoProjectsEmptyState = observer(() => { flag: "visited_profile", cta: { text: "home.empty.personalize_account.cta", - link: "/profile", + link: `/${workspaceSlug}/settings/account`, disabled: false, }, }, diff --git a/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx b/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx index b4c3f7ae55..f9aca06f2b 100644 --- a/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx +++ b/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx @@ -72,7 +72,7 @@ export const ProjectArchivedEmptyState: React.FC = observer(() => { assetPath={archivedIssuesResolvedPath} primaryButton={{ text: t("project_issues.empty_state.no_archived_issues.primary_button.text"), - onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`), + onClick: () => router.push(`/${workspaceSlug}/settings/projects/${projectId}/automations`), disabled: !canPerformEmptyStateActions, }} /> diff --git a/web/core/components/labels/project-setting-label-list.tsx b/web/core/components/labels/project-setting-label-list.tsx index 8150eac5fc..2df43aa97c 100644 --- a/web/core/components/labels/project-setting-label-list.tsx +++ b/web/core/components/labels/project-setting-label-list.tsx @@ -19,6 +19,7 @@ import { // hooks import { useLabel, useUserPermissions } from "@/hooks/store"; import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import { SettingsHeading } from "../settings"; // plane web imports export const ProjectSettingsLabelList: React.FC = observer(() => { @@ -75,14 +76,16 @@ export const ProjectSettingsLabelList: React.FC = observer(() => { data={selectDeleteLabel ?? null} onClose={() => setSelectDeleteLabel(null)} /> -
-

Labels

- {isEditable && ( - - )} -
+ +
{showLabelForm && (
@@ -106,6 +109,7 @@ export const ProjectSettingsLabelList: React.FC = observer(() => { title={t("project_settings.empty_state.labels.title")} description={t("project_settings.empty_state.labels.description")} assetPath={resolvedPath} + className="w-full !px-0 !py-4" />
) : ( diff --git a/web/core/components/preferences/list.tsx b/web/core/components/preferences/list.tsx new file mode 100644 index 0000000000..beb08fd1aa --- /dev/null +++ b/web/core/components/preferences/list.tsx @@ -0,0 +1,11 @@ +import { PREFERENCE_OPTIONS } from "@plane/constants"; +import { PREFERENCE_COMPONENTS } from "@/plane-web/components/preferences/config"; + +export const PreferencesList = () => ( +
+ {PREFERENCE_OPTIONS.map((option) => { + const Component = PREFERENCE_COMPONENTS[option.id as keyof typeof PREFERENCE_COMPONENTS]; + return ; + })} +
+); diff --git a/web/core/components/preferences/section.tsx b/web/core/components/preferences/section.tsx new file mode 100644 index 0000000000..5ba35bb55b --- /dev/null +++ b/web/core/components/preferences/section.tsx @@ -0,0 +1,15 @@ +interface SettingsSectionProps { + title: string; + description: string; + control: React.ReactNode; +} + +export const PreferencesSection = ({ title, description, control }: SettingsSectionProps) => ( +
+
+

{title}

+

{description}

+
+
{control}
+
+); diff --git a/web/core/components/profile/activity/profile-activity-list.tsx b/web/core/components/profile/activity/profile-activity-list.tsx index efcd83e730..36eca920fe 100644 --- a/web/core/components/profile/activity/profile-activity-list.tsx +++ b/web/core/components/profile/activity/profile-activity-list.tsx @@ -81,8 +81,8 @@ export const ProfileActivityListPage: React.FC = observer((props) => {
)} - -