Merge pull request #4831 from Infisical/PAM-10

pam: server-side filter, search, pagination
This commit is contained in:
Andre
2025-11-18 17:09:50 -05:00
committed by GitHub
24 changed files with 889 additions and 425 deletions
@@ -2,12 +2,15 @@ import { z } from "zod";
import { PamFoldersSchema } from "@app/db/schemas"; import { PamFoldersSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/pam-account-enums";
import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { OrderByDirection } from "@app/lib/types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
@@ -28,33 +31,69 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
schema: { schema: {
description: "List PAM accounts", description: "List PAM accounts",
querystring: z.object({ querystring: z.object({
projectId: z.string().uuid() projectId: z.string().uuid(),
accountPath: z.string().trim().default("/").transform(removeTrailingSlash),
accountView: z.nativeEnum(PamAccountView).default(PamAccountView.Flat),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(100),
orderBy: z.nativeEnum(PamAccountOrderBy).default(PamAccountOrderBy.Name),
orderDirection: z.nativeEnum(OrderByDirection).default(OrderByDirection.ASC),
search: z.string().trim().optional(),
filterResourceIds: z
.string()
.transform((val) =>
val
.split(",")
.map((s) => s.trim())
.filter(Boolean)
)
.optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({
accounts: SanitizedAccountSchema.array(), accounts: SanitizedAccountSchema.array(),
folders: PamFoldersSchema.array() folders: PamFoldersSchema.array(),
totalCount: z.number().default(0),
folderId: z.string().optional(),
folderPaths: z.record(z.string(), z.string())
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const response = await server.services.pamAccount.list(req.query.projectId, req.permission); const { projectId, accountPath, accountView, limit, offset, search, orderBy, orderDirection, filterResourceIds } =
req.query;
const { accounts, folders, totalCount, folderId, folderPaths } = await server.services.pamAccount.list({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
accountPath,
accountView,
limit,
offset,
search,
orderBy,
orderDirection,
filterResourceIds
});
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
orgId: req.permission.orgId, orgId: req.permission.orgId,
projectId: req.query.projectId, projectId,
event: { event: {
type: EventType.PAM_ACCOUNT_LIST, type: EventType.PAM_ACCOUNT_LIST,
metadata: { metadata: {
accountCount: response.accounts.length, accountCount: accounts.length,
folderCount: response.folders.length folderCount: folders.length
} }
} }
}); });
return response; return { accounts, folders, totalCount, folderId, folderPaths };
} }
}); });
@@ -5,6 +5,7 @@ import {
MySQLResourceListItemSchema, MySQLResourceListItemSchema,
SanitizedMySQLResourceSchema SanitizedMySQLResourceSchema
} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
import { PamResourceOrderBy } from "@app/ee/services/pam-resource/pam-resource-enums";
import { import {
PostgresResourceListItemSchema, PostgresResourceListItemSchema,
SanitizedPostgresResourceSchema SanitizedPostgresResourceSchema
@@ -13,6 +14,7 @@ import {
SanitizedSSHResourceSchema, SanitizedSSHResourceSchema,
SSHResourceListItemSchema SSHResourceListItemSchema
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
import { OrderByDirection } from "@app/lib/types";
import { readLimit } from "@app/server/config/rateLimiter"; import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
@@ -61,17 +63,46 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
schema: { schema: {
description: "List PAM resources", description: "List PAM resources",
querystring: z.object({ querystring: z.object({
projectId: z.string().uuid() projectId: z.string().uuid(),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(100),
orderBy: z.nativeEnum(PamResourceOrderBy).default(PamResourceOrderBy.Name),
orderDirection: z.nativeEnum(OrderByDirection).default(OrderByDirection.ASC),
search: z.string().trim().optional(),
filterResourceTypes: z
.string()
.transform((val) =>
val
.split(",")
.map((s) => s.trim())
.filter(Boolean)
)
.optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({
resources: SanitizedResourceSchema.array() resources: SanitizedResourceSchema.array(),
totalCount: z.number().default(0)
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const response = await server.services.pamResource.list(req.query.projectId, req.permission); const { projectId, limit, offset, search, orderBy, orderDirection, filterResourceTypes } = req.query;
const { resources, totalCount } = await server.services.pamResource.list({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
limit,
offset,
search,
orderBy,
orderDirection,
filterResourceTypes
});
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
@@ -80,12 +111,12 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
event: { event: {
type: EventType.PAM_RESOURCE_LIST, type: EventType.PAM_RESOURCE_LIST,
metadata: { metadata: {
count: response.resources.length count: resources.length
} }
} }
}); });
return response; return { resources, totalCount };
} }
}); });
}; };
@@ -1,46 +1,109 @@
import { Knex } from "knex"; import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TPamAccounts } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums";
export type TPamAccountDALFactory = ReturnType<typeof pamAccountDALFactory>; export type TPamAccountDALFactory = ReturnType<typeof pamAccountDALFactory>;
type PamAccountFindFilter = Parameters<typeof buildFindFilter<TPamAccounts>>[0];
export const pamAccountDALFactory = (db: TDbClient) => { export const pamAccountDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamAccount); const orm = ormify(db, TableName.PamAccount);
const findWithResourceDetails = async (filter: PamAccountFindFilter, tx?: Knex) => { const findByProjectIdWithResourceDetails = async (
const query = (tx || db.replicaNode())(TableName.PamAccount) {
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) projectId,
.select(selectAllTableCols(TableName.PamAccount)) folderId,
.select( accountView = PamAccountView.Nested,
search,
limit,
offset = 0,
orderBy = PamAccountOrderBy.Name,
orderDirection = OrderByDirection.ASC,
filterResourceIds
}: {
projectId: string;
folderId?: string | null;
accountView?: PamAccountView;
search?: string;
limit?: number;
offset?: number;
orderBy?: PamAccountOrderBy;
orderDirection?: OrderByDirection;
filterResourceIds?: string[];
},
tx?: Knex
) => {
try {
const dbInstance = tx || db.replicaNode();
const query = dbInstance(TableName.PamAccount)
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
.where(`${TableName.PamAccount}.projectId`, projectId);
if (accountView === PamAccountView.Nested) {
if (folderId) {
void query.where(`${TableName.PamAccount}.folderId`, folderId);
} else {
void query.whereNull(`${TableName.PamAccount}.folderId`);
}
}
if (search) {
// escape special characters (`%`, `_`) and the escape character itself (`\`)
const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
const pattern = `%${escapedSearch}%`;
void query.where((q) => {
void q
.whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamAccount, "name", pattern])
.orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "name", pattern])
.orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamAccount, "description", pattern]);
});
}
if (filterResourceIds && filterResourceIds.length) {
void query.whereIn(`${TableName.PamAccount}.resourceId`, filterResourceIds);
}
const countQuery = query.clone().count("*", { as: "count" }).first();
void query.select(selectAllTableCols(TableName.PamAccount)).select(
// resource // resource
db.ref("name").withSchema(TableName.PamResource).as("resourceName"), db.ref("name").withSchema(TableName.PamResource).as("resourceName"),
db.ref("resourceType").withSchema(TableName.PamResource), db.ref("resourceType").withSchema(TableName.PamResource),
db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource) db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource)
); );
if (filter) { const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
/* eslint-disable @typescript-eslint/no-misused-promises */
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PamAccount, filter))); void query.orderByRaw(`${TableName.PamAccount}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]);
if (typeof limit === "number") {
void query.limit(limit).offset(offset);
}
const [results, countResult] = await Promise.all([query, countQuery]);
const totalCount = Number(countResult?.count || 0);
const accounts = results.map(
// @ts-expect-error resourceName, resourceType, encryptedRotationAccountCredentials are from joined table
({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({
...account,
resourceId,
resource: {
id: resourceId,
name: resourceName as string,
resourceType,
encryptedRotationAccountCredentials
}
})
);
return { accounts, totalCount };
} catch (error) {
throw new DatabaseError({ error, name: "Find PAM accounts with resource details" });
} }
const accounts = await query;
return accounts.map(
({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({
...account,
resourceId,
resource: {
id: resourceId,
name: resourceName,
resourceType,
encryptedRotationAccountCredentials
}
})
);
}; };
const findAccountsDueForRotation = async (tx?: Knex) => { const findAccountsDueForRotation = async (tx?: Knex) => {
@@ -59,5 +122,9 @@ export const pamAccountDALFactory = (db: TDbClient) => {
return accounts; return accounts;
}; };
return { ...orm, findWithResourceDetails, findAccountsDueForRotation }; return {
...orm,
findByProjectIdWithResourceDetails,
findAccountsDueForRotation
};
}; };
@@ -0,0 +1,8 @@
export enum PamAccountOrderBy {
Name = "name"
}
export enum PamAccountView {
Flat = "flat",
Nested = "nested"
}
@@ -1,6 +1,6 @@
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamResources } from "@app/db/schemas"; import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamFolders, TPamResources } from "@app/db/schemas";
import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory"; import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory";
import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns"; import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
@@ -32,8 +32,9 @@ import { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
import { PamSessionStatus } from "../pam-session/pam-session-enums"; import { PamSessionStatus } from "../pam-session/pam-session-enums";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPamAccountDALFactory } from "./pam-account-dal"; import { TPamAccountDALFactory } from "./pam-account-dal";
import { PamAccountView } from "./pam-account-enums";
import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns"; import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns";
import { TAccessAccountDTO, TCreateAccountDTO, TUpdateAccountDTO } from "./pam-account-types"; import { TAccessAccountDTO, TCreateAccountDTO, TListAccountsDTO, TUpdateAccountDTO } from "./pam-account-types";
type TPamAccountServiceFactoryDep = { type TPamAccountServiceFactoryDep = {
pamResourceDAL: TPamResourceDALFactory; pamResourceDAL: TPamResourceDALFactory;
@@ -335,21 +336,96 @@ export const pamAccountServiceFactory = ({
}; };
}; };
const list = async (projectId: string, actor: OrgServiceActor) => { const list = async ({
projectId,
accountPath,
accountView,
actor,
actorId,
actorAuthMethod,
actorOrgId,
...params
}: TListAccountsDTO) => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
actor: actor.type, actor,
actorAuthMethod: actor.authMethod, actorId,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId, projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.PAM actionProjectType: ActionProjectType.PAM
}); });
const accountsWithResourceDetails = await pamAccountDAL.findWithResourceDetails({ projectId }); const limit = params.limit || 20;
const offset = params.offset || 0;
const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders); const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders);
const folders = canReadFolders ? await pamFolderDAL.find({ projectId }) : []; const folder = accountPath === "/" ? null : await pamFolderDAL.findByPath(projectId, accountPath);
if (accountPath !== "/" && !folder) {
return { accounts: [], folders: [], totalCount: 0, folderPaths: {} };
}
const folderId = folder?.id;
let totalFolderCount = 0;
if (canReadFolders && accountView === PamAccountView.Nested) {
const { totalCount } = await pamFolderDAL.findByProjectId({
projectId,
parentId: folderId,
search: params.search
});
totalFolderCount = totalCount;
}
let folders: TPamFolders[] = [];
if (canReadFolders && accountView === PamAccountView.Nested && offset < totalFolderCount) {
const folderLimit = Math.min(limit, totalFolderCount - offset);
const { folders: foldersResp } = await pamFolderDAL.findByProjectId({
projectId,
parentId: folderId,
limit: folderLimit,
offset,
search: params.search,
orderBy: params.orderBy,
orderDirection: params.orderDirection
});
folders = foldersResp;
}
let accountsWithResourceDetails: Awaited<
ReturnType<typeof pamAccountDAL.findByProjectIdWithResourceDetails>
>["accounts"] = [];
let totalAccountCount = 0;
const accountsToFetch = limit - folders.length;
if (accountsToFetch > 0) {
const accountOffset = Math.max(0, offset - totalFolderCount);
const { accounts, totalCount } = await pamAccountDAL.findByProjectIdWithResourceDetails({
projectId,
folderId,
accountView,
offset: accountOffset,
limit: accountsToFetch,
search: params.search,
orderBy: params.orderBy,
orderDirection: params.orderDirection,
filterResourceIds: params.filterResourceIds
});
accountsWithResourceDetails = accounts;
totalAccountCount = totalCount;
} else {
// if no accounts are to be fetched for the current page, we still need the total count for pagination
const { totalCount } = await pamAccountDAL.findByProjectIdWithResourceDetails({
projectId,
folderId,
accountView,
search: params.search,
filterResourceIds: params.filterResourceIds
});
totalAccountCount = totalCount;
}
const totalCount = totalFolderCount + totalAccountCount;
const decryptedAndPermittedAccounts: Array< const decryptedAndPermittedAccounts: Array<
TPamAccounts & { TPamAccounts & {
@@ -360,12 +436,6 @@ export const pamAccountServiceFactory = ({
> = []; > = [];
for await (const account of accountsWithResourceDetails) { for await (const account of accountsWithResourceDetails) {
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
// Check permission for each individual account // Check permission for each individual account
if ( if (
permission.can( permission.can(
@@ -392,9 +462,27 @@ export const pamAccountServiceFactory = ({
} }
} }
const folderPaths: Record<string, string> = {};
const accountFolderIds = [
...new Set(decryptedAndPermittedAccounts.flatMap((a) => (a.folderId ? [a.folderId] : [])))
];
await Promise.all(
accountFolderIds.map(async (fId) => {
folderPaths[fId] = await getFullPamFolderPath({
pamFolderDAL,
folderId: fId,
projectId
});
})
);
return { return {
accounts: decryptedAndPermittedAccounts, accounts: decryptedAndPermittedAccounts,
folders folders,
totalCount,
folderId,
folderPaths
}; };
}; };
@@ -1,4 +1,7 @@
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { TPamAccount } from "../pam-resource/pam-resource-types"; import { TPamAccount } from "../pam-resource/pam-resource-types";
import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums";
// DTOs // DTOs
export type TCreateAccountDTO = Pick< export type TCreateAccountDTO = Pick<
@@ -18,3 +21,14 @@ export type TAccessAccountDTO = {
actorUserAgent: string; actorUserAgent: string;
duration: number; duration: number;
}; };
export type TListAccountsDTO = {
accountPath: string;
accountView: PamAccountView;
search?: string;
orderBy?: PamAccountOrderBy;
orderDirection?: OrderByDirection;
limit?: number;
offset?: number;
filterResourceIds?: string[];
} & TProjectPermission;
@@ -1,9 +1,106 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { PamAccountOrderBy } from "../pam-account/pam-account-enums";
export type TPamFolderDALFactory = ReturnType<typeof pamFolderDALFactory>; export type TPamFolderDALFactory = ReturnType<typeof pamFolderDALFactory>;
export const pamFolderDALFactory = (db: TDbClient) => { export const pamFolderDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamFolder); const orm = ormify(db, TableName.PamFolder);
return { ...orm };
const findByProjectId = async (
{
projectId,
parentId,
search,
limit,
offset = 0,
orderBy = PamAccountOrderBy.Name,
orderDirection = OrderByDirection.ASC
}: {
projectId: string;
parentId?: string | null;
search?: string;
limit?: number;
offset?: number;
orderBy?: PamAccountOrderBy;
orderDirection?: OrderByDirection;
},
tx?: Knex
) => {
try {
const dbInstance = tx || db.replicaNode();
const query = dbInstance(TableName.PamFolder).where(`${TableName.PamFolder}.projectId`, projectId);
if (parentId) {
void query.where(`${TableName.PamFolder}.parentId`, parentId);
} else {
void query.whereNull(`${TableName.PamFolder}.parentId`);
}
if (search) {
// escape special characters (`%`, `_`) and the escape character itself (`\`)
const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
void query.whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamFolder, "name", `%${escapedSearch}%`]);
}
const countQuery = query.clone().count("*", { as: "count" }).first();
void query.select(selectAllTableCols(TableName.PamFolder));
const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
void query.orderByRaw(`${TableName.PamFolder}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]);
if (typeof limit === "number") {
void query.limit(limit).offset(offset);
}
const [folders, countResult] = await Promise.all([query, countQuery]);
const totalCount = Number(countResult?.count || 0);
return { folders, totalCount };
} catch (error) {
throw new DatabaseError({ error, name: "Find PAM folders" });
}
};
const findByPath = async (projectId: string, path: string, tx?: Knex) => {
try {
const dbInstance = tx || db.replicaNode();
const pathSegments = path.split("/").filter(Boolean);
let parentId: string | null = null;
let currentFolder: Awaited<ReturnType<typeof orm.findOne>> | undefined;
for await (const segment of pathSegments) {
const query = dbInstance(TableName.PamFolder)
.where(`${TableName.PamFolder}.projectId`, projectId)
.where(`${TableName.PamFolder}.name`, segment);
if (parentId) {
void query.where(`${TableName.PamFolder}.parentId`, parentId);
} else {
void query.whereNull(`${TableName.PamFolder}.parentId`);
}
currentFolder = await query.first();
if (!currentFolder) {
return undefined;
}
parentId = currentFolder.id;
}
return currentFolder;
} catch (error) {
throw new DatabaseError({ error, name: "Find PAM folder by path" });
}
};
return { ...orm, findByProjectId, findByPath };
}; };
@@ -2,7 +2,11 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex"; import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { PamResourceOrderBy } from "./pam-resource-enums";
export type TPamResourceDALFactory = ReturnType<typeof pamResourceDALFactory>; export type TPamResourceDALFactory = ReturnType<typeof pamResourceDALFactory>;
export const pamResourceDALFactory = (db: TDbClient) => { export const pamResourceDALFactory = (db: TDbClient) => {
@@ -20,5 +24,65 @@ export const pamResourceDALFactory = (db: TDbClient) => {
return doc; return doc;
}; };
return { ...orm, findById }; const findByProjectId = async (
{
projectId,
search,
limit,
offset = 0,
orderBy = PamResourceOrderBy.Name,
orderDirection = OrderByDirection.ASC,
filterResourceTypes
}: {
projectId: string;
search?: string;
limit?: number;
offset?: number;
orderBy?: PamResourceOrderBy;
orderDirection?: OrderByDirection;
filterResourceTypes?: string[];
},
tx?: Knex
) => {
try {
const dbInstance = tx || db.replicaNode();
const query = dbInstance(TableName.PamResource).where(`${TableName.PamResource}.projectId`, projectId);
if (search) {
// escape special characters (`%`, `_`) and the escape character itself (`\`)
const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
const pattern = `%${escapedSearch}%`;
void query.where((q) => {
void q
.whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "name", pattern])
.orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "resourceType", pattern]);
});
}
if (filterResourceTypes && filterResourceTypes.length) {
void query.whereIn(`${TableName.PamResource}.resourceType`, filterResourceTypes);
}
const countQuery = query.clone().count("*", { as: "count" }).first();
void query.select(selectAllTableCols(TableName.PamResource));
const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
void query.orderByRaw(`${TableName.PamResource}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]);
if (typeof limit === "number") {
void query.limit(limit).offset(offset);
}
const [resources, countResult] = await Promise.all([query, countQuery]);
const totalCount = Number(countResult?.count || 0);
return { resources, totalCount };
} catch (error) {
throw new DatabaseError({ error, name: "Find PAM resources" });
}
};
return { ...orm, findById, findByProjectId };
}; };
@@ -3,3 +3,7 @@ export enum PamResource {
MySQL = "mysql", MySQL = "mysql",
SSH = "ssh" SSH = "ssh"
} }
export enum PamResourceOrderBy {
Name = "name"
}
@@ -20,7 +20,7 @@ import {
encryptResourceConnectionDetails, encryptResourceConnectionDetails,
listResourceOptions listResourceOptions
} from "./pam-resource-fns"; } from "./pam-resource-fns";
import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types"; import { TCreateResourceDTO, TListResourcesDTO, TUpdateResourceDTO } from "./pam-resource-types";
type TPamResourceServiceFactoryDep = { type TPamResourceServiceFactoryDep = {
pamResourceDAL: TPamResourceDALFactory; pamResourceDAL: TPamResourceDALFactory;
@@ -267,22 +267,23 @@ export const pamResourceServiceFactory = ({
} }
}; };
const list = async (projectId: string, actor: OrgServiceActor) => { const list = async ({ projectId, actor, actorId, actorAuthMethod, actorOrgId, ...params }: TListResourcesDTO) => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
actor: actor.type, actor,
actorAuthMethod: actor.authMethod, actorId,
actorId: actor.id, actorAuthMethod,
actorOrgId: actor.orgId, actorOrgId,
projectId, projectId,
actionProjectType: ActionProjectType.PAM actionProjectType: ActionProjectType.PAM
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
const resources = await pamResourceDAL.find({ projectId }); const { resources, totalCount } = await pamResourceDAL.findByProjectId({ projectId, ...params });
return { return {
resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService))) resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService))),
totalCount
}; };
}; };
@@ -1,3 +1,5 @@
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { import {
TMySQLAccount, TMySQLAccount,
@@ -5,7 +7,7 @@ import {
TMySQLResource, TMySQLResource,
TMySQLResourceConnectionDetails TMySQLResourceConnectionDetails
} from "./mysql/mysql-resource-types"; } from "./mysql/mysql-resource-types";
import { PamResource } from "./pam-resource-enums"; import { PamResource, PamResourceOrderBy } from "./pam-resource-enums";
import { import {
TPostgresAccount, TPostgresAccount,
TPostgresAccountCredentials, TPostgresAccountCredentials,
@@ -41,6 +43,15 @@ export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType"
resourceId: string; resourceId: string;
}; };
export type TListResourcesDTO = {
search?: string;
orderBy?: PamResourceOrderBy;
orderDirection?: OrderByDirection;
limit?: number;
offset?: number;
filterResourceTypes?: string[];
} & TProjectPermission;
// Resource factory // Resource factory
export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>; export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>;
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = ( export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
+16
View File
@@ -1,3 +1,4 @@
// Resources
export enum PamResourceType { export enum PamResourceType {
Postgres = "postgres", Postgres = "postgres",
MySQL = "mysql", MySQL = "mysql",
@@ -18,9 +19,24 @@ export enum PamResourceType {
DynamoDB = "dynamodb" DynamoDB = "dynamodb"
} }
export enum PamResourceOrderBy {
Name = "name"
}
// Sessions
export enum PamSessionStatus { export enum PamSessionStatus {
Starting = "starting", Starting = "starting",
Active = "active", Active = "active",
Ended = "ended", Ended = "ended",
Terminated = "terminated" Terminated = "terminated"
} }
// Accounts
export enum PamAccountOrderBy {
Name = "name"
}
export enum PamAccountView {
Flat = "flat",
Nested = "nested"
}
+9 -9
View File
@@ -31,7 +31,7 @@ export const useCreatePamResource = () => {
return data.resource; return data.resource;
}, },
onSuccess: ({ projectId }) => { onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) }); queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
} }
}); });
}; };
@@ -48,7 +48,7 @@ export const useUpdatePamResource = () => {
return data.resource; return data.resource;
}, },
onSuccess: ({ projectId }) => { onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) }); queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
} }
}); });
}; };
@@ -64,7 +64,7 @@ export const useDeletePamResource = () => {
return data.resource; return data.resource;
}, },
onSuccess: ({ projectId }) => { onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) }); queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
} }
}); });
}; };
@@ -82,7 +82,7 @@ export const useCreatePamAccount = () => {
return data.account; return data.account;
}, },
onSuccess: ({ projectId }) => { onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
} }
}); });
}; };
@@ -99,7 +99,7 @@ export const useUpdatePamAccount = () => {
return data.account; return data.account;
}, },
onSuccess: ({ projectId }) => { onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
} }
}); });
}; };
@@ -115,7 +115,7 @@ export const useDeletePamAccount = () => {
return data.account; return data.account;
}, },
onSuccess: ({ projectId }) => { onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
} }
}); });
}; };
@@ -130,7 +130,7 @@ export const useCreatePamFolder = () => {
return data.folder; return data.folder;
}, },
onSuccess: ({ projectId }) => { onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
} }
}); });
}; };
@@ -147,7 +147,7 @@ export const useUpdatePamFolder = () => {
return data.folder; return data.folder;
}, },
onSuccess: ({ projectId }) => { onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
} }
}); });
}; };
@@ -163,7 +163,7 @@ export const useDeletePamFolder = () => {
return data.folder; return data.folder;
}, },
onSuccess: ({ projectId }) => { onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
} }
}); });
}; };
+50 -20
View File
@@ -4,7 +4,14 @@ import { apiRequest } from "@app/config/request";
import { TPamResourceOption } from "./types/resource-options"; import { TPamResourceOption } from "./types/resource-options";
import { PamResourceType } from "./enums"; import { PamResourceType } from "./enums";
import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types"; import {
TListPamAccountsDTO,
TListPamResourcesDTO,
TPamAccount,
TPamFolder,
TPamResource,
TPamSession
} from "./types";
export const pamKeys = { export const pamKeys = {
all: ["pam"] as const, all: ["pam"] as const,
@@ -12,14 +19,24 @@ export const pamKeys = {
account: () => [...pamKeys.all, "account"] as const, account: () => [...pamKeys.all, "account"] as const,
session: () => [...pamKeys.all, "session"] as const, session: () => [...pamKeys.all, "session"] as const,
listResourceOptions: () => [...pamKeys.resource(), "options"] as const, listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId], listResources: ({ projectId, ...params }: TListPamResourcesDTO) => [
...pamKeys.resource(),
"list",
projectId,
params
],
getResource: (resourceType: string, resourceId: string) => [ getResource: (resourceType: string, resourceId: string) => [
...pamKeys.resource(), ...pamKeys.resource(),
"get", "get",
resourceType, resourceType,
resourceId resourceId
], ],
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId], listAccounts: ({ projectId, ...params }: TListPamAccountsDTO) => [
...pamKeys.account(),
"list",
projectId,
params
],
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId], getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId] listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
}; };
@@ -49,28 +66,33 @@ export const useListPamResourceOptions = (
}); });
}; };
type TListPamResourcesResponse = {
resources: TPamResource[];
totalCount: number;
};
export const useListPamResources = ( export const useListPamResources = (
projectId: string, params: TListPamResourcesDTO,
options?: Omit< options?: Omit<
UseQueryOptions< UseQueryOptions<
TPamResource[], TListPamResourcesResponse,
unknown, unknown,
TPamResource[], TListPamResourcesResponse,
ReturnType<typeof pamKeys.listResources> ReturnType<typeof pamKeys.listResources>
>, >,
"queryKey" | "queryFn" "queryKey" | "queryFn"
> >
) => { ) => {
return useQuery({ return useQuery({
queryKey: pamKeys.listResources(projectId), queryKey: pamKeys.listResources(params),
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.get<{ resources: TPamResource[] }>( const { data } = await apiRequest.get<TListPamResourcesResponse>("/api/v1/pam/resources", {
"/api/v1/pam/resources", params
{ params: { projectId } } });
);
return data.resources; return data;
}, },
placeholderData: (prev) => prev,
...options ...options
}); });
}; };
@@ -98,28 +120,36 @@ export const useGetPamResourceById = (
}; };
// Accounts // Accounts
type TListPamAccountsResponse = {
accounts: TPamAccount[];
folders: TPamFolder[];
totalCount: number;
folderId?: string;
folderPaths: Record<string, string>;
};
export const useListPamAccounts = ( export const useListPamAccounts = (
projectId: string, params: TListPamAccountsDTO,
options?: Omit< options?: Omit<
UseQueryOptions< UseQueryOptions<
{ accounts: TPamAccount[]; folders: TPamFolder[] }, TListPamAccountsResponse,
unknown, unknown,
{ accounts: TPamAccount[]; folders: TPamFolder[] }, TListPamAccountsResponse,
ReturnType<typeof pamKeys.listAccounts> ReturnType<typeof pamKeys.listAccounts>
>, >,
"queryKey" | "queryFn" "queryKey" | "queryFn"
> >
) => { ) => {
return useQuery({ return useQuery({
queryKey: pamKeys.listAccounts(projectId), queryKey: pamKeys.listAccounts(params),
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.get<{ accounts: TPamAccount[]; folders: TPamFolder[] }>( const { data } = await apiRequest.get<TListPamAccountsResponse>("/api/v1/pam/accounts", {
"/api/v1/pam/accounts", params
{ params: { projectId } } });
);
return data; return data;
}, },
placeholderData: (prev) => prev,
...options ...options
}); });
}; };
+30 -1
View File
@@ -1,4 +1,11 @@
import { PamResourceType, PamSessionStatus } from "../enums"; import { OrderByDirection } from "../../generic/types";
import {
PamAccountOrderBy,
PamAccountView,
PamResourceOrderBy,
PamResourceType,
PamSessionStatus
} from "../enums";
import { TMySQLAccount, TMySQLResource } from "./mysql-resource"; import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
import { TPostgresAccount, TPostgresResource } from "./postgres-resource"; import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
import { TSSHAccount, TSSHResource } from "./ssh-resource"; import { TSSHAccount, TSSHResource } from "./ssh-resource";
@@ -59,6 +66,16 @@ export type TPamSession = {
}; };
// Resource DTOs // Resource DTOs
export type TListPamResourcesDTO = {
projectId: string;
offset?: number;
limit?: number;
orderBy?: PamResourceOrderBy;
orderDirection?: OrderByDirection;
search?: string;
filterResourceTypes?: string;
};
export type TCreatePamResourceDTO = Pick< export type TCreatePamResourceDTO = Pick<
TPamResource, TPamResource,
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
@@ -77,6 +94,18 @@ export type TDeletePamResourceDTO = {
}; };
// Account DTOs // Account DTOs
export type TListPamAccountsDTO = {
projectId: string;
accountPath?: string | null;
accountView?: PamAccountView;
offset?: number;
limit?: number;
orderBy?: PamAccountOrderBy;
orderDirection?: OrderByDirection;
search?: string;
filterResourceIds?: string;
};
export type TCreatePamAccountDTO = Pick< export type TCreatePamAccountDTO = Pick<
TPamAccount, TPamAccount,
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId" "name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId"
@@ -1,13 +1,9 @@
import { Button } from "@app/components/v2"; import { Button } from "@app/components/v2";
import { PamAccountView } from "@app/hooks/api/pam";
export enum AccountView {
Flat = "flat",
Nested = "nested"
}
type Props = { type Props = {
value: AccountView; value: PamAccountView;
onChange: (value: AccountView) => void; onChange: (value: PamAccountView) => void;
}; };
export const AccountViewToggle = ({ value, onChange }: Props) => { export const AccountViewToggle = ({ value, onChange }: Props) => {
@@ -16,11 +12,11 @@ export const AccountViewToggle = ({ value, onChange }: Props) => {
<Button <Button
variant="outline_bg" variant="outline_bg"
onClick={() => { onClick={() => {
onChange(AccountView.Flat); onChange(PamAccountView.Flat);
}} }}
size="xs" size="xs"
className={`${ className={`${
value === AccountView.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`} } min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
> >
Hide Folders Hide Folders
@@ -28,11 +24,11 @@ export const AccountViewToggle = ({ value, onChange }: Props) => {
<Button <Button
variant="outline_bg" variant="outline_bg"
onClick={() => { onClick={() => {
onChange(AccountView.Nested); onChange(PamAccountView.Nested);
}} }}
size="xs" size="xs"
className={`${ className={`${
value === AccountView.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`} } min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
> >
Show Folders Show Folders
@@ -1,23 +1,9 @@
import { ContentLoader } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { useListPamAccounts } from "@app/hooks/api/pam";
import { PamAccountsTable } from "./PamAccountsTable"; import { PamAccountsTable } from "./PamAccountsTable";
export const PamAccountsSection = () => { export const PamAccountsSection = () => {
const { currentProject } = useProject(); const { currentProject } = useProject();
const { data, isPending } = useListPamAccounts(currentProject.id, { return <PamAccountsTable projectId={currentProject.id} />;
refetchInterval: 30000
});
if (isPending) return <ContentLoader />;
return (
<PamAccountsTable
projectId={currentProject.id}
accounts={data?.accounts || []}
folders={data?.folders || []}
/>
);
}; };
@@ -29,6 +29,7 @@ import {
Pagination, Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton,
TBody, TBody,
Th, Th,
THead, THead,
@@ -40,11 +41,22 @@ import {
ProjectPermissionPamAccountActions, ProjectPermissionPamAccountActions,
ProjectPermissionSub ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types"; } from "@app/context/ProjectPermissionContext/types";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PAM_RESOURCE_TYPE_MAP, TPamAccount, TPamFolder } from "@app/hooks/api/pam"; import {
PAM_RESOURCE_TYPE_MAP,
PamAccountOrderBy,
PamAccountView,
TPamFolder
} from "@app/hooks/api/pam";
import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/queries";
import { AccountView, AccountViewToggle } from "./AccountViewToggle"; import { AccountViewToggle } from "./AccountViewToggle";
import { FolderBreadCrumbs } from "./FolderBreadCrumbs"; import { FolderBreadCrumbs } from "./FolderBreadCrumbs";
import { PamAccessAccountModal } from "./PamAccessAccountModal"; import { PamAccessAccountModal } from "./PamAccessAccountModal";
import { PamAccountRow } from "./PamAccountRow"; import { PamAccountRow } from "./PamAccountRow";
@@ -56,21 +68,15 @@ import { PamFolderRow } from "./PamFolderRow";
import { PamUpdateAccountModal } from "./PamUpdateAccountModal"; import { PamUpdateAccountModal } from "./PamUpdateAccountModal";
import { PamUpdateFolderModal } from "./PamUpdateFolderModal"; import { PamUpdateFolderModal } from "./PamUpdateFolderModal";
enum OrderBy { type PamAccountFilter = {
Name = "name" resourceIds: string[];
}
type Filters = {
resource: string[];
}; };
type Props = { type Props = {
accounts: TPamAccount[];
folders: TPamFolder[];
projectId: string; projectId: string;
}; };
export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => { export const PamAccountsTable = ({ projectId }: Props) => {
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path }); const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -92,18 +98,21 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
from: ROUTE_PATHS.Pam.AccountsPage.id from: ROUTE_PATHS.Pam.AccountsPage.id
}); });
const [accountView, setAccountView] = useState<AccountView>(initAccountView ?? AccountView.Flat); const [accountView, setAccountView] = useState<PamAccountView>(
initAccountView ?? PamAccountView.Flat
);
const [filters, setFilters] = useState<Filters>({ const [filter, setFilter] = useState<PamAccountFilter>({
resource: [] resourceIds: []
}); });
const { const {
search, search,
debouncedSearch,
setSearch, setSearch,
setPage,
page, page,
perPage, perPage,
setPage,
setPerPage, setPerPage,
offset, offset,
orderDirection, orderDirection,
@@ -111,114 +120,74 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
orderBy, orderBy,
setOrderDirection, setOrderDirection,
setOrderBy setOrderBy
} = usePagination<OrderBy>(OrderBy.Name, { initPerPage: 20, initSearch }); } = usePagination<PamAccountOrderBy>(PamAccountOrderBy.Name, {
initPerPage: getUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, 20),
initSearch
});
const { foldersByParentId, pathMap, folderPaths } = useMemo(() => { const handlePerPageChange = (newPerPage: number) => {
const foldersById: Record<string, TPamFolder> = {}; setPerPage(newPerPage);
const tempFoldersByParentId: Record<string, TPamFolder[]> = { null: [] }; setUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, newPerPage);
const tempPathMap: Record<string, string> = { "/": "null" }; };
const tempFolderPaths: Record<string, string> = {};
folders.forEach((folder) => { const { data, isLoading } = useListPamAccounts({
foldersById[folder.id] = folder; projectId,
if (!tempFoldersByParentId[folder.parentId || "null"]) { accountPath,
tempFoldersByParentId[folder.parentId || "null"] = []; accountView,
} offset,
tempFoldersByParentId[folder.parentId || "null"].push(folder); limit: perPage,
}); search: debouncedSearch,
orderBy,
orderDirection,
filterResourceIds: filter.resourceIds.length ? filter.resourceIds.join(",") : undefined
});
const buildPaths = (parentId: string | null, currentPath: string) => { const accounts = data?.accounts || [];
(tempFoldersByParentId[parentId || "null"] || []).forEach((folder) => { const folders = data?.folders || [];
const newPath = `${currentPath}${folder.name}/`; const totalCount = data?.totalCount || 0;
tempPathMap[newPath] = folder.id; const folderPaths = data?.folderPaths || {};
tempFolderPaths[folder.id] = newPath; const currentFolderId = data?.folderId ?? null;
buildPaths(folder.id, newPath);
});
};
buildPaths(null, "/");
return {
foldersByParentId: tempFoldersByParentId,
pathMap: tempPathMap,
folderPaths: tempFolderPaths
};
}, [folders]);
const effectiveFolderIdForFiltering = useMemo(() => {
if (accountView === AccountView.Flat) {
return null;
}
const folderId = pathMap[accountPath];
return folderId === "null" ? null : folderId || null;
}, [accountView, accountPath, pathMap]);
const foldersToRender = useMemo(() => {
if (accountView === AccountView.Flat) {
return [];
}
return (foldersByParentId[effectiveFolderIdForFiltering || "null"] || []).filter((folder) =>
folder.name.toLowerCase().includes(search.trim().toLowerCase())
);
}, [accountView, effectiveFolderIdForFiltering, foldersByParentId, search]);
const accountsToProcess = useMemo(() => {
if (accountView === AccountView.Flat) {
return accounts;
}
return accounts.filter(
(acc) => (acc.folderId || "null") === (effectiveFolderIdForFiltering || "null")
);
}, [accountView, accounts, effectiveFolderIdForFiltering]);
const filteredAccounts = useMemo(
() =>
accountsToProcess
.filter((account) => {
const {
name,
description,
resource: { name: resourceName, id: resourceId }
} = account;
if (filters.resource.length && !filters.resource.includes(resourceId)) {
return false;
}
const searchValue = search.trim().toLowerCase();
const path = (account.folderId && folderPaths[account.folderId]) || "";
return (
name.toLowerCase().includes(searchValue) ||
resourceName.toLowerCase().includes(searchValue) ||
(description || "").toLowerCase().includes(searchValue) ||
path.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [accOne, accTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case OrderBy.Name:
default:
return accOne.name.toLowerCase().localeCompare(accTwo.name.toLowerCase());
}
}),
[accountsToProcess, orderDirection, search, orderBy, filters, folderPaths]
);
useResetPageHelper({ useResetPageHelper({
totalCount: filteredAccounts.length, totalCount,
offset, offset,
setPage setPage
}); });
const currentPageData = useMemo( const foldersToRender = useMemo(() => {
() => filteredAccounts.slice(offset, perPage * page), if (accountView === PamAccountView.Flat) {
[filteredAccounts, offset, perPage, page] return [];
}
return folders.filter((folder) =>
folder.name.toLowerCase().includes(search.trim().toLowerCase())
);
}, [accountView, folders, search]);
const filteredAccounts = useMemo(
() =>
accounts.filter((account) => {
const {
name,
description,
resource: { name: resourceName, id: resourceId }
} = account;
if (filter.resourceIds.length && !filter.resourceIds.includes(resourceId)) {
return false;
}
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) ||
resourceName.toLowerCase().includes(searchValue) ||
(description || "").toLowerCase().includes(searchValue)
);
}),
[accounts, search, filter]
); );
const handleSort = (column: OrderBy) => { const handleSort = (column: PamAccountOrderBy) => {
if (column === orderBy) { if (column === orderBy) {
toggleOrderDirection(); toggleOrderDirection();
return; return;
@@ -228,15 +197,16 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
setOrderDirection(OrderByDirection.ASC); setOrderDirection(OrderByDirection.ASC);
}; };
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30"); const getClassName = (col: PamAccountOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: OrderBy) => const getColSortIcon = (col: PamAccountOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.resource.length); const isTableFiltered = Boolean(filter.resourceIds.length);
const handleFolderClick = (folder: TPamFolder) => { const handleFolderClick = (folder: TPamFolder) => {
if (accountView === AccountView.Flat) { if (accountView === PamAccountView.Flat) {
return; return;
} }
const newPath = `${accountPath}${folder.name}/`; const newPath = `${accountPath}${folder.name}/`;
@@ -246,17 +216,17 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
const isContentEmpty = !filteredAccounts.length && !foldersToRender.length; const isContentEmpty = !filteredAccounts.length && !foldersToRender.length;
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered); const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
const uniqueResources = useMemo(() => { const { data: resourcesData } = useListPamResources({
const resourceMap = new Map<string, TPamAccount["resource"]>(); projectId,
accounts.forEach((account) => { // temporarily returning a large number until we rework table filtering
resourceMap.set(account.resource.id, account.resource); limit: 100
}); });
return Array.from(resourceMap.values());
}, [accounts]); const resources = resourcesData?.resources || [];
return ( return (
<div> <div>
{accountView === AccountView.Nested && <FolderBreadCrumbs path={accountPath} />} {accountView === PamAccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
<div className="mt-4 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) =>
@@ -264,12 +234,14 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
<AccountViewToggle <AccountViewToggle
value={accountView} value={accountView}
onChange={(e) => { onChange={(e) => {
setPage(1);
setFilter({ resourceIds: [] });
setAccountView(e); setAccountView(e);
navigate({ navigate({
search: (prev) => ({ search: (prev) => ({
...prev, ...prev,
accountView: e === AccountView.Flat ? undefined : e, accountView: e === PamAccountView.Flat ? undefined : e,
accountPath: e === AccountView.Flat ? "/" : prev.accountPath accountPath: e === PamAccountView.Flat ? "/" : prev.accountPath
}) })
}); });
}} }}
@@ -307,25 +279,25 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end"> <DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end">
<DropdownMenuLabel>Resource</DropdownMenuLabel> <DropdownMenuLabel>Resource</DropdownMenuLabel>
{uniqueResources.length ? ( {resources.length ? (
uniqueResources.map((resource) => { resources.map((resource) => {
const { name, image } = PAM_RESOURCE_TYPE_MAP[resource.resourceType]; const { name, image } = PAM_RESOURCE_TYPE_MAP[resource.resourceType];
return ( return (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
const newResources = filters.resource.includes(resource.id) const newResources = filter.resourceIds.includes(resource.id)
? filters.resource.filter((a) => a !== resource.id) ? filter.resourceIds.filter((a) => a !== resource.id)
: [...filters.resource, resource.id]; : [...filter.resourceIds, resource.id];
setFilters((prev) => ({ setFilter((prev) => ({
...prev, ...prev,
resource: newResources resourceIds: newResources
})); }));
}} }}
key={resource.id} key={resource.id}
icon={ icon={
filters.resource.includes(resource.id) && ( filter.resourceIds.includes(resource.id) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} /> <FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
) )
} }
@@ -358,12 +330,12 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addAccount")} onClick={() => handlePopUpOpen("addAccount")}
isDisabled={!isAllowedToCreateAccounts} isDisabled={!isAllowedToCreateAccounts}
className={`h-10 transition-colors ${accountView === AccountView.Flat ? "" : "rounded-r-none"}`} className={`h-10 transition-colors ${accountView === PamAccountView.Flat ? "" : "rounded-r-none"}`}
> >
Add Account Add Account
</Button> </Button>
{accountView !== AccountView.Flat && ( {accountView !== PamAccountView.Flat && (
<DropdownMenu <DropdownMenu
open={popUp.misc.isOpen} open={popUp.misc.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
@@ -414,11 +386,11 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
Accounts Accounts
<IconButton <IconButton
variant="plain" variant="plain"
className={getClassName(OrderBy.Name)} className={getClassName(PamAccountOrderBy.Name)}
ariaLabel="sort" ariaLabel="sort"
onClick={() => handleSort(OrderBy.Name)} onClick={() => handleSort(PamAccountOrderBy.Name)}
> >
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} /> <FontAwesomeIcon icon={getColSortIcon(PamAccountOrderBy.Name)} />
</IconButton> </IconButton>
</div> </div>
</Th> </Th>
@@ -426,45 +398,48 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{accountView !== AccountView.Flat && {isLoading && <TableSkeleton columns={2} innerKey="pam-accounts" />}
foldersToRender.map((folder) => ( {!isLoading && (
<PamFolderRow <>
key={folder.id} {accountView !== PamAccountView.Flat &&
folder={folder} foldersToRender.map((folder) => (
search={search} <PamFolderRow
onClick={() => handleFolderClick(folder)} key={folder.id}
onUpdate={(e) => handlePopUpOpen("updateFolder", e)} folder={folder}
onDelete={(e) => handlePopUpOpen("deleteFolder", e)} search={search}
/> onClick={() => handleFolderClick(folder)}
))} onUpdate={(e) => handlePopUpOpen("updateFolder", e)}
{currentPageData.map((account) => ( onDelete={(e) => handlePopUpOpen("deleteFolder", e)}
<PamAccountRow />
key={account.id} ))}
account={account} {filteredAccounts.map((account) => (
search={search} <PamAccountRow
isFlatView={accountView === AccountView.Flat} key={account.id}
accountPath={ account={account}
account.folderId ? folderPaths[account.folderId]?.slice(0, -1) : undefined search={search}
} isFlatView={accountView === PamAccountView.Flat}
onAccess={(e) => { accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
handlePopUpOpen("accessAccount", e); onAccess={(e) => {
}} handlePopUpOpen("accessAccount", e);
onUpdate={(e) => handlePopUpOpen("updateAccount", e)} }}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)} onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
/> onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
))} />
))}
</>
)}
</TBody> </TBody>
</Table> </Table>
{Boolean(filteredAccounts.length) && ( {Boolean(totalCount) && !isLoading && (
<Pagination <Pagination
count={filteredAccounts.length} count={totalCount}
page={page} page={page}
perPage={perPage} perPage={perPage}
onChangePage={setPage} onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={setPerPage} onChangePerPage={handlePerPageChange}
/> />
)} )}
{isContentEmpty && ( {!isLoading && isContentEmpty && (
<EmptyState <EmptyState
title={isSearchEmpty ? "No accounts match search" : "No accounts"} title={isSearchEmpty ? "No accounts match search" : "No accounts"}
icon={isSearchEmpty ? faSearch : faCircleXmark} icon={isSearchEmpty ? faSearch : faCircleXmark}
@@ -485,7 +460,7 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
isOpen={popUp.addFolder.isOpen} isOpen={popUp.addFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
projectId={projectId} projectId={projectId}
currentFolderId={effectiveFolderIdForFiltering} currentFolderId={currentFolderId}
/> />
<PamAccessAccountModal <PamAccessAccountModal
isOpen={popUp.accessAccount.isOpen} isOpen={popUp.accessAccount.isOpen}
@@ -506,7 +481,7 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
isOpen={popUp.addAccount.isOpen} isOpen={popUp.addAccount.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addAccount", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("addAccount", isOpen)}
projectId={projectId} projectId={projectId}
currentFolderId={effectiveFolderIdForFiltering} currentFolderId={currentFolderId}
/> />
</div> </div>
); );
@@ -14,8 +14,6 @@ type Props = {
export const PamAddFolderModal = ({ isOpen, onOpenChange, projectId, currentFolderId }: Props) => { export const PamAddFolderModal = ({ isOpen, onOpenChange, projectId, currentFolderId }: Props) => {
const createPamFolder = useCreatePamFolder(); const createPamFolder = useCreatePamFolder();
console.log({ currentFolderId });
const onSubmit = async (formData: Pick<TPamFolder, "name" | "description">) => { const onSubmit = async (formData: Pick<TPamFolder, "name" | "description">) => {
await createPamFolder.mutateAsync({ await createPamFolder.mutateAsync({
...formData, ...formData,
@@ -1,11 +1,12 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form"; import { Controller, FormProvider, useForm } from "react-hook-form";
import { SingleValue } from "react-select"; import { SingleValue } from "react-select";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Button, FilterableSelect, FormControl, ModalClose, Spinner } from "@app/components/v2"; import { Button, FilterableSelect, FormControl, ModalClose } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { usePopUp } from "@app/hooks"; import { useDebounce, usePopUp } from "@app/hooks";
import { PamResourceType, useListPamResources } from "@app/hooks/api/pam"; import { PamResourceType, useListPamResources } from "@app/hooks/api/pam";
import { PamAddResourceModal } from "../../PamResourcesPage/components/PamAddResourceModal"; import { PamAddResourceModal } from "../../PamResourcesPage/components/PamAddResourceModal";
@@ -28,7 +29,17 @@ type FormData = z.infer<typeof formSchema>;
export const ResourceSelect = ({ onSubmit, projectId }: Props) => { export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const { isPending, data: resources } = useListPamResources(projectId);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 350);
const { isPending, data } = useListPamResources({
projectId,
limit: 100,
search: debouncedSearch
});
const resources = data?.resources || [];
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addResource"] as const); const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addResource"] as const);
@@ -43,15 +54,6 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
ProjectPermissionSub.PamResources ProjectPermissionSub.PamResources
); );
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
</div>
);
}
return ( return (
<> <>
<FormProvider {...form}> <FormProvider {...form}>
@@ -65,6 +67,12 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
<FormControl isError={Boolean(error)} errorText={error?.message} label="Resource"> <FormControl isError={Boolean(error)} errorText={error?.message} label="Resource">
<FilterableSelect <FilterableSelect
value={value} value={value}
inputValue={search}
onInputChange={(val, actionMeta) => {
if (actionMeta.action === "input-change") {
setSearch(val);
}
}}
onChange={(newValue) => { onChange={(newValue) => {
if ((newValue as SingleValue<{ id: string }>)?.id === "_create") { if ((newValue as SingleValue<{ id: string }>)?.id === "_create") {
handlePopUpOpen("addResource"); handlePopUpOpen("addResource");
@@ -2,12 +2,13 @@ import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react
import { zodValidator } from "@tanstack/zod-adapter"; import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod"; import { z } from "zod";
import { AccountView } from "./components/AccountViewToggle"; import { PamAccountView } from "@app/hooks/api/pam";
import { PamAccountsPage } from "./PamAccountsPage"; import { PamAccountsPage } from "./PamAccountsPage";
const PamAccountsPageQueryParamsSchema = z.object({ const PamAccountsPageQueryParamsSchema = z.object({
search: z.string().optional(), search: z.string().optional(),
accountView: z.nativeEnum(AccountView).optional(), accountView: z.nativeEnum(PamAccountView).optional(),
accountPath: z.string().catch("/") accountPath: z.string().catch("/")
}); });
@@ -1,17 +1,9 @@
import { ContentLoader } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { useListPamResources } from "@app/hooks/api/pam";
import { PamResourcesTable } from "./PamResourcesTable"; import { PamResourcesTable } from "./PamResourcesTable";
export const PamResourcesSection = () => { export const PamResourcesSection = () => {
const { currentProject } = useProject(); const { currentProject } = useProject();
const { data: resources = [], isPending } = useListPamResources(currentProject.id, { return <PamResourcesTable projectId={currentProject.id} />;
refetchInterval: 30000
});
if (isPending) return <ContentLoader />;
return <PamResourcesTable resources={resources} projectId={currentProject.id} />;
}; };
@@ -27,6 +27,7 @@ import {
Pagination, Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton,
TBody, TBody,
Th, Th,
THead, THead,
@@ -39,29 +40,34 @@ import {
OrgGatewayPermissionActions, OrgGatewayPermissionActions,
OrgPermissionSubjects OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types"; } from "@app/context/OrgPermissionContext/types";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PAM_RESOURCE_TYPE_MAP, PamResourceType, TPamResource } from "@app/hooks/api/pam"; import {
PAM_RESOURCE_TYPE_MAP,
PamResourceOrderBy,
PamResourceType,
useListPamResources
} from "@app/hooks/api/pam";
import { PamAddResourceModal } from "./PamAddResourceModal"; import { PamAddResourceModal } from "./PamAddResourceModal";
import { PamDeleteResourceModal } from "./PamDeleteResourceModal"; import { PamDeleteResourceModal } from "./PamDeleteResourceModal";
import { PamResourceRow } from "./PamResourceRow"; import { PamResourceRow } from "./PamResourceRow";
import { PamUpdateResourceModal } from "./PamUpdateResourceModal"; import { PamUpdateResourceModal } from "./PamUpdateResourceModal";
enum OrderBy { type PamResourceFilter = {
Name = "name" resourceTypes: PamResourceType[];
}
type Filters = {
resourceType: PamResourceType[];
}; };
type Props = { type Props = {
projectId: string; projectId: string;
resources: TPamResource[];
}; };
export const PamResourcesTable = ({ projectId, resources }: Props) => { export const PamResourcesTable = ({ projectId }: Props) => {
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.ResourcesPage.path }); const navigate = useNavigate({ from: ROUTE_PATHS.Pam.ResourcesPage.path });
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
@@ -74,12 +80,13 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
from: ROUTE_PATHS.Pam.ResourcesPage.id from: ROUTE_PATHS.Pam.ResourcesPage.id
}); });
const [filters, setFilters] = useState<Filters>({ const [filter, setFilter] = useState<PamResourceFilter>({
resourceType: [] resourceTypes: []
}); });
const { const {
search, search,
debouncedSearch,
setSearch, setSearch,
setPage, setPage,
page, page,
@@ -91,51 +98,55 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
orderBy, orderBy,
setOrderDirection, setOrderDirection,
setOrderBy setOrderBy
} = usePagination<OrderBy>(OrderBy.Name, { initPerPage: 20, initSearch }); } = usePagination<PamResourceOrderBy>(PamResourceOrderBy.Name, {
initPerPage: getUserTablePreference("pamResourcesTable", PreferenceKey.PerPage, 20),
initSearch
});
const filteredResources = useMemo( const handlePerPageChange = (newPerPage: number) => {
() => setPerPage(newPerPage);
resources setUserTablePreference("pamResourcesTable", PreferenceKey.PerPage, newPerPage);
.filter((resource) => { };
const { name, resourceType } = resource;
if (filters.resourceType.length && !filters.resourceType.includes(resourceType)) { const { data, isLoading } = useListPamResources({
return false; projectId,
} offset,
limit: perPage,
search: debouncedSearch,
orderBy,
orderDirection,
filterResourceTypes: filter.resourceTypes.length ? filter.resourceTypes.join(",") : undefined
});
const searchValue = search.trim().toLowerCase(); const resources = data?.resources || [];
const totalCount = data?.totalCount || 0;
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
return (
name.toLowerCase().includes(searchValue) ||
resourceTypeName.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [one, two] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case OrderBy.Name:
default:
return one.name.toLowerCase().localeCompare(two.name.toLowerCase());
}
}),
[resources, orderDirection, search, orderBy, filters]
);
useResetPageHelper({ useResetPageHelper({
totalCount: filteredResources.length, totalCount,
offset, offset,
setPage setPage
}); });
const currentPageData = useMemo( const filteredResources = useMemo(
() => filteredResources.slice(offset, perPage * page), () =>
[filteredResources, offset, perPage, page] resources.filter((resource) => {
const { name, resourceType } = resource;
if (filter.resourceTypes.length && !filter.resourceTypes.includes(resourceType)) {
return false;
}
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) ||
resourceType.toLowerCase().includes(searchValue)
);
}),
[resources, search, filter]
); );
const handleSort = (column: OrderBy) => { const handleSort = (column: PamResourceOrderBy) => {
if (column === orderBy) { if (column === orderBy) {
toggleOrderDirection(); toggleOrderDirection();
return; return;
@@ -145,12 +156,13 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
setOrderDirection(OrderByDirection.ASC); setOrderDirection(OrderByDirection.ASC);
}; };
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30"); const getClassName = (col: PamResourceOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: OrderBy) => const getColSortIcon = (col: PamResourceOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.resourceType.length); const isTableFiltered = Boolean(filter.resourceTypes.length);
const isContentEmpty = !filteredResources.length; const isContentEmpty = !filteredResources.length;
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered); const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
@@ -187,43 +199,38 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end"> <DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end">
<DropdownMenuLabel>Resource Type</DropdownMenuLabel> <DropdownMenuLabel>Resource Type</DropdownMenuLabel>
{resources.length ? ( {Object.entries(PAM_RESOURCE_TYPE_MAP).map(([type, { name, image }]) => {
[...new Set(resources.map(({ resourceType }) => resourceType))].map((type) => { const resourceType = type as PamResourceType;
const { name, image } = PAM_RESOURCE_TYPE_MAP[type]; return (
<DropdownMenuItem
return ( onClick={(e) => {
<DropdownMenuItem e.preventDefault();
onClick={(e) => { setFilter((prev) => ({
e.preventDefault(); ...prev,
setFilters((prev) => ({ resourceTypes: prev.resourceTypes.includes(resourceType)
...prev, ? prev.resourceTypes.filter((a) => a !== resourceType)
resourceType: prev.resourceType.includes(type) : [...prev.resourceTypes, resourceType]
? prev.resourceType.filter((a) => a !== type) }));
: [...prev.resourceType, type] }}
})); key={resourceType}
}} icon={
key={type} filter.resourceTypes.includes(resourceType) && (
icon={ <FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
filters.resourceType.includes(type) && ( )
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} /> }
) iconPos="right"
} >
iconPos="right" <div className="flex items-center gap-2">
> <img
<div className="flex items-center gap-2"> alt={`${name} resource type`}
<img src={`/images/integrations/${image}`}
alt={`${name} resource type`} className="h-4 w-4"
src={`/images/integrations/${image}`} />
className="h-4 w-4" <span>{name}</span>
/> </div>
<span>{name}</span> </DropdownMenuItem>
</div> );
</DropdownMenuItem> })}
);
})
) : (
<DropdownMenuItem isDisabled>No Resources</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<OrgPermissionCan <OrgPermissionCan
@@ -263,11 +270,11 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
Resource Resource
<IconButton <IconButton
variant="plain" variant="plain"
className={getClassName(OrderBy.Name)} className={getClassName(PamResourceOrderBy.Name)}
ariaLabel="sort" ariaLabel="sort"
onClick={() => handleSort(OrderBy.Name)} onClick={() => handleSort(PamResourceOrderBy.Name)}
> >
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} /> <FontAwesomeIcon icon={getColSortIcon(PamResourceOrderBy.Name)} />
</IconButton> </IconButton>
</div> </div>
</Th> </Th>
@@ -275,27 +282,29 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{currentPageData.map((resource) => ( {isLoading && <TableSkeleton columns={2} innerKey="pam-resources" />}
<PamResourceRow {!isLoading &&
key={resource.id} filteredResources.map((resource) => (
resource={resource} <PamResourceRow
onUpdate={(e) => handlePopUpOpen("updateResource", e)} key={resource.id}
onDelete={(e) => handlePopUpOpen("deleteResource", e)} resource={resource}
search={search.trim().toLowerCase()} onUpdate={(e) => handlePopUpOpen("updateResource", e)}
/> onDelete={(e) => handlePopUpOpen("deleteResource", e)}
))} search={search.trim().toLowerCase()}
/>
))}
</TBody> </TBody>
</Table> </Table>
{Boolean(filteredResources.length) && ( {Boolean(totalCount) && !isLoading && (
<Pagination <Pagination
count={filteredResources.length} count={totalCount}
page={page} page={page}
perPage={perPage} perPage={perPage}
onChangePage={setPage} onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={setPerPage} onChangePerPage={handlePerPageChange}
/> />
)} )}
{isContentEmpty && ( {!isLoading && isContentEmpty && (
<EmptyState <EmptyState
title={isSearchEmpty ? "No resources match search" : "No resources"} title={isSearchEmpty ? "No resources match search" : "No resources"}
icon={isSearchEmpty ? faSearch : faCircleXmark} icon={isSearchEmpty ? faSearch : faCircleXmark}
@@ -25,7 +25,7 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText"; import { HighlightText } from "@app/components/v2/HighlightText";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { PAM_RESOURCE_TYPE_MAP, TTerminalEvent, TPamSession } from "@app/hooks/api/pam"; import { PAM_RESOURCE_TYPE_MAP, TPamSession, TTerminalEvent } from "@app/hooks/api/pam";
import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils"; import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils";
import { aggregateTerminalEvents } from "../../PamSessionsByIDPage/components/terminal-utils"; import { aggregateTerminalEvents } from "../../PamSessionsByIDPage/components/terminal-utils";