[WEB-4882]feat: suspended users (#7844)

This commit is contained in:
Vamsi Krishna 2025-09-30 15:31:56 +05:30 committed by GitHub
parent 726529044e
commit c45151d5e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 176 additions and 37 deletions

View File

@ -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<IMemberFilters>) => {
updateFilters(filterUpdates);
@ -58,6 +59,11 @@ export const useMemberColumns = () => {
{
key: "Display name",
content: t("workspace_settings.settings.members.details.display_name"),
tdRender: (rowData: RowData) => (
<div className={`w-32 ${isSuspended(rowData) ? "text-custom-text-400" : ""}`}>
{rowData.member.display_name}
</div>
),
thRender: () => (
<MemberHeaderColumn
property="display_name"
@ -65,12 +71,16 @@ export const useMemberColumns = () => {
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div className="w-32">{rowData.member.display_name}</div>,
},
{
key: "Email address",
content: t("workspace_settings.settings.members.details.email_address"),
tdRender: (rowData: RowData) => (
<div className={`w-48 truncate ${isSuspended(rowData) ? "text-custom-text-400" : ""}`}>
{rowData.member.email}
</div>
),
thRender: () => (
<MemberHeaderColumn
property="email"
@ -78,7 +88,6 @@ export const useMemberColumns = () => {
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div className="w-48 truncate">{rowData.member.email}</div>,
},
{
@ -97,14 +106,17 @@ export const useMemberColumns = () => {
{
key: "Authentication",
content: t("workspace_settings.settings.members.details.authentication"),
tdRender: (rowData: RowData) => (
<div className="capitalize">{rowData.member.last_login_medium?.replace("-", " ")}</div>
),
tdRender: (rowData: RowData) =>
isSuspended(rowData) ? null : (
<div className="capitalize">{rowData.member.last_login_medium?.replace("-", " ")}</div>
),
},
{
key: "Joining date",
content: t("workspace_settings.settings.members.details.joining_date"),
tdRender: (rowData: RowData) =>
isSuspended(rowData) ? null : <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
thRender: () => (
<MemberHeaderColumn
property="joining_date"
@ -112,7 +124,6 @@ export const useMemberColumns = () => {
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
},
];
return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal };

View File

@ -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<Props> = observer((props: Props) => {
placement,
referenceElement,
} = props;
// router
const { workspaceSlug } = useParams();
// refs
const inputRef = useRef<HTMLInputElement | null>(null);
// states
@ -46,6 +52,9 @@ export const MemberOptions: React.FC<Props> = 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<Props> = observer((props: Props) => {
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
content: (
<div className="flex items-center gap-2">
<Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} />
<span className="flex-grow truncate">
<div className="w-4">
{isUserSuspended(userId, workspaceSlug?.toString()) ? (
<SuspendedUserIcon className="h-3.5 w-3.5 text-custom-text-400" />
) : (
<Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} />
)}
</div>
<span
className={cn(
"flex-grow truncate",
isUserSuspended(userId, workspaceSlug?.toString()) ? "text-custom-text-400" : ""
)}
>
{currentUser?.id === userId ? t("you") : userDetails?.display_name}
</span>
</div>
@ -133,15 +153,26 @@ export const MemberOptions: React.FC<Props> = 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 }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
{isUserSuspended(option.value, workspaceSlug?.toString()) && (
<Pill variant={EPillVariant.DEFAULT} size={EPillSize.XS} className="border-none">
Suspended
</Pill>
)}
</>
)}
</Combobox.Option>

View File

@ -30,6 +30,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

View File

@ -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<NameProps> = (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 (
<Disclosure>
@ -48,24 +52,39 @@ export const NameColumn: React.FC<NameProps> = (props) => {
{avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex h-6 w-6 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}
/>
{isSuspended ? (
<SuspendedUserIcon className="h-4 w-4 text-custom-text-400" />
) : (
<img
src={getFileURL(avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
)}
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex h-4 w-4 text-xs items-center justify-center rounded-full bg-gray-700 capitalize text-white">
{(email ?? display_name ?? "?")[0]}
<span
className={cn(
"relative flex h-4 w-4 text-xs items-center justify-center rounded-full capitalize text-white",
isSuspended ? "bg-custom-background-80" : "bg-gray-700"
)}
>
{isSuspended ? (
<SuspendedUserIcon className="h-4 w-4 text-custom-text-400" />
) : (
(email ?? display_name ?? "?")[0]
)}
</span>
</Link>
)}
{first_name} {last_name}
<span className={isSuspended ? "text-custom-text-400" : ""}>
{first_name} {last_name}
</span>
</div>
{(isAdmin || id === currentUser?.id) && (
{!isSuspended && (isAdmin || id === currentUser?.id) && (
<PopoverMenu
data={[""]}
keyExtractor={(item) => item}
@ -108,10 +127,17 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = 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 ? (
<div className="w-32 flex ">
<Pill variant={EPillVariant.DEFAULT} size={EPillSize.SM} className="border-none">
Suspended
</Pill>
</div>
) : isRoleNonEditable ? (
<div className="w-32 flex ">
<span>{ROLE[rowData.role]}</span>
</div>

View File

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

View File

@ -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 = <T extends { role: string | EUserPermissions }>(
export const filterWorkspaceMembersByRole = <T extends { role: string | EUserPermissions; is_active?: boolean }>(
members: T[],
roleFilters: string[]
): T[] => {
@ -67,7 +74,20 @@ export const filterWorkspaceMembersByRole = <T extends { role: string | EUserPer
return members.filter((member) => {
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 = <T>(
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 = <T extends { role: string | EUserPermissions }>(
export const sortWorkspaceMembers = <T extends { role: string | EUserPermissions; is_active?: boolean }>(
members: T[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: T) => string,
filters?: IMemberFilters
): T[] => {
// Apply role filtering first
const filteredMembers =
filters?.roles && filters.roles.length > 0 ? filterWorkspaceMembersByRole(members, filters.roles) : members;

View File

@ -53,6 +53,7 @@ export interface IWorkspaceMemberStore {
data: Partial<IWorkspaceMemberInvitation>
) => Promise<void>;
deleteMemberInvitation: (workspaceSlug: string, invitationId: string) => Promise<void>;
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;
});
}

View File

@ -56,3 +56,4 @@ export * from "./ai-icon";
export * from "./plane-icon";
export * from "./wiki-icon";
export * from "./brand";
export * from "./suspended-user";

View File

@ -0,0 +1,32 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const SuspendedUserIcon: React.FC<ISvgIcons> = ({ className, ...rest }) => (
<svg viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg" className={className} {...rest}>
<g clipPath="url(#clip0_806_120890)">
<path
d="M3 13C3 12.304 3.18158 11.6201 3.52681 11.0158C3.87204 10.4115 4.36897 9.90774 4.9685 9.55428C5.56802 9.20082 6.24939 9.00989 6.94529 9.00037C7.64119 8.99086 8.32753 9.16307 8.9365 9.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 9C8.38071 9 9.5 7.88071 9.5 6.5C9.5 5.11929 8.38071 4 7 4C5.61929 4 4.5 5.11929 4.5 6.5C4.5 7.88071 5.61929 9 7 9Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M10.5 11L13 13.5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" />
<path d="M13 11L10.5 13.5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" />
</g>
<defs>
<clipPath id="clip0_806_120890">
<path
d="M2 4.5C2 3.39543 2.89543 2.5 4 2.5H12C13.1046 2.5 14 3.39543 14 4.5V12.5C14 13.6046 13.1046 14.5 12 14.5H4C2.89543 14.5 2 13.6046 2 12.5V4.5Z"
fill="white"
/>
</clipPath>
</defs>
</svg>
);

View File

@ -1,2 +1,2 @@
export { Pill } from "./pill";
export { Pill, EPillVariant, EPillSize } from "./pill";
export type { PillProps } from "./pill";

View File

@ -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<HTMLSpanElement> {
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",