Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions apps/web/ce/components/workspace/settings/useMemberColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -58,27 +59,35 @@ 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"
displayFilters={filters}
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"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div className="w-48 truncate">{rowData.member.email}</div>,
},

{
Expand All @@ -97,22 +106,24 @@ 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"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
},
];
return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal };
Expand Down
41 changes: 36 additions & 5 deletions apps/web/core/components/dropdowns/member/member-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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
Expand All @@ -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, {
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 37 additions & 11 deletions apps/web/core/components/workspace/settings/member-columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +21,7 @@ import { useUser, useUserPermissions } from "@/hooks/store/user";
export interface RowData {
member: IWorkspaceMember;
role: EUserPermissions;
is_active: boolean;
}

type NameProps = {
Expand All @@ -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>
Expand All @@ -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}
Expand Down Expand Up @@ -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>
Expand Down
8 changes: 7 additions & 1 deletion apps/web/core/components/workspace/settings/members-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand Down
40 changes: 32 additions & 8 deletions apps/web/core/store/member/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -59,15 +66,28 @@ 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[] => {
if (roleFilters.length === 0) return members;

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);
});
};

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading