mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
11 Commits
refactor/e
...
feat-works
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
427d4888c4 | ||
|
|
b6b7baa9b8 | ||
|
|
57560c214d | ||
|
|
c82e35ff0c | ||
|
|
b6ef8aa541 | ||
|
|
3493939d9d | ||
|
|
71e1de2054 | ||
|
|
767788bdee | ||
|
|
89a29f75ed | ||
|
|
526d7da666 | ||
|
|
12bd0b8879 |
@@ -99,11 +99,16 @@ class UserMeSettingsSerializer(BaseSerializer):
|
||||
workspace_member__member=obj.id,
|
||||
workspace_member__is_active=True,
|
||||
).first()
|
||||
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
|
||||
return {
|
||||
"last_workspace_id": profile.last_workspace_id,
|
||||
"last_workspace_slug": (
|
||||
workspace.slug if workspace is not None else ""
|
||||
),
|
||||
"last_workspace_name": (
|
||||
workspace.name if workspace is not None else ""
|
||||
),
|
||||
"last_workspace_logo": (logo_asset_url),
|
||||
"fallback_workspace_id": profile.last_workspace_id,
|
||||
"fallback_workspace_slug": (
|
||||
workspace.slug if workspace is not None else ""
|
||||
|
||||
@@ -32,3 +32,4 @@ export * from "./dashboard";
|
||||
export * from "./page";
|
||||
export * from "./emoji";
|
||||
export * from "./subscription";
|
||||
export * from "./settings";
|
||||
|
||||
@@ -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/",
|
||||
},
|
||||
appearance: {
|
||||
key: "appearance",
|
||||
i18n_label: "profile.actions.appearance",
|
||||
href: `/settings/account/appearance`,
|
||||
highlight: (pathname: string) => pathname.includes("/settings/account/appearance"),
|
||||
},
|
||||
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["appearance"],
|
||||
PROFILE_SETTINGS["notifications"],
|
||||
PROFILE_SETTINGS["api-tokens"],
|
||||
];
|
||||
|
||||
export const PROFILE_VIEWER_TAB = [
|
||||
|
||||
52
packages/constants/src/settings.ts
Normal file
52
packages/constants/src/settings.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { PROFILE_SETTINGS } from ".";
|
||||
import { WORKSPACE_SETTINGS } from "./workspace";
|
||||
|
||||
export enum WORKSPACE_SETTINGS_CATEGORY {
|
||||
ADMINISTRATION = "administration",
|
||||
FEATURES = "features",
|
||||
DEVELOPER = "developer",
|
||||
}
|
||||
|
||||
export enum PROFILE_SETTINGS_CATEGORY {
|
||||
YOUR_PROFILE = "your profile",
|
||||
DEVELOPER = "developer",
|
||||
}
|
||||
|
||||
export enum PROJECT_SETTINGS_CATEGORY {
|
||||
PROJECTS = "projects",
|
||||
}
|
||||
|
||||
export const WORKSPACE_SETTINGS_CATEGORIES = [
|
||||
WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION,
|
||||
WORKSPACE_SETTINGS_CATEGORY.FEATURES,
|
||||
WORKSPACE_SETTINGS_CATEGORY.DEVELOPER,
|
||||
];
|
||||
|
||||
export const PROFILE_SETTINGS_CATEGORIES = [
|
||||
PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE,
|
||||
PROFILE_SETTINGS_CATEGORY.DEVELOPER,
|
||||
];
|
||||
|
||||
export const PROJECT_SETTINGS_CATEGORIES = [PROJECT_SETTINGS_CATEGORY.PROJECTS];
|
||||
|
||||
export const GROUPED_WORKSPACE_SETTINGS = {
|
||||
[WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [
|
||||
WORKSPACE_SETTINGS["general"],
|
||||
WORKSPACE_SETTINGS["members"],
|
||||
WORKSPACE_SETTINGS["billing-and-plans"],
|
||||
WORKSPACE_SETTINGS["export"],
|
||||
],
|
||||
[WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [],
|
||||
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]],
|
||||
};
|
||||
|
||||
export const GROUPED_PROFILE_SETTINGS = {
|
||||
[PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [
|
||||
PROFILE_SETTINGS["profile"],
|
||||
PROFILE_SETTINGS["security"],
|
||||
PROFILE_SETTINGS["activity"],
|
||||
PROFILE_SETTINGS["appearance"],
|
||||
PROFILE_SETTINGS["notifications"],
|
||||
],
|
||||
[PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]],
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -334,6 +334,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": {
|
||||
@@ -1352,6 +1354,8 @@
|
||||
"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": {
|
||||
@@ -1418,29 +1422,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": {
|
||||
@@ -1492,7 +1496,8 @@
|
||||
"security": "Security",
|
||||
"activity": "Activity",
|
||||
"appearance": "Appearance",
|
||||
"notifications": "Notifications"
|
||||
"notifications": "Notifications",
|
||||
"api-tokens": "Personal Access Tokens"
|
||||
},
|
||||
"tabs": {
|
||||
"summary": "Summary",
|
||||
|
||||
11
packages/types/src/users.d.ts
vendored
11
packages/types/src/users.d.ts
vendored
@@ -76,6 +76,8 @@ export interface IUserSettings {
|
||||
workspace: {
|
||||
last_workspace_id: string | undefined;
|
||||
last_workspace_slug: string | undefined;
|
||||
last_workspace_name: string | undefined;
|
||||
last_workspace_logo: string | undefined;
|
||||
fallback_workspace_id: string | undefined;
|
||||
fallback_workspace_slug: string | undefined;
|
||||
invites: number | undefined;
|
||||
@@ -155,14 +157,7 @@ export interface IUserProfileProjectSegregation {
|
||||
id: string;
|
||||
pending_issues: number;
|
||||
}[];
|
||||
user_data: Pick<
|
||||
IUser,
|
||||
| "avatar_url"
|
||||
| "cover_image_url"
|
||||
| "display_name"
|
||||
| "first_name"
|
||||
| "last_name"
|
||||
> & {
|
||||
user_data: Pick<IUser, "avatar_url" | "cover_image_url" | "display_name" | "first_name" | "last_name"> & {
|
||||
date_joined: Date;
|
||||
user_timezone: string;
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ const ProjectCyclesPage = observer(() => {
|
||||
primaryButton={{
|
||||
text: t("disabled_project.empty_state.cycle.primary_button.text"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
|
||||
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
|
||||
},
|
||||
disabled: !hasAdminLevelPermission,
|
||||
}}
|
||||
|
||||
@@ -42,7 +42,7 @@ const ProjectInboxPage = observer(() => {
|
||||
primaryButton={{
|
||||
text: t("disabled_project.empty_state.inbox.primary_button.text"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
|
||||
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
|
||||
@@ -61,7 +61,7 @@ const ProjectModulesPage = observer(() => {
|
||||
primaryButton={{
|
||||
text: t("disabled_project.empty_state.module.primary_button.text"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
|
||||
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
|
||||
@@ -54,7 +54,7 @@ const ProjectPagesPage = observer(() => {
|
||||
primaryButton={{
|
||||
text: t("disabled_project.empty_state.page.primary_button.text"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
|
||||
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
|
||||
@@ -68,7 +68,7 @@ const ProjectViewsPage = observer(() => {
|
||||
primaryButton={{
|
||||
text: t("disabled_project.empty_state.view.primary_button.text"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
|
||||
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane web helpers
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
export const WorkspaceSettingsSidebar = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// mobx store
|
||||
const { t } = useTranslation();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
return (
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400 uppercase">{t("settings")}</span>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(link) =>
|
||||
shouldRenderSettingLink(workspaceSlug.toString(), link.key) &&
|
||||
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
|
||||
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
|
||||
<SidebarNavItem
|
||||
key={link.key}
|
||||
isActive={link.highlight(pathname, `/${workspaceSlug}`)}
|
||||
className="text-sm font-medium px-4 py-2"
|
||||
>
|
||||
{t(link.i18n_label)}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Settings } from "lucide-react";
|
||||
// ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
export const WorkspaceSettingHeader: FC = observer(() => {
|
||||
const { currentWorkspace, loader } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${currentWorkspace?.slug}/settings`}
|
||||
label={currentWorkspace?.name ?? "Workspace"}
|
||||
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink label={t("settings")} />} />
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
21
web/app/[workspaceSlug]/(settings)/layout.tsx
Normal file
21
web/app/[workspaceSlug]/(settings)/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { ContentWrapper } from "@/components/core";
|
||||
import { SettingsHeader } from "@/components/settings";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers";
|
||||
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<WorkspaceAuthWrapper>
|
||||
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{/* Header */}
|
||||
<SettingsHeader />
|
||||
{/* Content */}
|
||||
<ContentWrapper className="px-12 py-page-y flex">{children}</ContentWrapper>
|
||||
</main>
|
||||
</WorkspaceAuthWrapper>
|
||||
</AuthenticationWrapper>
|
||||
);
|
||||
}
|
||||
@@ -3,56 +3,55 @@
|
||||
import { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { AppHeader } from "@/components/core";
|
||||
// hooks
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { SettingsContentWrapper } from "@/components/settings";
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane web constants
|
||||
// local components
|
||||
import { WorkspaceSettingHeader } from "../header";
|
||||
import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs";
|
||||
import { WorkspaceSettingsSidebar } from "./sidebar";
|
||||
|
||||
export interface IWorkspaceSettingLayout {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const pathnameToAccessKey = (pathname: string) => {
|
||||
const pathArray = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes
|
||||
const workspaceSlug = pathArray[0];
|
||||
const accessKey = pathArray.slice(1, 3).join("/");
|
||||
return { workspaceSlug, accessKey: `/${accessKey}` || "" };
|
||||
};
|
||||
|
||||
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
|
||||
const { children } = props;
|
||||
|
||||
// store hooks
|
||||
const { workspaceUserInfo } = useUserPermissions();
|
||||
// next hooks
|
||||
const pathname = usePathname();
|
||||
const [workspaceSlug, suffix, route] = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes
|
||||
|
||||
// derived values
|
||||
const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname);
|
||||
const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role;
|
||||
|
||||
const isAuthorized =
|
||||
pathname &&
|
||||
workspaceSlug &&
|
||||
userWorkspaceRole &&
|
||||
WORKSPACE_SETTINGS_ACCESS[route ? `/${suffix}/${route}` : `/${suffix}`]?.includes(
|
||||
userWorkspaceRole as EUserWorkspaceRoles
|
||||
);
|
||||
WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceSettingHeader />} />
|
||||
<MobileWorkspaceSettingsTabs />
|
||||
<CommandPalette />
|
||||
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
|
||||
{workspaceUserInfo && !isAuthorized ? (
|
||||
<NotAuthorizedView section="settings" />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
|
||||
<WorkspaceSettingsSidebar />
|
||||
</div>
|
||||
<div className="flex flex-col relative w-full overflow-hidden">
|
||||
<div className="w-full h-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
|
||||
<WorkspaceSettingsSidebar workspaceSlug={workspaceSlug} pathname={pathname} />
|
||||
</div>
|
||||
<SettingsContentWrapper>{children}</SettingsContentWrapper>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
|
||||
import {
|
||||
EUserPermissionsLevel,
|
||||
GROUPED_WORKSPACE_SETTINGS,
|
||||
WORKSPACE_SETTINGS_CATEGORIES,
|
||||
EUserWorkspaceRoles,
|
||||
} from "@plane/constants";
|
||||
import { SettingsSidebar } from "@/components/settings";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
type TWorkspaceSettingsSidebarProps = {
|
||||
workspaceSlug: string;
|
||||
pathname: string;
|
||||
};
|
||||
|
||||
export const WorkspaceActionIcons = ({
|
||||
type,
|
||||
size,
|
||||
className,
|
||||
}: {
|
||||
type: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) => {
|
||||
const icons = {
|
||||
general: Building,
|
||||
members: Users,
|
||||
export: ArrowUpToLine,
|
||||
"billing-and-plans": CreditCard,
|
||||
webhooks: Webhook,
|
||||
};
|
||||
|
||||
if (type === undefined) return null;
|
||||
const Icon = icons[type as keyof typeof icons];
|
||||
if (!Icon) return null;
|
||||
return <Icon size={size} className={className} strokeWidth={2} />;
|
||||
};
|
||||
|
||||
export const WorkspaceSettingsSidebar = (props: TWorkspaceSettingsSidebarProps) => {
|
||||
const { workspaceSlug, pathname } = props;
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
return (
|
||||
<SettingsSidebar
|
||||
categories={WORKSPACE_SETTINGS_CATEGORIES}
|
||||
groupedSettings={GROUPED_WORKSPACE_SETTINGS}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isActive={(data: { href: string }) =>
|
||||
data.href === "/settings"
|
||||
? pathname === `/${workspaceSlug}${data.href}/`
|
||||
: new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname)
|
||||
}
|
||||
shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) =>
|
||||
data.access
|
||||
? shouldRenderSettingLink(workspaceSlug.toString(), data.key) &&
|
||||
allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())
|
||||
: false
|
||||
}
|
||||
actionIcons={WorkspaceActionIcons}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { DetailedEmptyState } from "@/components/empty-state";
|
||||
import { ProfileActivityListPage, ProfileSettingContentHeader } from "@/components/profile";
|
||||
// hooks
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
const ProfileActivityPage = observer(() => {
|
||||
// states
|
||||
const [pageCount, setPageCount] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
const [isEmpty, setIsEmpty] = useState(false);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/profile/activity" });
|
||||
|
||||
const updateTotalPages = (count: number) => setTotalPages(count);
|
||||
|
||||
const updateResultsCount = (count: number) => setResultsCount(count);
|
||||
|
||||
const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty);
|
||||
|
||||
const handleLoadMore = () => setPageCount((prev) => prev + 1);
|
||||
|
||||
const activityPages: JSX.Element[] = [];
|
||||
for (let i = 0; i < pageCount; i++)
|
||||
activityPages.push(
|
||||
<ProfileActivityListPage
|
||||
key={i}
|
||||
cursor={`${PER_PAGE}:${i}:0`}
|
||||
perPage={PER_PAGE}
|
||||
updateResultsCount={updateResultsCount}
|
||||
updateTotalPages={updateTotalPages}
|
||||
updateEmptyState={updateEmptyState}
|
||||
/>
|
||||
);
|
||||
|
||||
const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<DetailedEmptyState
|
||||
title={t("profile.empty_state.activity.title")}
|
||||
description={t("profile.empty_state.activity.description")}
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Activity" />
|
||||
<ProfileSettingContentHeader title={t("activity")} />
|
||||
<div className="w-full">{activityPages}</div>
|
||||
{isLoadMoreVisible && (
|
||||
<div className="flex w-full items-center justify-center text-xs">
|
||||
<Button variant="accent-primary" size="sm" onClick={handleLoadMore}>
|
||||
{t("load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileActivityPage;
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IUserTheme } from "@plane/types";
|
||||
import { setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { CustomThemeSelector, ThemeSwitch, PageHead } from "@/components/core";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile";
|
||||
// constants
|
||||
// helpers
|
||||
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store";
|
||||
const ProfileAppearancePage = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { setTheme } = useTheme();
|
||||
// states
|
||||
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
|
||||
// hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile?.theme?.theme) {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
if (userThemeOption) {
|
||||
setCurrentTheme(userThemeOption);
|
||||
}
|
||||
}
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
const handleThemeChange = (themeOption: I_THEME_OPTION) => {
|
||||
applyThemeChange({ theme: themeOption.value });
|
||||
|
||||
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updateCurrentUserThemePromise, {
|
||||
loading: "Updating theme...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Theme updated successfully!",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Failed to Update the theme",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const applyThemeChange = (theme: Partial<IUserTheme>) => {
|
||||
setTheme(theme?.theme || "system");
|
||||
|
||||
if (theme?.theme === "custom" && theme?.palette) {
|
||||
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
|
||||
} else unsetCustomCssVariables();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Appearance" />
|
||||
{userProfile ? (
|
||||
<>
|
||||
<ProfileSettingContentHeader title={t("appearance")} />
|
||||
<div className="gap-4 py-6 sm:gap-16 w-full">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-lg font-semibold text-custom-text-100">{t("theme")}</h4>
|
||||
<p className="text-sm text-custom-text-200">{t("select_or_customize_your_interface_color_scheme")}</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
|
||||
</div>
|
||||
</div>
|
||||
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileAppearancePage;
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// components
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { SettingsContentWrapper } from "@/components/settings";
|
||||
import { ProfileSidebar } from "./sidebar";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function ProfileSettingsLayout(props: Props) {
|
||||
const { children } = props;
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandPalette />
|
||||
<div className="relative flex h-full w-full">
|
||||
<ProfileSidebar workspaceSlug={workspaceSlug.toString()} pathname={pathname} />
|
||||
<SettingsContentWrapper>{children}</SettingsContentWrapper>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile";
|
||||
import { EmailNotificationForm } from "@/components/profile/notification";
|
||||
import { EmailSettingsLoader } from "@/components/ui";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
export default function ProfileNotificationPage() {
|
||||
const { t } = useTranslation();
|
||||
// fetching user email notification settings
|
||||
const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
|
||||
userService.currentUserEmailNotificationSettings()
|
||||
);
|
||||
|
||||
if (!data || isLoading) {
|
||||
return <EmailSettingsLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("notifications")}`} />
|
||||
<ProfileSettingContentHeader
|
||||
title={t("email_notifications")}
|
||||
description={t("stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified")}
|
||||
/>
|
||||
<EmailNotificationForm data={data} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
web/app/[workspaceSlug]/(settings)/settings/account/page.tsx
Normal file
32
web/app/[workspaceSlug]/(settings)/settings/account/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProfileForm } from "@/components/profile";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
|
||||
const ProfileSettingsPage = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { data: currentUser, userProfile } = useUser();
|
||||
|
||||
if (!currentUser)
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
|
||||
<ProfileForm user={currentUser} profile={userProfile.data} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileSettingsPage;
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { PasswordStrengthMeter } from "@/components/account";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
|
||||
// helpers
|
||||
import { authErrorHandler } from "@/helpers/authentication.helper";
|
||||
import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
export interface FormValues {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
}
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
old_password: "",
|
||||
new_password: "",
|
||||
confirm_password: "",
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
const defaultShowPassword = {
|
||||
oldPassword: false,
|
||||
password: false,
|
||||
confirmPassword: false,
|
||||
};
|
||||
|
||||
const SecurityPage = observer(() => {
|
||||
// store
|
||||
const { data: currentUser, changePassword } = useUser();
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState(defaultShowPassword);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
|
||||
|
||||
// use form
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<FormValues>({ defaultValues });
|
||||
// derived values
|
||||
const oldPassword = watch("old_password");
|
||||
const password = watch("new_password");
|
||||
const confirmPassword = watch("confirm_password");
|
||||
const oldPasswordRequired = !currentUser?.is_password_autoset;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword;
|
||||
|
||||
const handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleChangePassword = async (formData: FormValues) => {
|
||||
const { old_password, new_password } = formData;
|
||||
try {
|
||||
const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token);
|
||||
if (!csrfToken) throw new Error("csrf token not found");
|
||||
|
||||
await changePassword(csrfToken, {
|
||||
...(oldPasswordRequired && { old_password }),
|
||||
new_password,
|
||||
});
|
||||
|
||||
reset(defaultValues);
|
||||
setShowPassword(defaultShowPassword);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("auth.common.password.toast.change_password.success.title"),
|
||||
message: t("auth.common.password.toast.change_password.success.message"),
|
||||
});
|
||||
} catch (err: any) {
|
||||
const errorInfo = authErrorHandler(err.error_code?.toString());
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: errorInfo?.title ?? t("auth.common.password.toast.error.title"),
|
||||
message:
|
||||
typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled =
|
||||
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID ||
|
||||
(oldPasswordRequired && oldPassword.trim() === "") ||
|
||||
password.trim() === "" ||
|
||||
confirmPassword.trim() === "" ||
|
||||
password !== confirmPassword ||
|
||||
password === oldPassword;
|
||||
|
||||
const passwordSupport = password.length > 0 &&
|
||||
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
|
||||
<PasswordStrengthMeter password={password} isFocused={isPasswordInputFocused} />
|
||||
);
|
||||
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Security" />
|
||||
<ProfileSettingContentHeader title={t("auth.common.password.change_password.label.default")} />
|
||||
<form onSubmit={handleSubmit(handleChangePassword)} className="flex flex-col gap-8 w-full mt-8">
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
{oldPasswordRequired && (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm">{t("auth.common.password.current_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="old_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="old_password"
|
||||
type={showPassword?.oldPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={t("old_password")}
|
||||
className="w-full"
|
||||
hasError={Boolean(errors.old_password)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showPassword?.oldPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("oldPassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("oldPassword")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{errors.old_password && <span className="text-xs text-red-500">{errors.old_password.message}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm">{t("auth.common.password.new_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="new_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="new_password"
|
||||
type={showPassword?.password ? "text" : "password"}
|
||||
value={value}
|
||||
placeholder={t("auth.common.password.new_password.placeholder")}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
hasError={Boolean(errors.new_password)}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showPassword?.password ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{passwordSupport}
|
||||
{isNewPasswordSameAsOldPassword && !isPasswordInputFocused && (
|
||||
<span className="text-xs text-red-500">{t("new_password_must_be_different_from_old_password")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm">{t("auth.common.password.confirm_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirm_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type={showPassword?.confirmPassword ? "text" : "password"}
|
||||
placeholder={t("auth.common.password.confirm_password.placeholder")}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
hasError={Boolean(errors.confirm_password)}
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showPassword?.confirmPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("confirmPassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("confirmPassword")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && (
|
||||
<span className="text-sm text-red-500">{t("auth.common.password.errors.match")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<Button variant="primary" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
|
||||
{isSubmitting
|
||||
? `${t("auth.common.password.change_password.label.submitting")}`
|
||||
: t("auth.common.password.change_password.label.default")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default SecurityPage;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks } from "lucide-react";
|
||||
import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants";
|
||||
import { SettingsSidebar } from "@/components/settings";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
type TProfileSidebarProps = {
|
||||
workspaceSlug: string;
|
||||
pathname: string;
|
||||
};
|
||||
|
||||
export const ProjectActionIcons = ({ type, size, className }: { type: string; size?: number; className?: string }) => {
|
||||
const icons = {
|
||||
profile: CircleUser,
|
||||
security: KeyRound,
|
||||
activity: Activity,
|
||||
appearance: Settings2,
|
||||
notifications: Bell,
|
||||
"api-tokens": Blocks,
|
||||
};
|
||||
|
||||
if (type === undefined) return null;
|
||||
const Icon = icons[type as keyof typeof icons];
|
||||
if (!Icon) return null;
|
||||
return <Icon size={size} className={className} strokeWidth={2} />;
|
||||
};
|
||||
|
||||
export const ProfileSidebar = (props: TProfileSidebarProps) => {
|
||||
const { workspaceSlug, pathname } = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
return (
|
||||
<SettingsSidebar
|
||||
categories={PROFILE_SETTINGS_CATEGORIES}
|
||||
groupedSettings={GROUPED_PROFILE_SETTINGS}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`}
|
||||
customHeader={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-shrink-0">
|
||||
{!currentUser?.avatar_url || currentUser?.avatar_url === "" ? (
|
||||
<div className="h-8 w-8 rounded-full">
|
||||
<CircleUserRound className="h-full w-full text-custom-text-200" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-8 w-8 overflow-hidden">
|
||||
<img
|
||||
src={getFileURL(currentUser?.avatar_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
|
||||
alt={currentUser?.display_name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full overflow-hidden">
|
||||
<div className="text-base font-medium text-custom-text-200 truncate">{currentUser?.display_name}</div>
|
||||
<div className="text-sm text-custom-text-300 truncate">{currentUser?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
actionIcons={ProjectActionIcons}
|
||||
shouldRender
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IProject } from "@plane/types";
|
||||
// ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation";
|
||||
import { PageHead } from "@/components/core";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const AutomationSettingsPage = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails: projectDetails, updateProject } = useProject();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
const handleChange = async (formData: Partial<IProject>) => {
|
||||
if (!workspaceSlug || !projectId || !projectDetails) return;
|
||||
|
||||
await updateProject(workspaceSlug.toString(), projectId.toString(), formData).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// derived values
|
||||
const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined;
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<div className="flex flex-col items-start border-b border-custom-border-100 pb-3.5">
|
||||
<h3 className="text-xl font-medium leading-normal">{t("project_settings.automations.label")}</h3>
|
||||
</div>
|
||||
<AutoArchiveAutomation handleChange={handleChange} />
|
||||
<AutoCloseAutomation handleChange={handleChange} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default AutomationSettingsPage;
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EstimateRoot } from "@/components/estimates";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const EstimatesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined;
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div
|
||||
className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}
|
||||
>
|
||||
<EstimateRoot
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
isAdmin={canPerformProjectAdminActions}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default EstimatesSettingsPage;
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectFeaturesList } from "@/components/project";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const FeaturesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
|
||||
const { currentProjectDetails } = useProject();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined;
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
if (!workspaceSlug || !projectId) return null;
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<ProjectFeaturesList
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isAdmin={canPerformProjectAdminActions}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default FeaturesSettingsPage;
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectSettingsLabelList } from "@/components/labels";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const LabelsSettingsPage = observer(() => {
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined;
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// derived values
|
||||
const canPerformProjectMemberActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
// Enable Auto Scroll for Labels list
|
||||
useEffect(() => {
|
||||
const element = scrollableContainerRef.current;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
return combine(
|
||||
autoScrollForElements({
|
||||
element,
|
||||
})
|
||||
);
|
||||
}, [scrollableContainerRef?.current]);
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectMemberActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div ref={scrollableContainerRef} className="h-full w-full gap-10 overflow-y-auto">
|
||||
<ProjectSettingsLabelList />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default LabelsSettingsPage;
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const MembersSettingsPage = observer(() => {
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
|
||||
const isProjectMemberOrAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin;
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectMemberActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto`}>
|
||||
<ProjectSettingsMemberDefaults />
|
||||
<ProjectMemberList />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default MembersSettingsPage;
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { PageHead } from "@/components/core";
|
||||
import {
|
||||
ArchiveRestoreProjectModal,
|
||||
ArchiveProjectSelection,
|
||||
DeleteProjectModal,
|
||||
DeleteProjectSection,
|
||||
ProjectDetailsForm,
|
||||
ProjectDetailsFormLoader,
|
||||
} from "@/components/project";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const ProjectSettingsPage = observer(() => {
|
||||
// states
|
||||
const [selectProject, setSelectedProject] = useState<string | null>(null);
|
||||
const [archiveProject, setArchiveProject] = useState<boolean>(false);
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { currentProjectDetails, fetchProjectDetails } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// api call to fetch project details
|
||||
// TODO: removed this API if not necessary
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
// derived values
|
||||
const isAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString()
|
||||
);
|
||||
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{currentProjectDetails && workspaceSlug && projectId && (
|
||||
<>
|
||||
<ArchiveRestoreProjectModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isOpen={archiveProject}
|
||||
onClose={() => setArchiveProject(false)}
|
||||
archive
|
||||
/>
|
||||
<DeleteProjectModal
|
||||
project={currentProjectDetails}
|
||||
isOpen={Boolean(selectProject)}
|
||||
onClose={() => setSelectedProject(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
|
||||
{currentProjectDetails && workspaceSlug && projectId && !isLoading ? (
|
||||
<ProjectDetailsForm
|
||||
project={currentProjectDetails}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
) : (
|
||||
<ProjectDetailsFormLoader />
|
||||
)}
|
||||
|
||||
{isAdmin && currentProjectDetails && (
|
||||
<>
|
||||
<ArchiveProjectSelection
|
||||
projectDetails={currentProjectDetails}
|
||||
handleArchive={() => setArchiveProject(true)}
|
||||
/>
|
||||
<DeleteProjectSection
|
||||
projectDetails={currentProjectDetails}
|
||||
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectSettingsPage;
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import range from "lodash/range";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane web constants
|
||||
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
|
||||
|
||||
export const ProjectSettingsSidebar = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const pathname = usePathname();
|
||||
// mobx store
|
||||
const { allowPermissions, projectUserInfo } = useUserPermissions();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role;
|
||||
|
||||
if (!currentProjectRole) {
|
||||
return (
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
||||
<Loader className="flex w-full flex-col gap-2">
|
||||
{range(8).map((index) => (
|
||||
<Loader.Item key={index} height="34px" />
|
||||
))}
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{PROJECT_SETTINGS_LINKS.map(
|
||||
(link) =>
|
||||
allowPermissions(
|
||||
link.access,
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString()
|
||||
) && (
|
||||
<Link key={link.key} href={`/${workspaceSlug}/projects/${projectId}${link.href}`}>
|
||||
<SidebarNavItem
|
||||
key={link.key}
|
||||
isActive={link.highlight(pathname, `/${workspaceSlug}/projects/${projectId}`)}
|
||||
className="text-sm font-medium px-4 py-2"
|
||||
>
|
||||
{t(link.i18n_label)}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectStateRoot } from "@/components/project-states";
|
||||
// hook
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const StatesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined;
|
||||
// derived values
|
||||
const canPerformProjectMemberActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectMemberActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="w-full">
|
||||
<div className="flex items-center border-b border-custom-border-100">
|
||||
<h3 className="text-xl font-medium">{t("common.states")}</h3>
|
||||
</div>
|
||||
{workspaceSlug && projectId && (
|
||||
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default StatesSettingsPage;
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// components
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { SettingsContentWrapper } from "@/components/settings";
|
||||
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const ProjectSettingsLayout = observer((props: Props) => {
|
||||
const { children } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const { joinedProjectIds } = useProject();
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) return;
|
||||
if (joinedProjectIds.length > 0) {
|
||||
router.push(`/${workspaceSlug}/settings/project/${joinedProjectIds[0]}`);
|
||||
}
|
||||
}, [joinedProjectIds, router, workspaceSlug, projectId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandPalette />
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
|
||||
<div className="relative flex h-full w-full">
|
||||
{projectId && <ProjectSettingsSidebar workspaceSlug={workspaceSlug.toString()} pathname={pathname} />}
|
||||
<SettingsContentWrapper>{children}</SettingsContentWrapper>
|
||||
</div>
|
||||
</ProjectAuthWrapper>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectSettingsLayout;
|
||||
38
web/app/[workspaceSlug]/(settings)/settings/project/page.tsx
Normal file
38
web/app/[workspaceSlug]/(settings)/settings/project/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button, getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useCommandPalette } from "@/hooks/store";
|
||||
|
||||
const ProjectSettingsPage = () => {
|
||||
// store hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
// derived values
|
||||
const resolvedPath =
|
||||
resolvedTheme === "dark"
|
||||
? "/empty-state/project-settings/no-projects-dark.png"
|
||||
: "/empty-state/project-settings/no-projects-light.png";
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center justify-center h-full max-w-[480px] mx-auto">
|
||||
<Image src={resolvedPath} alt="No projects yet" width={384} height={250} />
|
||||
<div className="text-lg font-semibold text-custom-text-350">No projects yet</div>
|
||||
<div className="text-sm text-custom-text-350 text-center">
|
||||
Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you
|
||||
need to get things done.
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="https://plane.so/" target="_blank" className={cn(getButtonStyling("neutral-primary", "sm"))}>
|
||||
Learn more about projects
|
||||
</Link>
|
||||
<Button size="sm" onClick={() => toggleCreateProjectModal(true)}>
|
||||
Start your first project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSettingsPage;
|
||||
@@ -44,7 +44,7 @@ export const DeleteWorkspaceSection: FC<TDeleteWorkspace> = observer((props) =>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-base tracking-tight">
|
||||
<span className="text-base tracking-tight w-[800px]">
|
||||
{t("workspace_settings.settings.general.delete_workspace_description")}
|
||||
</span>
|
||||
<div>
|
||||
|
||||
@@ -4,26 +4,33 @@ import packageJson from "package.json";
|
||||
// ui
|
||||
import { Button, Tooltip } from "@plane/ui";
|
||||
// hooks
|
||||
import { cn } from "@plane/utils";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local components
|
||||
import { PaidPlanUpgradeModal } from "../license";
|
||||
|
||||
export const WorkspaceEditionBadge = observer(() => {
|
||||
export const WorkspaceEditionBadge = observer((props: { className?: string; isEditable?: boolean }) => {
|
||||
const { className, isEditable = true } = props;
|
||||
const { isMobile } = usePlatformOS();
|
||||
// states
|
||||
const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaidPlanUpgradeModal
|
||||
isOpen={isPaidPlanPurchaseModalOpen}
|
||||
handleClose={() => setIsPaidPlanPurchaseModalOpen(false)}
|
||||
/>
|
||||
{isEditable && (
|
||||
<PaidPlanUpgradeModal
|
||||
isOpen={isPaidPlanPurchaseModalOpen}
|
||||
handleClose={() => setIsPaidPlanPurchaseModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<Tooltip tooltipContent={`Version: v${packageJson.version}`} isMobile={isMobile}>
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
variant="accent-primary"
|
||||
className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none"
|
||||
className={cn(
|
||||
"w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none",
|
||||
className
|
||||
)}
|
||||
onClick={() => setIsPaidPlanPurchaseModalOpen(true)}
|
||||
>
|
||||
Community
|
||||
|
||||
@@ -9,57 +9,57 @@ export const PROJECT_SETTINGS = {
|
||||
general: {
|
||||
key: "general",
|
||||
i18n_label: "common.general",
|
||||
href: `/settings`,
|
||||
href: ``,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
members: {
|
||||
key: "members",
|
||||
i18n_label: "members",
|
||||
href: `/settings/members`,
|
||||
href: `/members`,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
features: {
|
||||
key: "features",
|
||||
i18n_label: "common.features",
|
||||
href: `/settings/features`,
|
||||
href: `/features`,
|
||||
access: [EUserPermissions.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
states: {
|
||||
key: "states",
|
||||
i18n_label: "common.states",
|
||||
href: `/settings/states`,
|
||||
href: `/states`,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/states/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
labels: {
|
||||
key: "labels",
|
||||
i18n_label: "common.labels",
|
||||
href: `/settings/labels`,
|
||||
href: `/labels`,
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/labels/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
estimates: {
|
||||
key: "estimates",
|
||||
i18n_label: "common.estimates",
|
||||
href: `/settings/estimates`,
|
||||
href: `/estimates`,
|
||||
access: [EUserPermissions.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/estimates/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
automations: {
|
||||
key: "automations",
|
||||
i18n_label: "project_settings.automations.label",
|
||||
href: `/settings/automations`,
|
||||
href: `/automations`,
|
||||
access: [EUserPermissions.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/automations/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
};
|
||||
|
||||
112
web/core/components/exporter/column.tsx
Normal file
112
web/core/components/exporter/column.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div>
|
||||
{avatar_url && avatar_url.trim() !== "" ? (
|
||||
<span className="relative flex h-4 w-4 items-center justify-center rounded-full capitalize text-white">
|
||||
<img
|
||||
src={getFileURL(avatar_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
|
||||
alt={display_name || email}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative flex h-4 w-4 items-center justify-center rounded-full bg-gray-700 capitalize text-white text-xs">
|
||||
{(email ?? display_name ?? "?")[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>{display_name}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Exported On",
|
||||
content: "Exported On",
|
||||
tdRender: (rowData: RowData) => <span>{renderFormattedDate(rowData.created_at)}</span>,
|
||||
},
|
||||
|
||||
{
|
||||
key: "Exported projects",
|
||||
content: "Exported projects",
|
||||
tdRender: (rowData: RowData) => <div className="text-sm">{rowData.project.length} project(s)</div>,
|
||||
},
|
||||
{
|
||||
key: "Format",
|
||||
content: "Format",
|
||||
tdRender: (rowData: RowData) => (
|
||||
<span className="text-sm">
|
||||
{rowData.provider === "csv"
|
||||
? "CSV"
|
||||
: rowData.provider === "xlsx"
|
||||
? "Excel"
|
||||
: rowData.provider === "json"
|
||||
? "JSON"
|
||||
: ""}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "Status",
|
||||
content: "Status",
|
||||
tdRender: (rowData: RowData) => (
|
||||
<span
|
||||
className={`rounded text-xs px-2 py-1 capitalize ${
|
||||
rowData.status === "completed"
|
||||
? "bg-green-500/20 text-green-500"
|
||||
: rowData.status === "processing"
|
||||
? "bg-yellow-500/20 text-yellow-500"
|
||||
: rowData.status === "failed"
|
||||
? "bg-red-500/20 text-red-500"
|
||||
: rowData.status === "expired"
|
||||
? "bg-orange-500/20 text-orange-500"
|
||||
: "bg-gray-500/20 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{rowData.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "Download",
|
||||
content: "Download",
|
||||
tdRender: (rowData: RowData) =>
|
||||
checkExpiry(rowData.created_at) ? (
|
||||
<>
|
||||
{rowData.status == "completed" ? (
|
||||
<a target="_blank" href={rowData?.url} rel="noopener noreferrer">
|
||||
<button className="w-full flex items-center gap-1 text-custom-primary-100 font-medium">
|
||||
<Download className="h-4 w-4" />
|
||||
<div>Download</div>
|
||||
</button>
|
||||
</a>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-red-500">Expired</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
return columns;
|
||||
};
|
||||
172
web/core/components/exporter/export-form.tsx
Normal file
172
web/core/components/exporter/export-form.tsx
Normal file
@@ -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<FormData>({
|
||||
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: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[0.65rem] text-custom-text-200 flex-shrink-0">{projectDetails?.identifier}</span>
|
||||
<span className="truncate">{projectDetails?.name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// 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 (
|
||||
<form onSubmit={handleSubmit(ExportCSVToMail)} className="flex flex-col gap-4 mt-4">
|
||||
<div className="flex gap-4">
|
||||
{/* Project Selector */}
|
||||
<div className="w-1/2">
|
||||
<div className="text-sm font-medium text-custom-text-200 mb-2">
|
||||
{t("workspace_settings.settings.exports.exporting_projects")}
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
disabled={!isAdmin && (!hasProjects || !canPerformAnyCreateAction)}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSearchSelect
|
||||
value={value ?? []}
|
||||
onChange={(val: string[]) => 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
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Format Selector */}
|
||||
<div className="w-1/2">
|
||||
<div className="text-sm font-medium text-custom-text-200 mb-2">
|
||||
{t("workspace_settings.settings.exports.format")}
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider"
|
||||
disabled={!isAdmin && (!hasProjects || !canPerformAnyCreateAction)}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={t(value.i18n_title)}
|
||||
optionsClassName="max-w-48 sm:max-w-[532px]"
|
||||
placement="bottom-end"
|
||||
buttonClassName="py-2 text-sm"
|
||||
>
|
||||
{EXPORTERS_LIST.map((service) => (
|
||||
<CustomSelect.Option key={service.provider} className="flex items-center gap-2" value={service}>
|
||||
<span className="truncate">{t(service.i18n_title)}</span>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between ">
|
||||
<Button variant="primary" type="submit" loading={exportLoading}>
|
||||
{exportLoading ? `${t("workspace_settings.settings.exports.exporting")}...` : t("export")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -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<string | undefined>(`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<string | undefined>(`10:0:0`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full w-full">
|
||||
<>
|
||||
<div>
|
||||
{EXPORTERS_LIST.map((service) => (
|
||||
<div
|
||||
key={service.provider}
|
||||
className="flex items-center justify-between gap-2 border-b border-custom-border-100 bg-custom-background-100 py-6"
|
||||
>
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="item-center flex gap-2.5">
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
src={getExporterLogo(service?.provider)}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
alt={`${t(service.i18n_title)} Logo`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="flex items-center gap-4 text-sm font-medium">{t(service.i18n_title)}</h3>
|
||||
<p className="text-sm tracking-tight text-custom-text-200">{t(service.i18n_description)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={`/${workspaceSlug}/settings/exports?provider=${service.provider}`}>
|
||||
<span>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="capitalize"
|
||||
disabled={!isAdmin && (!hasProjects || !canPerformAnyCreateAction)}
|
||||
>
|
||||
{t(service.type)}
|
||||
</Button>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-custom-border-100 pb-3.5 pt-7">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="flex gap-2 text-xl font-medium">
|
||||
{t("workspace_settings.settings.exports.previous_exports")}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs outline-none"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
|
||||
{refreshing ? `${t("refreshing")}...` : t("refresh_status")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
disabled={!exporterServices?.prev_page_results}
|
||||
onClick={() => exporterServices?.prev_page_results && setCursor(exporterServices?.prev_cursor)}
|
||||
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
|
||||
exporterServices?.prev_page_results
|
||||
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
|
||||
: "cursor-not-allowed opacity-75"
|
||||
}`}
|
||||
>
|
||||
<MoveLeft className="h-4 w-4" />
|
||||
<div className="pr-1">{t("prev")}</div>
|
||||
</button>
|
||||
<button
|
||||
disabled={!exporterServices?.next_page_results}
|
||||
onClick={() => exporterServices?.next_page_results && setCursor(exporterServices?.next_cursor)}
|
||||
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
|
||||
exporterServices?.next_page_results
|
||||
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
|
||||
: "cursor-not-allowed opacity-75"
|
||||
}`}
|
||||
>
|
||||
<div className="pl-1">{t("next")}</div>
|
||||
<MoveRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{exporterServices && exporterServices?.results ? (
|
||||
exporterServices?.results?.length > 0 ? (
|
||||
<div>
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
{exporterServices?.results.map((service) => (
|
||||
<SingleExport key={service.id} service={service} refreshing={refreshing} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<DetailedEmptyState
|
||||
title={t("workspace_settings.empty_state.exports.title")}
|
||||
description={t("workspace_settings.empty_state.exports.description")}
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<ImportExportSettingsLoader />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
{provider && (
|
||||
<Exporter
|
||||
isOpen
|
||||
handleClose={() => handleCsvClose()}
|
||||
data={null}
|
||||
user={currentUser || null}
|
||||
<ExportForm
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
provider={provider}
|
||||
mutateServices={() => mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`))}
|
||||
/>
|
||||
)}
|
||||
<PrevExports
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
cursor={cursor}
|
||||
per_page={per_page}
|
||||
setCursor={setCursor}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
135
web/core/components/exporter/prev-exports.tsx
Normal file
135
web/core/components/exporter/prev-exports.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-custom-border-100 pb-3.5 pt-7">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="flex gap-2 text-xl font-medium">
|
||||
{t("workspace_settings.settings.exports.previous_exports")}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs outline-none"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
|
||||
{refreshing ? `${t("refreshing")}...` : t("refresh_status")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
disabled={!exporterServices?.prev_page_results}
|
||||
onClick={() => exporterServices?.prev_page_results && setCursor(exporterServices?.prev_cursor)}
|
||||
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
|
||||
exporterServices?.prev_page_results
|
||||
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
|
||||
: "cursor-not-allowed opacity-75"
|
||||
}`}
|
||||
>
|
||||
<MoveLeft className="h-4 w-4" />
|
||||
<div className="pr-1">{t("prev")}</div>
|
||||
</button>
|
||||
<button
|
||||
disabled={!exporterServices?.next_page_results}
|
||||
onClick={() => exporterServices?.next_page_results && setCursor(exporterServices?.next_cursor)}
|
||||
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
|
||||
exporterServices?.next_page_results
|
||||
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
|
||||
: "cursor-not-allowed opacity-75"
|
||||
}`}
|
||||
>
|
||||
<div className="pl-1">{t("next")}</div>
|
||||
<MoveRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{exporterServices && exporterServices?.results ? (
|
||||
exporterServices?.results?.length > 0 ? (
|
||||
<div>
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={exporterServices?.results ?? []}
|
||||
keyExtractor={(rowData: RowData) => 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-[50px] text-custom-text-200"
|
||||
tHeadTrClassName="divide-x-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<DetailedEmptyState
|
||||
title={t("workspace_settings.empty_state.exports.title")}
|
||||
description={t("workspace_settings.empty_state.exports.description")}
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<ImportExportSettingsLoader />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -115,7 +115,7 @@ export const NoProjectsEmptyState = observer(() => {
|
||||
flag: "visited_profile",
|
||||
cta: {
|
||||
text: "home.empty.personalize_account.cta",
|
||||
link: "/profile",
|
||||
link: `/${workspaceSlug}/settings/account`,
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -72,7 +72,7 @@ export const ProjectArchivedEmptyState: React.FC = observer(() => {
|
||||
assetPath={archivedIssuesResolvedPath}
|
||||
primaryButton={{
|
||||
text: t("project_issues.empty_state.no_archived_issues.primary_button.text"),
|
||||
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`),
|
||||
onClick: () => router.push(`/${workspaceSlug}/settings/project/${projectId}/automations`),
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -163,7 +163,7 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="w-full overflow-y-scroll">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="relative h-44 w-full">
|
||||
<img
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
// types
|
||||
interface IEmailNotificationFormProps {
|
||||
interface IEmailNotificationFormProps {
|
||||
data: IUserEmailNotificationSettings;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
|
||||
const { data } = props;
|
||||
const { t } = useTranslation();
|
||||
// form data
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
} = useForm<IUserEmailNotificationSettings>({
|
||||
const { control, reset } = useForm<IUserEmailNotificationSettings>({
|
||||
defaultValues: {
|
||||
...data,
|
||||
},
|
||||
@@ -55,10 +52,9 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pt-6 text-lg font-medium text-custom-text-100">{t("notify_me_when")}:</div>
|
||||
{/* Notification Settings */}
|
||||
<div className="flex flex-col py-2">
|
||||
<div className="flex gap-2 items-center pt-6">
|
||||
<div className="flex gap-2 items-center pt-2">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-base font-medium text-custom-text-100">{t("property_changes")}</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">{t("property_changes_description")}</div>
|
||||
@@ -83,9 +79,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
|
||||
<div className="flex gap-2 items-center pt-6 pb-2">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-base font-medium text-custom-text-100">{t("state_change")}</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">
|
||||
{t("state_change_description")}
|
||||
</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">{t("state_change_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
@@ -129,9 +123,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
|
||||
<div className="flex gap-2 items-center pt-6">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-base font-medium text-custom-text-100">{t("comments")}</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">
|
||||
{t("comments_description")}
|
||||
</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">{t("comments_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
@@ -153,9 +145,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
|
||||
<div className="flex gap-2 items-center pt-6">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-base font-medium text-custom-text-100">{t("mentions")}</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">
|
||||
{t("mentions_description")}
|
||||
</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">{t("mentions_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
|
||||
@@ -9,7 +9,7 @@ type Props = {
|
||||
export const ProfileSettingContentHeader: FC<Props> = (props) => {
|
||||
const { title, description } = props;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-4 border-b border-custom-border-100">
|
||||
<div className="flex flex-col gap-1 pb-4 border-b border-custom-border-100 w-full">
|
||||
<div className="text-xl font-medium text-custom-text-100">{title}</div>
|
||||
{description && <div className="text-sm font-normal text-custom-text-300">{description}</div>}
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ProfileSidebar: FC<TProfileSidebar> = observer((props) => {
|
||||
// refs
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// router
|
||||
const { userId } = useParams();
|
||||
const { userId, workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { profileSidebarCollapsed, toggleProfileSidebar } = useAppTheme();
|
||||
@@ -94,7 +94,7 @@ export const ProfileSidebar: FC<TProfileSidebar> = observer((props) => {
|
||||
<div className="relative h-[110px]">
|
||||
{currentUser?.id === userId && (
|
||||
<div className="absolute right-3.5 top-3.5 grid h-5 w-5 place-items-center rounded bg-white">
|
||||
<Link href="/profile">
|
||||
<Link href={`/${workspaceSlug}/settings/account`}>
|
||||
<span className="grid place-items-center text-black">
|
||||
<Pencil className="h-3 w-3" />
|
||||
</span>
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ArchiveProjectSelection: React.FC<IArchiveProject> = (props) => {
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="flex flex-col gap-8 pt-4">
|
||||
<span className="text-sm tracking-tight">
|
||||
<span className="text-sm tracking-tight w-[800px]">
|
||||
Archiving a project will unlist your project from your side navigation although you will still be able
|
||||
to access it from your projects page. You can restore the project or delete it whenever you want.
|
||||
</span>
|
||||
|
||||
@@ -36,7 +36,7 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) =>
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="flex flex-col gap-8 pt-4">
|
||||
<span className="text-sm tracking-tight">
|
||||
<span className="text-sm tracking-tight w-[800px]">
|
||||
When deleting a project, all of the data and resources within that project will be permanently removed
|
||||
and cannot be recovered.
|
||||
</span>
|
||||
|
||||
5
web/core/components/settings/content-wrapper.tsx
Normal file
5
web/core/components/settings/content-wrapper.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const SettingsContentWrapper = ({ children }: { children: ReactNode }) => (
|
||||
<div className="relative flex flex-col min-w-[60%] items-center mx-auto overflow-y-scroll px-12">{children}</div>
|
||||
);
|
||||
65
web/core/components/settings/header.tsx
Normal file
65
web/core/components/settings/header.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getButtonStyling } from "@plane/ui/src/button";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useUserSettings } from "@/hooks/store";
|
||||
import SettingsTabs from "./tabs";
|
||||
import WorkspaceLogo from "./workspace-logo";
|
||||
|
||||
export const SettingsHeader = observer(() => {
|
||||
// hooks
|
||||
const { data: currentUserSettings } = useUserSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// redirect url for normal mode
|
||||
const redirectWorkspaceSlug =
|
||||
currentUserSettings?.workspace?.last_workspace_slug ||
|
||||
currentUserSettings?.workspace?.fallback_workspace_slug ||
|
||||
"";
|
||||
|
||||
return (
|
||||
<div className="bg-custom-background-90 px-12 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<Link
|
||||
href={`/${redirectWorkspaceSlug}`}
|
||||
className="group flex items-center gap-2 text-custom-text-300 mb-4 border border-transparent hover:bg-custom-background-100 hover:border-custom-border-200 w-fit pr-2 rounded-lg "
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
getButtonStyling("neutral-primary", "sm"),
|
||||
"rounded-lg p-1 hover:bg-custom-background-100 hover:border-custom-border-200",
|
||||
"group-hover:bg-custom-background-100 group-hover:border-transparent"
|
||||
)}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4 my-auto" />
|
||||
</button>
|
||||
<div className="text-sm my-auto font-semibold">{t("back_to_workspace")}</div>
|
||||
{/* Last workspace */}
|
||||
<div className="flex items-center gap-1">
|
||||
<WorkspaceLogo
|
||||
workspace={{
|
||||
logo_url: currentUserSettings?.workspace?.last_workspace_logo || "",
|
||||
name: currentUserSettings?.workspace?.last_workspace_name || "",
|
||||
}}
|
||||
size="sm"
|
||||
className="my-auto"
|
||||
/>
|
||||
<div className="text-xs my-auto text-custom-text-100 font-semibold">
|
||||
{currentUserSettings?.workspace?.last_workspace_name}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Description */}
|
||||
<div className="text-custom-text-100 font-semibold text-2xl">{t("settings")}</div>
|
||||
<div className="text-custom-text-300 text-base">{t("settings_description")}</div>
|
||||
{/* Actions */}
|
||||
<SettingsTabs />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
3
web/core/components/settings/index.ts
Normal file
3
web/core/components/settings/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./header";
|
||||
export * from "./sidebar";
|
||||
export * from "./content-wrapper";
|
||||
1
web/core/components/settings/project/sidebar/index.ts
Normal file
1
web/core/components/settings/project/sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,73 @@
|
||||
import { range } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useParams } from "next/navigation";
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Loader } from "@plane/ui";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
|
||||
|
||||
export const NavItemChildren = observer((props: { projectId: string }) => {
|
||||
const { projectId } = props;
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// mobx store
|
||||
const { getProjectById } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
const currentProject = getProjectById(projectId);
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Loader className="flex w-full flex-col gap-2">
|
||||
{range(8).map((index) => (
|
||||
<Loader.Item key={index} height="34px" />
|
||||
))}
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{PROJECT_SETTINGS_LINKS.map((link) => {
|
||||
const isActive = link.highlight(pathname, `/${workspaceSlug}/settings/project/${projectId}`);
|
||||
return (
|
||||
allowPermissions(
|
||||
link.access,
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString()
|
||||
) && (
|
||||
<Link key={link.key} href={`/${workspaceSlug}/settings/project/${projectId}${link.href}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-pointer relative group w-full flex items-center justify-between gap-1.5 rounded p-1 px-1.5 outline-none",
|
||||
{
|
||||
"text-custom-primary-200 bg-custom-primary-100/10": isActive,
|
||||
"text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90":
|
||||
!isActive,
|
||||
},
|
||||
"text-sm font-medium"
|
||||
)}
|
||||
>
|
||||
{t(link.i18n_label)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
47
web/core/components/settings/project/sidebar/root.tsx
Normal file
47
web/core/components/settings/project/sidebar/root.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PROJECT_SETTINGS_CATEGORIES, PROJECT_SETTINGS_CATEGORY } from "@plane/constants";
|
||||
import { Logo } from "@/components/common";
|
||||
|
||||
import { getUserRole } from "@/helpers/user.helper";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { SettingsSidebar } from "../..";
|
||||
import { NavItemChildren } from "./nav-item-children";
|
||||
|
||||
type TProjectSettingsSidebarProps = {
|
||||
workspaceSlug: string;
|
||||
pathname: string;
|
||||
};
|
||||
|
||||
export const ProjectSettingsSidebar = observer((props: TProjectSettingsSidebarProps) => {
|
||||
const { workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { joinedProjectIds, projectMap } = useProject();
|
||||
|
||||
const groupedProject = joinedProjectIds.map((projectId) => ({
|
||||
key: projectId,
|
||||
i18n_label: projectMap[projectId].name,
|
||||
href: `/settings/project/${projectId}`,
|
||||
icon: <Logo logo={projectMap[projectId].logo_props} />,
|
||||
}));
|
||||
|
||||
return (
|
||||
<SettingsSidebar
|
||||
categories={PROJECT_SETTINGS_CATEGORIES}
|
||||
groupedSettings={{
|
||||
[PROJECT_SETTINGS_CATEGORY.PROJECTS]: groupedProject,
|
||||
}}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isActive={false}
|
||||
appendItemsToTitle={(key: string) => {
|
||||
const role = projectMap[key].member_role;
|
||||
return (
|
||||
<div className="text-xs font-medium text-custom-text-200 capitalize bg-custom-background-90 rounded-md px-1 py-0.5">
|
||||
{role ? getUserRole(role)?.toLowerCase() : "Guest"}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
shouldRender
|
||||
renderChildren={(key: string) => <NavItemChildren projectId={key} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
38
web/core/components/settings/sidebar/header.tsx
Normal file
38
web/core/components/settings/sidebar/header.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getUserRole } from "@/helpers/user.helper";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
|
||||
import WorkspaceLogo from "../workspace-logo";
|
||||
|
||||
export const SettingsSidebarHeader = (props: { customHeader?: React.ReactNode }) => {
|
||||
const { customHeader } = props;
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
return customHeader
|
||||
? customHeader
|
||||
: currentWorkspace && (
|
||||
<div className="flex w-full gap-3 items-center justify-between">
|
||||
<div className="flex w-full gap-3 items-center overflow-hidden">
|
||||
<WorkspaceLogo
|
||||
workspace={{
|
||||
logo_url: currentWorkspace.logo_url || "",
|
||||
name: currentWorkspace.name,
|
||||
}}
|
||||
size="md"
|
||||
/>
|
||||
<div className="w-full overflow-hidden">
|
||||
<div className="text-base font-medium text-custom-text-200 truncate text-ellipsis ">
|
||||
{currentWorkspace.name}
|
||||
</div>
|
||||
<div className="text-sm text-custom-text-300 capitalize">
|
||||
{getUserRole(currentWorkspace.role)?.toLowerCase() || "guest"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<WorkspaceEditionBadge
|
||||
isEditable={false}
|
||||
className="text-xs rounded-md min-w-fit px-1 py-0.5 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
web/core/components/settings/sidebar/index.ts
Normal file
1
web/core/components/settings/sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
88
web/core/components/settings/sidebar/nav-item.tsx
Normal file
88
web/core/components/settings/sidebar/nav-item.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
import { EUserWorkspaceRoles } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
export type TSettingItem = {
|
||||
key: string;
|
||||
i18n_label: string;
|
||||
href: string;
|
||||
access?: EUserWorkspaceRoles[];
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
export type TSettingsSidebarNavItemProps = {
|
||||
workspaceSlug: string;
|
||||
setting: TSettingItem;
|
||||
isActive: boolean | ((data: { href: string }) => boolean);
|
||||
actionIcons?: (props: { type: string; size?: number; className?: string }) => React.ReactNode;
|
||||
appendItemsToTitle?: (key: string) => React.ReactNode;
|
||||
renderChildren?: (key: string) => React.ReactNode;
|
||||
};
|
||||
|
||||
const SettingsSidebarNavItem = (props: TSettingsSidebarNavItemProps) => {
|
||||
const { workspaceSlug, setting, isActive, actionIcons, appendItemsToTitle, renderChildren } = props;
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// state
|
||||
const [isExpanded, setIsExpanded] = useState(projectId === setting.key);
|
||||
// derived
|
||||
const buttonClass = cn(
|
||||
"flex w-full items-center px-2 py-1.5 rounded text-custom-text-200 justify-between",
|
||||
"hover:bg-custom-primary-100/10",
|
||||
{
|
||||
"text-custom-primary-200 bg-custom-primary-100/10": typeof isActive === "function" ? isActive(setting) : isActive,
|
||||
"hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90":
|
||||
typeof isActive === "function" ? !isActive(setting) : !isActive,
|
||||
}
|
||||
);
|
||||
|
||||
const titleElement = (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
{setting.icon ? setting.icon : actionIcons && actionIcons({ type: setting.key, size: 16 })}
|
||||
<div className="text-sm font-medium truncate">{t(setting.i18n_label)}</div>
|
||||
</div>
|
||||
{appendItemsToTitle?.(setting.key)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Disclosure as="div" className="flex flex-col w-full" defaultOpen={isExpanded} key={setting.key}>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn(
|
||||
"group w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400"
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{renderChildren ? (
|
||||
<div className={buttonClass}>{titleElement}</div>
|
||||
) : (
|
||||
<Link href={`/${workspaceSlug}/${setting.href}`} className={buttonClass}>
|
||||
{titleElement}
|
||||
</Link>
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
{/* Nested Navigation */}
|
||||
{isExpanded && (
|
||||
<Disclosure.Panel
|
||||
as="div"
|
||||
className={cn("flex flex-col gap-0.5", {
|
||||
"space-y-0 ml-0": isExpanded,
|
||||
})}
|
||||
static
|
||||
>
|
||||
<div className="ml-4 border-l border-custom-border-200 pl-2 my-0.5">{renderChildren?.(setting.key)}</div>
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsSidebarNavItem;
|
||||
64
web/core/components/settings/sidebar/root.tsx
Normal file
64
web/core/components/settings/sidebar/root.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { SettingsSidebarHeader } from "./header";
|
||||
import SettingsSidebarNavItem, { TSettingItem } from "./nav-item";
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
customHeader?: React.ReactNode;
|
||||
categories: string[];
|
||||
groupedSettings: {
|
||||
[key: string]: TSettingItem[];
|
||||
};
|
||||
workspaceSlug: string;
|
||||
isActive: boolean | ((data: { href: string }) => boolean);
|
||||
shouldRender: boolean | ((setting: TSettingItem) => boolean);
|
||||
actionIcons?: (props: { type: string; size?: number; className?: string }) => React.ReactNode;
|
||||
appendItemsToTitle?: (key: string) => React.ReactNode;
|
||||
renderChildren?: (key: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const SettingsSidebar = (props: SettingsSidebarProps) => {
|
||||
const {
|
||||
customHeader,
|
||||
categories,
|
||||
groupedSettings,
|
||||
workspaceSlug,
|
||||
isActive,
|
||||
shouldRender,
|
||||
actionIcons,
|
||||
appendItemsToTitle,
|
||||
renderChildren,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex w-[220px] flex-col gap-2 h-full">
|
||||
{/* Header */}
|
||||
<SettingsSidebarHeader customHeader={customHeader} />
|
||||
{/* Navigation */}
|
||||
<div className="divide-y divide-custom-border-100 overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto vertical-scrollbar">
|
||||
{categories.map((category) => (
|
||||
<div key={category} className="py-3">
|
||||
<span className="text-sm font-semibold text-custom-text-400 capitalize mb-2">{t(category)}</span>
|
||||
{groupedSettings[category].length > 0 && (
|
||||
<div className="relative flex flex-col gap-0.5 overflow-y-scroll h-full mt-2">
|
||||
{groupedSettings[category].map(
|
||||
(setting) =>
|
||||
(typeof shouldRender === "function" ? shouldRender(setting) : shouldRender) && (
|
||||
<SettingsSidebarNavItem
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isActive={isActive}
|
||||
appendItemsToTitle={appendItemsToTitle}
|
||||
renderChildren={renderChildren}
|
||||
actionIcons={actionIcons}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
web/core/components/settings/tabs.tsx
Normal file
63
web/core/components/settings/tabs.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const TABS = {
|
||||
account: {
|
||||
key: "account",
|
||||
label: "Account",
|
||||
href: `/settings/account/`,
|
||||
},
|
||||
workspace: {
|
||||
key: "workspace",
|
||||
label: "Workspace",
|
||||
href: `/settings/`,
|
||||
},
|
||||
projects: {
|
||||
key: "projects",
|
||||
label: "Projects",
|
||||
href: `/settings/project/`,
|
||||
},
|
||||
};
|
||||
|
||||
const SettingsTabs = observer(() => {
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { joinedProjectIds } = useProject();
|
||||
|
||||
const currentTab = pathname.includes(TABS.projects.href)
|
||||
? TABS.projects
|
||||
: pathname.includes(TABS.account.href)
|
||||
? TABS.account
|
||||
: TABS.workspace;
|
||||
|
||||
return (
|
||||
<div className="flex w-fit min-w-fit items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80/60 mt-2">
|
||||
{Object.values(TABS).map((tab) => {
|
||||
const isActive = currentTab?.key === tab.key;
|
||||
const href = tab.key === TABS.projects.key ? `${tab.href}${joinedProjectIds[0] || ""}` : tab.href;
|
||||
return (
|
||||
<Link
|
||||
key={tab.key}
|
||||
href={`/${workspaceSlug}${href}`}
|
||||
className={cn(
|
||||
"flex items-center justify-center p-1 min-w-fit w-full font-medium outline-none focus:outline-none cursor-pointer transition-all rounded text-custom-text-400 ",
|
||||
{
|
||||
"bg-custom-background-100 text-custom-text-300 shadow-sm": isActive,
|
||||
"hover:text-custom-text-300 hover:bg-custom-background-80/60": !isActive,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="text-xs font-semibold p-1">{tab.label}</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default SettingsTabs;
|
||||
40
web/core/components/settings/workspace-logo.tsx
Normal file
40
web/core/components/settings/workspace-logo.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
|
||||
interface IWorkspaceLogoProps {
|
||||
workspace: {
|
||||
logo_url: string;
|
||||
name: string;
|
||||
};
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const WorkspaceLogo = (props: IWorkspaceLogoProps) => {
|
||||
const { workspace, className, size = "md" } = props;
|
||||
const sizeClass = size === "sm" ? "h-4 w-4 text-[9px]" : size === "md" ? "h-8 w-8 text-sm" : "h-10 w-10 text-base";
|
||||
return (
|
||||
<div className={cn("flex gap-3 items-center", className)}>
|
||||
{workspace.logo_url && workspace.logo_url !== "" ? (
|
||||
<div className={cn("relative my-auto flex rounded", sizeClass)}>
|
||||
<img
|
||||
src={getFileURL(workspace.logo_url)}
|
||||
className={cn("absolute left-0 top-0 h-full w-full rounded-md object-cover", sizeClass)}
|
||||
alt="Workspace Logo"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded bg-gray-700 uppercase text-white my-auto",
|
||||
sizeClass
|
||||
)}
|
||||
>
|
||||
{workspace?.name?.charAt(0) ?? "N"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceLogo;
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
// types
|
||||
@@ -30,6 +31,7 @@ export const DeleteWorkspaceForm: React.FC<Props> = observer((props) => {
|
||||
const { data, onClose } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { captureWorkspaceEvent } = useEventTracker();
|
||||
const { deleteWorkspace } = useWorkspace();
|
||||
@@ -60,7 +62,7 @@ export const DeleteWorkspaceForm: React.FC<Props> = observer((props) => {
|
||||
await deleteWorkspace(data.slug)
|
||||
.then(() => {
|
||||
handleClose();
|
||||
router.push("/profile");
|
||||
router.push(`/${workspaceSlug}/settings/account`);
|
||||
captureWorkspaceEvent({
|
||||
eventName: WORKSPACE_DELETED,
|
||||
payload: {
|
||||
|
||||
@@ -45,7 +45,7 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
|
||||
state: "SUCCESS",
|
||||
element: "Workspace settings members page",
|
||||
});
|
||||
router.push("/profile");
|
||||
router.push(`/${workspaceSlug}/settings/account`);
|
||||
})
|
||||
.catch((err: any) =>
|
||||
setToast({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Fragment, Ref, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { usePopper } from "react-popper";
|
||||
// icons
|
||||
import { ChevronDown, CirclePlus, LogOut, Mails, Settings } from "lucide-react";
|
||||
@@ -26,7 +27,7 @@ import SidebarDropdownItem from "./dropdown-item";
|
||||
|
||||
export const SidebarDropdown = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
|
||||
const { data: currentUser } = useUser();
|
||||
@@ -217,7 +218,7 @@ export const SidebarDropdown = observer(() => {
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 pb-2">
|
||||
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span>
|
||||
<Link href="/profile">
|
||||
<Link href={`/${workspaceSlug}/settings/account`}>
|
||||
<Menu.Item as="div">
|
||||
<span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
|
||||
<Settings className="h-4 w-4 stroke-[1.5]" />
|
||||
|
||||
@@ -353,7 +353,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem>
|
||||
<Link href={`/${workspaceSlug}/projects/${project?.id}/settings`}>
|
||||
<Link href={`/${workspaceSlug}/settings/project/${project?.id}`}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
<span>{t("settings")}</span>
|
||||
|
||||
@@ -178,7 +178,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
||||
<Button>Go Home</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/profile">
|
||||
<Link href={`/${workspaceSlug}/settings/account`}>
|
||||
<Button variant="neutral-primary">Visit Profile</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,8 @@ export class UserSettingsStore implements IUserSettingsStore {
|
||||
workspace: {
|
||||
last_workspace_id: undefined,
|
||||
last_workspace_slug: undefined,
|
||||
last_workspace_name: undefined,
|
||||
last_workspace_logo: undefined,
|
||||
fallback_workspace_id: undefined,
|
||||
fallback_workspace_slug: undefined,
|
||||
invites: undefined,
|
||||
|
||||
BIN
web/public/empty-state/project-settings/no-projects-dark.png
Normal file
BIN
web/public/empty-state/project-settings/no-projects-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
BIN
web/public/empty-state/project-settings/no-projects-light.png
Normal file
BIN
web/public/empty-state/project-settings/no-projects-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
Reference in New Issue
Block a user