feat(frontend): update org/sub-org user membership page UI

This commit is contained in:
Scott Wilson
2026-01-06 11:22:51 -08:00
parent 00bef0f2ff
commit 5cd384e5ce
9 changed files with 673 additions and 553 deletions

View File

@@ -1,23 +1,19 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { ChevronLeftIcon, EllipsisIcon } from "lucide-react";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, PageHeader } from "@app/components/v2";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
PageHeader,
Tooltip
} from "@app/components/v2";
UnstableButton,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger
} from "@app/components/v3";
import { ROUTE_PATHS } from "@app/const/routes";
import {
OrgPermissionActions,
@@ -100,18 +96,18 @@ const Page = withPermission(
};
return (
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex max-w-8xl flex-col">
{membership && (
<div className="mx-auto w-full max-w-8xl">
<>
<Link
to="/organizations/$orgId/access-management"
params={{ orgId }}
search={{
selectedTab: OrgAccessControlTabSections.Member
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
className="mb-4 flex w-fit items-center gap-x-1 text-sm text-mineshaft-400 transition duration-100 hover:text-mineshaft-400/80"
>
<FontAwesomeIcon icon={faChevronLeft} />
<ChevronLeftIcon size={16} />
{isSubOrganization ? "Sub-" : ""}Organization Users
</Link>
<PageHeader
@@ -124,139 +120,126 @@ const Page = withPermission(
membership.inviteEmail ??
"Unknown User")
}
description={`${isSubOrganization ? "Sub-" : ""}Organization User Membership`}
description={`Configure and manage${isSubOrganization ? " sub-" : " "}organization user membership`}
>
<div>
{userId !== membership.user.id && (
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<Button variant="outline_bg" size="sm">
More
</Button>
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role,
roleId: membership.roleId
})
}
disabled={!isAllowed}
>
Edit User
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={
membership.isActive
? twMerge(
isAllowed
? "hover:bg-red-500! hover:text-white!"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
}
onClick={async () => {
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when SCIM is enabled for your organization",
type: "error"
});
return;
}
if (!membership.isActive) {
// activate user
await updateOrgMembership({
organizationId: orgId,
membershipId,
isActive: true
});
return;
}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId: membershipId,
username: membership.user.username
{userId !== membership.user.id && (
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableButton variant="outline">
Options
<EllipsisIcon />
</UnstableButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(membership.user.id);
createNotification({
text: "User ID copied to clipboard",
type: "info"
});
}}
>
Copy User ID
</UnstableDropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
onClick={() =>
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role,
roleId: membership.roleId,
metadata: membership.metadata
})
}
>
Edit User
</UnstableDropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
onClick={async () => {
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when SCIM is enabled for your organization",
type: "error"
});
}}
disabled={!isAllowed}
>
{`${membership.isActive ? "Deactivate" : "Activate"} User`}
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:bg-red-500! hover:text-white!"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() => {
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when SCIM is enabled for your organization",
type: "error"
});
return;
}
return;
}
handlePopUpOpen("removeMember", {
orgMembershipId: membershipId,
username: membership.user.username
if (!membership.isActive) {
// activate user
await updateOrgMembership({
organizationId: orgId,
membershipId,
isActive: true
});
}}
disabled={!isAllowed}
>
Remove User
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
return;
}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId: membershipId,
username: membership.user.username
});
}}
>
{`${membership.isActive ? "Deactivate" : "Activate"} User`}
</UnstableDropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
variant="danger"
isDisabled={!isAllowed}
onClick={() => {
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when SCIM is enabled for your organization",
type: "error"
});
return;
}
handlePopUpOpen("removeMember", {
orgMembershipId: membershipId,
username: membership.user.username
});
}}
>
Remove User
</UnstableDropdownMenuItem>
)}
</OrgPermissionCan>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
)}
</PageHeader>
<div className="flex flex-col gap-4 md:flex-row">
<div className="w-full md:w-96">
<UserDetailsSection membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
</div>
<div className="w-full space-y-2">
<div className="w-full space-y-4">
<UserProjectsSection membershipId={membershipId} />
<UserGroupsSection orgMembership={membership} />
<UserAuditLogsSection orgMembership={membership} />
</div>
<div className="flex flex-col gap-5 lg:flex-row">
<UserDetailsSection membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
<div className="flex flex-1 flex-col gap-y-5">
<UserProjectsSection membershipId={membershipId} />
<UserGroupsSection orgMembership={membership} />
<UserAuditLogsSection orgMembership={membership} />
</div>
</div>
</div>
</>
)}
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}

