improvement(frontend): update and refactor user project membership page styling and add details section

This commit is contained in:
Scott Wilson
2025-12-10 16:13:43 -08:00
parent 539daf9316
commit 67047c2de7
11 changed files with 708 additions and 473 deletions
@@ -32,7 +32,7 @@ export const PageHeader = ({ title, description, children, className, scope }: P
<div className="mr-4 flex w-full items-center"> <div className="mr-4 flex w-full items-center">
<h1 <h1
className={twMerge( className={twMerge(
"text-2xl font-medium text-white capitalize underline decoration-2 underline-offset-4", "text-2xl font-medium text-white underline decoration-2 underline-offset-4",
scope === "org" && "decoration-org/90", scope === "org" && "decoration-org/90",
scope === "instance" && "decoration-neutral/90", scope === "instance" && "decoration-neutral/90",
scope === "namespace" && "decoration-sub-org/90", scope === "namespace" && "decoration-sub-org/90",
@@ -1,7 +1,6 @@
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { DropdownMenu } from "@radix-ui/react-dropdown-menu";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { ChevronLeftIcon, EllipsisIcon, InfoIcon } from "lucide-react"; import { ChevronLeftIcon, EllipsisIcon, InfoIcon } from "lucide-react";
@@ -26,6 +25,7 @@ import {
UnstableCardDescription, UnstableCardDescription,
UnstableCardHeader, UnstableCardHeader,
UnstableCardTitle, UnstableCardTitle,
UnstableDropdownMenu,
UnstableDropdownMenuContent, UnstableDropdownMenuContent,
UnstableDropdownMenuItem, UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger, UnstableDropdownMenuTrigger,
@@ -187,7 +187,7 @@ const Page = () => {
description={`Configure and manage${isProjectIdentity ? " machine identity and " : " "}project access control`} description={`Configure and manage${isProjectIdentity ? " machine identity and " : " "}project access control`}
title={identityMembershipDetails.identity.name} title={identityMembershipDetails.identity.name}
> >
<DropdownMenu> <UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild> <UnstableDropdownMenuTrigger asChild>
<UnstableButton variant="outline"> <UnstableButton variant="outline">
Options Options
@@ -197,7 +197,7 @@ const Page = () => {
<UnstableDropdownMenuContent align="end"> <UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem <UnstableDropdownMenuItem
onClick={() => { onClick={() => {
navigator.clipboard.writeText(identityMembershipDetails.id); navigator.clipboard.writeText(identityMembershipDetails.identity.id);
createNotification({ createNotification({
text: "Machine identity ID copied to clipboard", text: "Machine identity ID copied to clipboard",
type: "info" type: "info"
@@ -211,7 +211,6 @@ const Page = () => {
a={subject(ProjectPermissionSub.Identity, { a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails?.identity.id identityId: identityMembershipDetails?.identity.id
})} })}
passThrough={false}
> >
{(isAllowed) => ( {(isAllowed) => (
<UnstableDropdownMenuItem <UnstableDropdownMenuItem
@@ -221,7 +220,7 @@ const Page = () => {
Assume Privileges Assume Privileges
<Tooltip <Tooltip
side="bottom" side="bottom"
content="Assume the privileges of the machine identity, allowing you to replicate their access behavior." content="Assume the privileges of this machine identity, allowing you to replicate their access behavior."
> >
<div> <div>
<InfoIcon className="text-muted" /> <InfoIcon className="text-muted" />
@@ -251,7 +250,7 @@ const Page = () => {
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</UnstableDropdownMenuContent> </UnstableDropdownMenuContent>
</DropdownMenu> </UnstableDropdownMenu>
</PageHeader> </PageHeader>
<div className="flex flex-col gap-5 lg:flex-row"> <div className="flex flex-col gap-5 lg:flex-row">
<ProjectIdentityDetailsSection <ProjectIdentityDetailsSection
@@ -283,7 +282,7 @@ const Page = () => {
<UnstableAlertDescription> <UnstableAlertDescription>
<p> <p>
This machine identity&apos;s authentication methods are controlled by your This machine identity&apos;s authentication methods are controlled by your
organization. To make changes,{" "} organization. <br /> To make changes,{" "}
<OrgPermissionCan <OrgPermissionCan
I={OrgPermissionIdentityActions.Read} I={OrgPermissionIdentityActions.Read}
an={OrgPermissionSubjects.Identity} an={OrgPermissionSubjects.Identity}
@@ -114,6 +114,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
const { const {
handleSubmit, handleSubmit,
reset,
formState: { isDirty, isSubmitting } formState: { isDirty, isSubmitting }
} = form; } = form;
@@ -310,7 +311,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
variant="link" variant="link"
isDisabled={isSubmitting} isDisabled={isSubmitting}
isLoading={isSubmitting} isLoading={isSubmitting}
onClick={onGoBack} onClick={() => reset()}
> >
Discard Changes Discard Changes
</Button> </Button>
@@ -81,9 +81,7 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
return ( return (
<> <>
<UnstableCard> <UnstableCard>
<UnstableCardHeader <UnstableCardHeader>
// className="border-b"
>
<UnstableCardTitle>Project Additional Privileges</UnstableCardTitle> <UnstableCardTitle>Project Additional Privileges</UnstableCardTitle>
<UnstableCardDescription> <UnstableCardDescription>
Assign one-off policies to this machine identity Assign one-off policies to this machine identity
@@ -96,9 +96,7 @@ export const IdentityRoleDetailsSection = ({
return ( return (
<> <>
<UnstableCard> <UnstableCard>
<UnstableCardHeader <UnstableCardHeader>
// className="border-b"
>
<UnstableCardTitle>Project Roles</UnstableCardTitle> <UnstableCardTitle>Project Roles</UnstableCardTitle>
<UnstableCardDescription> <UnstableCardDescription>
Manage roles assigned to this machine identity Manage roles assigned to this machine identity
@@ -208,8 +206,6 @@ export const IdentityRoleDetailsSection = ({
a={subject(ProjectPermissionSub.Identity, { a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails.identity.id identityId: identityMembershipDetails.identity.id
})} })}
renderTooltip
allowedLabel="Remove Role"
> >
{(isAllowed) => ( {(isAllowed) => (
<UnstableDropdownMenuItem <UnstableDropdownMenuItem
@@ -223,7 +219,6 @@ export const IdentityRoleDetailsSection = ({
isDisabled={!isAllowed} isDisabled={!isAllowed}
variant="danger" variant="danger"
> >
{/* <TrashIcon /> */}
Remove Role Remove Role
</UnstableDropdownMenuItem> </UnstableDropdownMenuItem>
)} )}
@@ -43,10 +43,8 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
return ( return (
<> <>
<UnstableCard className="w-full max-w-[22rem]"> <UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader <UnstableCardHeader>
// className="border-b"
>
<UnstableCardTitle>Details</UnstableCardTitle> <UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>Machine identity details</UnstableCardDescription> <UnstableCardDescription>Machine identity details</UnstableCardDescription>
{!isOrgIdentity && ( {!isOrgIdentity && (
@@ -3,25 +3,34 @@ import { useTranslation } from "react-i18next";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router"; 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 { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button,
ConfirmActionModal, ConfirmActionModal,
DeleteActionModal, DeleteActionModal,
EmptyState, EmptyState,
PageHeader, PageHeader,
Spinner Spinner,
Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import {
Badge,
UnstableButton,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger
} from "@app/components/v3";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionMemberActions, ProjectPermissionMemberActions,
ProjectPermissionSub, ProjectPermissionSub,
useOrganization, useOrganization,
useProject useProject,
useUser
} from "@app/context"; } from "@app/context";
import { getProjectBaseURL, getProjectHomePage } from "@app/helpers/project"; import { getProjectBaseURL, getProjectHomePage } from "@app/helpers/project";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
@@ -35,6 +44,7 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection"; import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection";
import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection"; import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection";
import { ProjectMemberDetailsSection } from "./components/ProjectMemberDetailsSection";
export const Page = () => { export const Page = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -44,12 +54,14 @@ export const Page = () => {
}); });
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { currentProject, projectId } = useProject(); const { currentProject, projectId } = useProject();
const {
user: { id: currentUserId }
} = useUser();
const { data: membershipDetails, isPending: isMembershipDetailsLoading } = const { data: membershipDetails, isPending: isMembershipDetailsLoading } =
useGetWorkspaceUserDetails(projectId, membershipId); useGetWorkspaceUserDetails(projectId, membershipId);
const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } = const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
useDeleteUserFromWorkspace();
const assumePrivileges = useAssumeProjectPrivileges(); const assumePrivileges = useAssumeProjectPrivileges();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
@@ -112,8 +124,10 @@ export const Page = () => {
); );
} }
const isOwnProjectMembershipDetails = currentUserId === membershipDetails?.user?.id;
return ( return (
<div className="mx-auto flex max-w-8xl flex-col justify-between bg-bunker-800 text-white"> <div className="mx-auto flex max-w-8xl flex-col">
{membershipDetails ? ( {membershipDetails ? (
<> <>
<Link <Link
@@ -125,7 +139,7 @@ export const Page = () => {
search={{ search={{
selectedTab: ProjectAccessControlTabs.Member 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"
> >
<FontAwesomeIcon icon={faChevronLeft} /> <FontAwesomeIcon icon={faChevronLeft} />
Project Users Project Users
@@ -135,62 +149,104 @@ export const Page = () => {
title={ title={
membershipDetails.user.firstName || membershipDetails.user.lastName membershipDetails.user.firstName || membershipDetails.user.lastName
? `${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"
> >
<ProjectPermissionCan {isOwnProjectMembershipDetails ? (
I={ProjectPermissionMemberActions.AssumePrivileges} <Tooltip
a={ProjectPermissionSub.Member} side="right"
renderTooltip content="You cannot modify your own membership. Ask a project admin to make changes to your membership."
allowedLabel="Assume privileges of the user" >
passThrough={false} <Badge variant="info" className="ml-2">
> <InfoIcon /> Your project membership
{(isAllowed) => ( </Badge>
<Button </Tooltip>
variant="outline_bg" ) : (
size="xs" <UnstableDropdownMenu>
isDisabled={!isAllowed} <UnstableDropdownMenuTrigger asChild>
isLoading={assumePrivileges.isPending} <UnstableButton variant="outline">
onClick={() => Options
handlePopUpOpen("assumePrivileges", { userId: membershipDetails?.user?.id }) <EllipsisIcon />
} </UnstableButton>
> </UnstableDropdownMenuTrigger>
Assume Privileges <UnstableDropdownMenuContent align="end">
</Button> <UnstableDropdownMenuItem
)} onClick={() => {
</ProjectPermissionCan> navigator.clipboard.writeText(membershipDetails.user.id);
createNotification({
<ProjectPermissionCan text: "User ID copied to clipboard",
I={ProjectPermissionMemberActions.Delete} type: "info"
a={ProjectPermissionSub.Member} });
renderTooltip }}
allowedLabel="Remove from project" >
> Copy User ID
{(isAllowed) => ( </UnstableDropdownMenuItem>
<Button {!isOwnProjectMembershipDetails && (
colorSchema="danger" <>
variant="outline_bg" <ProjectPermissionCan
size="xs" I={ProjectPermissionMemberActions.AssumePrivileges}
isDisabled={!isAllowed} a={ProjectPermissionSub.Member}
isLoading={isRemovingUserFromWorkspace} >
onClick={() => handlePopUpOpen("removeMember")} {(isAllowed) => (
> <UnstableDropdownMenuItem
Remove User isDisabled={!isAllowed}
</Button> onClick={() =>
)} handlePopUpOpen("assumePrivileges", {
</ProjectPermissionCan> userId: membershipDetails.user.id
})
}
>
Assume Privileges
<Tooltip
side="bottom"
content="Assume the privileges of this user, allowing you to replicate their access behavior."
>
<div>
<InfoIcon className="text-muted" />
</div>
</Tooltip>
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionMemberActions.Delete}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
variant="danger"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("removeMember")}
>
Remove User From Project
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
</>
)}
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
)}
</PageHeader> </PageHeader>
<MemberRoleDetailsSection <div className="flex flex-col gap-5 lg:flex-row">
membershipDetails={membershipDetails} <ProjectMemberDetailsSection membership={membershipDetails} />
isMembershipDetailsLoading={isMembershipDetailsLoading} <div className="flex flex-1 flex-col gap-y-5">
onOpenUpgradeModal={() => <MemberRoleDetailsSection
handlePopUpOpen("upgradePlan", { membershipDetails={membershipDetails}
text: "Assigning custom roles to members can be unlocked if you upgrade to Infisical Pro plan." isMembershipDetailsLoading={isMembershipDetailsLoading}
}) onOpenUpgradeModal={() =>
} handlePopUpOpen("upgradePlan", {
/> text: "Assigning custom roles to members can be unlocked if you upgrade to Infisical Pro plan."
<MemberProjectAdditionalPrivilegeSection membershipDetails={membershipDetails} /> })
}
/>
<MemberProjectAdditionalPrivilegeSection membershipDetails={membershipDetails} />
</div>
</div>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.removeMember.isOpen} isOpen={popUp.removeMember.isOpen}
deleteKey="remove" deleteKey="remove"
@@ -1,26 +1,35 @@
import { faEllipsisV, faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistance } from "date-fns"; import { format, formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion"; import { ClockAlertIcon, ClockIcon, EllipsisIcon, PlusIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, Lottie, Modal, ModalContent, Tooltip } from "@app/components/v2";
import { import {
DeleteActionModal, Badge,
EmptyState, UnstableButton,
IconButton, UnstableCard,
Table, UnstableCardAction,
TableContainer, UnstableCardContent,
TableSkeleton, UnstableCardDescription,
Tag, UnstableCardHeader,
TBody, UnstableCardTitle,
Td, UnstableDropdownMenu,
Th, UnstableDropdownMenuContent,
THead, UnstableDropdownMenuItem,
Tooltip, UnstableDropdownMenuTrigger,
Tr UnstableEmpty,
} from "@app/components/v2"; UnstableEmptyContent,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle,
UnstableIconButton,
UnstableTable,
UnstableTableBody,
UnstableTableCell,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionMemberActions, ProjectPermissionMemberActions,
@@ -68,188 +77,216 @@ export const MemberProjectAdditionalPrivilegeSection = ({ membershipDetails }: P
handlePopUpClose("deletePrivilege"); handlePopUpClose("deletePrivilege");
}; };
const hasAdditionalPrivileges = Boolean(userProjectPrivileges?.length);
return ( return (
<div className="relative"> <>
<AnimatePresence> <UnstableCard>
{popUp?.modifyPrivilege.isOpen ? ( <UnstableCardHeader>
<motion.div <UnstableCardTitle>Project Additional Privileges</UnstableCardTitle>
key="privilege-modify" <UnstableCardDescription>Assign one-off policies to this user</UnstableCardDescription>
transition={{ duration: 0.3 }} {!isOwnProjectMembershipDetails && hasAdditionalPrivileges && (
initial={{ opacity: 0, translateX: 30 }} <UnstableCardAction>
animate={{ opacity: 1, translateX: 0 }} <ProjectPermissionCan
exit={{ opacity: 0, translateX: 30 }} I={ProjectPermissionActions.Edit}
className="absolute min-h-40 w-full" a={ProjectPermissionSub.Member}
> >
<MembershipProjectAdditionalPrivilegeModifySection {(isAllowed) => (
onGoBack={() => handlePopUpClose("modifyPrivilege")} <UnstableButton
projectMembershipId={membershipDetails?.id} variant="outline"
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} size="xs"
isDisabled={ onClick={() => {
isOwnProjectMembershipDetails || handlePopUpOpen("modifyPrivilege");
permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member) }}
} isDisabled={!isAllowed}
/>
</motion.div>
) : (
<motion.div
key="privilege-list"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 0 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
className="absolute 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">
Project Additional Privileges
</h3>
{userId !== membershipDetails?.user?.id &&
membershipDetails?.status !== "invited" && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Add Privilege"
> >
{(isAllowed) => ( <PlusIcon />
<IconButton Add Additional Privileges
ariaLabel="copy icon" </UnstableButton>
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("modifyPrivilege");
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</ProjectPermissionCan>
)} )}
</ProjectPermissionCan>
</UnstableCardAction>
)}
</UnstableCardHeader>
<UnstableCardContent>
{/* eslint-disable-next-line no-nested-ternary */}
{isPending ? (
// scott: todo proper loader
<div className="flex h-40 w-full items-center justify-center">
<Lottie icon="infisical_loading_white" isAutoPlay className="w-16" />
</div> </div>
<div className="py-4"> ) : userProjectPrivileges?.length ? (
<TableContainer> <UnstableTable>
<Table> <UnstableTableHeader>
<THead> <UnstableTableRow>
<Tr> <UnstableTableHead className="w-1/2">Name</UnstableTableHead>
<Th>Name</Th> <UnstableTableHead className="w-1/2">Duration</UnstableTableHead>
<Th>Duration</Th> {!isOwnProjectMembershipDetails && <UnstableTableHead className="w-5" />}
<Th className="w-5" /> </UnstableTableRow>
</Tr> </UnstableTableHeader>
</THead> <UnstableTableBody>
<TBody> {!isPending &&
{isPending && <TableSkeleton columns={3} innerKey="user-project-memberships" />} userProjectPrivileges?.map((privilegeDetails) => {
{!isPending && const isTemporary = privilegeDetails?.isTemporary;
userProjectPrivileges?.map((privilegeDetails) => { const isExpired =
const isTemporary = privilegeDetails?.isTemporary; privilegeDetails.isTemporary &&
const isExpired = new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
privilegeDetails.isTemporary &&
new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
let text = "Permanent"; let text = "Permanent";
let toolTipText = "Non-Expiring Access"; let toolTipText = "Non-Expiring Access";
if (privilegeDetails.isTemporary) { if (privilegeDetails.isTemporary) {
if (isExpired) { if (isExpired) {
text = "Access Expired"; text = "Access Expired";
toolTipText = "Timed Access Expired"; toolTipText = "Timed Access Expired";
} else { } else {
text = formatDistance( text = formatDistance(
new Date(privilegeDetails.temporaryAccessEndTime || ""), new Date(privilegeDetails.temporaryAccessEndTime || ""),
new Date() new Date()
); );
toolTipText = `Until ${format( toolTipText = `Until ${format(
new Date(privilegeDetails.temporaryAccessEndTime || ""), new Date(privilegeDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa" "yyyy-MM-dd hh:mm:ss aaa"
)}`; )}`;
} }
} }
return ( return (
<Tr <UnstableTableRow key={`user-project-privilege-${privilegeDetails?.id}`}>
key={`user-project-privilege-${privilegeDetails?.id}`} <UnstableTableCell className="max-w-0 truncate">
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" {privilegeDetails.slug}
role="button" </UnstableTableCell>
tabIndex={0} <UnstableTableCell>
onKeyDown={(evt) => { {isTemporary ? (
if (evt.key === "Enter") { <Tooltip content={toolTipText}>
handlePopUpOpen("modifyPrivilege", privilegeDetails); <Badge
} className="capitalize"
}} variant={isExpired ? "danger" : "warning"}
onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)} >
> {isExpired ? <ClockAlertIcon /> : <ClockIcon />}
<Td>{privilegeDetails.slug}</Td> {text}
<Td> </Badge>
<Tooltip asChild={false} content={toolTipText}> </Tooltip>
<Tag ) : (
className={twMerge( text
"capitalize", )}
isTemporary && "text-primary", </UnstableTableCell>
isExpired && "text-red-600" {!isOwnProjectMembershipDetails && (
)} <UnstableTableCell>
> <UnstableDropdownMenu>
{text} <UnstableDropdownMenuTrigger asChild>
</Tag> <UnstableIconButton size="xs" variant="ghost">
</Tooltip> <EllipsisIcon />
</Td> </UnstableIconButton>
<Td> </UnstableDropdownMenuTrigger>
<div className="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100"> <UnstableDropdownMenuContent align="end">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member} a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Remove Role"
> >
{(isAllowed) => ( {(isAllowed) => (
<IconButton <UnstableDropdownMenuItem
colorSchema="danger" isDisabled={!isAllowed}
ariaLabel="delete-icon" onClick={(e) => {
variant="plain" e.stopPropagation();
className="group relative" handlePopUpOpen("modifyPrivilege", privilegeDetails);
isDisabled={!isAllowed || isOwnProjectMembershipDetails} }}
>
Edit Additional Privilege
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
variant="danger"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
handlePopUpOpen("deletePrivilege", { handlePopUpOpen("deletePrivilege", {
id: privilegeDetails?.id, id: privilegeDetails?.id,
slug: privilegeDetails?.slug slug: privilegeDetails?.slug
}); });
}} }}
> >
<FontAwesomeIcon icon={faTrash} /> Remove Additional Privilege
</IconButton> </UnstableDropdownMenuItem>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<IconButton </UnstableDropdownMenuContent>
ariaLabel="more-icon" </UnstableDropdownMenu>
variant="plain" </UnstableTableCell>
className="group relative" )}
> </UnstableTableRow>
<FontAwesomeIcon icon={faEllipsisV} /> );
</IconButton> })}
</div> </UnstableTableBody>
</Td> </UnstableTable>
</Tr> ) : (
); <UnstableEmpty className="border">
})} <UnstableEmptyHeader>
</TBody> <UnstableEmptyTitle>This user has no additional privileges</UnstableEmptyTitle>
</Table> <UnstableEmptyDescription>
{!isPending && !userProjectPrivileges?.length && ( Add an additional privilege to grant one-off access policies
<EmptyState title="This user has no additional privileges" icon={faFolder} /> </UnstableEmptyDescription>
)} </UnstableEmptyHeader>
</TableContainer> {!isOwnProjectMembershipDetails && (
</div> <UnstableEmptyContent>
<DeleteActionModal <ProjectPermissionCan
isOpen={popUp.deletePrivilege.isOpen} I={ProjectPermissionActions.Edit}
deleteKey="remove" a={ProjectPermissionSub.Member}
title={`Do you want to remove role ${ >
(popUp?.deletePrivilege?.data as { slug: string; id: string })?.slug {(isAllowed) => (
}?`} <UnstableButton
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)} variant="project"
onDeleteApproved={() => handlePrivilegeDelete()} size="xs"
/> onClick={() => {
</motion.div> handlePopUpOpen("modifyPrivilege");
)} }}
</AnimatePresence> isDisabled={!isAllowed || isOwnProjectMembershipDetails}
</div> >
<PlusIcon />
Add Additional Privileges
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableEmptyContent>
)}
</UnstableEmpty>
)}
</UnstableCardContent>
</UnstableCard>
<Modal
isOpen={popUp.modifyPrivilege.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("modifyPrivilege", isOpen)}
>
<ModalContent
className="max-w-6xl"
title="Additional Privileges"
subTitle="Additional privileges take precedence over roles when permissions conflict"
>
<MembershipProjectAdditionalPrivilegeModifySection
onGoBack={() => handlePopUpClose("modifyPrivilege")}
projectMembershipId={membershipDetails?.id}
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={
isOwnProjectMembershipDetails ||
permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member)
}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
deleteKey="remove"
title={`Do you want to remove role ${
(popUp?.deletePrivilege?.data as { slug: string; id: string })?.slug
}?`}
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
onDeleteApproved={() => handlePrivilegeDelete()}
/>
</>
); );
}; };
@@ -1,5 +1,5 @@
import { Controller, FormProvider, useForm } from "react-hook-form"; 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns"; import { format, formatDistance } from "date-fns";
@@ -20,6 +20,7 @@ import {
Tag, Tag,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { UnstableSeparator } from "@app/components/v3";
import { import {
ProjectPermissionMemberActions, ProjectPermissionMemberActions,
ProjectPermissionSub, ProjectPermissionSub,
@@ -111,6 +112,7 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
const { const {
handleSubmit, handleSubmit,
reset,
formState: { isDirty, isSubmitting } formState: { isDirty, isSubmitting }
} = form; } = form;
@@ -176,59 +178,9 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
} }
return ( return (
<form <form className="flex flex-col gap-y-4" onSubmit={handleSubmit(onSubmit)}>
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<FormProvider {...form}> <FormProvider {...form}>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> <div>
<Button
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
className="text-lg font-medium text-mineshaft-100"
variant="link"
onClick={onGoBack}
>
Back
</Button>
<div className="flex items-center space-x-4">
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={onGoBack}
>
Discard
</Button>
)}
<div className="flex items-center">
<Button
variant="outline_bg"
type="submit"
className={twMerge(
"mr-4 h-10 border border-primary",
isDirty && "bg-primary text-black"
)}
isDisabled={isSubmitting || !isDirty || isDisabled}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
<AddPoliciesButton
isDisabled={isDisabled}
projectType={currentProject.type}
projectId={projectId}
/>
</div>
</div>
</div>
<div className="mt-2 border-b border-gray-800 p-4 pt-2 first:rounded-t-md last:rounded-b-md">
<div className="text-lg">Overview</div>
<p className="mb-4 text-sm text-mineshaft-300">
Additional privileges take precedence over roles when permissions conflict
</p>
<div className="flex items-end space-x-6"> <div className="flex items-end space-x-6">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<Controller <Controller
@@ -344,22 +296,61 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
</div> </div>
</div> </div>
</div> </div>
<div className="p-4"> <UnstableSeparator />
<div className="mb-2 text-lg">Policies</div> <div>
{(isCreate || !isPending) && <PermissionEmptyState />} <div className="mb-3 flex w-full items-center justify-between">
<div> <div className="text-lg">Policies</div>
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( <div className="flex items-center space-x-4">
<GeneralPermissionPolicies {isDirty && (
subject={subject} <Button
actions={PROJECT_PERMISSION_OBJECT[subject].actions} className="mr-4 text-mineshaft-300"
title={PROJECT_PERMISSION_OBJECT[subject].title} variant="link"
key={`project-permission-${subject}`} isDisabled={isSubmitting}
isDisabled={isDisabled} isLoading={isSubmitting}
> onClick={() => reset()}
{renderConditionalComponents(subject, isDisabled)} >
</GeneralPermissionPolicies> Discard Changes
))} </Button>
)}
<div className="flex items-center">
<AddPoliciesButton
isDisabled={isDisabled}
projectType={currentProject.type}
projectId={projectId}
/>
</div>
</div>
</div> </div>
{(isCreate || !isPending) && <PermissionEmptyState />}
<div className="scrollbar-thin max-h-[50vh] overflow-y-auto">
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map(
(permissionSubject) => (
<GeneralPermissionPolicies
subject={permissionSubject}
actions={PROJECT_PERMISSION_OBJECT[permissionSubject].actions}
title={PROJECT_PERMISSION_OBJECT[permissionSubject].title}
key={`project-permission-${permissionSubject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(permissionSubject, isDisabled)}
</GeneralPermissionPolicies>
)
)}
</div>
</div>
<UnstableSeparator />
<div className="flex w-full items-center justify-end gap-x-2">
<Button colorSchema="secondary" variant="plain" onClick={onGoBack}>
Cancel
</Button>
<Button
type="submit"
isDisabled={isSubmitting || !isDirty || isDisabled}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
</div> </div>
</FormProvider> </FormProvider>
</form> </form>
@@ -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 { 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 { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, Lottie, Modal, ModalContent, Tooltip } from "@app/components/v2";
import { import {
DeleteActionModal, Badge,
EmptyState, UnstableButton,
IconButton, UnstableCard,
Modal, UnstableCardAction,
ModalContent, UnstableCardContent,
Table, UnstableCardDescription,
TableContainer, UnstableCardHeader,
TableSkeleton, UnstableCardTitle,
Tag, UnstableDropdownMenu,
TBody, UnstableDropdownMenuContent,
Td, UnstableDropdownMenuItem,
Th, UnstableDropdownMenuTrigger,
THead, UnstableEmpty,
Tooltip, UnstableEmptyContent,
Tr UnstableEmptyDescription,
} from "@app/components/v2"; UnstableEmptyHeader,
UnstableEmptyTitle,
UnstableIconButton,
UnstableTable,
UnstableTableBody,
UnstableTableCell,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3/generic";
import { ProjectPermissionActions, ProjectPermissionSub, useProject, useUser } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useProject, useUser } from "@app/context";
import { formatProjectRoleName } from "@app/helpers/roles"; import { formatProjectRoleName } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
@@ -88,131 +96,176 @@ export const MemberRoleDetailsSection = ({
handlePopUpClose("deleteRole"); handlePopUpClose("deleteRole");
}; };
const hasRoles = Boolean(membershipDetails?.roles.length);
return ( return (
<div className="mb-4 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"> <UnstableCard>
<h3 className="text-lg font-medium text-mineshaft-100">Project Roles</h3> <UnstableCardHeader>
{!isOwnProjectMembershipDetails && membershipDetails?.status !== "invited" && ( <UnstableCardTitle>Project Roles</UnstableCardTitle>
<ProjectPermissionCan <UnstableCardDescription>Manage roles assigned to this user</UnstableCardDescription>
I={ProjectPermissionActions.Edit} {!isOwnProjectMembershipDetails && hasRoles && (
a={ProjectPermissionSub.Member} <UnstableCardAction>
renderTooltip <ProjectPermissionCan
allowedLabel="Edit Role(s)" I={ProjectPermissionActions.Edit}
> a={ProjectPermissionSub.Member}
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed}
> >
<FontAwesomeIcon icon={faPencil} /> {(isAllowed) => (
</IconButton> <UnstableButton
)} size="xs"
</ProjectPermissionCan> variant="outline"
)} onClick={() => {
</div> handlePopUpOpen("modifyRole");
<div className="py-4"> }}
<TableContainer> isDisabled={!isAllowed}
<Table> >
<THead> <PencilIcon />
<Tr> Edit Roles
<Th>Role</Th> </UnstableButton>
<Th>Duration</Th> )}
<Th className="w-5" /> </ProjectPermissionCan>
</Tr> </UnstableCardAction>
</THead>
<TBody>
{isMembershipDetailsLoading && (
<TableSkeleton columns={3} innerKey="user-project-memberships" />
)}
{!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 (
<Tr className="group h-10" key={`user-project-membership-${roleDetails?.id}`}>
<Td className="capitalize">
{roleDetails.role === "custom"
? roleDetails.customRoleName
: formatProjectRoleName(roleDetails.role)}
</Td>
<Td>
<Tooltip asChild={false} content={toolTipText}>
<Tag
className={twMerge(
"capitalize",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{text}
</Tag>
</Tooltip>
</Td>
<Td>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", {
id: roleDetails?.id,
slug: roleDetails?.customRoleName || roleDetails?.role
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isMembershipDetailsLoading && !membershipDetails?.roles?.length && (
<EmptyState title="This user has no roles" icon={faFolder} />
)} )}
</TableContainer> </UnstableCardHeader>
</div> <UnstableCardContent>
{
/* eslint-disable-next-line no-nested-ternary */
isMembershipDetailsLoading ? (
// scott: todo proper loader
<div className="flex h-40 w-full items-center justify-center">
<Lottie icon="infisical_loading_white" isAutoPlay className="w-16" />
</div>
) : hasRoles ? (
<UnstableTable>
<UnstableTableHeader>
<UnstableTableRow>
<UnstableTableHead className="w-1/2">Role</UnstableTableHead>
<UnstableTableHead className="w-1/2">Duration</UnstableTableHead>
{!isOwnProjectMembershipDetails && <UnstableTableHead className="w-5" />}
</UnstableTableRow>
</UnstableTableHeader>
<UnstableTableBody>
{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 (
<UnstableTableRow
className="group h-10"
key={`user-project-identity-${roleDetails?.id}`}
>
<UnstableTableCell className="max-w-0 truncate">
{roleDetails.role === "custom"
? roleDetails.customRoleName
: formatProjectRoleName(roleDetails.role)}
</UnstableTableCell>
<UnstableTableCell>
{isTemporary ? (
<Tooltip content={toolTipText}>
<Badge
className="capitalize"
variant={isExpired ? "danger" : "warning"}
>
{isExpired ? <ClockAlertIcon /> : <ClockIcon />}
{text}
</Badge>
</Tooltip>
) : (
text
)}
</UnstableTableCell>
{!isOwnProjectMembershipDetails && (
<UnstableTableCell>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableIconButton size="xs" variant="ghost">
<EllipsisIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", {
id: roleDetails?.id,
slug: roleDetails?.customRoleName || roleDetails?.role
});
}}
isDisabled={!isAllowed}
variant="danger"
>
Remove Role
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
)}
</UnstableTableRow>
);
})}
</UnstableTableBody>
</UnstableTable>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>This user doesn&nbsp;t have any roles</UnstableEmptyTitle>
<UnstableEmptyDescription>
Give this user one or more roles
</UnstableEmptyDescription>
</UnstableEmptyHeader>
<UnstableEmptyContent>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableButton
variant="project"
size="xs"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
>
<PencilIcon />
Edit Roles
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableEmptyContent>
</UnstableEmpty>
)
}
</UnstableCardContent>
</UnstableCard>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteRole.isOpen} isOpen={popUp.deleteRole.isOpen}
deleteKey="remove" deleteKey="remove"
@@ -234,6 +287,6 @@ export const MemberRoleDetailsSection = ({
/> />
</ModalContent> </ModalContent>
</Modal> </Modal>
</div> </>
); );
}; };
@@ -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<string>({
initialState: "Copy ID to clipboard"
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/naming-convention
const [_copyEmail, isCopyingEmail, setCopyEmail] = useTimedReset<string>({
initialState: "Copy email to clipboard"
});
const {
user: { email, username, firstName, lastName, id: userId }
} = membership;
const name = firstName || lastName ? `${firstName} ${lastName}`.trim() : null;
return (
<UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader>
<UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>User membership details</UnstableCardDescription>
</UnstableCardHeader>
<UnstableCardContent>
<DetailGroup>
<Detail>
<DetailLabel>Name</DetailLabel>
<DetailValue>{name || <span className="text-muted italic">Not set</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(userId);
setCopyTextId("Copied");
}}
variant="ghost"
size="xs"
>
{/* TODO(scott): color this should be a button variant */}
{isCopyingId ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
</UnstableIconButton>
</Tooltip>
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Email</DetailLabel>
<DetailValue className="flex items-center gap-x-1">
{email}
<Tooltip content="Copy user email to clipboard">
<UnstableIconButton
onClick={() => {
navigator.clipboard.writeText(email);
setCopyEmail("Copied");
}}
variant="ghost"
size="xs"
>
{/* TODO(scott): color this should be a button variant */}
{isCopyingEmail ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
</UnstableIconButton>
</Tooltip>
</DetailValue>
</Detail>
{username !== email && (
<Detail>
<DetailLabel>Username</DetailLabel>
<DetailValue>
{email || <span className="text-muted italic">Not set</span>}
</DetailValue>
</Detail>
)}
<Detail>
<DetailLabel>Joined project</DetailLabel>
<DetailValue>{format(membership.createdAt, "PPpp")}</DetailValue>
</Detail>
</DetailGroup>
</UnstableCardContent>
</UnstableCard>
);
};