cards for flat account view

This commit is contained in:
x032205
2025-12-10 14:54:17 -05:00
parent 929b8252e8
commit b65689a616
6 changed files with 177 additions and 107 deletions
@@ -49,7 +49,7 @@ export const Pagination = ({
return ( return (
<div <div
className={twMerge( className={twMerge(
"flex w-full items-center justify-end border-t border-mineshaft-600 bg-mineshaft-800 px-4 py-3 text-white", "border-mineshaft-600 bg-mineshaft-800 flex w-full items-center justify-end border-t px-4 py-3 text-white",
className className
)} )}
> >
@@ -94,7 +94,7 @@ export const Pagination = ({
onClick={() => onChangePage(1)} onClick={() => onChangePage(1)}
isDisabled={!canGoFirst} isDisabled={!canGoFirst}
> >
<FontAwesomeIcon className="absolute top-1 left-2.5 text-xs" icon={faChevronLeft} /> <FontAwesomeIcon className="absolute left-2.5 top-1 text-xs" icon={faChevronLeft} />
<FontAwesomeIcon className="text-xs" icon={faChevronLeft} /> <FontAwesomeIcon className="text-xs" icon={faChevronLeft} />
</IconButton> </IconButton>
<IconButton <IconButton
@@ -120,7 +120,7 @@ export const Pagination = ({
onClick={() => onChangePage(upperLimit)} onClick={() => onChangePage(upperLimit)}
isDisabled={!canGoLast} isDisabled={!canGoLast}
> >
<FontAwesomeIcon className="absolute top-1 left-2.5 text-xs" icon={faChevronRight} /> <FontAwesomeIcon className="absolute left-2.5 top-1 text-xs" icon={faChevronRight} />
<FontAwesomeIcon className="text-xs" icon={faChevronRight} /> <FontAwesomeIcon className="text-xs" icon={faChevronRight} />
</IconButton> </IconButton>
</div> </div>
@@ -1,4 +1,7 @@
import { Button } from "@app/components/v2"; import { faBorderAll, faList } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconButton } from "@app/components/v2";
import { PamAccountView } from "@app/hooks/api/pam"; import { PamAccountView } from "@app/hooks/api/pam";
type Props = { type Props = {
@@ -8,31 +11,33 @@ type Props = {
export const AccountViewToggle = ({ value, onChange }: Props) => { export const AccountViewToggle = ({ value, onChange }: Props) => {
return ( return (
<div className="flex gap-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1"> <div className="border-mineshaft-600 bg-mineshaft-800 flex gap-0.5 rounded-md border p-1">
<Button <IconButton
variant="outline_bg" variant="outline_bg"
onClick={() => { onClick={() => {
onChange(PamAccountView.Flat); onChange(PamAccountView.Flat);
}} }}
ariaLabel="grid"
size="xs" size="xs"
className={`${ className={`${
value === PamAccountView.Flat ? "bg-mineshaft-500" : "bg-transparent" value === PamAccountView.Flat ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`} } hover:bg-mineshaft-600 min-w-[2.4rem] rounded border-none`}
> >
Hide Folders <FontAwesomeIcon icon={faBorderAll} />
</Button> </IconButton>
<Button <IconButton
variant="outline_bg" variant="outline_bg"
onClick={() => { onClick={() => {
onChange(PamAccountView.Nested); onChange(PamAccountView.Nested);
}} }}
ariaLabel="list"
size="xs" size="xs"
className={`${ className={`${
value === PamAccountView.Nested ? "bg-mineshaft-500" : "bg-transparent" value === PamAccountView.Nested ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`} } hover:bg-mineshaft-600 min-w-[2.4rem] rounded border-none`}
> >
Show Folders <FontAwesomeIcon icon={faList} />
</Button> </IconButton>
</div> </div>
); );
}; };
@@ -27,7 +27,7 @@ export const FolderBreadCrumbs = ({ path = "/" }: Props) => {
return ( return (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
className="breadcrumb relative z-20 border-solid border-mineshaft-600 bg-mineshaft-800 py-1 pr-2 pl-5 text-sm hover:bg-mineshaft-600" className="breadcrumb border-mineshaft-600 bg-mineshaft-800 hover:bg-mineshaft-600 relative z-20 border-solid py-1 pl-5 pr-2 text-sm"
onClick={() => onFolderCrumbClick(0)} onClick={() => onFolderCrumbClick(0)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
@@ -48,7 +48,7 @@ export const FolderBreadCrumbs = ({ path = "/" }: Props) => {
key={`path-${index + 1}`} key={`path-${index + 1}`}
className={`breadcrumb relative z-20 ${ className={`breadcrumb relative z-20 ${
index + 1 === arr.length ? "cursor-default" : "cursor-pointer" index + 1 === arr.length ? "cursor-default" : "cursor-pointer"
} border-solid border-mineshaft-600 py-1 pr-2 pl-5 text-sm text-mineshaft-200`} } border-mineshaft-600 text-mineshaft-200 border-solid py-1 pl-5 pr-2 text-sm`}
onClick={() => onFolderCrumbClick(index + 1)} onClick={() => onFolderCrumbClick(index + 1)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
@@ -0,0 +1,46 @@
import { PAM_RESOURCE_TYPE_MAP, TPamAccount } from "@app/hooks/api/pam";
import { LogInIcon } from "lucide-react";
type Props = {
account: TPamAccount;
onAccess: (resource: TPamAccount) => void;
accountPath?: string;
};
export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => {
const { name, description } = account;
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[account.resource.resourceType];
return (
<button
onClick={() => onAccess(account)}
type="button"
key={account.id}
className="border-mineshaft-600 bg-mineshaft-800 hover:bg-mineshaft-700 flex cursor-pointer flex-col overflow-clip rounded-sm border p-4 text-start transition-transform duration-100 hover:scale-[103%]"
>
<div className="flex items-center gap-4">
<div className="border-mineshaft-500 bg-mineshaft-600 rounded-sm border p-1.5 shadow-inner">
<img
alt={resourceTypeName}
src={`/images/integrations/${image}`}
className="size-7 object-contain"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex justify-between gap-2">
<p className="text-mineshaft-100 truncate text-lg font-medium">{name}</p>
<LogInIcon className="text-mineshaft-400 size-5" />
</div>
<p
className={`${accountPath ? "text-mineshaft-300" : "text-mineshaft-400"} truncate text-xs leading-4`}
>
{accountPath || "root"}
</p>
</div>
</div>
<p className="text-mineshaft-400 mt-4 truncate text-sm">{description || "No description"}</p>
</button>
);
};
@@ -10,7 +10,7 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns"; import { formatDistance } from "date-fns";
import { FolderIcon, PackageOpenIcon } from "lucide-react"; import { PackageOpenIcon } from "lucide-react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -39,8 +39,6 @@ type Props = {
onUpdate: (resource: TPamAccount) => void; onUpdate: (resource: TPamAccount) => void;
onDelete: (resource: TPamAccount) => void; onDelete: (resource: TPamAccount) => void;
search: string; search: string;
isFlatView: boolean;
accountPath?: string;
isAccessLoading?: boolean; isAccessLoading?: boolean;
}; };
@@ -50,8 +48,6 @@ export const PamAccountRow = ({
onAccess, onAccess,
onUpdate, onUpdate,
onDelete, onDelete,
isFlatView,
accountPath,
isAccessLoading isAccessLoading
}: Props) => { }: Props) => {
const { id, name } = account; const { id, name } = account;
@@ -95,14 +91,6 @@ export const PamAccountRow = ({
<HighlightText text={account.resource.name} highlight={search} /> <HighlightText text={account.resource.name} highlight={search} />
</span> </span>
</Badge> </Badge>
{isFlatView && accountPath && (
<Badge variant="neutral">
<FolderIcon />
<span>
<HighlightText text={accountPath} highlight={search} />
</span>
</Badge>
)}
{"lastRotatedAt" in account && account.lastRotatedAt && ( {"lastRotatedAt" in account && account.lastRotatedAt && (
<Tooltip <Tooltip
className="max-w-sm text-center" className="max-w-sm text-center"
@@ -62,6 +62,7 @@ import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/quer
import { AccountViewToggle } from "./AccountViewToggle"; import { AccountViewToggle } from "./AccountViewToggle";
import { FolderBreadCrumbs } from "./FolderBreadCrumbs"; import { FolderBreadCrumbs } from "./FolderBreadCrumbs";
import { PamAccessAccountModal } from "./PamAccessAccountModal"; import { PamAccessAccountModal } from "./PamAccessAccountModal";
import { PamAccountCard } from "./PamAccountCard";
import { PamAccountRow } from "./PamAccountRow"; import { PamAccountRow } from "./PamAccountRow";
import { PamAddAccountModal } from "./PamAddAccountModal"; import { PamAddAccountModal } from "./PamAddAccountModal";
import { PamAddFolderModal } from "./PamAddFolderModal"; import { PamAddFolderModal } from "./PamAddFolderModal";
@@ -128,7 +129,7 @@ export const PamAccountsTable = ({ projectId }: Props) => {
setOrderDirection, setOrderDirection,
setOrderBy setOrderBy
} = usePagination<PamAccountOrderBy>(PamAccountOrderBy.Name, { } = usePagination<PamAccountOrderBy>(PamAccountOrderBy.Name, {
initPerPage: getUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, 20), initPerPage: getUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, 16),
initSearch initSearch
}); });
@@ -231,10 +232,25 @@ export const PamAccountsTable = ({ projectId }: Props) => {
const resources = resourcesData?.resources || []; const resources = resourcesData?.resources || [];
function accessAccount(account: TPamAccount) {
// For AWS IAM, directly open console without modal
if (account.resource.resourceType === PamResourceType.AwsIam) {
let fullAccountPath = account?.name;
const folderPath = account.folderId ? folderPaths[account.folderId] : undefined;
if (folderPath) {
const path = folderPath.replace(/^\/+|\/+$/g, "");
fullAccountPath = `${path}/${account?.name}`;
}
accessAwsIam(account, fullAccountPath);
} else {
handlePopUpOpen("accessAccount", account);
}
}
return ( return (
<div> <div className="flex flex-col gap-4">
{accountView === PamAccountView.Nested && <FolderBreadCrumbs path={accountPath} />} <div className="flex gap-2">
<div className="mt-4 flex gap-2">
<ProjectPermissionCan I={ProjectPermissionActions.Read} a={ProjectPermissionSub.PamFolders}> <ProjectPermissionCan I={ProjectPermissionActions.Read} a={ProjectPermissionSub.PamFolders}>
{(isAllowed) => {(isAllowed) =>
isAllowed && ( isAllowed && (
@@ -244,6 +260,12 @@ export const PamAccountsTable = ({ projectId }: Props) => {
setPage(1); setPage(1);
setFilter({ resourceIds: [] }); setFilter({ resourceIds: [] });
setAccountView(e); setAccountView(e);
// Reset perPage to appropriate default for the view
const newPerPage = e === PamAccountView.Flat ? 16 : 10;
setPerPage(newPerPage);
setUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, newPerPage);
navigate({ navigate({
search: (prev) => ({ search: (prev) => ({
...prev, ...prev,
@@ -277,14 +299,14 @@ export const PamAccountsTable = ({ projectId }: Props) => {
variant="plain" variant="plain"
size="sm" size="sm"
className={twMerge( className={twMerge(
"flex h-10 min-w-10 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", "border-mineshaft-600 bg-mineshaft-800 hover:border-primary/60 hover:bg-primary/10 flex h-10 min-w-10 items-center justify-center overflow-hidden border p-0 transition-all",
isTableFiltered && "border-primary/50 text-primary" isTableFiltered && "border-primary/50 text-primary"
)} )}
> >
<FontAwesomeIcon icon={faFilter} /> <FontAwesomeIcon icon={faFilter} />
</IconButton> </IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end"> <DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Resource</DropdownMenuLabel> <DropdownMenuLabel>Resource</DropdownMenuLabel>
{resources.length ? ( {resources.length ? (
resources.map((resource) => { resources.map((resource) => {
@@ -359,7 +381,7 @@ export const PamAccountsTable = ({ projectId }: Props) => {
<IconButton <IconButton
variant="outline_bg" variant="outline_bg"
ariaLabel="add-folder-or-import" ariaLabel="add-folder-or-import"
className="rounded-l-none bg-mineshaft-600 p-3" className="bg-mineshaft-600 rounded-l-none p-3"
> >
<FontAwesomeIcon icon={faAngleDown} /> <FontAwesomeIcon icon={faAngleDown} />
</IconButton> </IconButton>
@@ -392,32 +414,55 @@ export const PamAccountsTable = ({ projectId }: Props) => {
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
<TableContainer className="mt-4"> {accountView === PamAccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
<Table> {accountView === PamAccountView.Flat ? (
<THead> <>
<Tr> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Th> {filteredAccounts.map((account) => (
<div className="flex items-center"> <PamAccountCard
Accounts key={account.id}
<IconButton account={account}
variant="plain" accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
className={getClassName(PamAccountOrderBy.Name)} onAccess={(e: TPamAccount) => accessAccount(e)}
ariaLabel="sort" />
onClick={() => handleSort(PamAccountOrderBy.Name)} ))}
> </div>
<FontAwesomeIcon icon={getColSortIcon(PamAccountOrderBy.Name)} /> <Pagination
</IconButton> className="justify-start! col-span-full border-transparent bg-transparent pl-2"
</div> count={totalCount}
</Th> page={page}
<Th className="w-5" /> perPage={perPage}
</Tr> onChangePage={(newPage) => setPage(newPage)}
</THead> onChangePerPage={handlePerPageChange}
<TBody> perPageList={[8, 12, 16, 20, 40]}
{isLoading && <TableSkeleton columns={2} innerKey="pam-accounts" />} />
{!isLoading && ( </>
<> ) : (
{accountView !== PamAccountView.Flat && <TableContainer>
foldersToRender.map((folder) => ( <Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Accounts
<IconButton
variant="plain"
className={getClassName(PamAccountOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(PamAccountOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(PamAccountOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="pam-accounts" />}
{!isLoading && (
<>
{foldersToRender.map((folder) => (
<PamFolderRow <PamFolderRow
key={folder.id} key={folder.id}
folder={folder} folder={folder}
@@ -427,53 +472,39 @@ export const PamAccountsTable = ({ projectId }: Props) => {
onDelete={(e) => handlePopUpOpen("deleteFolder", e)} onDelete={(e) => handlePopUpOpen("deleteFolder", e)}
/> />
))} ))}
{filteredAccounts.map((account) => ( {filteredAccounts.map((account) => (
<PamAccountRow <PamAccountRow
key={account.id} key={account.id}
account={account} account={account}
search={search} search={search}
isFlatView={accountView === PamAccountView.Flat} isAccessLoading={loadingAccountId === account.id}
accountPath={account.folderId ? folderPaths[account.folderId] : undefined} onAccess={(e: TPamAccount) => accessAccount(e)}
isAccessLoading={loadingAccountId === account.id} onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onAccess={(e: TPamAccount) => { onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
// For AWS IAM, directly open console without modal />
if (e.resource.resourceType === PamResourceType.AwsIam) { ))}
let fullAccountPath = e?.name; </>
const folderPath = e.folderId ? folderPaths[e.folderId] : undefined; )}
if (folderPath) { </TBody>
const path = folderPath.replace(/^\/+|\/+$/g, ""); </Table>
fullAccountPath = `${path}/${e?.name}`;
}
accessAwsIam(e, fullAccountPath); {Boolean(totalCount) && !isLoading && (
} else { <Pagination
handlePopUpOpen("accessAccount", e); count={totalCount}
} page={page}
}} perPage={perPage}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)} onChangePage={(newPage) => setPage(newPage)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)} onChangePerPage={handlePerPageChange}
/> />
))} )}
</> {!isLoading && isContentEmpty && (
)} <EmptyState
</TBody> title={isSearchEmpty ? "No accounts match search" : "No accounts"}
</Table> icon={isSearchEmpty ? faSearch : faCircleXmark}
{Boolean(totalCount) && !isLoading && ( />
<Pagination )}
count={totalCount} </TableContainer>
page={page} )}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isLoading && isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
/>
)}
</TableContainer>
<PamDeleteFolderModal <PamDeleteFolderModal
isOpen={popUp.deleteFolder.isOpen} isOpen={popUp.deleteFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}