diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 05e0c131b..ffe4c9dae 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -32,7 +32,7 @@ export const PageHeader = ({ title, description, children, className, scope }: P

{ description={`Configure and manage${isProjectIdentity ? " machine identity and " : " "}project access control`} title={identityMembershipDetails.identity.name} > - + Options @@ -197,7 +197,7 @@ const Page = () => { { - navigator.clipboard.writeText(identityMembershipDetails.id); + navigator.clipboard.writeText(identityMembershipDetails.identity.id); createNotification({ text: "Machine identity ID copied to clipboard", type: "info" @@ -211,7 +211,6 @@ const Page = () => { a={subject(ProjectPermissionSub.Identity, { identityId: identityMembershipDetails?.identity.id })} - passThrough={false} > {(isAllowed) => ( { Assume Privileges
@@ -251,7 +250,7 @@ const Page = () => { )} - +
{

This machine identity's authentication methods are controlled by your - organization. To make changes,{" "} + organization.
To make changes,{" "} reset()} > Discard Changes diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx index cc686a245..9d37c50d1 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx @@ -81,9 +81,7 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe return ( <> - + Project Additional Privileges Assign one-off policies to this machine identity diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx index 9d88924b3..b16b74690 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx @@ -96,9 +96,7 @@ export const IdentityRoleDetailsSection = ({ return ( <> - + Project Roles Manage roles assigned to this machine identity @@ -208,8 +206,6 @@ export const IdentityRoleDetailsSection = ({ a={subject(ProjectPermissionSub.Identity, { identityId: identityMembershipDetails.identity.id })} - renderTooltip - allowedLabel="Remove Role" > {(isAllowed) => ( - {/* */} Remove Role )} diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx index f26dd60da..f33675e8e 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx @@ -43,10 +43,8 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members return ( <> - - + + Details Machine identity details {!isOrgIdentity && ( diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx index 46883733d..5e0c7166e 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx @@ -3,25 +3,34 @@ 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 { formatRelative } from "date-fns"; +import { EllipsisIcon, InfoIcon } from "lucide-react"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { - Button, ConfirmActionModal, DeleteActionModal, EmptyState, PageHeader, - Spinner + Spinner, + Tooltip } from "@app/components/v2"; +import { + Badge, + UnstableButton, + UnstableDropdownMenu, + UnstableDropdownMenuContent, + UnstableDropdownMenuItem, + UnstableDropdownMenuTrigger +} from "@app/components/v3"; import { ProjectPermissionActions, ProjectPermissionMemberActions, ProjectPermissionSub, useOrganization, - useProject + useProject, + useUser } from "@app/context"; import { getProjectBaseURL, getProjectHomePage } from "@app/helpers/project"; import { usePopUp } from "@app/hooks"; @@ -35,6 +44,7 @@ import { ProjectAccessControlTabs } from "@app/types/project"; import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection"; import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection"; +import { ProjectMemberDetailsSection } from "./components/ProjectMemberDetailsSection"; export const Page = () => { const navigate = useNavigate(); @@ -44,12 +54,14 @@ export const Page = () => { }); const { currentOrg } = useOrganization(); const { currentProject, projectId } = useProject(); + const { + user: { id: currentUserId } + } = useUser(); const { data: membershipDetails, isPending: isMembershipDetailsLoading } = useGetWorkspaceUserDetails(projectId, membershipId); - const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } = - useDeleteUserFromWorkspace(); + const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace(); const assumePrivileges = useAssumeProjectPrivileges(); const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ @@ -112,8 +124,10 @@ export const Page = () => { ); } + const isOwnProjectMembershipDetails = currentUserId === membershipDetails?.user?.id; + return ( -

+
{membershipDetails ? ( <> { search={{ selectedTab: ProjectAccessControlTabs.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" > Project Users @@ -135,62 +149,104 @@ export const Page = () => { title={ membershipDetails.user.firstName || membershipDetails.user.lastName ? `${membershipDetails.user.firstName} ${membershipDetails.user.lastName}` - : "-" + : membershipDetails.user.email || + membershipDetails.user.username || + membershipDetails.inviteEmail || + "Unnamed User" } - description={`User joined on ${membershipDetails?.createdAt && formatRelative(new Date(membershipDetails?.createdAt || ""), new Date())}`} + description="Configure and manage project access control" > - - {(isAllowed) => ( - - )} - - - - {(isAllowed) => ( - - )} - + {isOwnProjectMembershipDetails ? ( + + + Your project membership + + + ) : ( + + + + Options + + + + + { + navigator.clipboard.writeText(membershipDetails.user.id); + createNotification({ + text: "User ID copied to clipboard", + type: "info" + }); + }} + > + Copy User ID + + {!isOwnProjectMembershipDetails && ( + <> + + {(isAllowed) => ( + + handlePopUpOpen("assumePrivileges", { + userId: membershipDetails.user.id + }) + } + > + Assume Privileges + +
+ +
+
+
+ )} +
+ + {(isAllowed) => ( + handlePopUpOpen("removeMember")} + > + Remove User From Project + + )} + + + )} +
+
+ )} - - handlePopUpOpen("upgradePlan", { - text: "Assigning custom roles to members can be unlocked if you upgrade to Infisical Pro plan." - }) - } - /> - +
+ +
+ + handlePopUpOpen("upgradePlan", { + text: "Assigning custom roles to members can be unlocked if you upgrade to Infisical Pro plan." + }) + } + /> + +
+
- - {popUp?.modifyPrivilege.isOpen ? ( - - handlePopUpClose("modifyPrivilege")} - projectMembershipId={membershipDetails?.id} - privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} - isDisabled={ - isOwnProjectMembershipDetails || - permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member) - } - /> - - ) : ( - -
-

- Project Additional Privileges -

- {userId !== membershipDetails?.user?.id && - membershipDetails?.status !== "invited" && ( - + + + Project Additional Privileges + Assign one-off policies to this user + {!isOwnProjectMembershipDetails && hasAdditionalPrivileges && ( + + + {(isAllowed) => ( + { + handlePopUpOpen("modifyPrivilege"); + }} + isDisabled={!isAllowed} > - {(isAllowed) => ( - { - handlePopUpOpen("modifyPrivilege"); - }} - isDisabled={!isAllowed} - > - - - )} - + + Add Additional Privileges + )} + + + )} + + + {/* eslint-disable-next-line no-nested-ternary */} + {isPending ? ( + // scott: todo proper loader +
+
-
- - - - - - - - - - {isPending && } - {!isPending && - userProjectPrivileges?.map((privilegeDetails) => { - const isTemporary = privilegeDetails?.isTemporary; - const isExpired = - privilegeDetails.isTemporary && - new Date() > new Date(privilegeDetails.temporaryAccessEndTime || ""); + ) : userProjectPrivileges?.length ? ( + + + + Name + Duration + {!isOwnProjectMembershipDetails && } + + + + {!isPending && + userProjectPrivileges?.map((privilegeDetails) => { + const isTemporary = privilegeDetails?.isTemporary; + const isExpired = + privilegeDetails.isTemporary && + new Date() > new Date(privilegeDetails.temporaryAccessEndTime || ""); - let text = "Permanent"; - let toolTipText = "Non-Expiring Access"; - if (privilegeDetails.isTemporary) { - if (isExpired) { - text = "Access Expired"; - toolTipText = "Timed Access Expired"; - } else { - text = formatDistance( - new Date(privilegeDetails.temporaryAccessEndTime || ""), - new Date() - ); - toolTipText = `Until ${format( - new Date(privilegeDetails.temporaryAccessEndTime || ""), - "yyyy-MM-dd hh:mm:ss aaa" - )}`; - } - } + let text = "Permanent"; + let toolTipText = "Non-Expiring Access"; + if (privilegeDetails.isTemporary) { + if (isExpired) { + text = "Access Expired"; + toolTipText = "Timed Access Expired"; + } else { + text = formatDistance( + new Date(privilegeDetails.temporaryAccessEndTime || ""), + new Date() + ); + toolTipText = `Until ${format( + new Date(privilegeDetails.temporaryAccessEndTime || ""), + "yyyy-MM-dd hh:mm:ss aaa" + )}`; + } + } - return ( - { - if (evt.key === "Enter") { - handlePopUpOpen("modifyPrivilege", privilegeDetails); - } - }} - onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)} - > - - - - - ); - })} - -
NameDuration -
{privilegeDetails.slug} - - - {text} - - - -
+ return ( + + + {privilegeDetails.slug} + + + {isTemporary ? ( + + + {isExpired ? : } + {text} + + + ) : ( + text + )} + + {!isOwnProjectMembershipDetails && ( + + + + + + + + {(isAllowed) => ( - { + e.stopPropagation(); + handlePopUpOpen("modifyPrivilege", privilegeDetails); + }} + > + Edit Additional Privilege + + )} + + + {(isAllowed) => ( + { e.stopPropagation(); - e.preventDefault(); handlePopUpOpen("deletePrivilege", { id: privilegeDetails?.id, slug: privilegeDetails?.slug }); }} > - - + Remove Additional Privilege + )} - - - -
-
- {!isPending && !userProjectPrivileges?.length && ( - - )} -
-
- handlePopUpToggle("deletePrivilege", isOpen)} - onDeleteApproved={() => handlePrivilegeDelete()} - /> - - )} - -
+ + + + )} + + ); + })} + + + ) : ( + + + This user has no additional privileges + + Add an additional privilege to grant one-off access policies + + + {!isOwnProjectMembershipDetails && ( + + + {(isAllowed) => ( + { + handlePopUpOpen("modifyPrivilege"); + }} + isDisabled={!isAllowed || isOwnProjectMembershipDetails} + > + + Add Additional Privileges + + )} + + + )} + + )} + + + handlePopUpToggle("modifyPrivilege", isOpen)} + > + + handlePopUpClose("modifyPrivilege")} + projectMembershipId={membershipDetails?.id} + privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} + isDisabled={ + isOwnProjectMembershipDetails || + permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member) + } + /> + + + handlePopUpToggle("deletePrivilege", isOpen)} + onDeleteApproved={() => handlePrivilegeDelete()} + /> + ); }; diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx index 6719d3785..7185f5a2f 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx @@ -1,5 +1,5 @@ import { Controller, FormProvider, useForm } from "react-hook-form"; -import { faCaretDown, faChevronLeft, faClock, faSave } from "@fortawesome/free-solid-svg-icons"; +import { faCaretDown, faClock, faSave } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { format, formatDistance } from "date-fns"; @@ -20,6 +20,7 @@ import { Tag, Tooltip } from "@app/components/v2"; +import { UnstableSeparator } from "@app/components/v3"; import { ProjectPermissionMemberActions, ProjectPermissionSub, @@ -111,6 +112,7 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({ const { handleSubmit, + reset, formState: { isDirty, isSubmitting } } = form; @@ -176,59 +178,9 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({ } return ( -
+ -
- -
- {isDirty && ( - - )} -
- - -
-
-
-
-
Overview
-

- Additional privileges take precedence over roles when permissions conflict -

+
-
-
Policies
- {(isCreate || !isPending) && } -
- {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( - - {renderConditionalComponents(subject, isDisabled)} - - ))} + +
+
+
Policies
+
+ {isDirty && ( + + )} +
+ +
+
+ {(isCreate || !isPending) && } +
+ {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map( + (permissionSubject) => ( + + {renderConditionalComponents(permissionSubject, isDisabled)} + + ) + )} +
+
+ +
+ +
diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx index 2654e237e..f935eb67b 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx @@ -1,27 +1,35 @@ -import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format, formatDistance } from "date-fns"; -import { twMerge } from "tailwind-merge"; +import { ClockAlertIcon, ClockIcon, EllipsisIcon, PencilIcon } from "lucide-react"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; +import { DeleteActionModal, Lottie, Modal, ModalContent, Tooltip } from "@app/components/v2"; import { - DeleteActionModal, - EmptyState, - IconButton, - Modal, - ModalContent, - Table, - TableContainer, - TableSkeleton, - Tag, - TBody, - Td, - Th, - THead, - Tooltip, - Tr -} from "@app/components/v2"; + Badge, + UnstableButton, + UnstableCard, + UnstableCardAction, + UnstableCardContent, + UnstableCardDescription, + UnstableCardHeader, + UnstableCardTitle, + UnstableDropdownMenu, + UnstableDropdownMenuContent, + UnstableDropdownMenuItem, + UnstableDropdownMenuTrigger, + UnstableEmpty, + UnstableEmptyContent, + UnstableEmptyDescription, + UnstableEmptyHeader, + UnstableEmptyTitle, + UnstableIconButton, + UnstableTable, + UnstableTableBody, + UnstableTableCell, + UnstableTableHead, + UnstableTableHeader, + UnstableTableRow +} from "@app/components/v3/generic"; import { ProjectPermissionActions, ProjectPermissionSub, useProject, useUser } from "@app/context"; import { formatProjectRoleName } from "@app/helpers/roles"; import { usePopUp } from "@app/hooks"; @@ -88,131 +96,176 @@ export const MemberRoleDetailsSection = ({ handlePopUpClose("deleteRole"); }; + const hasRoles = Boolean(membershipDetails?.roles.length); + return ( -
-
-

Project Roles

- {!isOwnProjectMembershipDetails && membershipDetails?.status !== "invited" && ( - - {(isAllowed) => ( - { - handlePopUpOpen("modifyRole"); - }} - isDisabled={!isAllowed} + <> + + + Project Roles + Manage roles assigned to this user + {!isOwnProjectMembershipDetails && hasRoles && ( + + - - - )} - - )} -
-
- - - - - - - - - - {isMembershipDetailsLoading && ( - - )} - {!isMembershipDetailsLoading && - membershipDetails?.roles?.map((roleDetails) => { - const isTemporary = roleDetails?.isTemporary; - const isExpired = - roleDetails.isTemporary && - new Date() > new Date(roleDetails.temporaryAccessEndTime || ""); - - let text = "Permanent"; - let toolTipText = "Non-Expiring Access"; - if (roleDetails.isTemporary) { - if (isExpired) { - text = "Access Expired"; - toolTipText = "Timed Access Expired"; - } else { - text = formatDistance( - new Date(roleDetails.temporaryAccessEndTime || ""), - new Date() - ); - toolTipText = `Until ${format( - new Date(roleDetails.temporaryAccessEndTime || ""), - "yyyy-MM-dd hh:mm:ss aaa" - )}`; - } - } - - return ( - - - - - - ); - })} - -
RoleDuration -
- {roleDetails.role === "custom" - ? roleDetails.customRoleName - : formatProjectRoleName(roleDetails.role)} - - - - {text} - - - -
- - {(isAllowed) => ( - { - e.stopPropagation(); - handlePopUpOpen("deleteRole", { - id: roleDetails?.id, - slug: roleDetails?.customRoleName || roleDetails?.role - }); - }} - > - - - )} - -
-
- {!isMembershipDetailsLoading && !membershipDetails?.roles?.length && ( - + {(isAllowed) => ( + { + handlePopUpOpen("modifyRole"); + }} + isDisabled={!isAllowed} + > + + Edit Roles + + )} + + )} -
-
+ + + { + /* eslint-disable-next-line no-nested-ternary */ + isMembershipDetailsLoading ? ( + // scott: todo proper loader +
+ +
+ ) : hasRoles ? ( + + + + Role + Duration + {!isOwnProjectMembershipDetails && } + + + + {membershipDetails?.roles?.map((roleDetails) => { + const isTemporary = roleDetails?.isTemporary; + const isExpired = + roleDetails.isTemporary && + new Date() > new Date(roleDetails.temporaryAccessEndTime || ""); + + let text = "Permanent"; + let toolTipText = "Non-Expiring Access"; + if (roleDetails.isTemporary) { + if (isExpired) { + text = "Access Expired"; + toolTipText = "Timed Access Expired"; + } else { + text = formatDistance( + new Date(roleDetails.temporaryAccessEndTime || ""), + new Date() + ); + toolTipText = `Until ${format( + new Date(roleDetails.temporaryAccessEndTime || ""), + "yyyy-MM-dd hh:mm:ss aaa" + )}`; + } + } + + return ( + + + {roleDetails.role === "custom" + ? roleDetails.customRoleName + : formatProjectRoleName(roleDetails.role)} + + + {isTemporary ? ( + + + {isExpired ? : } + {text} + + + ) : ( + text + )} + + {!isOwnProjectMembershipDetails && ( + + + + + + + + + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteRole", { + id: roleDetails?.id, + slug: roleDetails?.customRoleName || roleDetails?.role + }); + }} + isDisabled={!isAllowed} + variant="danger" + > + Remove Role + + )} + + + + + )} + + ); + })} + + + ) : ( + + + This user doesn t have any roles + + Give this user one or more roles + + + + + {(isAllowed) => ( + { + handlePopUpOpen("modifyRole"); + }} + isDisabled={!isAllowed || isOwnProjectMembershipDetails} + > + + Edit Roles + + )} + + + + ) + } +
+ + -
+ ); }; diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/components/ProjectMemberDetailsSection.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/components/ProjectMemberDetailsSection.tsx new file mode 100644 index 000000000..78b3dc5ce --- /dev/null +++ b/frontend/src/pages/project/MemberDetailsByIDPage/components/ProjectMemberDetailsSection.tsx @@ -0,0 +1,107 @@ +import { format } from "date-fns"; +import { CheckIcon, ClipboardListIcon } from "lucide-react"; + +import { Tooltip } from "@app/components/v2"; +import { + Detail, + DetailGroup, + DetailLabel, + DetailValue, + UnstableCard, + UnstableCardContent, + UnstableCardDescription, + UnstableCardHeader, + UnstableCardTitle, + UnstableIconButton +} from "@app/components/v3"; +import { useTimedReset } from "@app/hooks"; +import { TWorkspaceUser } from "@app/hooks/api/types"; + +type Props = { + membership: TWorkspaceUser; +}; + +export const ProjectMemberDetailsSection = ({ membership }: Props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/naming-convention + const [_copyId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/naming-convention + const [_copyEmail, isCopyingEmail, setCopyEmail] = useTimedReset({ + initialState: "Copy email to clipboard" + }); + + const { + user: { email, username, firstName, lastName, id: userId } + } = membership; + + const name = firstName || lastName ? `${firstName} ${lastName}`.trim() : null; + + return ( + + + Details + User membership details + + + + + Name + {name || Not set} + + + ID + + {membership.user.id} + + { + navigator.clipboard.writeText(userId); + setCopyTextId("Copied"); + }} + variant="ghost" + size="xs" + > + {/* TODO(scott): color this should be a button variant */} + {isCopyingId ? : } + + + + + + Email + + {email} + + { + navigator.clipboard.writeText(email); + setCopyEmail("Copied"); + }} + variant="ghost" + size="xs" + > + {/* TODO(scott): color this should be a button variant */} + {isCopyingEmail ? : } + + + + + {username !== email && ( + + Username + + {email || Not set} + + + )} + + Joined project + {format(membership.createdAt, "PPpp")} + + + + + ); +};