View File

@@ -1,17 +1,33 @@
import {
faCheck,
faCheckCircle,
faCircleXmark,
faCopy,
faKey,
faPencil
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import {
BanIcon,
CheckIcon,
ClipboardListIcon,
MailIcon,
PencilIcon,
UserCheckIcon,
XIcon
} from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Tag, Tooltip } from "@app/components/v2";
import { Tooltip } from "@app/components/v2";
import {
Badge,
Detail,
DetailGroup,
DetailLabel,
DetailValue,
UnstableButton,
UnstableButtonGroup,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableIconButton
} from "@app/components/v3";
import {
OrgPermissionActions,
OrgPermissionSubjects,
@@ -30,8 +46,12 @@ type Props = {
};
export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => {
const [copyTextUsername, isCopyingUsername, setCopyTextUsername] = useTimedReset<string>({
initialState: "Copy username to clipboard"
const [, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const [, isCopyingEmail, setCopyEmail] = useTimedReset<string>({
initialState: "Copy email to clipboard"
});
const { user } = useUser();
@@ -62,174 +82,199 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
const getStatus = (m: OrgUser) => {
if (!m.isActive) {
return "Deactivated";
return { label: "Deactivated", variant: "neutral" as const, Icon: <BanIcon /> };
}
return m.status === "invited" ? "Invited" : "Active";
return m.status === "invited"
? { label: "Invited", variant: "info" as const, Icon: <MailIcon /> }
: { label: "Active", variant: "success" as const, Icon: <UserCheckIcon /> };
};
const roleName = roles?.find(
(r) => r.slug === membership?.role || r.slug === membership?.customRoleSlug
)?.name;
const name =
membership?.user.firstName || membership?.user.lastName
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
: null;
const status = membership ? getStatus(membership) : null;
return membership ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">User Details</h3>
<UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader className="border-b">
<UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>User membership details</UnstableCardDescription>
{userId !== membership.user.id && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Member}>
{(isAllowed) => {
return (
<Tooltip content="Edit Membership">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role,
roleId: membership.roleId,
metadata: membership.metadata
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</OrgPermissionCan>
)}
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">
{membership.user.firstName || membership.user.lastName
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
: "-"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Username</p>
<div className="group flex align-top">
<p className="text-sm break-all text-mineshaft-300">{membership.user.username}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextUsername}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
<UnstableCardAction>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Member}>
{(isAllowed) => (
<UnstableIconButton
isDisabled={!isAllowed}
onClick={() => {
navigator.clipboard.writeText(membership.user.username);
setCopyTextUsername("Copied");
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role,
roleId: membership.roleId,
metadata: membership.metadata
});
}}
size="xs"
variant="outline"
>
<FontAwesomeIcon icon={isCopyingUsername ? faCheck : faCopy} />
</IconButton>
<PencilIcon />
</UnstableIconButton>
)}
</OrgPermissionCan>
</UnstableCardAction>
)}
</UnstableCardHeader>
<UnstableCardContent>
<DetailGroup>
<Detail>
<DetailLabel>Name</DetailLabel>
<DetailValue>{name || <span className="text-muted"></span>}</DetailValue>
</Detail>
<Detail>
<DetailLabel>ID</DetailLabel>
<DetailValue className="flex items-center gap-x-1">
{membership.user.id}
<Tooltip content="Copy user ID to clipboard">
<UnstableIconButton
onClick={() => {
navigator.clipboard.writeText(membership.user.id);
setCopyTextId("Copied");
}}
variant="ghost"
size="xs"
>
{isCopyingId ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
</UnstableIconButton>
</Tooltip>
</div>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Email</p>
<div className="flex items-center">
<p className="mr-2 text-sm break-all text-mineshaft-300">
{membership.user.email ?? "-"}{" "}
<Tooltip
content={
membership.user.isEmailVerified
? "Email has been verified"
: "Email has not been verified"
}
>
<FontAwesomeIcon
size="sm"
icon={membership.user.isEmailVerified ? faCheckCircle : faCircleXmark}
className={membership.user.isEmailVerified ? "text-green" : "text-red"}
/>
</Tooltip>
</p>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Last Login Auth Method</p>
<div className="group flex align-top">
<p className="text-sm break-all text-mineshaft-300">
{membership.lastLoginAuthMethod || "-"}
</p>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Last Login Time</p>
<div className="group flex align-top">
<p className="text-sm break-all text-mineshaft-300">
{membership.lastLoginTime ? format(membership.lastLoginTime, "PPpp") : "-"}
</p>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{roleName ?? "-"}</p>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Status</p>
<p className="text-sm text-mineshaft-300">{getStatus(membership)}</p>
</div>
<div>
<p className="text-sm font-medium text-mineshaft-300">Metadata</p>
{membership?.metadata?.length ? (
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
{membership.metadata?.map((el) => (
<div key={el.id} className="flex items-center">
<Tag
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Email</DetailLabel>
<DetailValue className="flex items-center gap-x-1">
{membership.user.email ? (
<>
{membership.user.email}
<Tooltip
content={
membership.user.isEmailVerified
? "Email has been verified"
: "Email has not been verified"
}
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tag>
</div>
))}
</div>
) : (
<p className="text-sm text-mineshaft-300">-</p>
{membership.user.isEmailVerified ? (
<CheckIcon size={14} className="text-success" />
) : (
<XIcon size={14} className="text-danger" />
)}
</Tooltip>
<Tooltip content="Copy user email to clipboard">
<UnstableIconButton
onClick={() => {
navigator.clipboard.writeText(membership.user.email!);
setCopyEmail("Copied");
}}
variant="ghost"
size="xs"
>
{isCopyingEmail ? (
<CheckIcon />
) : (
<ClipboardListIcon className="text-label" />
)}
</UnstableIconButton>
</Tooltip>
</>
) : (
<span className="text-muted"></span>
)}
</DetailValue>
</Detail>
{membership.user.username !== membership.user.email && (
<Detail>
<DetailLabel>Username</DetailLabel>
<DetailValue>
{membership.user.username || <span className="text-muted"></span>}
</DetailValue>
</Detail>
)}
</div>
<Detail>
<DetailLabel>Organization Role</DetailLabel>
<DetailValue>{roleName ?? <span className="text-muted"></span>}</DetailValue>
</Detail>
<Detail>
<DetailLabel>Status</DetailLabel>
<DetailValue>
{status && (
<Badge variant={status.variant}>
{status.Icon}
{status.label}
</Badge>
)}
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Last Login Method</DetailLabel>
<DetailValue>
{membership.lastLoginAuthMethod || <span className="text-muted"></span>}
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Last Logged In</DetailLabel>
<DetailValue>
{membership.lastLoginTime ? (
format(membership.lastLoginTime, "PPpp")
) : (
<span className="text-muted"></span>
)}
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Metadata</DetailLabel>
<DetailValue className="flex flex-wrap gap-2">
{membership?.metadata?.length ? (
membership.metadata?.map((el) => (
<UnstableButtonGroup className="min-w-0" key={el.id}>
<Badge isTruncatable>
<span>{el.key}</span>
</Badge>
<Badge variant="outline" isTruncatable>
<span>{el.value}</span>
</Badge>
</UnstableButtonGroup>
))
) : (
<span className="text-muted"></span>
)}
</DetailValue>
</Detail>
</DetailGroup>
{!isSubOrganization &&
membership.isActive &&
(membership.status === "invited" || membership.status === "verified") &&
membership.user.email &&
serverDetails?.emailConfigured && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Member}>
{(isAllowed) => {
return (
<Button
isDisabled={!isAllowed}
className="mt-4 w-full"
colorSchema="primary"
type="submit"
isLoading={isPending}
onClick={onResendInvite}
>
Resend Invite
</Button>
);
}}
{(isAllowed) => (
<UnstableButton
isDisabled={!isAllowed}
className="mt-4 w-full"
variant="org"
isPending={isPending}
onClick={onResendInvite}
>
Resend Invite
</UnstableButton>
)}
</OrgPermissionCan>
)}
</div>
</div>
</UnstableCardContent>
</UnstableCard>
) : (
<div />
);

View File

@@ -1,8 +1,14 @@
/* eslint-disable react/jsx-no-useless-fragment */
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { MoreHorizontalIcon } from "lucide-react";
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
import {
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstableIconButton,
UnstableTableCell,
UnstableTableRow
} from "@app/components/v3";
import { TGroupWithProjectMemberships } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -13,34 +19,31 @@ type Props = {
export const UserGroupsRow = ({ group, handlePopUpOpen }: Props) => {
return (
<>
<Tr
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`user-project-membership-${group.id}`}
>
<Td>{group.name}</Td>
<Td>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Unassign user from group">
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeUserFromGroup", {
groupId: group.id,
groupSlug: group.slug
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
</Td>
</Tr>
</>
<UnstableTableRow key={`user-group-membership-${group.id}`}>
<UnstableTableCell>{group.name}</UnstableTableCell>
<UnstableTableCell>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger>
<UnstableIconButton variant="ghost" size="xs">
<MoreHorizontalIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
variant="danger"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeUserFromGroup", {
groupId: group.id,
groupSlug: group.slug
});
}}
>
Unassign from Group
</UnstableDropdownMenuItem>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
</UnstableTableRow>
);
};

