Merge pull request #5020 from Infisical/ENG-4283-1

improvement(frontend): update and refactor user project membership page components and add details section
This commit is contained in:
Scott Wilson
2025-12-12 09:48:01 -08:00
committed by GitHub
14 changed files with 738 additions and 492 deletions

View File

@@ -142,6 +142,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr
data: {
...req.body,
...req.body.type,
name: req.body.slug,
permissions: req.body.permissions
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error this is valid ts

View File

@@ -32,7 +32,7 @@ export const PageHeader = ({ title, description, children, className, scope }: P
<div className="mr-4 flex w-full items-center">
<h1
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 === "instance" && "decoration-neutral/90",
scope === "namespace" && "decoration-sub-org/90",

View File

@@ -11,8 +11,9 @@ const alertVariants = cva(
variants: {
variant: {
default: "bg-container text-card-foreground",
info: "bg-info/10 text-info border-info/20",
org: "bg-org/10 text-org border-org/20"
info: "bg-info/5 text-info border-info/20",
org: "bg-org/5 text-org border-org/20",
"sub-org": "bg-sub-org/5 text-sub-org border-sub-org/20"
}
},
defaultVariants: {

View File

@@ -16,7 +16,7 @@ export const IdentityAuthFieldDisplay = ({ label, children, className }: Props)
{children ? (
<p className="break-words">{children}</p>
) : (
<p className="text-muted italic">Not set</p>
<p className="text-muted">Not set</p>
)}
</DetailValue>
</Detail>

View File

@@ -1,7 +1,6 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability";
import { DropdownMenu } from "@radix-ui/react-dropdown-menu";
import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { ChevronLeftIcon, EllipsisIcon, InfoIcon } from "lucide-react";
@@ -17,6 +16,7 @@ import {
} from "@app/components/v2";
import {
OrgIcon,
SubOrgIcon,
UnstableAlert,
UnstableAlertDescription,
UnstableAlertTitle,
@@ -26,6 +26,7 @@ import {
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
@@ -63,7 +64,7 @@ const Page = () => {
select: (el) => el.identityId as string
});
const { currentProject, projectId } = useProject();
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const { data: identityMembershipDetails, isPending: isMembershipDetailsLoading } =
useGetProjectIdentityMembershipV2(projectId, identityId);
@@ -164,6 +165,12 @@ const Page = () => {
return <UnstablePageLoader />;
}
const isOrgIdentity = !isProjectIdentity;
const isSubOrgIdentity =
isOrgIdentity &&
isSubOrganization &&
currentOrg.rootOrgId !== identityMembershipDetails?.identity.orgId;
return (
<div className="mx-auto flex max-w-8xl flex-col">
{identityMembershipDetails ? (
@@ -187,7 +194,7 @@ const Page = () => {
description={`Configure and manage${isProjectIdentity ? " machine identity and " : " "}project access control`}
title={identityMembershipDetails.identity.name}
>
<DropdownMenu>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableButton variant="outline">
Options
@@ -197,7 +204,7 @@ const Page = () => {
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(identityMembershipDetails.id);
navigator.clipboard.writeText(identityMembershipDetails.identity.id);
createNotification({
text: "Machine identity ID copied to clipboard",
type: "info"
@@ -211,7 +218,6 @@ const Page = () => {
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails?.identity.id
})}
passThrough={false}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
@@ -221,7 +227,7 @@ const Page = () => {
Assume Privileges
<Tooltip
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>
<InfoIcon className="text-muted" />
@@ -251,12 +257,13 @@ const Page = () => {
)}
</ProjectPermissionCan>
</UnstableDropdownMenuContent>
</DropdownMenu>
</UnstableDropdownMenu>
</PageHeader>
<div className="flex flex-col gap-5 lg:flex-row">
<ProjectIdentityDetailsSection
identity={identity || { ...identityMembershipDetails?.identity, projectId: "" }}
isOrgIdentity={!isProjectIdentity}
isOrgIdentity={isOrgIdentity}
isSubOrgIdentity={isSubOrgIdentity}
membership={identityMembershipDetails!}
/>
@@ -275,15 +282,15 @@ const Page = () => {
</UnstableCardDescription>
</UnstableCardHeader>
<UnstableCardContent>
<UnstableAlert variant="org">
<OrgIcon />
<UnstableAlert variant={isSubOrgIdentity ? "sub-org" : "org"}>
{isSubOrgIdentity ? <SubOrgIcon /> : <OrgIcon />}
<UnstableAlertTitle>
Machine identity managed by organization
Machine identity managed by {isSubOrgIdentity ? "sub-" : ""}organization
</UnstableAlertTitle>
<UnstableAlertDescription>
<p>
This machine identity&apos;s authentication methods are controlled by your
organization. To make changes,{" "}
This machine identity&apos;s authentication methods are managed by your
{isSubOrgIdentity ? "sub-" : ""}organization. <br /> To make changes,{" "}
<OrgPermissionCan
I={OrgPermissionIdentityActions.Read}
an={OrgPermissionSubjects.Identity}
@@ -295,10 +302,10 @@ const Page = () => {
className="inline-block cursor-pointer text-foreground underline underline-offset-2"
params={{
identityId,
orgId: currentOrg.id
orgId: identityMembershipDetails.identity.orgId
}}
>
go to organization access control
go to {isSubOrgIdentity ? "sub-" : ""}organization access control
</Link>
) : null
}

View File

@@ -114,6 +114,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
const {
handleSubmit,
reset,
formState: { isDirty, isSubmitting }
} = form;
@@ -310,7 +311,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={onGoBack}
onClick={() => reset()}
>
Discard Changes
</Button>

View File

@@ -81,9 +81,7 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
return (
<>
<UnstableCard>
<UnstableCardHeader
// className="border-b"
>
<UnstableCardHeader>
<UnstableCardTitle>Project Additional Privileges</UnstableCardTitle>
<UnstableCardDescription>
Assign one-off policies to this machine identity

View File

@@ -96,9 +96,7 @@ export const IdentityRoleDetailsSection = ({
return (
<>
<UnstableCard>
<UnstableCardHeader
// className="border-b"
>
<UnstableCardHeader>
<UnstableCardTitle>Project Roles</UnstableCardTitle>
<UnstableCardDescription>
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) => (
<UnstableDropdownMenuItem
@@ -223,7 +219,6 @@ export const IdentityRoleDetailsSection = ({
isDisabled={!isAllowed}
variant="danger"
>
{/* <TrashIcon /> */}
Remove Role
</UnstableDropdownMenuItem>
)}

View File

@@ -12,6 +12,7 @@ import {
DetailValue,
OrgIcon,
ProjectIcon,
SubOrgIcon,
UnstableButtonGroup,
UnstableCard,
UnstableCardAction,
@@ -30,10 +31,16 @@ import { ProjectIdentityModal } from "@app/pages/project/AccessControlPage/compo
type Props = {
identity: TProjectIdentity;
isOrgIdentity?: boolean;
isSubOrgIdentity?: boolean;
membership: IdentityProjectMembershipV1;
};
export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, membership }: Props) => {
export const ProjectIdentityDetailsSection = ({
identity,
isOrgIdentity,
isSubOrgIdentity,
membership
}: Props) => {
// eslint-disable-next-line @typescript-eslint/naming-convention,@typescript-eslint/no-unused-vars
const [_, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
@@ -43,10 +50,8 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
return (
<>
<UnstableCard className="w-full max-w-[22rem]">
<UnstableCardHeader
// className="border-b"
>
<UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader>
<UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>Machine identity details</UnstableCardDescription>
{!isOrgIdentity && (
@@ -92,7 +97,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
variant="ghost"
size="xs"
>
{/* TODO(scott): color this should be a button variant */}
{/* TODO(scott): color this should be a button variant and create re-usable copy button */}
{isCopyingId ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
</UnstableIconButton>
</Tooltip>
@@ -102,9 +107,9 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
<DetailLabel>Managed by</DetailLabel>
<DetailValue>
{isOrgIdentity ? (
<Badge variant="org">
<OrgIcon />
Organization
<Badge variant={isSubOrgIdentity ? "sub-org" : "org"}>
{isSubOrgIdentity ? <SubOrgIcon /> : <OrgIcon />}
{isSubOrgIdentity ? "Sub-" : ""}Organization
</Badge>
) : (
<Badge variant="project">
@@ -129,7 +134,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
</UnstableButtonGroup>
))
) : (
<span className="text-muted italic">No metadata</span>
<span className="text-muted">No metadata</span>
)}
</DetailValue>
</Detail>
@@ -145,7 +150,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
{membership.lastLoginAuthMethod ? (
identityAuthToNameMap[membership.lastLoginAuthMethod]
) : (
<span className="text-muted italic">N/A</span>
<span className="text-muted">N/A</span>
)}
</DetailValue>
</Detail>
@@ -155,7 +160,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
{membership.lastLoginTime ? (
format(membership.lastLoginTime, "PPpp")
) : (
<span className="text-muted italic">N/A</span>
<span className="text-muted">N/A</span>
)}
</DetailValue>
</Detail>

View File

@@ -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 (
<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 ? (
<>
<Link
@@ -125,7 +139,7 @@ export const Page = () => {
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"
>
<FontAwesomeIcon icon={faChevronLeft} />
Project Users
@@ -135,62 +149,100 @@ 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"
>
<ProjectPermissionCan
I={ProjectPermissionMemberActions.AssumePrivileges}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Assume privileges of the user"
passThrough={false}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
isLoading={assumePrivileges.isPending}
onClick={() =>
handlePopUpOpen("assumePrivileges", { userId: membershipDetails?.user?.id })
}
>
Assume Privileges
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionMemberActions.Delete}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Remove from project"
>
{(isAllowed) => (
<Button
colorSchema="danger"
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
isLoading={isRemovingUserFromWorkspace}
onClick={() => handlePopUpOpen("removeMember")}
>
Remove User
</Button>
)}
</ProjectPermissionCan>
{isOwnProjectMembershipDetails ? (
<Tooltip
side="right"
content="You cannot modify your own membership. Ask a project admin to make changes to your membership."
>
<Badge variant="info" className="ml-2">
<InfoIcon /> Your project membership
</Badge>
</Tooltip>
) : (
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableButton variant="outline">
Options
<EllipsisIcon />
</UnstableButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(membershipDetails.user.id);
createNotification({
text: "User ID copied to clipboard",
type: "info"
});
}}
>
Copy User ID
</UnstableDropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionMemberActions.AssumePrivileges}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
onClick={() =>
handlePopUpOpen("assumePrivileges", {
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>
<MemberRoleDetailsSection
membershipDetails={membershipDetails}
isMembershipDetailsLoading={isMembershipDetailsLoading}
onOpenUpgradeModal={() =>
handlePopUpOpen("upgradePlan", {
text: "Assigning custom roles to members can be unlocked if you upgrade to Infisical Pro plan."
})
}
/>
<MemberProjectAdditionalPrivilegeSection membershipDetails={membershipDetails} />
<div className="flex flex-col gap-5 lg:flex-row">
<ProjectMemberDetailsSection membership={membershipDetails} />
<div className="flex flex-1 flex-col gap-y-5">
<MemberRoleDetailsSection
membershipDetails={membershipDetails}
isMembershipDetailsLoading={isMembershipDetailsLoading}
onOpenUpgradeModal={() =>
handlePopUpOpen("upgradePlan", {
text: "Assigning custom roles to members can be unlocked if you upgrade to Infisical Pro plan."
})
}
/>
<MemberProjectAdditionalPrivilegeSection membershipDetails={membershipDetails} />
</div>
</div>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
deleteKey="remove"

View File

@@ -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 { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { ClockAlertIcon, ClockIcon, EllipsisIcon, PlusIcon } 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,
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";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
@@ -68,188 +77,216 @@ export const MemberProjectAdditionalPrivilegeSection = ({ membershipDetails }: P
handlePopUpClose("deletePrivilege");
};
const hasAdditionalPrivileges = Boolean(userProjectPrivileges?.length);
return (
<div className="relative">
<AnimatePresence>
{popUp?.modifyPrivilege.isOpen ? (
<motion.div
key="privilege-modify"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="absolute min-h-40 w-full"
>
<MembershipProjectAdditionalPrivilegeModifySection
onGoBack={() => handlePopUpClose("modifyPrivilege")}
projectMembershipId={membershipDetails?.id}
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={
isOwnProjectMembershipDetails ||
permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member)
}
/>
</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"
<>
<UnstableCard>
<UnstableCardHeader>
<UnstableCardTitle>Project Additional Privileges</UnstableCardTitle>
<UnstableCardDescription>Assign one-off policies to this user</UnstableCardDescription>
{!isOwnProjectMembershipDetails && hasAdditionalPrivileges && (
<UnstableCardAction>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableButton
variant="outline"
size="xs"
onClick={() => {
handlePopUpOpen("modifyPrivilege");
}}
isDisabled={!isAllowed}
>
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("modifyPrivilege");
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</ProjectPermissionCan>
<PlusIcon />
Add Additional Privileges
</UnstableButton>
)}
</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 className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Duration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
{!isPending &&
userProjectPrivileges?.map((privilegeDetails) => {
const isTemporary = privilegeDetails?.isTemporary;
const isExpired =
privilegeDetails.isTemporary &&
new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
) : userProjectPrivileges?.length ? (
<UnstableTable>
<UnstableTableHeader>
<UnstableTableRow>
<UnstableTableHead className="w-1/2">Name</UnstableTableHead>
<UnstableTableHead className="w-1/2">Duration</UnstableTableHead>
{!isOwnProjectMembershipDetails && <UnstableTableHead className="w-5" />}
</UnstableTableRow>
</UnstableTableHeader>
<UnstableTableBody>
{!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 (
<Tr
key={`user-project-privilege-${privilegeDetails?.id}`}
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
handlePopUpOpen("modifyPrivilege", privilegeDetails);
}
}}
onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)}
>
<Td>{privilegeDetails.slug}</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="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
return (
<UnstableTableRow key={`user-project-privilege-${privilegeDetails?.id}`}>
<UnstableTableCell className="max-w-0 truncate">
{privilegeDetails.slug}
</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}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<IconButton
colorSchema="danger"
ariaLabel="delete-icon"
variant="plain"
className="group relative"
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("modifyPrivilege", privilegeDetails);
}}
>
Edit Additional Privilege
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
variant="danger"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handlePopUpOpen("deletePrivilege", {
id: privilegeDetails?.id,
slug: privilegeDetails?.slug
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
Remove Additional Privilege
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
<IconButton
ariaLabel="more-icon"
variant="plain"
className="group relative"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !userProjectPrivileges?.length && (
<EmptyState title="This user has no additional privileges" icon={faFolder} />
)}
</TableContainer>
</div>
<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()}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
)}
</UnstableTableRow>
);
})}
</UnstableTableBody>
</UnstableTable>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>This user has no additional privileges</UnstableEmptyTitle>
<UnstableEmptyDescription>
Add an additional privilege to grant one-off access policies
</UnstableEmptyDescription>
</UnstableEmptyHeader>
{!isOwnProjectMembershipDetails && (
<UnstableEmptyContent>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableButton
variant="project"
size="xs"
onClick={() => {
handlePopUpOpen("modifyPrivilege");
}}
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
>
<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()}
/>
</>
);
};

View File

@@ -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 (
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<form className="flex flex-col gap-y-4" onSubmit={handleSubmit(onSubmit)}>
<FormProvider {...form}>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<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>
<div className="flex items-end space-x-6">
<div className="w-full max-w-md">
<Controller
@@ -344,22 +296,61 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
</div>
</div>
</div>
<div className="p-4">
<div className="mb-2 text-lg">Policies</div>
{(isCreate || !isPending) && <PermissionEmptyState />}
<div>
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(subject, isDisabled)}
</GeneralPermissionPolicies>
))}
<UnstableSeparator />
<div>
<div className="mb-3 flex w-full items-center justify-between">
<div className="text-lg">Policies</div>
<div className="flex items-center space-x-4">
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={() => reset()}
>
Discard Changes
</Button>
)}
<div className="flex items-center">
<AddPoliciesButton
isDisabled={isDisabled}
projectType={currentProject.type}
projectId={projectId}
/>
</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>
</FormProvider>
</form>

View File

@@ -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 (
<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">
<h3 className="text-lg font-medium text-mineshaft-100">Project Roles</h3>
{!isOwnProjectMembershipDetails && membershipDetails?.status !== "invited" && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Edit Role(s)"
>
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed}
<>
<UnstableCard>
<UnstableCardHeader>
<UnstableCardTitle>Project Roles</UnstableCardTitle>
<UnstableCardDescription>Manage roles assigned to this user</UnstableCardDescription>
{!isOwnProjectMembershipDetails && hasRoles && (
<UnstableCardAction>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
)}
</ProjectPermissionCan>
)}
</div>
<div className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Role</Th>
<Th>Duration</Th>
<Th className="w-5" />
</Tr>
</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} />
{(isAllowed) => (
<UnstableButton
size="xs"
variant="outline"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed}
>
<PencilIcon />
Edit Roles
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableCardAction>
)}
</TableContainer>
</div>
</UnstableCardHeader>
<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
isOpen={popUp.deleteRole.isOpen}
deleteKey="remove"
@@ -234,6 +287,6 @@ export const MemberRoleDetailsSection = ({
/>
</ModalContent>
</Modal>
</div>
</>
);
};

View File

@@ -0,0 +1,105 @@
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">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 and create re-usable copy button */}
{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 and create re-usable copy button */}
{isCopyingEmail ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
</UnstableIconButton>
</Tooltip>
</DetailValue>
</Detail>
{username !== email && (
<Detail>
<DetailLabel>Username</DetailLabel>
<DetailValue>{username || <span className="text-muted">Not set</span>}</DetailValue>
</Detail>
)}
<Detail>
<DetailLabel>Joined project</DetailLabel>
<DetailValue>{format(membership.createdAt, "PPpp")}</DetailValue>
</Detail>
</DetailGroup>
</UnstableCardContent>
</UnstableCard>
);
};