Merge pull request #5072 from Infisical/ENG-4283-2

improvement(frontend): update project group membership page and v3 component additions
This commit is contained in:
Scott Wilson
2026-01-05 13:06:51 -08:00
committed by GitHub
30 changed files with 611 additions and 473 deletions

View File

@@ -30,10 +30,10 @@ const SCOPE_BADGE: Record<NonNullable<Props["scope"]>, { icon: LucideIcon; class
export const PageHeader = ({ title, description, children, className, scope }: Props) => (
<div className={twMerge("mb-10 w-full", className)}>
<div className="flex w-full justify-between">
<div className="mr-4 flex w-full items-center">
<div className="mr-4 flex min-w-0 flex-1 items-center">
<h1
className={twMerge(
"text-2xl font-medium text-white underline decoration-2 underline-offset-4",
"truncate 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

@@ -10,7 +10,7 @@ function UnstableAccordion({ ...props }: React.ComponentProps<typeof AccordionPr
return (
<AccordionPrimitive.Root
data-slot="accordion"
className="border border-border bg-container"
className="overflow-clip rounded-md border border-border bg-container"
{...props}
/>
);

View File

@@ -8,7 +8,7 @@ import { cn } from "@app/components/v3/utils";
const buttonVariants = cva(
cn(
"inline-flex items-center active:scale-[0.95] justify-center border cursor-pointer whitespace-nowrap",
"inline-flex items-center rounded-md active:scale-[0.95] justify-center border cursor-pointer whitespace-nowrap",
" text-sm transition-all disabled:pointer-events-none disabled:opacity-75 shrink-0",
"[&>svg]:pointer-events-none [&>svg]:shrink-0",
"focus-visible:ring-ring outline-0 focus-visible:ring-2 select-none"
@@ -36,10 +36,10 @@ const buttonVariants = cva(
"border-danger/25 bg-danger/15 text-foreground hover:bg-danger/30 hover:border-danger/30"
},
size: {
xs: "h-7 px-2 rounded-[3px] text-xs [&>svg]:size-3 gap-1.5",
sm: "h-8 px-2.5 rounded-[4px] text-sm [&>svg]:size-3 gap-1.5",
md: "h-9 px-3 rounded-[5px] text-sm [&>svg]:size-3.5 gap-1.5",
lg: "h-10 px-3 rounded-[6px] text-sm [&>svg]:size-3.5 gap-1.5"
xs: "h-7 px-2 text-xs rounded-sm [&>svg]:size-3 gap-1.5",
sm: "h-8 px-2.5 text-sm [&>svg]:size-3 gap-1.5",
md: "h-9 px-3 text-sm [&>svg]:size-3.5 gap-1.5",
lg: "h-10 px-3 text-sm [&>svg]:size-3.5 gap-1.5"
},
isPending: {
true: "text-transparent"

View File

@@ -9,7 +9,7 @@ function UnstableCard({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"flex h-fit flex-col gap-6 rounded-md border border-border bg-card p-5 text-foreground shadow-sm",
"flex h-fit flex-col gap-5 rounded-md border border-border bg-card p-5 text-foreground shadow-sm",
className
)}
{...props}

View File

@@ -11,7 +11,9 @@ function DetailLabel({ className, ...props }: React.ComponentProps<"div">) {
}
function DetailValue({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="detail-value" className={cn("text-sm", className)} {...props} />;
return (
<div data-slot="detail-value" className={cn("text-sm break-words", className)} {...props} />
);
}
function DetailGroup({ className, ...props }: React.ComponentProps<"div">) {

View File

@@ -104,7 +104,7 @@ function UnstableDropdownMenuCheckboxItem({
>
<span className="pointer-events-none absolute right-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<CheckIcon className="size-3.5" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}

View File

@@ -5,7 +5,7 @@ function UnstableEmpty({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 border-dashed border-border bg-container p-6 text-center text-balance md:p-12",
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-md border-dashed border-border bg-container p-6 text-center text-balance md:p-12",
className
)}
{...props}

View File

@@ -9,7 +9,7 @@ import { cn } from "@app/components/v3/utils";
const iconButtonVariants = cva(
cn(
"inline-flex items-center active:scale-[0.99] justify-center border cursor-pointer whitespace-nowrap rounded-[4px] text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-75 [&_svg]:pointer-events-none shrink-0 [&>svg]:shrink-0",
"inline-flex items-center active:scale-[0.99] justify-center border cursor-pointer whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&>svg]:shrink-0",
"focus-visible:ring-ring outline-0 focus-visible:ring-2"
),
{
@@ -22,22 +22,22 @@ const iconButtonVariants = cva(
outline: "text-foreground hover:bg-foreground/20 border-border hover:border-foreground/50",
ghost: "text-foreground hover:bg-foreground/40 border-transparent",
project:
"border-project/75 bg-project/40 text-foreground hover:bg-project/50 hover:border-kms",
org: "border-org/75 bg-org/40 text-foreground hover:bg-org/50 hover:border-org",
"border-project/65 bg-project/20 text-foreground hover:bg-project/30 hover:border-kms",
org: "border-org/65 bg-org/20 text-foreground hover:bg-org/30 hover:border-org",
"sub-org":
"border-sub-org/75 bg-sub-org/40 text-foreground hover:bg-sub-org/50 hover:border-namespace",
"border-sub-org/65 bg-sub-org/20 text-foreground hover:bg-sub-org/30 hover:border-namespace",
success:
"border-success/75 bg-success/40 text-foreground hover:bg-success/50 hover:border-success",
info: "border-info/75 bg-info/40 text-foreground hover:bg-info/50 hover:border-info",
"border-success/65 bg-success/20 text-foreground hover:bg-success/30 hover:border-success",
info: "border-info/65 bg-info/20 text-foreground hover:bg-info/30 hover:border-info",
warning:
"border-warning/75 bg-warning/40 text-foreground hover:bg-warning/50 hover:border-warning",
"border-warning/65 bg-warning/20 text-foreground hover:bg-warning/30 hover:border-warning",
danger:
"border-danger/75 bg-danger/40 text-foreground hover:bg-danger/50 hover:border-danger"
"border-danger/65 bg-danger/20 text-foreground hover:bg-danger/30 hover:border-danger"
},
size: {
xs: "h-7 w-7 [&>svg]:size-3.5 [&>svg]:stroke-[1.75]",
xs: "h-7 w-7 [&>svg]:size-4 rounded-sm [&>svg]:stroke-[1.75]",
sm: "h-8 w-8 [&>svg]:size-4 [&>svg]:stroke-[1.5]",
md: "h-9 w-9 [&>svg]:size-6 [&>svg]:stroke-[1.5]",
md: "h-9 w-9 [&>svg]:size-4 [&>svg]:stroke-[1.5]",
lg: "h-10 w-10 [&>svg]:size-7 [&>svg]:stroke-[1.5]"
},
isPending: {

View File

@@ -0,0 +1,20 @@
import { cn } from "../../utils";
function UnstableInput({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"selection:bg-foreground selection:text-background",
className
)}
{...props}
/>
);
}
export { UnstableInput };

View File

@@ -0,0 +1 @@
export * from "./Input";

View File

@@ -0,0 +1,121 @@
import { ReactElement } from "react";
import {
ChevronDownIcon,
ChevronFirstIcon,
ChevronLastIcon,
ChevronLeftIcon,
ChevronRightIcon
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import {
UnstableDropdownMenu,
UnstableDropdownMenuCheckboxItem,
UnstableDropdownMenuContent,
UnstableDropdownMenuTrigger
} from "../Dropdown";
import { UnstableIconButton } from "../IconButton";
type UnstablePaginationProps = {
count: number;
page: number;
perPage?: number;
onChangePage: (pageNumber: number) => void;
onChangePerPage: (newRows: number) => void;
className?: string;
perPageList?: number[];
startAdornment?: ReactElement;
};
const UnstablePagination = ({
count,
page = 1,
perPage = 20,
onChangePage,
onChangePerPage,
perPageList = [10, 20, 50, 100],
className,
startAdornment
}: UnstablePaginationProps) => {
const prevPageNumber = Math.max(1, page - 1);
const canGoPrev = page > 1;
const upperLimit = Math.ceil(count / perPage);
const nextPageNumber = Math.min(upperLimit, page + 1);
const canGoNext = page + 1 <= upperLimit;
const canGoFirst = page > 1;
const canGoLast = page < upperLimit;
return (
<div className={twMerge("flex w-full items-center justify-end px-2 pt-2", className)}>
{startAdornment}
<div className={twMerge("mr-4 flex items-center space-x-2", startAdornment && "ml-auto")}>
<div className="text-xs">
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
</div>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableIconButton variant="ghost" size="xs">
<ChevronDownIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
{perPageList.map((perPageOption) => (
<UnstableDropdownMenuCheckboxItem
checked={perPageOption === perPage}
key={`pagination-per-page-options-${perPageOption}`}
onClick={() => {
const totalPages = Math.ceil(count / perPageOption);
if (page > totalPages) {
onChangePage(totalPages);
}
onChangePerPage(perPageOption);
}}
>
{perPageOption} rows per page
</UnstableDropdownMenuCheckboxItem>
))}
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</div>
<div className="flex items-center space-x-2">
<UnstableIconButton
variant="ghost"
size="xs"
onClick={() => onChangePage(1)}
isDisabled={!canGoFirst}
>
<ChevronFirstIcon />
</UnstableIconButton>
<UnstableIconButton
variant="ghost"
size="xs"
onClick={() => onChangePage(prevPageNumber)}
isDisabled={!canGoPrev}
>
<ChevronLeftIcon />
</UnstableIconButton>
<UnstableIconButton
variant="ghost"
size="xs"
onClick={() => onChangePage(nextPageNumber)}
isDisabled={!canGoNext}
>
<ChevronRightIcon />
</UnstableIconButton>
<UnstableIconButton
variant="ghost"
size="xs"
onClick={() => onChangePage(upperLimit)}
isDisabled={!canGoLast}
>
<ChevronLastIcon />
</UnstableIconButton>
</div>
</div>
);
};
export { UnstablePagination, type UnstablePaginationProps };

View File

@@ -0,0 +1 @@
export * from "./Pagination";

View File

@@ -8,7 +8,7 @@ function UnstableTable({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto border border-border bg-container"
className="relative w-full overflow-x-auto rounded-md border border-border bg-container"
>
<table
data-slot="table"
@@ -64,6 +64,7 @@ function UnstableTableHead({ className, ...props }: React.ComponentProps<"th">)
data-slot="table-head"
className={cn(
"h-[30px] border-x-0 border-t-0 border-b border-border px-3 text-left align-middle text-xs whitespace-nowrap text-accent [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"has-[>svg]:cursor-pointer [&>svg]:ml-1 [&>svg]:inline-block [&>svg]:size-4",
className
)}
{...props}
@@ -71,12 +72,17 @@ function UnstableTableHead({ className, ...props }: React.ComponentProps<"th">)
);
}
function UnstableTableCell({ className, ...props }: React.ComponentProps<"td">) {
function UnstableTableCell({
className,
isTruncatable,
...props
}: React.ComponentProps<"td"> & { isTruncatable?: boolean }) {
return (
<td
data-slot="table-cell"
className={cn(
"h-[40px] px-3 align-middle whitespace-nowrap text-mineshaft-200 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
isTruncatable && "max-w-0 truncate",
className
)}
{...props}

View File

@@ -8,6 +8,8 @@ export * from "./Detail";
export * from "./Dropdown";
export * from "./Empty";
export * from "./IconButton";
export * from "./Input";
export * from "./PageLoader";
export * from "./Pagination";
export * from "./Separator";
export * from "./Table";

View File

@@ -263,21 +263,23 @@ const GroupRolesForm = ({ projectRoles, roles, groupId, onClose }: FormProps) =>
setSearchRoles("");
};
const filteredRoles =
projectRoles?.filter(
({ name, slug }) =>
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
slug.toLowerCase().includes(searchRoles.toLowerCase())
) ?? [];
return (
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
<div className="max-h-80 thin-scrollbar space-y-4 overflow-y-auto">
{projectRoles
?.filter(
({ name, slug }) =>
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
slug.toLowerCase().includes(searchRoles.toLowerCase())
)
?.map(({ id, name, slug }) => {
{filteredRoles.length > 0 ? (
filteredRoles.map(({ id, name, slug }) => {
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
return (
<div key={id} className="flex items-center space-x-4">
<div className="grow">
<div className="flex-1 truncate">
<Controller
control={control}
defaultValue={Boolean(userProjectRoleDetails?.id)}
@@ -332,7 +334,10 @@ const GroupRolesForm = ({ projectRoles, roles, groupId, onClose }: FormProps) =>
</div>
</div>
);
})}
})
) : (
<span className="text-sm text-mineshaft-400">No roles match search...</span>
)}
</div>
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
<div>
@@ -385,7 +390,9 @@ export const GroupRoles = ({
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatProjectRoleName(role, customRoleName)}</div>
<div className="max-w-32 truncate">
{formatProjectRoleName(role, customRoleName)}
</div>
{isTemporary && (
<div>
<Tooltip content={isExpired ? "Expired Temporary Access" : "Temporary Access"}>

View File

@@ -57,7 +57,7 @@ export const GroupsSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Project Groups</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/groups#user-groups" />

View File

@@ -195,7 +195,7 @@ export const IdentityTab = withProjectPermission(
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Project Machine Identities</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/machine-identities" />

View File

@@ -47,7 +47,7 @@ export const MembersSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Project Users</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/user-identities" />

View File

@@ -162,7 +162,7 @@ export const ProjectRoleList = () => {
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Project Roles</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls" />
@@ -193,7 +193,7 @@ export const ProjectRoleList = () => {
<Table>
<THead>
<Tr>
<Th>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
@@ -206,7 +206,7 @@ export const ProjectRoleList = () => {
</IconButton>
</div>
</Th>
<Th>
<Th className="w-1/3">
<div className="flex items-center">
Slug
<IconButton
@@ -257,8 +257,8 @@ export const ProjectRoleList = () => {
})
}
>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td className="max-w-0 truncate">{name}</Td>
<Td className="max-w-0 truncate">{slug}</Td>
<Td>
<Badge variant="ghost">
{isCustomProjectRole(slug) ? (

View File

@@ -41,7 +41,7 @@ export const ServiceTokenSection = withProjectPermission(
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-2 flex items-center justify-between">
<div className="mb-2 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Service Tokens</p>

View File

@@ -1,12 +1,18 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useParams } from "@tanstack/react-router";
import { formatRelative } from "date-fns";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { ChevronLeftIcon, EllipsisIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { EmptyState, PageHeader, Spinner } from "@app/components/v2";
import { DeleteActionModal, EmptyState, PageHeader, Spinner } from "@app/components/v2";
import {
UnstableButton,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger
} from "@app/components/v3";
import {
ProjectPermissionActions,
ProjectPermissionSub,
@@ -14,6 +20,8 @@ import {
useProject
} from "@app/context";
import { getProjectBaseURL } from "@app/helpers/project";
import { usePopUp } from "@app/hooks";
import { useDeleteGroupFromWorkspace } from "@app/hooks/api";
import { useGetWorkspaceGroupMembershipDetails } from "@app/hooks/api/projects/queries";
import { ProjectAccessControlTabs } from "@app/types/project";
@@ -34,6 +42,37 @@ const Page = () => {
groupId
);
const { mutateAsync: deleteMutateAsync } = useDeleteGroupFromWorkspace();
const navigate = useNavigate();
const { handlePopUpToggle, popUp, handlePopUpClose, handlePopUpOpen } = usePopUp([
"deleteGroup"
] as const);
const onRemoveGroupSubmit = async () => {
await deleteMutateAsync({
groupId: groupMembership!.group.id,
projectId: currentProject.id
});
createNotification({
text: "Successfully removed group from project",
type: "success"
});
navigate({
to: `${getProjectBaseURL(currentProject.type)}/access-management`,
params: {
projectId: currentProject.id
},
search: {
selectedTab: "groups"
}
});
handlePopUpClose("deleteGroup");
};
if (isPending)
return (
<div className="flex w-full items-center justify-center p-24">
@@ -42,9 +81,9 @@ const Page = () => {
);
return (
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex max-w-8xl flex-col">
{groupMembership ? (
<div className="mx-auto mb-6 w-full max-w-8xl">
<>
<Link
to={`${getProjectBaseURL(currentProject.type)}/access-management`}
params={{
@@ -54,26 +93,71 @@ const Page = () => {
search={{
selectedTab: ProjectAccessControlTabs.Groups
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
className="mb-4 flex w-fit items-center gap-x-1 text-sm text-mineshaft-400 transition duration-100 hover:text-mineshaft-400/80"
>
<FontAwesomeIcon icon={faChevronLeft} />
<ChevronLeftIcon size={16} />
Project Groups
</Link>
<PageHeader
scope={currentProject.type}
title={groupMembership.group.name}
description={`Group joined on ${formatRelative(new Date(groupMembership.createdAt || ""), new Date())}`}
/>
<div className="flex">
<div className="mr-4 w-96">
<GroupDetailsSection groupMembership={groupMembership} />
</div>
description="Configure and manage project access control"
>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableButton variant="outline">
Options
<EllipsisIcon />
</UnstableButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(groupMembership.group.id);
createNotification({
text: "Group ID copied to clipboard",
type: "info"
});
}}
>
Copy Group ID
</UnstableDropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
variant="danger"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("deleteGroup")}
>
Remove From Project
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</PageHeader>
<div className="flex flex-col gap-5 lg:flex-row">
<GroupDetailsSection groupMembership={groupMembership} />
<GroupMembersSection groupMembership={groupMembership} />
</div>
</div>
</>
) : (
<EmptyState title="Error: Unable to find the group." className="py-12" />
)}
<DeleteActionModal
isOpen={popUp.deleteGroup.isOpen}
title={`Are you sure you want to remove the group ${
groupMembership?.group.name
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
deleteKey="confirm"
buttonText="Remove"
onDeleteApproved={onRemoveGroupSubmit}
/>
</div>
);
};

View File

@@ -1,23 +1,22 @@
import { faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { CheckIcon, ClipboardListIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Tooltip } from "@app/components/v2";
import {
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton
} from "@app/components/v2";
import { CopyButton } from "@app/components/v2/CopyButton";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
import { getProjectBaseURL } from "@app/helpers/project";
import { usePopUp } from "@app/hooks";
import { useDeleteGroupFromWorkspace } from "@app/hooks/api";
Detail,
DetailGroup,
DetailLabel,
DetailValue,
UnstableCard,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableIconButton
} from "@app/components/v3";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { TGroupMembership } from "@app/hooks/api/groups/types";
import { GroupRoles } from "@app/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupRoles";
@@ -26,123 +25,68 @@ type Props = {
};
export const GroupDetailsSection = ({ groupMembership }: Props) => {
const { handlePopUpToggle, popUp, handlePopUpClose, handlePopUpOpen } = usePopUp([
"deleteGroup"
] as const);
const { group } = groupMembership;
const { mutateAsync: deleteMutateAsync } = useDeleteGroupFromWorkspace();
const { currentProject } = useProject();
const navigate = useNavigate();
const onRemoveGroupSubmit = async () => {
await deleteMutateAsync({
groupId: groupMembership.group.id,
projectId: currentProject.id
});
createNotification({
text: "Successfully removed group from project",
type: "success"
});
navigate({
to: `${getProjectBaseURL(currentProject.type)}/access-management`,
params: {
projectId: currentProject.id
},
search: {
selectedTab: "groups"
}
});
handlePopUpClose("deleteGroup");
};
// eslint-disable-next-line @typescript-eslint/naming-convention,@typescript-eslint/no-unused-vars
const [_, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Group Details</h3>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton ariaLabel="Options" colorSchema="secondary" className="w-6" variant="plain">
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => {
return (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => handlePopUpOpen("deleteGroup")}
isDisabled={!isAllowed}
>
Remove Group From Project
</DropdownMenuItem>
);
}}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Group ID</p>
<div className="group flex items-center gap-2">
<p className="text-sm text-mineshaft-300">{groupMembership.group.id}</p>
<CopyButton
value={groupMembership.group.id}
name="Group ID"
size="xs"
variant="plain"
/>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{groupMembership.group.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Slug</p>
<div className="group flex items-center gap-2">
<p className="text-sm text-mineshaft-300">{groupMembership.group.slug}</p>
<CopyButton value={groupMembership.group.slug} name="Slug" size="xs" variant="plain" />
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Project Role</p>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Groups}>
{(isAllowed) => (
<GroupRoles
className="mt-1"
popperContentProps={{ side: "right" }}
roles={groupMembership.roles}
groupId={groupMembership.group.id}
disableEdit={!isAllowed}
/>
)}
</ProjectPermissionCan>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Assigned to Project</p>
<p className="text-sm text-mineshaft-300">
{format(groupMembership.createdAt, "M/d/yyyy")}
</p>
</div>
</div>
<DeleteActionModal
isOpen={popUp.deleteGroup.isOpen}
title={`Are you sure you want to remove the group ${
groupMembership.group.name
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
deleteKey="confirm"
buttonText="Remove"
onDeleteApproved={onRemoveGroupSubmit}
/>
</div>
<UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader>
<UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>Group details</UnstableCardDescription>
</UnstableCardHeader>
<UnstableCardContent>
<DetailGroup>
<Detail>
<DetailLabel>Name</DetailLabel>
<DetailValue>{group.name}</DetailValue>
</Detail>
<Detail>
<DetailLabel>ID</DetailLabel>
<DetailValue className="flex items-center gap-x-1">
{group.id}
<Tooltip content="Copy group ID to clipboard">
<UnstableIconButton
onClick={() => {
navigator.clipboard.writeText(group.id);
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>Project Role</DetailLabel>
<DetailValue>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<GroupRoles
popperContentProps={{ side: "right" }}
roles={groupMembership.roles}
groupId={groupMembership.group.id}
disableEdit={!isAllowed}
/>
)}
</ProjectPermissionCan>
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Joined project</DetailLabel>
<DetailValue>{format(groupMembership.createdAt, "PPpp")}</DetailValue>
</Detail>
</DetailGroup>
</UnstableCardContent>
</UnstableCard>
);
};

View File

@@ -1,3 +1,10 @@
import {
UnstableCard,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle
} from "@app/components/v3";
import { TGroupMembership } from "@app/hooks/api/groups/types";
import { GroupMembersTable } from "./GroupMembersTable";
@@ -8,13 +15,14 @@ type Props = {
export const GroupMembersSection = ({ groupMembership }: Props) => {
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Group Members</h3>
</div>
<div className="py-4">
<UnstableCard className="flex-1">
<UnstableCardHeader>
<UnstableCardTitle>Group Members</UnstableCardTitle>
<UnstableCardDescription>View members of this group</UnstableCardDescription>
</UnstableCardHeader>
<UnstableCardContent>
<GroupMembersTable groupMembership={groupMembership} />
</div>
</div>
</UnstableCardContent>
</UnstableCard>
);
};

View File

@@ -1,38 +1,29 @@
import { useEffect, useState } from "react";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { HardDriveIcon, UserIcon } from "lucide-react";
import { ChevronDownIcon, FilterIcon, HardDriveIcon, UserIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ConfirmActionModal, Lottie } from "@app/components/v2";
import {
ConfirmActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
UnstableDropdownMenu,
UnstableDropdownMenuCheckboxItem,
UnstableDropdownMenuContent,
UnstableDropdownMenuLabel,
UnstableDropdownMenuTrigger,
UnstableEmpty,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle,
UnstableIconButton,
UnstableInput,
UnstablePagination,
UnstableTable,
UnstableTableBody,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3";
import { useOrganization, useProject } from "@app/context";
import { getProjectHomePage } from "@app/helpers/project";
import {
@@ -114,6 +105,8 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
memberTypeFilter: memberTypeFilter.length > 0 ? memberTypeFilter : undefined
});
const isFiltered = Boolean(search) || memberTypeFilter.length > 0;
const { members = [], totalCount = 0 } = groupMemberships ?? {};
useResetPageHelper({
@@ -167,40 +160,36 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
}
];
if (isPending) {
return (
// 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>
);
}
return (
<div>
<div className="flex gap-2">
<Input
<>
<div className="mb-5 flex gap-2.5">
{/* TODO(scott): add input group with icon once component added */}
<UnstableInput
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
placeholder="Search group members..."
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Members"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
memberTypeFilter.length > 0 && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={2}
className="max-h-[70vh] thin-scrollbar overflow-y-auto"
align="end"
>
<DropdownMenuLabel>Filter by Member Type</DropdownMenuLabel>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableIconButton variant={memberTypeFilter.length ? "project" : "outline"}>
<FilterIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuLabel>Filter by Member Type</UnstableDropdownMenuLabel>
{filterOptions.map((option) => (
<DropdownMenuItem
<UnstableDropdownMenuCheckboxItem
key={option.value}
className="flex items-center gap-2"
iconPos="right"
checked={memberTypeFilter.includes(option.value)}
onClick={(e) => {
e.preventDefault();
setMemberTypeFilter((prev) => {
@@ -211,95 +200,85 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
});
setPage(1);
}}
icon={
memberTypeFilter.includes(option.value) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
>
<div className="flex items-center gap-2">
{option.icon}
{option.label}
</div>
</DropdownMenuItem>
{option.icon}
{option.label}
</UnstableDropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-5" />
<Th className="w-1/2 pl-2">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Added On</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
{!isPending &&
groupMemberships?.members?.map((userGroupMembership) => {
return userGroupMembership.type === GroupMemberType.USER ? (
<GroupMembershipUserRow
key={`user-group-membership-${userGroupMembership.id}`}
user={userGroupMembership}
onAssumePrivileges={(userId) =>
handlePopUpOpen("assumePrivileges", {
actorId: userId,
actorType: ActorType.USER
})
}
/>
) : (
<GroupMembershipIdentityRow
key={`identity-group-membership-${userGroupMembership.id}`}
identity={userGroupMembership}
onAssumePrivileges={(identityId) =>
handlePopUpOpen("assumePrivileges", {
actorId: identityId,
actorType: ActorType.IDENTITY
})
}
/>
);
})}
</TBody>
</Table>
{Boolean(totalCount) && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !members.length && (
<EmptyState
title={
groupMemberships?.members.length
? "No members match this search..."
: "This group does not have any members yet"
}
icon={groupMemberships?.members.length ? faSearch : faFolder}
/>
)}
</TableContainer>
{members.length > 0 ? (
<UnstableTable>
<UnstableTableHeader>
<UnstableTableRow>
<UnstableTableHead className="w-5" />
<UnstableTableHead className="w-1/2" onClick={toggleOrderDirection}>
Name
<ChevronDownIcon
className={twMerge(
orderDirection === OrderByDirection.DESC && "rotate-180",
"transition-transform"
)}
/>
</UnstableTableHead>
<UnstableTableHead>Joined Group</UnstableTableHead>
<UnstableTableHead className="w-5" />
</UnstableTableRow>
</UnstableTableHeader>
<UnstableTableBody>
{groupMemberships?.members?.map((userGroupMembership) => {
return userGroupMembership.type === GroupMemberType.USER ? (
<GroupMembershipUserRow
key={`user-group-membership-${userGroupMembership.id}`}
user={userGroupMembership}
onAssumePrivileges={(userId) =>
handlePopUpOpen("assumePrivileges", {
actorId: userId,
actorType: ActorType.USER
})
}
/>
) : (
<GroupMembershipIdentityRow
key={`identity-group-membership-${userGroupMembership.id}`}
identity={userGroupMembership}
onAssumePrivileges={(identityId) =>
handlePopUpOpen("assumePrivileges", {
actorId: identityId,
actorType: ActorType.IDENTITY
})
}
/>
);
})}
</UnstableTableBody>
</UnstableTable>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>
{isFiltered
? "No group members match this search"
: "This group doesn't have any members"}
</UnstableEmptyTitle>
<UnstableEmptyDescription>
{isFiltered
? "Adjust search filters to view group members."
: "Assign members from organization access control or contact an organization admin."}
</UnstableEmptyDescription>
</UnstableEmptyHeader>
</UnstableEmpty>
)}
{Boolean(totalCount) && (
<UnstablePagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
<ConfirmActionModal
isOpen={popUp.assumePrivileges.isOpen}
confirmKey="assume"
@@ -309,6 +288,6 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
onConfirmed={handleAssumePrivileges}
buttonText="Confirm"
/>
</div>
</>
);
};

View File

@@ -1,18 +1,15 @@
import { faEllipsisV, faUser } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { HardDriveIcon } from "lucide-react";
import { EllipsisIcon, HardDriveIcon } from "lucide-react";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstableIconButton,
UnstableTableCell,
UnstableTableRow
} from "@app/components/v3";
import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/context";
import { TGroupMemberMachineIdentity } from "@app/hooks/api/groups/types";
@@ -30,52 +27,38 @@ export const GroupMembershipIdentityRow = ({
onAssumePrivileges
}: Props) => {
return (
<Tr className="items-center" key={`group-identity-${id}`}>
<Td className="pr-0">
<HardDriveIcon size={20} />
</Td>
<Td className="pl-2">
<p>{name}</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.AssumePrivileges}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => {
return (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUser} />}
onClick={() => onAssumePrivileges(id)}
isDisabled={!isAllowed}
>
Assume Privileges
</DropdownMenuItem>
);
}}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
<UnstableTableRow>
<UnstableTableCell>
<HardDriveIcon size={14} className="text-mineshaft-400" />
</UnstableTableCell>
<UnstableTableCell isTruncatable>{name}</UnstableTableCell>
<UnstableTableCell>{new Date(joinedGroupAt).toLocaleDateString()}</UnstableTableCell>
<UnstableTableCell>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableIconButton variant="ghost" size="xs">
<EllipsisIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.AssumePrivileges}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => {
return (
<UnstableDropdownMenuItem
onClick={() => onAssumePrivileges(id)}
isDisabled={!isAllowed}
>
Assume Privileges
</UnstableDropdownMenuItem>
);
}}
</ProjectPermissionCan>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
</UnstableTableRow>
);
};

View File

@@ -1,18 +1,15 @@
import { faEllipsisV, faUser } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UserIcon } from "lucide-react";
import { EllipsisIcon, UserIcon } from "lucide-react";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstableIconButton,
UnstableTableCell,
UnstableTableRow
} from "@app/components/v3";
import { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/context";
import { TGroupMemberUser } from "@app/hooks/api/groups/types";
@@ -30,55 +27,41 @@ export const GroupMembershipUserRow = ({
onAssumePrivileges
}: Props) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td className="pr-0">
<UserIcon size={20} />
</Td>
<Td className="pl-2">
<p>
{`${firstName ?? "-"} ${lastName ?? ""}`}{" "}
<span className="text-mineshaft-400">({email})</span>
</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionMemberActions.AssumePrivileges}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => {
return (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUser} />}
onClick={() => onAssumePrivileges(id)}
isDisabled={!isAllowed}
>
Assume Privileges
</DropdownMenuItem>
);
}}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
<UnstableTableRow>
<UnstableTableCell>
<UserIcon size={14} className="text-mineshaft-400" />
</UnstableTableCell>
<UnstableTableCell isTruncatable>
{`${firstName ?? "-"} ${lastName ?? ""}`}{" "}
<span className="text-mineshaft-400">({email})</span>
</UnstableTableCell>
<UnstableTableCell>{new Date(joinedGroupAt).toLocaleDateString()}</UnstableTableCell>
<UnstableTableCell>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableIconButton size="xs" variant="ghost">
<EllipsisIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<ProjectPermissionCan
I={ProjectPermissionMemberActions.AssumePrivileges}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => {
return (
<UnstableDropdownMenuItem
onClick={() => onAssumePrivileges(id)}
isDisabled={!isAllowed}
>
Assume Privileges
</UnstableDropdownMenuItem>
);
}}
</ProjectPermissionCan>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
</UnstableTableRow>
);
};

View File

@@ -169,10 +169,7 @@ export const IdentityRoleDetailsSection = ({
}
return (
<UnstableTableRow
className="group h-10"
key={`user-project-identity-${roleDetails?.id}`}
>
<UnstableTableRow key={`user-project-identity-${roleDetails?.id}`}>
<UnstableTableCell className="max-w-0 truncate">
{roleDetails.role === "custom"
? roleDetails.customRoleName
@@ -235,7 +232,7 @@ export const IdentityRoleDetailsSection = ({
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>
This machine identity doesn&nbsp;t have any roles
This machine identity doesn&apos;t have any roles
</UnstableEmptyTitle>
<UnstableEmptyDescription>
Give this machine identity one or more roles

View File

@@ -134,7 +134,7 @@ export const ProjectIdentityDetailsSection = ({
</UnstableButtonGroup>
))
) : (
<span className="text-muted">No metadata</span>
<span className="text-muted"></span>
)}
</DetailValue>
</Detail>
@@ -150,7 +150,7 @@ export const ProjectIdentityDetailsSection = ({
{membership.lastLoginAuthMethod ? (
identityAuthToNameMap[membership.lastLoginAuthMethod]
) : (
<span className="text-muted">N/A</span>
<span className="text-muted"></span>
)}
</DetailValue>
</Detail>
@@ -160,7 +160,7 @@ export const ProjectIdentityDetailsSection = ({
{membership.lastLoginTime ? (
format(membership.lastLoginTime, "PPpp")
) : (
<span className="text-muted">N/A</span>
<span className="text-muted"></span>
)}
</DetailValue>
</Detail>

View File

@@ -235,7 +235,7 @@ export const MemberRoleDetailsSection = ({
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>This user doesn&nbsp;t have any roles</UnstableEmptyTitle>
<UnstableEmptyTitle>This user doesn&apos;t have any roles</UnstableEmptyTitle>
<UnstableEmptyDescription>
Give this user one or more roles
</UnstableEmptyDescription>

View File

@@ -48,7 +48,7 @@ export const ProjectMemberDetailsSection = ({ membership }: Props) => {
<DetailGroup>
<Detail>
<DetailLabel>Name</DetailLabel>
<DetailValue>{name || <span className="text-muted">Not set</span>}</DetailValue>
<DetailValue>{name || <span className="text-muted"></span>}</DetailValue>
</Detail>
<Detail>
<DetailLabel>ID</DetailLabel>
@@ -91,7 +91,7 @@ export const ProjectMemberDetailsSection = ({ membership }: Props) => {
{username !== email && (
<Detail>
<DetailLabel>Username</DetailLabel>
<DetailValue>{username || <span className="text-muted">Not set</span>}</DetailValue>
<DetailValue>{username || <span className="text-muted"></span>}</DetailValue>
</Detail>
)}
<Detail>