View File

@@ -2,6 +2,13 @@ import { useCallback } from "react";
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import {
UnstableCard,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle
} from "@app/components/v3";
import { useRemoveUserFromGroup } from "@app/hooks/api";
import { OrgUser } from "@app/hooks/api/users/types";
import { usePopUp } from "@app/hooks/usePopUp";
@@ -36,13 +43,15 @@ export const UserGroupsSection = ({ orgMembership }: Props) => {
return (
<>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Groups</h3>
</div>
<UserGroupsTable orgMembership={orgMembership} handlePopUpOpen={handlePopUpOpen} />
</div>
<UnstableCard>
<UnstableCardHeader>
<UnstableCardTitle>Groups</UnstableCardTitle>
<UnstableCardDescription>Manage user group memberships</UnstableCardDescription>
</UnstableCardHeader>
<UnstableCardContent>
<UserGroupsTable orgMembership={orgMembership} handlePopUpOpen={handlePopUpOpen} />
</UnstableCardContent>
</UnstableCard>
<DeleteActionModal
isOpen={popUp.removeUserFromGroup.isOpen}

View File

@@ -1,25 +1,21 @@
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faMagnifyingGlass,
faSearch,
faUser
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ChevronDownIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Lottie } from "@app/components/v2";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
UnstableEmpty,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle,
UnstableInput,
UnstablePagination,
UnstableTable,
UnstableTableBody,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3";
import {
getUserTablePreference,
PreferenceKey,
@@ -85,37 +81,39 @@ export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
setPage
});
if (isPending) {
return (
<div className="flex h-40 w-full items-center justify-center">
<Lottie icon="infisical_loading_white" isAutoPlay className="w-16" />
</div>
);
}
return (
<div>
<Input
<>
<UnstableInput
className="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search groups..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-full">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{filteredGroupMemberships.length ? (
<UnstableTable>
<UnstableTableHeader>
<UnstableTableRow>
<UnstableTableHead onClick={toggleOrderDirection} className="w-full">
Name
<ChevronDownIcon
className={twMerge(
orderDirection === OrderByDirection.DESC && "rotate-180",
"transition-transform"
)}
/>
</UnstableTableHead>
<UnstableTableHead className="w-5" />
</UnstableTableRow>
</UnstableTableHeader>
<UnstableTableBody>
{filteredGroupMemberships.slice(offset, perPage * page).map((group) => (
<UserGroupsRow
key={`user-group-${group.id}`}
@@ -123,28 +121,33 @@ export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
handlePopUpOpen={handlePopUpOpen}
/>
))}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
<Pagination
count={filteredGroupMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroupMemberships?.length && (
<EmptyState
title={
groupMemberships.length
? "No groups match search..."
: "This user has not been assigned to any groups"
}
icon={groupMemberships.length ? faSearch : faUser}
/>
)}
</TableContainer>
</div>
</UnstableTableBody>
</UnstableTable>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>
{groupMemberships.length
? "No groups match this search"
: "This user has not been assigned to any groups"}
</UnstableEmptyTitle>
<UnstableEmptyDescription>
{groupMemberships.length
? "Adjust search filters to view group memberships."
: "Assign this user to a group from the group access control page."}
</UnstableEmptyDescription>
</UnstableEmptyHeader>
</UnstableEmpty>
)}
{Boolean(filteredGroupMemberships.length) && (
<UnstablePagination
count={filteredGroupMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
</>
);
};

View File

@@ -1,10 +1,17 @@
import { useMemo } from "react";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { MoreHorizontalIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
import {
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstableIconButton,
UnstableTableCell,
UnstableTableRow
} from "@app/components/v3";
import { useOrganization } from "@app/context";
import { getProjectBaseURL } from "@app/helpers/project";
import { formatProjectRoleName } from "@app/helpers/roles";
@@ -40,8 +47,7 @@ export const UserProjectRow = ({
}, [workspaces, project]);
return (
<Tr
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
<UnstableTableRow
key={`user-project-membership-${id}`}
onClick={() => {
if (isAccessible) {
@@ -64,34 +70,53 @@ export const UserProjectRow = ({
});
}}
>
<Td className="max-w-0 truncate">{project.name}</Td>
<Td>{`${formatProjectRoleName(roles[0].role, roles[0].customRoleName)}${
<UnstableTableCell className="max-w-0 truncate">{project.name}</UnstableTableCell>
<UnstableTableCell>{`${formatProjectRoleName(roles[0].role, roles[0].customRoleName)}${
roles.length > 1 ? ` (+${roles.length - 1})` : ""
}`}</Td>
<Td>
{isAccessible && (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Remove">
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeUserFromProject", {
username: user.username,
projectId: project.id,
projectName: project.name
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</Td>
</Tr>
}`}</UnstableTableCell>
<UnstableTableCell>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger>
<UnstableIconButton variant="ghost" size="xs">
<MoreHorizontalIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
isDisabled={!isAccessible}
onClick={(e) => {
e.stopPropagation();
navigate({
to: `${getProjectBaseURL(project.type)}/access-management` as const,
params: {
orgId: currentOrg?.id || "",
projectId: project.id
},
search: {
selectedTab: OrgAccessControlTabSections.Member
}
});
}}
>
Access Project
</UnstableDropdownMenuItem>
<UnstableDropdownMenuItem
variant="danger"
isDisabled={!isAccessible}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeUserFromProject", {
username: user.username,
projectId: project.id,
projectName: project.name
});
}}
>
Remove From Project
</UnstableDropdownMenuItem>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
</UnstableTableRow>
);
};

