Skip to content

Commit c45151d

Browse files
[WEB-4882]feat: suspended users (#7844)
1 parent 7265290 commit c45151d

File tree

11 files changed

+176
-37
lines changed

11 files changed

+176
-37
lines changed

apps/web/ce/components/workspace/settings/useMemberColumns.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const useMemberColumns = () => {
2727
// derived values
2828
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
2929

30+
const isSuspended = (rowData: RowData) => rowData.is_active === false;
3031
// handlers
3132
const handleDisplayFilterUpdate = (filterUpdates: Partial<IMemberFilters>) => {
3233
updateFilters(filterUpdates);
@@ -58,27 +59,35 @@ export const useMemberColumns = () => {
5859
{
5960
key: "Display name",
6061
content: t("workspace_settings.settings.members.details.display_name"),
62+
tdRender: (rowData: RowData) => (
63+
<div className={`w-32 ${isSuspended(rowData) ? "text-custom-text-400" : ""}`}>
64+
{rowData.member.display_name}
65+
</div>
66+
),
6167
thRender: () => (
6268
<MemberHeaderColumn
6369
property="display_name"
6470
displayFilters={filters}
6571
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
6672
/>
6773
),
68-
tdRender: (rowData: RowData) => <div className="w-32">{rowData.member.display_name}</div>,
6974
},
7075

7176
{
7277
key: "Email address",
7378
content: t("workspace_settings.settings.members.details.email_address"),
79+
tdRender: (rowData: RowData) => (
80+
<div className={`w-48 truncate ${isSuspended(rowData) ? "text-custom-text-400" : ""}`}>
81+
{rowData.member.email}
82+
</div>
83+
),
7484
thRender: () => (
7585
<MemberHeaderColumn
7686
property="email"
7787
displayFilters={filters}
7888
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
7989
/>
8090
),
81-
tdRender: (rowData: RowData) => <div className="w-48 truncate">{rowData.member.email}</div>,
8291
},
8392

8493
{
@@ -97,22 +106,24 @@ export const useMemberColumns = () => {
97106
{
98107
key: "Authentication",
99108
content: t("workspace_settings.settings.members.details.authentication"),
100-
tdRender: (rowData: RowData) => (
101-
<div className="capitalize">{rowData.member.last_login_medium?.replace("-", " ")}</div>
102-
),
109+
tdRender: (rowData: RowData) =>
110+
isSuspended(rowData) ? null : (
111+
<div className="capitalize">{rowData.member.last_login_medium?.replace("-", " ")}</div>
112+
),
103113
},
104114

105115
{
106116
key: "Joining date",
107117
content: t("workspace_settings.settings.members.details.joining_date"),
118+
tdRender: (rowData: RowData) =>
119+
isSuspended(rowData) ? null : <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
108120
thRender: () => (
109121
<MemberHeaderColumn
110122
property="joining_date"
111123
displayFilters={filters}
112124
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
113125
/>
114126
),
115-
tdRender: (rowData: RowData) => <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
116127
},
117128
];
118129
return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal };

apps/web/core/components/dropdowns/member/member-options.tsx

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@
33
import { useEffect, useRef, useState } from "react";
44
import { Placement } from "@popperjs/core";
55
import { observer } from "mobx-react";
6+
import { useParams } from "next/navigation";
67
import { createPortal } from "react-dom";
78
import { usePopper } from "react-popper";
89
import { Check, Search } from "lucide-react";
910
import { Combobox } from "@headlessui/react";
1011
// plane imports
1112
import { useTranslation } from "@plane/i18n";
13+
import { SuspendedUserIcon } from "@plane/propel/icons";
14+
import { EPillSize, EPillVariant, Pill } from "@plane/propel/pill";
1215
import { IUserLite } from "@plane/types";
1316
import { Avatar } from "@plane/ui";
1417
import { cn, getFileURL } from "@plane/utils";
1518
// hooks
19+
import { useMember } from "@/hooks/store/use-member";
1620
import { useUser } from "@/hooks/store/user";
1721
import { usePlatformOS } from "@/hooks/use-platform-os";
1822

@@ -37,6 +41,8 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
3741
placement,
3842
referenceElement,
3943
} = props;
44+
// router
45+
const { workspaceSlug } = useParams();
4046
// refs
4147
const inputRef = useRef<HTMLInputElement | null>(null);
4248
// states
@@ -46,6 +52,9 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
4652
const { t } = useTranslation();
4753
// store hooks
4854
const { data: currentUser } = useUser();
55+
const {
56+
workspace: { isUserSuspended },
57+
} = useMember();
4958
const { isMobile } = usePlatformOS();
5059
// popper-js init
5160
const { styles, attributes } = usePopper(referenceElement, popperElement, {
@@ -84,8 +93,19 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
8493
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
8594
content: (
8695
<div className="flex items-center gap-2">
87-
<Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} />
88-
<span className="flex-grow truncate">
96+
<div className="w-4">
97+
{isUserSuspended(userId, workspaceSlug?.toString()) ? (
98+
<SuspendedUserIcon className="h-3.5 w-3.5 text-custom-text-400" />
99+
) : (
100+
<Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} />
101+
)}
102+
</div>
103+
<span
104+
className={cn(
105+
"flex-grow truncate",
106+
isUserSuspended(userId, workspaceSlug?.toString()) ? "text-custom-text-400" : ""
107+
)}
108+
>
89109
{currentUser?.id === userId ? t("you") : userDetails?.display_name}
90110
</span>
91111
</div>
@@ -133,15 +153,26 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
133153
key={option.value}
134154
value={option.value}
135155
className={({ active, selected }) =>
136-
`flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${
137-
active ? "bg-custom-background-80" : ""
138-
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
156+
cn(
157+
"flex w-full select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5",
158+
active && "bg-custom-background-80",
159+
selected ? "text-custom-text-100" : "text-custom-text-200",
160+
isUserSuspended(option.value, workspaceSlug?.toString())
161+
? "cursor-not-allowed"
162+
: "cursor-pointer"
163+
)
139164
}
165+
disabled={isUserSuspended(option.value, workspaceSlug?.toString())}
140166
>
141167
{({ selected }) => (
142168
<>
143169
<span className="flex-grow truncate">{option.content}</span>
144170
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
171+
{isUserSuspended(option.value, workspaceSlug?.toString()) && (
172+
<Pill variant={EPillVariant.DEFAULT} size={EPillSize.XS} className="border-none">
173+
Suspended
174+
</Pill>
175+
)}
145176
</>
146177
)}
147178
</Combobox.Option>

apps/web/core/components/project/dropdowns/filters/member-list.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const WORKSPACE_ROLE_OPTIONS: IRoleOption[] = [
3030
{ value: String(EUserWorkspaceRoles.ADMIN), label: "Admin" },
3131
{ value: String(EUserWorkspaceRoles.MEMBER), label: "Member" },
3232
{ value: String(EUserWorkspaceRoles.GUEST), label: "Guest" },
33+
{ value: "suspended", label: "Suspended" },
3334
];
3435

3536
// Role filter group component

apps/web/core/components/workspace/settings/member-columns.tsx

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { Trash2 } from "lucide-react";
55
import { Disclosure } from "@headlessui/react";
66
// plane imports
77
import { ROLE, EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
8+
import { SuspendedUserIcon } from "@plane/propel/icons";
9+
import { Pill, EPillVariant, EPillSize } from "@plane/propel/pill";
810
import { IUser, IWorkspaceMember } from "@plane/types";
911
// plane ui
10-
import { CustomSelect, PopoverMenu, TOAST_TYPE, setToast } from "@plane/ui";
12+
import { CustomSelect, PopoverMenu, TOAST_TYPE, cn, setToast } from "@plane/ui";
1113
// constants
1214
// helpers
1315
import { getFileURL } from "@plane/utils";
@@ -19,6 +21,7 @@ import { useUser, useUserPermissions } from "@/hooks/store/user";
1921
export interface RowData {
2022
member: IWorkspaceMember;
2123
role: EUserPermissions;
24+
is_active: boolean;
2225
}
2326

2427
type NameProps = {
@@ -38,6 +41,7 @@ export const NameColumn: React.FC<NameProps> = (props) => {
3841
const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props;
3942
// derived values
4043
const { avatar_url, display_name, email, first_name, id, last_name } = rowData.member;
44+
const isSuspended = rowData.is_active === false;
4145

4246
return (
4347
<Disclosure>
@@ -48,24 +52,39 @@ export const NameColumn: React.FC<NameProps> = (props) => {
4852
{avatar_url && avatar_url.trim() !== "" ? (
4953
<Link href={`/${workspaceSlug}/profile/${id}`}>
5054
<span className="relative flex h-6 w-6 items-center justify-center rounded-full capitalize text-white">
51-
<img
52-
src={getFileURL(avatar_url)}
53-
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
54-
alt={display_name || email}
55-
/>
55+
{isSuspended ? (
56+
<SuspendedUserIcon className="h-4 w-4 text-custom-text-400" />
57+
) : (
58+
<img
59+
src={getFileURL(avatar_url)}
60+
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
61+
alt={display_name || email}
62+
/>
63+
)}
5664
</span>
5765
</Link>
5866
) : (
5967
<Link href={`/${workspaceSlug}/profile/${id}`}>
60-
<span className="relative flex h-4 w-4 text-xs items-center justify-center rounded-full bg-gray-700 capitalize text-white">
61-
{(email ?? display_name ?? "?")[0]}
68+
<span
69+
className={cn(
70+
"relative flex h-4 w-4 text-xs items-center justify-center rounded-full capitalize text-white",
71+
isSuspended ? "bg-custom-background-80" : "bg-gray-700"
72+
)}
73+
>
74+
{isSuspended ? (
75+
<SuspendedUserIcon className="h-4 w-4 text-custom-text-400" />
76+
) : (
77+
(email ?? display_name ?? "?")[0]
78+
)}
6279
</span>
6380
</Link>
6481
)}
65-
{first_name} {last_name}
82+
<span className={isSuspended ? "text-custom-text-400" : ""}>
83+
{first_name} {last_name}
84+
</span>
6685
</div>
6786

68-
{(isAdmin || id === currentUser?.id) && (
87+
{!isSuspended && (isAdmin || id === currentUser?.id) && (
6988
<PopoverMenu
7089
data={[""]}
7190
keyExtractor={(item) => item}
@@ -108,10 +127,17 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
108127
const isCurrentUser = currentUser?.id === rowData.member.id;
109128
const isAdminRole = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
110129
const isRoleNonEditable = isCurrentUser || !isAdminRole;
130+
const isSuspended = rowData.is_active === false;
111131

112132
return (
113133
<>
114-
{isRoleNonEditable ? (
134+
{isSuspended ? (
135+
<div className="w-32 flex ">
136+
<Pill variant={EPillVariant.DEFAULT} size={EPillSize.SM} className="border-none">
137+
Suspended
138+
</Pill>
139+
</div>
140+
) : isRoleNonEditable ? (
115141
<div className="w-32 flex ">
116142
<span>{ROLE[rowData.role]}</span>
117143
</div>

apps/web/core/components/workspace/settings/members-list.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }>
5353
const filteredMemberIds = workspaceSlug ? getFilteredWorkspaceMemberIds(workspaceSlug.toString()) : [];
5454
const searchedMemberIds = searchQuery ? getSearchedWorkspaceMemberIds(searchQuery) : filteredMemberIds;
5555
const searchedInvitationsIds = getSearchedWorkspaceInvitationIds(searchQuery);
56-
const memberDetails = searchedMemberIds?.map((memberId) => getWorkspaceMemberDetails(memberId));
56+
const memberDetails = searchedMemberIds
57+
?.map((memberId) => getWorkspaceMemberDetails(memberId))
58+
.sort((a, b) => {
59+
if (a?.is_active && !b?.is_active) return -1;
60+
if (!a?.is_active && b?.is_active) return 1;
61+
return 0;
62+
});
5763

5864
return (
5965
<>

apps/web/core/store/member/utils.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,15 @@ export const getMemberSortKey = (memberDetails: IUserLite, field: string, member
3737
}
3838
case "email":
3939
return memberDetails.email?.toLowerCase() || "";
40-
case "joining_date":
41-
return memberDetails.joining_date ? new Date(memberDetails.joining_date) : new Date(NaN);
40+
case "joining_date": {
41+
if (!memberDetails.joining_date) {
42+
// Return a very old date for missing dates to sort them last
43+
return new Date(0);
44+
}
45+
const date = new Date(memberDetails.joining_date);
46+
// Return a very old date for invalid dates to sort them last
47+
return isNaN(date.getTime()) ? new Date(0) : date;
48+
}
4249
case "role":
4350
return (memberRole ?? "").toString().toLowerCase();
4451
default:
@@ -59,15 +66,28 @@ export const filterProjectMembersByRole = (
5966
});
6067
};
6168

62-
export const filterWorkspaceMembersByRole = <T extends { role: string | EUserPermissions }>(
69+
export const filterWorkspaceMembersByRole = <T extends { role: string | EUserPermissions; is_active?: boolean }>(
6370
members: T[],
6471
roleFilters: string[]
6572
): T[] => {
6673
if (roleFilters.length === 0) return members;
6774

6875
return members.filter((member) => {
6976
const memberRole = String(member.role ?? "");
70-
return roleFilters.includes(memberRole);
77+
const isSuspended = member.is_active === false;
78+
79+
// Check if suspended is in the role filters
80+
const hasSuspendedFilter = roleFilters.includes("suspended");
81+
// Get non-suspended role filters
82+
const activeRoleFilters = roleFilters.filter((role) => role !== "suspended");
83+
84+
// For suspended users, include them only if suspended filter is selected
85+
if (isSuspended) {
86+
return hasSuspendedFilter;
87+
}
88+
89+
// For active users, include them only if their role matches any active role filter
90+
return activeRoleFilters.includes(memberRole);
7191
});
7292
};
7393

@@ -100,10 +120,15 @@ export const sortMembers = <T>(
100120
let comparison = 0;
101121

102122
if (field === "joining_date") {
103-
// For dates, we need to handle Date objects
123+
// For dates, we need to handle Date objects and ensure they're valid
104124
const aDate = aValue instanceof Date ? aValue : new Date(aValue);
105125
const bDate = bValue instanceof Date ? bValue : new Date(bValue);
106-
comparison = aDate.getTime() - bDate.getTime();
126+
127+
// Handle invalid dates by treating them as very old dates
128+
const aTime = isNaN(aDate.getTime()) ? 0 : aDate.getTime();
129+
const bTime = isNaN(bDate.getTime()) ? 0 : bDate.getTime();
130+
131+
comparison = aTime - bTime;
107132
} else {
108133
// For strings, use localeCompare for proper alphabetical sorting
109134
const aStr = String(aValue);
@@ -139,13 +164,12 @@ export const sortProjectMembers = (
139164
);
140165
};
141166

142-
export const sortWorkspaceMembers = <T extends { role: string | EUserPermissions }>(
167+
export const sortWorkspaceMembers = <T extends { role: string | EUserPermissions; is_active?: boolean }>(
143168
members: T[],
144169
memberDetailsMap: Record<string, IUserLite>,
145170
getMemberKey: (member: T) => string,
146171
filters?: IMemberFilters
147172
): T[] => {
148-
// Apply role filtering first
149173
const filteredMembers =
150174
filters?.roles && filters.roles.length > 0 ? filterWorkspaceMembersByRole(members, filters.roles) : members;
151175

0 commit comments

Comments
 (0)