Merge pull request #5016 from Infisical/PAM-79

improvement(pam): cards for flat account view
This commit is contained in:
Andre
2025-12-11 18:51:37 -05:00
committed by GitHub
5 changed files with 176 additions and 97 deletions

View File

@@ -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";
type Props = {
@@ -9,30 +12,32 @@ type Props = {
export const AccountViewToggle = ({ value, onChange }: Props) => {
return (
<div className="flex gap-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Button
<IconButton
variant="outline_bg"
onClick={() => {
onChange(PamAccountView.Flat);
}}
ariaLabel="grid"
size="xs"
className={`${
value === PamAccountView.Flat ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
Hide Folders
</Button>
<Button
<FontAwesomeIcon icon={faBorderAll} />
</IconButton>
<IconButton
variant="outline_bg"
onClick={() => {
onChange(PamAccountView.Nested);
}}
ariaLabel="list"
size="xs"
className={`${
value === PamAccountView.Nested ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
Show Folders
</Button>
<FontAwesomeIcon icon={faList} />
</IconButton>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { Badge, UnstableButton } from "@app/components/v3";
import { PAM_RESOURCE_TYPE_MAP, TPamAccount } from "@app/hooks/api/pam";
import { LogInIcon, PackageOpenIcon } from "lucide-react";
type Props = {
account: TPamAccount;
onAccess: (resource: TPamAccount) => void;
accountPath?: string;
};
export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => {
const { name, description, resource } = account;
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[account.resource.resourceType];
return (
<button
type="button"
key={account.id}
className="flex flex-col overflow-clip rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-4 text-start transition-transform duration-100"
>
<div className="flex items-center gap-3.5">
<img
alt={resourceTypeName}
src={`/images/integrations/${image}`}
className="size-10 object-contain"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-lg font-medium text-mineshaft-100">{name}</p>
<UnstableButton onClick={() => onAccess(account)} size="xs" variant="outline">
<LogInIcon />
Connect
</UnstableButton>
</div>
<p
className={`${accountPath ? "text-mineshaft-300" : "text-mineshaft-400"} truncate text-xs leading-4`}
>
{resourceTypeName} - {accountPath || "root"}
</p>
</div>
</div>
<Badge variant="neutral" className="mt-3.5">
<PackageOpenIcon />
{resource.name}
</Badge>
<p className="mt-2 truncate text-sm text-mineshaft-400">{description || "No description"}</p>
</button>
);
};

View File

@@ -10,7 +10,7 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns";
import { FolderIcon, PackageOpenIcon } from "lucide-react";
import { PackageOpenIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -39,8 +39,6 @@ type Props = {
onUpdate: (resource: TPamAccount) => void;
onDelete: (resource: TPamAccount) => void;
search: string;
isFlatView: boolean;
accountPath?: string;
isAccessLoading?: boolean;
};
@@ -50,8 +48,6 @@ export const PamAccountRow = ({
onAccess,
onUpdate,
onDelete,
isFlatView,
accountPath,
isAccessLoading
}: Props) => {
const { id, name } = account;
@@ -95,14 +91,6 @@ export const PamAccountRow = ({
<HighlightText text={account.resource.name} highlight={search} />
</span>
</Badge>
{isFlatView && accountPath && (
<Badge variant="neutral">
<FolderIcon />
<span>
<HighlightText text={accountPath} highlight={search} />
</span>
</Badge>
)}
{"lastRotatedAt" in account && account.lastRotatedAt && (
<Tooltip
className="max-w-sm text-center"

View File

@@ -62,6 +62,7 @@ import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/quer
import { AccountViewToggle } from "./AccountViewToggle";
import { FolderBreadCrumbs } from "./FolderBreadCrumbs";
import { PamAccessAccountModal } from "./PamAccessAccountModal";
import { PamAccountCard } from "./PamAccountCard";
import { PamAccountRow } from "./PamAccountRow";
import { PamAddAccountModal } from "./PamAddAccountModal";
import { PamAddFolderModal } from "./PamAddFolderModal";
@@ -128,7 +129,7 @@ export const PamAccountsTable = ({ projectId }: Props) => {
setOrderDirection,
setOrderBy
} = usePagination<PamAccountOrderBy>(PamAccountOrderBy.Name, {
initPerPage: getUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, 20),
initPerPage: getUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, 18),
initSearch
});
@@ -231,10 +232,25 @@ export const PamAccountsTable = ({ projectId }: Props) => {
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 (
<div>
{accountView === PamAccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
<div className="mt-4 flex gap-2">
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<ProjectPermissionCan I={ProjectPermissionActions.Read} a={ProjectPermissionSub.PamFolders}>
{(isAllowed) =>
isAllowed && (
@@ -392,32 +408,64 @@ export const PamAccountsTable = ({ projectId }: Props) => {
)}
</ProjectPermissionCan>
</div>
<TableContainer className="mt-4">
<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 && (
<>
{accountView !== PamAccountView.Flat &&
foldersToRender.map((folder) => (
{accountView === PamAccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
{accountView === PamAccountView.Flat ? (
<>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{filteredAccounts.map((account) => (
<PamAccountCard
key={account.id}
account={account}
accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
onAccess={(e: TPamAccount) => accessAccount(e)}
/>
))}
</div>
{!isLoading && isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
className="rounded border border-mineshaft-500"
/>
)}
{Boolean(totalCount) && !isLoading && !isContentEmpty && (
<Pagination
className="col-span-full justify-start! border-transparent bg-transparent pl-2"
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
perPageList={[9, 18, 48, 99]}
/>
)}
</>
) : (
<TableContainer>
<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
key={folder.id}
folder={folder}
@@ -427,53 +475,39 @@ export const PamAccountsTable = ({ projectId }: Props) => {
onDelete={(e) => handlePopUpOpen("deleteFolder", e)}
/>
))}
{filteredAccounts.map((account) => (
<PamAccountRow
key={account.id}
account={account}
search={search}
isFlatView={accountView === PamAccountView.Flat}
accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
isAccessLoading={loadingAccountId === account.id}
onAccess={(e: TPamAccount) => {
// 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) {
const path = folderPath.replace(/^\/+|\/+$/g, "");
fullAccountPath = `${path}/${e?.name}`;
}
accessAwsIam(e, fullAccountPath);
} else {
handlePopUpOpen("accessAccount", e);
}
}}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
/>
))}
</>
)}
</TBody>
</Table>
{Boolean(totalCount) && !isLoading && (
<Pagination
count={totalCount}
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>
{filteredAccounts.map((account) => (
<PamAccountRow
key={account.id}
account={account}
search={search}
isAccessLoading={loadingAccountId === account.id}
onAccess={(e: TPamAccount) => accessAccount(e)}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
/>
))}
</>
)}
</TBody>
</Table>
{!isLoading && isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
/>
)}
{Boolean(totalCount) && !isLoading && !isContentEmpty && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
perPageList={[9, 18, 48, 99]}
/>
)}
</TableContainer>
)}
<PamDeleteFolderModal
isOpen={popUp.deleteFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}

View File

@@ -48,6 +48,7 @@ export const PamAddAccountModal = ({
onComplete={(account) => {
if (onComplete) onComplete(account);
onOpenChange(false);
setSelectedResource(null);
}}
onBack={() => setSelectedResource(null)}
resourceId={selectedResource.id}