View File

@@ -1,10 +1,22 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PlusIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal, IconButton } from "@app/components/v2";
import { DeleteActionModal } from "@app/components/v2";
import {
UnstableButton,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle
} from "@app/components/v3";
import { useOrganization, useUser } from "@app/context";
import { useDeleteUserFromWorkspace, useGetOrgMembership } from "@app/hooks/api";
import {
useDeleteUserFromWorkspace,
useGetOrgMembership,
useGetOrgMembershipProjectMemberships
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { UserAddToProjectModal } from "./UserAddToProjectModal";
@@ -22,6 +34,7 @@ export const UserProjectsSection = ({ membershipId }: Props) => {
const orgId = currentOrg?.id || "";
const { data: membership } = useGetOrgMembership(orgId, membershipId);
const { data: projectMemberships } = useGetOrgMembershipProjectMemberships(orgId, membershipId);
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
@@ -39,28 +52,44 @@ export const UserProjectsSection = ({ membershipId }: Props) => {
handlePopUpClose("removeUserFromProject");
};
return membership ? (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Projects</h3>
{userId !== membership.user.id && membership.status !== "invited" && (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addUserToProject", {
username: membership.user.username
});
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</div>
<div className="py-4">
<UserProjectsTable membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
</div>
if (!membership) {
return null;
}
const canAddToProject = userId !== membership.user.id;
return (
<>
<UnstableCard>
<UnstableCardHeader>
<UnstableCardTitle>Projects</UnstableCardTitle>
<UnstableCardDescription>Manage user project memberships</UnstableCardDescription>
{canAddToProject && Boolean(projectMemberships?.length) && (
<UnstableCardAction>
<UnstableButton
onClick={() => {
handlePopUpOpen("addUserToProject", {
username: membership.user.username
});
}}
size="xs"
variant="outline"
>
<PlusIcon />
Add to Project
</UnstableButton>
</UnstableCardAction>
)}
</UnstableCardHeader>
<UnstableCardContent>
<UserProjectsTable
membershipId={membershipId}
handlePopUpOpen={handlePopUpOpen}
canAddToProject={canAddToProject}
username={membership.user.username}
/>
</UnstableCardContent>
</UnstableCard>
<UserAddToProjectModal
membershipId={membershipId}
popUp={popUp}
@@ -83,8 +112,6 @@ export const UserProjectsSection = ({ membershipId }: Props) => {
return handleRemoveUser(popupData.projectId, popupData.username);
}}
/>
</div>
) : (
<div />
</>
);
};

View File

@@ -1,26 +1,23 @@
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ChevronDownIcon, PlusIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Lottie } from "@app/components/v2";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
UnstableButton,
UnstableEmpty,
UnstableEmptyContent,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle,
UnstableInput,
UnstablePagination,
UnstableTable,
UnstableTableBody,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3";
import { useOrganization } from "@app/context";
import {
getUserTablePreference,
@@ -36,18 +33,25 @@ import { UserProjectRow } from "./UserProjectRow";
type Props = {
membershipId: string;
username: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeUserFromProject"]>,
popUpName: keyof UsePopUpState<["removeUserFromProject", "addUserToProject"]>,
data?: object
) => void;
canAddToProject: boolean;
};
enum UserProjectsOrderBy {
Name = "Name"
}
export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
const { currentOrg } = useOrganization();
export const UserProjectsTable = ({
membershipId,
username,
handlePopUpOpen,
canAddToProject
}: Props) => {
const { currentOrg, isSubOrganization } = useOrganization();
const orgId = currentOrg?.id || "";
const {
search,
@@ -96,71 +100,92 @@ export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
setPage
});
if (isPending) {
return (
<div className="flex h-40 w-full items-center justify-center">
<Lottie icon="infisical_loading_white" isAutoPlay className="w-16" />
</div>
);
}
return (
<div>
<Input
<>
<UnstableInput
className="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search projects..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-2/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
{!isPending &&
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
return (
<UserProjectRow
key={`user-project-membership-${membership.id}`}
membership={membership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredProjectMemberships.length) && (
<Pagination
count={filteredProjectMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredProjectMemberships?.length && (
<EmptyState
title={
projectMemberships.length
? "No projects match search..."
: "This user has not been assigned to any projects"
}
icon={projectMemberships.length ? faSearch : faFolder}
/>
)}
</TableContainer>
</div>
{filteredProjectMemberships.length ? (
<UnstableTable>
<UnstableTableHeader>
<UnstableTableRow>
<UnstableTableHead onClick={toggleOrderDirection} className="w-2/3">
Name
<ChevronDownIcon
className={twMerge(
orderDirection === OrderByDirection.DESC && "rotate-180",
"transition-transform"
)}
/>
</UnstableTableHead>
<UnstableTableHead>Role</UnstableTableHead>
<UnstableTableHead className="w-5" />
</UnstableTableRow>
</UnstableTableHeader>
<UnstableTableBody>
{filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
return (
<UserProjectRow
key={`user-project-membership-${membership.id}`}
membership={membership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</UnstableTableBody>
</UnstableTable>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>
{projectMemberships.length
? "No projects match this search"
: "This user is not a member of any projects"}
</UnstableEmptyTitle>
<UnstableEmptyDescription>
{projectMemberships.length
? "Adjust search filters to view project memberships."
: "Assign this user to a project."}
</UnstableEmptyDescription>
{!projectMemberships.length && canAddToProject && (
<UnstableEmptyContent>
<UnstableButton
variant={isSubOrganization ? "sub-org" : "org"}
size="xs"
onClick={() =>
handlePopUpOpen("addUserToProject", {
username
})
}
>
<PlusIcon />
Add to Project
</UnstableButton>
</UnstableEmptyContent>
)}
</UnstableEmptyHeader>
</UnstableEmpty>
)}
{Boolean(filteredProjectMemberships.length) && (
<UnstablePagination
count={filteredProjectMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
</>
);
};

View File

@@ -15,10 +15,10 @@ import {
ModalContent,
TextArea
} from "@app/components/v2";
import { UnstableAlert, UnstableAlertDescription, UnstableAlertTitle } from "@app/components/v3";
import { useProject } from "@app/context";
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
import { useCreateApprovalRequest } from "@app/hooks/api/approvalRequests/mutations";
import { UnstableAlert, UnstableAlertDescription, UnstableAlertTitle } from "@app/components/v3";
type Props = {
accountPath?: string;