From 5cd384e5ce083f6651b89c43ab861a6d645b240f Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 6 Jan 2026 11:22:51 -0800 Subject: [PATCH] feat(frontend): update org/sub-org user membership page UI --- .../UserDetailsByIDPage.tsx | 267 ++++++------- .../components/UserDetailsSection.tsx | 357 ++++++++++-------- .../UserProjectsSection/UserGroupsRow.tsx | 69 ++-- .../UserProjectsSection/UserGroupsSection.tsx | 23 +- .../UserProjectsSection/UserGroupsTable.tsx | 139 +++---- .../UserProjectsSection/UserProjectRow.tsx | 91 +++-- .../UserProjectsSection.tsx | 85 +++-- .../UserProjectsSection/UserProjectsTable.tsx | 193 +++++----- .../PamRequestAccountAccessModal.tsx | 2 +- 9 files changed, 673 insertions(+), 553 deletions(-) diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx index c4198c72b9..bbf7254388 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx @@ -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 ( -
+
{membership && ( -
+ <> - + {isSubOrganization ? "Sub-" : ""}Organization Users -
- {userId !== membership.user.id && ( - - -
- - - -
-
- - - {(isAllowed) => ( - - handlePopUpOpen("orgMembership", { - membershipId: membership.id, - role: membership.role, - roleId: membership.roleId - }) - } - disabled={!isAllowed} - > - Edit User - - )} - - - {(isAllowed) => ( - { - 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 && ( + + + + Options + + + + + { + navigator.clipboard.writeText(membership.user.id); + createNotification({ + text: "User ID copied to clipboard", + type: "info" + }); + }} + > + Copy User ID + + + {(isAllowed) => ( + + handlePopUpOpen("orgMembership", { + membershipId: membership.id, + role: membership.role, + roleId: membership.roleId, + metadata: membership.metadata + }) + } + > + Edit User + + )} + + + {(isAllowed) => ( + { + 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`} - - )} - - - {(isAllowed) => ( - { - 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 - - )} - - -
- )} -
+ + return; + } + + // deactivate user + handlePopUpOpen("deactivateMember", { + orgMembershipId: membershipId, + username: membership.user.username + }); + }} + > + {`${membership.isActive ? "Deactivate" : "Activate"} User`} + + )} + + + {(isAllowed) => ( + { + 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 + + )} + + + + )}
-
-
- -
-
-
- - - -
+
+ +
+ + +
-
+ )} { - const [copyTextUsername, isCopyingUsername, setCopyTextUsername] = useTimedReset({ - initialState: "Copy username to clipboard" + const [, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + const [, isCopyingEmail, setCopyEmail] = useTimedReset({ + 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: }; } - return m.status === "invited" ? "Invited" : "Active"; + return m.status === "invited" + ? { label: "Invited", variant: "info" as const, Icon: } + : { label: "Active", variant: "success" as const, Icon: }; }; 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 ? ( -
-
-

User Details

+ + + Details + User membership details {userId !== membership.user.id && ( - - {(isAllowed) => { - return ( - - { - handlePopUpOpen("orgMembership", { - membershipId: membership.id, - role: membership.role, - roleId: membership.roleId, - metadata: membership.metadata - }); - }} - > - - - - ); - }} - - )} -
-
-
-

Name

-

- {membership.user.firstName || membership.user.lastName - ? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim() - : "-"} -

-
-
-

Username

-
-

{membership.user.username}

-
- - + + {(isAllowed) => ( + { - 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" > - - + + + )} + + + )} + + + + + Name + {name || } + + + ID + + {membership.user.id} + + { + navigator.clipboard.writeText(membership.user.id); + setCopyTextId("Copied"); + }} + variant="ghost" + size="xs" + > + {isCopyingId ? : } + -
-
-
-
-

Email

-
-

- {membership.user.email ?? "-"}{" "} - - - -

-
-
-
-

Last Login Auth Method

-
-

- {membership.lastLoginAuthMethod || "-"} -

-
-
-
-

Last Login Time

-
-

- {membership.lastLoginTime ? format(membership.lastLoginTime, "PPpp") : "-"} -

-
-
-
-

Organization Role

-

{roleName ?? "-"}

-
-
-

Status

-

{getStatus(membership)}

-
-
-

Metadata

- {membership?.metadata?.length ? ( -
- {membership.metadata?.map((el) => ( -
- + + + Email + + {membership.user.email ? ( + <> + {membership.user.email} + - -
{el.key}
-
- -
- {el.value} -
-
-
- ))} -
- ) : ( -

-

+ {membership.user.isEmailVerified ? ( + + ) : ( + + )} + + + { + navigator.clipboard.writeText(membership.user.email!); + setCopyEmail("Copied"); + }} + variant="ghost" + size="xs" + > + {isCopyingEmail ? ( + + ) : ( + + )} + + + + ) : ( + + )} + + + {membership.user.username !== membership.user.email && ( + + Username + + {membership.user.username || } + + )} -
+ + Organization Role + {roleName ?? } + + + Status + + {status && ( + + {status.Icon} + {status.label} + + )} + + + + Last Login Method + + {membership.lastLoginAuthMethod || } + + + + Last Logged In + + {membership.lastLoginTime ? ( + format(membership.lastLoginTime, "PPpp") + ) : ( + + )} + + + + Metadata + + {membership?.metadata?.length ? ( + membership.metadata?.map((el) => ( + + + {el.key} + + + {el.value} + + + )) + ) : ( + + )} + + + {!isSubOrganization && membership.isActive && (membership.status === "invited" || membership.status === "verified") && membership.user.email && serverDetails?.emailConfigured && ( - {(isAllowed) => { - return ( - - ); - }} + {(isAllowed) => ( + + Resend Invite + + )} )} -
-
+ + ) : (
); diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserGroupsRow.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserGroupsRow.tsx index 5543ac1678..d6047920b2 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserGroupsRow.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserGroupsRow.tsx @@ -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 ( - <> - - {group.name} - -
- - { - e.stopPropagation(); - handlePopUpOpen("removeUserFromGroup", { - groupId: group.id, - groupSlug: group.slug - }); - }} - > - - - -
- - - + + {group.name} + + + + + + + + + { + e.stopPropagation(); + handlePopUpOpen("removeUserFromGroup", { + groupId: group.id, + groupSlug: group.slug + }); + }} + > + Unassign from Group + + + + + ); }; diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserGroupsSection.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserGroupsSection.tsx index fafa7aff3a..b4ce1a34a9 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserGroupsSection.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserGroupsSection.tsx @@ -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 ( <> -
-
-

Groups

-
- - -
+ + + Groups + Manage user group memberships + + + + + { setPage }); + if (isPending) { + return ( +
+ +
+ ); + } + return ( -
- + setSearch(e.target.value)} - leftIcon={} placeholder="Search groups..." /> - - - - - - - - + {filteredGroupMemberships.length ? ( + + + + + Name + + + + + + {filteredGroupMemberships.slice(offset, perPage * page).map((group) => ( { handlePopUpOpen={handlePopUpOpen} /> ))} - -
-
- Name - - - -
-
-
- {Boolean(filteredGroupMemberships.length) && ( - - )} - {!isPending && !filteredGroupMemberships?.length && ( - - )} -
-
+ + + ) : ( + + + + {groupMemberships.length + ? "No groups match this search" + : "This user has not been assigned to any groups"} + + + {groupMemberships.length + ? "Adjust search filters to view group memberships." + : "Assign this user to a group from the group access control page."} + + + + )} + {Boolean(filteredGroupMemberships.length) && ( + + )} + ); }; diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectRow.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectRow.tsx index 71dc22394c..d11b4d2ea5 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectRow.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectRow.tsx @@ -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 ( - { if (isAccessible) { @@ -64,34 +70,53 @@ export const UserProjectRow = ({ }); }} > - {project.name} - {`${formatProjectRoleName(roles[0].role, roles[0].customRoleName)}${ + {project.name} + {`${formatProjectRoleName(roles[0].role, roles[0].customRoleName)}${ roles.length > 1 ? ` (+${roles.length - 1})` : "" - }`} - - {isAccessible && ( -
- - { - e.stopPropagation(); - handlePopUpOpen("removeUserFromProject", { - username: user.username, - projectId: project.id, - projectName: project.name - }); - }} - > - - - -
- )} - - + }`}
+ + + + + + + + + { + e.stopPropagation(); + navigate({ + to: `${getProjectBaseURL(project.type)}/access-management` as const, + params: { + orgId: currentOrg?.id || "", + projectId: project.id + }, + search: { + selectedTab: OrgAccessControlTabSections.Member + } + }); + }} + > + Access Project + + { + e.stopPropagation(); + handlePopUpOpen("removeUserFromProject", { + username: user.username, + projectId: project.id, + projectName: project.name + }); + }} + > + Remove From Project + + + + + ); }; diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsSection.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsSection.tsx index 6e128036b2..55e26ab5b7 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsSection.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsSection.tsx @@ -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 ? ( -
-
-

Projects

- {userId !== membership.user.id && membership.status !== "invited" && ( - { - handlePopUpOpen("addUserToProject", { - username: membership.user.username - }); - }} - > - - - )} -
-
- -
+ if (!membership) { + return null; + } + + const canAddToProject = userId !== membership.user.id; + + return ( + <> + + + Projects + Manage user project memberships + {canAddToProject && Boolean(projectMemberships?.length) && ( + + { + handlePopUpOpen("addUserToProject", { + username: membership.user.username + }); + }} + size="xs" + variant="outline" + > + + Add to Project + + + )} + + + + + { return handleRemoveUser(popupData.projectId, popupData.username); }} /> -
- ) : ( -
+ ); }; diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsTable.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsTable.tsx index bb752624f7..171cb8c84b 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsTable.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsTable.tsx @@ -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 ( +
+ +
+ ); + } + return ( -
- + setSearch(e.target.value)} - leftIcon={} placeholder="Search projects..." /> - - - - - - - - - - {isPending && } - {!isPending && - filteredProjectMemberships.slice(offset, perPage * page).map((membership) => { - return ( - - ); - })} - -
-
- Name - - - -
-
Role -
- {Boolean(filteredProjectMemberships.length) && ( - - )} - {!isPending && !filteredProjectMemberships?.length && ( - - )} -
-
+ {filteredProjectMemberships.length ? ( + + + + + Name + + + Role + + + + + {filteredProjectMemberships.slice(offset, perPage * page).map((membership) => { + return ( + + ); + })} + + + ) : ( + + + + {projectMemberships.length + ? "No projects match this search" + : "This user is not a member of any projects"} + + + {projectMemberships.length + ? "Adjust search filters to view project memberships." + : "Assign this user to a project."} + + {!projectMemberships.length && canAddToProject && ( + + + handlePopUpOpen("addUserToProject", { + username + }) + } + > + + Add to Project + + + )} + + + )} + {Boolean(filteredProjectMemberships.length) && ( + + )} + ); }; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamRequestAccountAccessModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamRequestAccountAccessModal.tsx index f50df38f9a..6cd87ea721 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamRequestAccountAccessModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamRequestAccountAccessModal.tsx @@ -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;