diff --git a/apps/web/ce/components/workspace/settings/useMemberColumns.tsx b/apps/web/ce/components/workspace/settings/useMemberColumns.tsx index 67dd102aa1d..7142bf720e4 100644 --- a/apps/web/ce/components/workspace/settings/useMemberColumns.tsx +++ b/apps/web/ce/components/workspace/settings/useMemberColumns.tsx @@ -27,6 +27,7 @@ export const useMemberColumns = () => { // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const isSuspended = (rowData: RowData) => rowData.is_active === false; // handlers const handleDisplayFilterUpdate = (filterUpdates: Partial) => { updateFilters(filterUpdates); @@ -58,6 +59,11 @@ export const useMemberColumns = () => { { key: "Display name", content: t("workspace_settings.settings.members.details.display_name"), + tdRender: (rowData: RowData) => ( +
+ {rowData.member.display_name} +
+ ), thRender: () => ( { handleDisplayFilterUpdate={handleDisplayFilterUpdate} /> ), - tdRender: (rowData: RowData) =>
{rowData.member.display_name}
, }, { key: "Email address", content: t("workspace_settings.settings.members.details.email_address"), + tdRender: (rowData: RowData) => ( +
+ {rowData.member.email} +
+ ), thRender: () => ( { handleDisplayFilterUpdate={handleDisplayFilterUpdate} /> ), - tdRender: (rowData: RowData) =>
{rowData.member.email}
, }, { @@ -97,14 +106,17 @@ export const useMemberColumns = () => { { key: "Authentication", content: t("workspace_settings.settings.members.details.authentication"), - tdRender: (rowData: RowData) => ( -
{rowData.member.last_login_medium?.replace("-", " ")}
- ), + tdRender: (rowData: RowData) => + isSuspended(rowData) ? null : ( +
{rowData.member.last_login_medium?.replace("-", " ")}
+ ), }, { key: "Joining date", content: t("workspace_settings.settings.members.details.joining_date"), + tdRender: (rowData: RowData) => + isSuspended(rowData) ? null :
{renderFormattedDate(rowData?.member?.joining_date)}
, thRender: () => ( { handleDisplayFilterUpdate={handleDisplayFilterUpdate} /> ), - tdRender: (rowData: RowData) =>
{renderFormattedDate(rowData?.member?.joining_date)}
, }, ]; return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal }; diff --git a/apps/web/core/components/dropdowns/member/member-options.tsx b/apps/web/core/components/dropdowns/member/member-options.tsx index f49cd8438d6..6f7cf99cca0 100644 --- a/apps/web/core/components/dropdowns/member/member-options.tsx +++ b/apps/web/core/components/dropdowns/member/member-options.tsx @@ -3,16 +3,20 @@ import { useEffect, useRef, useState } from "react"; import { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { Check, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; +import { SuspendedUserIcon } from "@plane/propel/icons"; +import { EPillSize, EPillVariant, Pill } from "@plane/propel/pill"; import { IUserLite } from "@plane/types"; import { Avatar } from "@plane/ui"; import { cn, getFileURL } from "@plane/utils"; // hooks +import { useMember } from "@/hooks/store/use-member"; import { useUser } from "@/hooks/store/user"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -37,6 +41,8 @@ export const MemberOptions: React.FC = observer((props: Props) => { placement, referenceElement, } = props; + // router + const { workspaceSlug } = useParams(); // refs const inputRef = useRef(null); // states @@ -46,6 +52,9 @@ export const MemberOptions: React.FC = observer((props: Props) => { const { t } = useTranslation(); // store hooks const { data: currentUser } = useUser(); + const { + workspace: { isUserSuspended }, + } = useMember(); const { isMobile } = usePlatformOS(); // popper-js init const { styles, attributes } = usePopper(referenceElement, popperElement, { @@ -84,8 +93,19 @@ export const MemberOptions: React.FC = observer((props: Props) => { query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, content: (
- - +
+ {isUserSuspended(userId, workspaceSlug?.toString()) ? ( + + ) : ( + + )} +
+ {currentUser?.id === userId ? t("you") : userDetails?.display_name}
@@ -133,15 +153,26 @@ export const MemberOptions: React.FC = observer((props: Props) => { key={option.value} value={option.value} className={({ active, selected }) => - `flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + cn( + "flex w-full select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5", + active && "bg-custom-background-80", + selected ? "text-custom-text-100" : "text-custom-text-200", + isUserSuspended(option.value, workspaceSlug?.toString()) + ? "cursor-not-allowed" + : "cursor-pointer" + ) } + disabled={isUserSuspended(option.value, workspaceSlug?.toString())} > {({ selected }) => ( <> {option.content} {selected && } + {isUserSuspended(option.value, workspaceSlug?.toString()) && ( + + Suspended + + )} )} diff --git a/apps/web/core/components/project/dropdowns/filters/member-list.tsx b/apps/web/core/components/project/dropdowns/filters/member-list.tsx index e0aff0cf13c..6410d0a3827 100644 --- a/apps/web/core/components/project/dropdowns/filters/member-list.tsx +++ b/apps/web/core/components/project/dropdowns/filters/member-list.tsx @@ -29,6 +29,7 @@ const WORKSPACE_ROLE_OPTIONS: IRoleOption[] = [ { value: String(EUserWorkspaceRoles.ADMIN), label: "Admin" }, { value: String(EUserWorkspaceRoles.MEMBER), label: "Member" }, { value: String(EUserWorkspaceRoles.GUEST), label: "Guest" }, + { value: "suspended", label: "Suspended" }, ]; // Role filter group component diff --git a/apps/web/core/components/workspace/settings/member-columns.tsx b/apps/web/core/components/workspace/settings/member-columns.tsx index 56275f2497c..87d28c371e9 100644 --- a/apps/web/core/components/workspace/settings/member-columns.tsx +++ b/apps/web/core/components/workspace/settings/member-columns.tsx @@ -5,9 +5,11 @@ import { Trash2 } from "lucide-react"; import { Disclosure } from "@headlessui/react"; // plane imports import { ROLE, EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants"; +import { SuspendedUserIcon } from "@plane/propel/icons"; +import { Pill, EPillVariant, EPillSize } from "@plane/propel/pill"; import { IUser, IWorkspaceMember } from "@plane/types"; // plane ui -import { CustomSelect, PopoverMenu, TOAST_TYPE, setToast } from "@plane/ui"; +import { CustomSelect, PopoverMenu, TOAST_TYPE, cn, setToast } from "@plane/ui"; // constants // helpers import { getFileURL } from "@plane/utils"; @@ -19,6 +21,7 @@ import { useUser, useUserPermissions } from "@/hooks/store/user"; export interface RowData { member: IWorkspaceMember; role: EUserPermissions; + is_active: boolean; } type NameProps = { @@ -38,6 +41,7 @@ export const NameColumn: React.FC = (props) => { const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props; // derived values const { avatar_url, display_name, email, first_name, id, last_name } = rowData.member; + const isSuspended = rowData.is_active === false; return ( @@ -48,24 +52,39 @@ export const NameColumn: React.FC = (props) => { {avatar_url && avatar_url.trim() !== "" ? ( - {display_name + {isSuspended ? ( + + ) : ( + {display_name + )} ) : ( - - {(email ?? display_name ?? "?")[0]} + + {isSuspended ? ( + + ) : ( + (email ?? display_name ?? "?")[0] + )} )} - {first_name} {last_name} + + {first_name} {last_name} + - {(isAdmin || id === currentUser?.id) && ( + {!isSuspended && (isAdmin || id === currentUser?.id) && ( item} @@ -108,10 +127,17 @@ export const AccountTypeColumn: React.FC = observer((props) => const isCurrentUser = currentUser?.id === rowData.member.id; const isAdminRole = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const isRoleNonEditable = isCurrentUser || !isAdminRole; + const isSuspended = rowData.is_active === false; return ( <> - {isRoleNonEditable ? ( + {isSuspended ? ( +
+ + Suspended + +
+ ) : isRoleNonEditable ? (
{ROLE[rowData.role]}
diff --git a/apps/web/core/components/workspace/settings/members-list.tsx b/apps/web/core/components/workspace/settings/members-list.tsx index c9bf00b9621..089d2f9a21f 100644 --- a/apps/web/core/components/workspace/settings/members-list.tsx +++ b/apps/web/core/components/workspace/settings/members-list.tsx @@ -53,7 +53,13 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }> const filteredMemberIds = workspaceSlug ? getFilteredWorkspaceMemberIds(workspaceSlug.toString()) : []; const searchedMemberIds = searchQuery ? getSearchedWorkspaceMemberIds(searchQuery) : filteredMemberIds; const searchedInvitationsIds = getSearchedWorkspaceInvitationIds(searchQuery); - const memberDetails = searchedMemberIds?.map((memberId) => getWorkspaceMemberDetails(memberId)); + const memberDetails = searchedMemberIds + ?.map((memberId) => getWorkspaceMemberDetails(memberId)) + .sort((a, b) => { + if (a?.is_active && !b?.is_active) return -1; + if (!a?.is_active && b?.is_active) return 1; + return 0; + }); return ( <> diff --git a/apps/web/core/store/member/utils.ts b/apps/web/core/store/member/utils.ts index 465fd7b0576..9f13d694df4 100644 --- a/apps/web/core/store/member/utils.ts +++ b/apps/web/core/store/member/utils.ts @@ -37,8 +37,15 @@ export const getMemberSortKey = (memberDetails: IUserLite, field: string, member } case "email": return memberDetails.email?.toLowerCase() || ""; - case "joining_date": - return memberDetails.joining_date ? new Date(memberDetails.joining_date) : new Date(NaN); + case "joining_date": { + if (!memberDetails.joining_date) { + // Return a very old date for missing dates to sort them last + return new Date(0); + } + const date = new Date(memberDetails.joining_date); + // Return a very old date for invalid dates to sort them last + return isNaN(date.getTime()) ? new Date(0) : date; + } case "role": return (memberRole ?? "").toString().toLowerCase(); default: @@ -59,7 +66,7 @@ export const filterProjectMembersByRole = ( }); }; -export const filterWorkspaceMembersByRole = ( +export const filterWorkspaceMembersByRole = ( members: T[], roleFilters: string[] ): T[] => { @@ -67,7 +74,20 @@ export const filterWorkspaceMembersByRole = { const memberRole = String(member.role ?? ""); - return roleFilters.includes(memberRole); + const isSuspended = member.is_active === false; + + // Check if suspended is in the role filters + const hasSuspendedFilter = roleFilters.includes("suspended"); + // Get non-suspended role filters + const activeRoleFilters = roleFilters.filter((role) => role !== "suspended"); + + // For suspended users, include them only if suspended filter is selected + if (isSuspended) { + return hasSuspendedFilter; + } + + // For active users, include them only if their role matches any active role filter + return activeRoleFilters.includes(memberRole); }); }; @@ -100,10 +120,15 @@ export const sortMembers = ( let comparison = 0; if (field === "joining_date") { - // For dates, we need to handle Date objects + // For dates, we need to handle Date objects and ensure they're valid const aDate = aValue instanceof Date ? aValue : new Date(aValue); const bDate = bValue instanceof Date ? bValue : new Date(bValue); - comparison = aDate.getTime() - bDate.getTime(); + + // Handle invalid dates by treating them as very old dates + const aTime = isNaN(aDate.getTime()) ? 0 : aDate.getTime(); + const bTime = isNaN(bDate.getTime()) ? 0 : bDate.getTime(); + + comparison = aTime - bTime; } else { // For strings, use localeCompare for proper alphabetical sorting const aStr = String(aValue); @@ -139,13 +164,12 @@ export const sortProjectMembers = ( ); }; -export const sortWorkspaceMembers = ( +export const sortWorkspaceMembers = ( members: T[], memberDetailsMap: Record, getMemberKey: (member: T) => string, filters?: IMemberFilters ): T[] => { - // Apply role filtering first const filteredMembers = filters?.roles && filters.roles.length > 0 ? filterWorkspaceMembersByRole(members, filters.roles) : members; diff --git a/apps/web/core/store/member/workspace/workspace-member.store.ts b/apps/web/core/store/member/workspace/workspace-member.store.ts index a6d1de820b1..9baf9ed114e 100644 --- a/apps/web/core/store/member/workspace/workspace-member.store.ts +++ b/apps/web/core/store/member/workspace/workspace-member.store.ts @@ -53,6 +53,7 @@ export interface IWorkspaceMemberStore { data: Partial ) => Promise; deleteMemberInvitation: (workspaceSlug: string, invitationId: string) => Promise; + isUserSuspended: (userId: string, workspaceSlug: string) => boolean; } export class WorkspaceMemberStore implements IWorkspaceMemberStore { @@ -126,9 +127,7 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { (m) => this.memberRoot?.memberMap?.[m.member]?.display_name?.toLowerCase(), ]); //filter out bots - const memberIds = members - .filter((m) => m.is_active && !this.memberRoot?.memberMap?.[m.member]?.is_bot) - .map((m) => m.member); + const memberIds = members.filter((m) => !this.memberRoot?.memberMap?.[m.member]?.is_bot).map((m) => m.member); return memberIds; }); @@ -139,7 +138,7 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { getFilteredWorkspaceMemberIds = computedFn((workspaceSlug: string) => { let members = Object.values(this.workspaceMemberMap?.[workspaceSlug] ?? {}); //filter out bots and inactive members - members = members.filter((m) => m.is_active && !this.memberRoot?.memberMap?.[m.member]?.is_bot); + members = members.filter((m) => !this.memberRoot?.memberMap?.[m.member]?.is_bot); // Use filters store to get filtered member ids const memberIds = this.filtersStore.getFilteredMemberIds( @@ -350,4 +349,10 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { ); }); }); + + isUserSuspended = computedFn((userId: string, workspaceSlug: string) => { + if (!workspaceSlug) return false; + const workspaceMember = this.workspaceMemberMap?.[workspaceSlug]?.[userId]; + return workspaceMember?.is_active === false; + }); } diff --git a/packages/propel/src/icons/index.ts b/packages/propel/src/icons/index.ts index f673cf730b6..fc6e24f0ae0 100644 --- a/packages/propel/src/icons/index.ts +++ b/packages/propel/src/icons/index.ts @@ -56,3 +56,4 @@ export * from "./ai-icon"; export * from "./plane-icon"; export * from "./wiki-icon"; export * from "./brand"; +export * from "./suspended-user"; diff --git a/packages/propel/src/icons/suspended-user.tsx b/packages/propel/src/icons/suspended-user.tsx new file mode 100644 index 00000000000..8ae0206b2ee --- /dev/null +++ b/packages/propel/src/icons/suspended-user.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const SuspendedUserIcon: React.FC = ({ className, ...rest }) => ( + + + + + + + + + + + + + +); diff --git a/packages/propel/src/pill/index.ts b/packages/propel/src/pill/index.ts index 9b9f4c84ea4..523ccb2b0c6 100644 --- a/packages/propel/src/pill/index.ts +++ b/packages/propel/src/pill/index.ts @@ -1,2 +1,2 @@ -export { Pill } from "./pill"; +export { Pill, EPillVariant, EPillSize } from "./pill"; export type { PillProps } from "./pill"; diff --git a/packages/propel/src/pill/pill.tsx b/packages/propel/src/pill/pill.tsx index dbbf60d8d01..f9c58bc9fe6 100644 --- a/packages/propel/src/pill/pill.tsx +++ b/packages/propel/src/pill/pill.tsx @@ -14,6 +14,7 @@ export enum EPillSize { SM = "sm", MD = "md", LG = "lg", + XS = "xs", } export type TPillVariant = @@ -23,7 +24,7 @@ export type TPillVariant = | EPillVariant.WARNING | EPillVariant.ERROR | EPillVariant.INFO; -export type TPillSize = EPillSize.SM | EPillSize.MD | EPillSize.LG; +export type TPillSize = EPillSize.SM | EPillSize.MD | EPillSize.LG | EPillSize.XS; export interface PillProps extends React.HTMLAttributes { variant?: TPillVariant; @@ -42,6 +43,7 @@ const pillVariants = { }; const pillSizes = { + [EPillSize.XS]: "px-1.5 py-0.5 text-xs", [EPillSize.SM]: "px-2 py-0.5 text-xs", [EPillSize.MD]: "px-2.5 py-1 text-sm", [EPillSize.LG]: "px-3 py-1.5 text-base",