mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #4831 from Infisical/PAM-10
pam: server-side filter, search, pagination
This commit is contained in:
@@ -2,12 +2,15 @@ import { z } from "zod";
|
||||
|
||||
import { PamFoldersSchema } from "@app/db/schemas";
|
||||
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 { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
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 { BadRequestError } from "@app/lib/errors";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
@@ -28,33 +31,69 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
schema: {
|
||||
description: "List PAM accounts",
|
||||
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: {
|
||||
200: z.object({
|
||||
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]),
|
||||
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({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_LIST,
|
||||
metadata: {
|
||||
accountCount: response.accounts.length,
|
||||
folderCount: response.folders.length
|
||||
accountCount: accounts.length,
|
||||
folderCount: folders.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
return { accounts, folders, totalCount, folderId, folderPaths };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
MySQLResourceListItemSchema,
|
||||
SanitizedMySQLResourceSchema
|
||||
} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
||||
import { PamResourceOrderBy } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import {
|
||||
PostgresResourceListItemSchema,
|
||||
SanitizedPostgresResourceSchema
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
SanitizedSSHResourceSchema,
|
||||
SSHResourceListItemSchema
|
||||
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
@@ -61,17 +63,46 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
||||
schema: {
|
||||
description: "List PAM resources",
|
||||
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: {
|
||||
200: z.object({
|
||||
resources: SanitizedResourceSchema.array()
|
||||
resources: SanitizedResourceSchema.array(),
|
||||
totalCount: z.number().default(0)
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
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({
|
||||
...req.auditLogInfo,
|
||||
@@ -80,12 +111,12 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_LIST,
|
||||
metadata: {
|
||||
count: response.resources.length
|
||||
count: resources.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
return { resources, totalCount };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,46 +1,109 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TPamAccounts } from "@app/db/schemas";
|
||||
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
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>;
|
||||
|
||||
type PamAccountFindFilter = Parameters<typeof buildFindFilter<TPamAccounts>>[0];
|
||||
|
||||
export const pamAccountDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamAccount);
|
||||
|
||||
const findWithResourceDetails = async (filter: PamAccountFindFilter, tx?: Knex) => {
|
||||
const query = (tx || db.replicaNode())(TableName.PamAccount)
|
||||
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
|
||||
.select(selectAllTableCols(TableName.PamAccount))
|
||||
.select(
|
||||
const findByProjectIdWithResourceDetails = async (
|
||||
{
|
||||
projectId,
|
||||
folderId,
|
||||
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
|
||||
db.ref("name").withSchema(TableName.PamResource).as("resourceName"),
|
||||
db.ref("resourceType").withSchema(TableName.PamResource),
|
||||
db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource)
|
||||
);
|
||||
|
||||
if (filter) {
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PamAccount, filter)));
|
||||
const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
|
||||
|
||||
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) => {
|
||||
@@ -59,5 +122,9 @@ export const pamAccountDALFactory = (db: TDbClient) => {
|
||||
return accounts;
|
||||
};
|
||||
|
||||
return { ...orm, findWithResourceDetails, findAccountsDueForRotation };
|
||||
return {
|
||||
...orm,
|
||||
findByProjectIdWithResourceDetails,
|
||||
findAccountsDueForRotation
|
||||
};
|
||||
};
|
||||
|
||||
8
backend/src/ee/services/pam-account/pam-account-enums.ts
Normal file
8
backend/src/ee/services/pam-account/pam-account-enums.ts
Normal file
@@ -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 { 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 { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns";
|
||||
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 { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { TPamAccountDALFactory } from "./pam-account-dal";
|
||||
import { PamAccountView } from "./pam-account-enums";
|
||||
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 = {
|
||||
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({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
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 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<
|
||||
TPamAccounts & {
|
||||
@@ -360,12 +436,6 @@ export const pamAccountServiceFactory = ({
|
||||
> = [];
|
||||
|
||||
for await (const account of accountsWithResourceDetails) {
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: account.folderId,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
// Check permission for each individual account
|
||||
if (
|
||||
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 {
|
||||
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 { PamAccountOrderBy, PamAccountView } from "./pam-account-enums";
|
||||
|
||||
// DTOs
|
||||
export type TCreateAccountDTO = Pick<
|
||||
@@ -18,3 +21,14 @@ export type TAccessAccountDTO = {
|
||||
actorUserAgent: string;
|
||||
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 { 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 const pamFolderDALFactory = (db: TDbClient) => {
|
||||
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 { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
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 const pamResourceDALFactory = (db: TDbClient) => {
|
||||
@@ -20,5 +24,65 @@ export const pamResourceDALFactory = (db: TDbClient) => {
|
||||
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",
|
||||
SSH = "ssh"
|
||||
}
|
||||
|
||||
export enum PamResourceOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
encryptResourceConnectionDetails,
|
||||
listResourceOptions
|
||||
} from "./pam-resource-fns";
|
||||
import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types";
|
||||
import { TCreateResourceDTO, TListResourcesDTO, TUpdateResourceDTO } from "./pam-resource-types";
|
||||
|
||||
type TPamResourceServiceFactoryDep = {
|
||||
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({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
|
||||
|
||||
const resources = await pamResourceDAL.find({ projectId });
|
||||
const { resources, totalCount } = await pamResourceDAL.findByProjectId({ projectId, ...params });
|
||||
|
||||
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 {
|
||||
TMySQLAccount,
|
||||
@@ -5,7 +7,7 @@ import {
|
||||
TMySQLResource,
|
||||
TMySQLResourceConnectionDetails
|
||||
} from "./mysql/mysql-resource-types";
|
||||
import { PamResource } from "./pam-resource-enums";
|
||||
import { PamResource, PamResourceOrderBy } from "./pam-resource-enums";
|
||||
import {
|
||||
TPostgresAccount,
|
||||
TPostgresAccountCredentials,
|
||||
@@ -41,6 +43,15 @@ export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType"
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
export type TListResourcesDTO = {
|
||||
search?: string;
|
||||
orderBy?: PamResourceOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filterResourceTypes?: string[];
|
||||
} & TProjectPermission;
|
||||
|
||||
// Resource factory
|
||||
export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>;
|
||||
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Resources
|
||||
export enum PamResourceType {
|
||||
Postgres = "postgres",
|
||||
MySQL = "mysql",
|
||||
@@ -18,9 +19,24 @@ export enum PamResourceType {
|
||||
DynamoDB = "dynamodb"
|
||||
}
|
||||
|
||||
export enum PamResourceOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
// Sessions
|
||||
export enum PamSessionStatus {
|
||||
Starting = "starting",
|
||||
Active = "active",
|
||||
Ended = "ended",
|
||||
Terminated = "terminated"
|
||||
}
|
||||
|
||||
// Accounts
|
||||
export enum PamAccountOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export enum PamAccountView {
|
||||
Flat = "flat",
|
||||
Nested = "nested"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const useCreatePamResource = () => {
|
||||
return data.resource;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -48,7 +48,7 @@ export const useUpdatePamResource = () => {
|
||||
return data.resource;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -64,7 +64,7 @@ export const useDeletePamResource = () => {
|
||||
return data.resource;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -82,7 +82,7 @@ export const useCreatePamAccount = () => {
|
||||
return data.account;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -99,7 +99,7 @@ export const useUpdatePamAccount = () => {
|
||||
return data.account;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -115,7 +115,7 @@ export const useDeletePamAccount = () => {
|
||||
return data.account;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -130,7 +130,7 @@ export const useCreatePamFolder = () => {
|
||||
return data.folder;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -147,7 +147,7 @@ export const useUpdatePamFolder = () => {
|
||||
return data.folder;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -163,7 +163,7 @@ export const useDeletePamFolder = () => {
|
||||
return data.folder;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,7 +4,14 @@ import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TPamResourceOption } from "./types/resource-options";
|
||||
import { PamResourceType } from "./enums";
|
||||
import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types";
|
||||
import {
|
||||
TListPamAccountsDTO,
|
||||
TListPamResourcesDTO,
|
||||
TPamAccount,
|
||||
TPamFolder,
|
||||
TPamResource,
|
||||
TPamSession
|
||||
} from "./types";
|
||||
|
||||
export const pamKeys = {
|
||||
all: ["pam"] as const,
|
||||
@@ -12,14 +19,24 @@ export const pamKeys = {
|
||||
account: () => [...pamKeys.all, "account"] as const,
|
||||
session: () => [...pamKeys.all, "session"] 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) => [
|
||||
...pamKeys.resource(),
|
||||
"get",
|
||||
resourceType,
|
||||
resourceId
|
||||
],
|
||||
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId],
|
||||
listAccounts: ({ projectId, ...params }: TListPamAccountsDTO) => [
|
||||
...pamKeys.account(),
|
||||
"list",
|
||||
projectId,
|
||||
params
|
||||
],
|
||||
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
|
||||
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
|
||||
};
|
||||
@@ -49,28 +66,33 @@ export const useListPamResourceOptions = (
|
||||
});
|
||||
};
|
||||
|
||||
type TListPamResourcesResponse = {
|
||||
resources: TPamResource[];
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
export const useListPamResources = (
|
||||
projectId: string,
|
||||
params: TListPamResourcesDTO,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TPamResource[],
|
||||
TListPamResourcesResponse,
|
||||
unknown,
|
||||
TPamResource[],
|
||||
TListPamResourcesResponse,
|
||||
ReturnType<typeof pamKeys.listResources>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pamKeys.listResources(projectId),
|
||||
queryKey: pamKeys.listResources(params),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ resources: TPamResource[] }>(
|
||||
"/api/v1/pam/resources",
|
||||
{ params: { projectId } }
|
||||
);
|
||||
const { data } = await apiRequest.get<TListPamResourcesResponse>("/api/v1/pam/resources", {
|
||||
params
|
||||
});
|
||||
|
||||
return data.resources;
|
||||
return data;
|
||||
},
|
||||
placeholderData: (prev) => prev,
|
||||
...options
|
||||
});
|
||||
};
|
||||
@@ -98,28 +120,36 @@ export const useGetPamResourceById = (
|
||||
};
|
||||
|
||||
// Accounts
|
||||
type TListPamAccountsResponse = {
|
||||
accounts: TPamAccount[];
|
||||
folders: TPamFolder[];
|
||||
totalCount: number;
|
||||
folderId?: string;
|
||||
folderPaths: Record<string, string>;
|
||||
};
|
||||
|
||||
export const useListPamAccounts = (
|
||||
projectId: string,
|
||||
params: TListPamAccountsDTO,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
{ accounts: TPamAccount[]; folders: TPamFolder[] },
|
||||
TListPamAccountsResponse,
|
||||
unknown,
|
||||
{ accounts: TPamAccount[]; folders: TPamFolder[] },
|
||||
TListPamAccountsResponse,
|
||||
ReturnType<typeof pamKeys.listAccounts>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pamKeys.listAccounts(projectId),
|
||||
queryKey: pamKeys.listAccounts(params),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ accounts: TPamAccount[]; folders: TPamFolder[] }>(
|
||||
"/api/v1/pam/accounts",
|
||||
{ params: { projectId } }
|
||||
);
|
||||
const { data } = await apiRequest.get<TListPamAccountsResponse>("/api/v1/pam/accounts", {
|
||||
params
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
placeholderData: (prev) => prev,
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 { TPostgresAccount, TPostgresResource } from "./postgres-resource";
|
||||
import { TSSHAccount, TSSHResource } from "./ssh-resource";
|
||||
@@ -59,6 +66,16 @@ export type TPamSession = {
|
||||
};
|
||||
|
||||
// Resource DTOs
|
||||
export type TListPamResourcesDTO = {
|
||||
projectId: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: PamResourceOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
search?: string;
|
||||
filterResourceTypes?: string;
|
||||
};
|
||||
|
||||
export type TCreatePamResourceDTO = Pick<
|
||||
TPamResource,
|
||||
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
|
||||
@@ -77,6 +94,18 @@ export type TDeletePamResourceDTO = {
|
||||
};
|
||||
|
||||
// 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<
|
||||
TPamAccount,
|
||||
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId"
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { Button } from "@app/components/v2";
|
||||
|
||||
export enum AccountView {
|
||||
Flat = "flat",
|
||||
Nested = "nested"
|
||||
}
|
||||
import { PamAccountView } from "@app/hooks/api/pam";
|
||||
|
||||
type Props = {
|
||||
value: AccountView;
|
||||
onChange: (value: AccountView) => void;
|
||||
value: PamAccountView;
|
||||
onChange: (value: PamAccountView) => void;
|
||||
};
|
||||
|
||||
export const AccountViewToggle = ({ value, onChange }: Props) => {
|
||||
@@ -16,11 +12,11 @@ export const AccountViewToggle = ({ value, onChange }: Props) => {
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
onChange(AccountView.Flat);
|
||||
onChange(PamAccountView.Flat);
|
||||
}}
|
||||
size="xs"
|
||||
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`}
|
||||
>
|
||||
Hide Folders
|
||||
@@ -28,11 +24,11 @@ export const AccountViewToggle = ({ value, onChange }: Props) => {
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
onChange(AccountView.Nested);
|
||||
onChange(PamAccountView.Nested);
|
||||
}}
|
||||
size="xs"
|
||||
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`}
|
||||
>
|
||||
Show Folders
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import { ContentLoader } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useListPamAccounts } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamAccountsTable } from "./PamAccountsTable";
|
||||
|
||||
export const PamAccountsSection = () => {
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { data, isPending } = useListPamAccounts(currentProject.id, {
|
||||
refetchInterval: 30000
|
||||
});
|
||||
|
||||
if (isPending) return <ContentLoader />;
|
||||
|
||||
return (
|
||||
<PamAccountsTable
|
||||
projectId={currentProject.id}
|
||||
accounts={data?.accounts || []}
|
||||
folders={data?.folders || []}
|
||||
/>
|
||||
);
|
||||
return <PamAccountsTable projectId={currentProject.id} />;
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
@@ -40,11 +41,22 @@ import {
|
||||
ProjectPermissionPamAccountActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
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 { PamAccessAccountModal } from "./PamAccessAccountModal";
|
||||
import { PamAccountRow } from "./PamAccountRow";
|
||||
@@ -56,21 +68,15 @@ import { PamFolderRow } from "./PamFolderRow";
|
||||
import { PamUpdateAccountModal } from "./PamUpdateAccountModal";
|
||||
import { PamUpdateFolderModal } from "./PamUpdateFolderModal";
|
||||
|
||||
enum OrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
type Filters = {
|
||||
resource: string[];
|
||||
type PamAccountFilter = {
|
||||
resourceIds: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
accounts: TPamAccount[];
|
||||
folders: TPamFolder[];
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
export const PamAccountsTable = ({ projectId }: Props) => {
|
||||
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
@@ -92,18 +98,21 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
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>({
|
||||
resource: []
|
||||
const [filter, setFilter] = useState<PamAccountFilter>({
|
||||
resourceIds: []
|
||||
});
|
||||
|
||||
const {
|
||||
search,
|
||||
debouncedSearch,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
@@ -111,114 +120,74 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
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 foldersById: Record<string, TPamFolder> = {};
|
||||
const tempFoldersByParentId: Record<string, TPamFolder[]> = { null: [] };
|
||||
const tempPathMap: Record<string, string> = { "/": "null" };
|
||||
const tempFolderPaths: Record<string, string> = {};
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
folders.forEach((folder) => {
|
||||
foldersById[folder.id] = folder;
|
||||
if (!tempFoldersByParentId[folder.parentId || "null"]) {
|
||||
tempFoldersByParentId[folder.parentId || "null"] = [];
|
||||
}
|
||||
tempFoldersByParentId[folder.parentId || "null"].push(folder);
|
||||
});
|
||||
const { data, isLoading } = useListPamAccounts({
|
||||
projectId,
|
||||
accountPath,
|
||||
accountView,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search: debouncedSearch,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
filterResourceIds: filter.resourceIds.length ? filter.resourceIds.join(",") : undefined
|
||||
});
|
||||
|
||||
const buildPaths = (parentId: string | null, currentPath: string) => {
|
||||
(tempFoldersByParentId[parentId || "null"] || []).forEach((folder) => {
|
||||
const newPath = `${currentPath}${folder.name}/`;
|
||||
tempPathMap[newPath] = folder.id;
|
||||
tempFolderPaths[folder.id] = newPath;
|
||||
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]
|
||||
);
|
||||
const accounts = data?.accounts || [];
|
||||
const folders = data?.folders || [];
|
||||
const totalCount = data?.totalCount || 0;
|
||||
const folderPaths = data?.folderPaths || {};
|
||||
const currentFolderId = data?.folderId ?? null;
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredAccounts.length,
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const currentPageData = useMemo(
|
||||
() => filteredAccounts.slice(offset, perPage * page),
|
||||
[filteredAccounts, offset, perPage, page]
|
||||
const foldersToRender = useMemo(() => {
|
||||
if (accountView === PamAccountView.Flat) {
|
||||
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) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
@@ -228,15 +197,16 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
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;
|
||||
|
||||
const isTableFiltered = Boolean(filters.resource.length);
|
||||
const isTableFiltered = Boolean(filter.resourceIds.length);
|
||||
|
||||
const handleFolderClick = (folder: TPamFolder) => {
|
||||
if (accountView === AccountView.Flat) {
|
||||
if (accountView === PamAccountView.Flat) {
|
||||
return;
|
||||
}
|
||||
const newPath = `${accountPath}${folder.name}/`;
|
||||
@@ -246,17 +216,17 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
const isContentEmpty = !filteredAccounts.length && !foldersToRender.length;
|
||||
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
|
||||
|
||||
const uniqueResources = useMemo(() => {
|
||||
const resourceMap = new Map<string, TPamAccount["resource"]>();
|
||||
accounts.forEach((account) => {
|
||||
resourceMap.set(account.resource.id, account.resource);
|
||||
});
|
||||
return Array.from(resourceMap.values());
|
||||
}, [accounts]);
|
||||
const { data: resourcesData } = useListPamResources({
|
||||
projectId,
|
||||
// temporarily returning a large number until we rework table filtering
|
||||
limit: 100
|
||||
});
|
||||
|
||||
const resources = resourcesData?.resources || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{accountView === AccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
|
||||
{accountView === PamAccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Read} a={ProjectPermissionSub.PamFolders}>
|
||||
{(isAllowed) =>
|
||||
@@ -264,12 +234,14 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
<AccountViewToggle
|
||||
value={accountView}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setFilter({ resourceIds: [] });
|
||||
setAccountView(e);
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
accountView: e === AccountView.Flat ? undefined : e,
|
||||
accountPath: e === AccountView.Flat ? "/" : prev.accountPath
|
||||
accountView: e === PamAccountView.Flat ? undefined : e,
|
||||
accountPath: e === PamAccountView.Flat ? "/" : prev.accountPath
|
||||
})
|
||||
});
|
||||
}}
|
||||
@@ -307,25 +279,25 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end">
|
||||
<DropdownMenuLabel>Resource</DropdownMenuLabel>
|
||||
{uniqueResources.length ? (
|
||||
uniqueResources.map((resource) => {
|
||||
{resources.length ? (
|
||||
resources.map((resource) => {
|
||||
const { name, image } = PAM_RESOURCE_TYPE_MAP[resource.resourceType];
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const newResources = filters.resource.includes(resource.id)
|
||||
? filters.resource.filter((a) => a !== resource.id)
|
||||
: [...filters.resource, resource.id];
|
||||
setFilters((prev) => ({
|
||||
const newResources = filter.resourceIds.includes(resource.id)
|
||||
? filter.resourceIds.filter((a) => a !== resource.id)
|
||||
: [...filter.resourceIds, resource.id];
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
resource: newResources
|
||||
resourceIds: newResources
|
||||
}));
|
||||
}}
|
||||
key={resource.id}
|
||||
icon={
|
||||
filters.resource.includes(resource.id) && (
|
||||
filter.resourceIds.includes(resource.id) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
@@ -358,12 +330,12 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addAccount")}
|
||||
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
|
||||
</Button>
|
||||
|
||||
{accountView !== AccountView.Flat && (
|
||||
{accountView !== PamAccountView.Flat && (
|
||||
<DropdownMenu
|
||||
open={popUp.misc.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
||||
@@ -414,11 +386,11 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
Accounts
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(OrderBy.Name)}
|
||||
className={getClassName(PamAccountOrderBy.Name)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(OrderBy.Name)}
|
||||
onClick={() => handleSort(PamAccountOrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} />
|
||||
<FontAwesomeIcon icon={getColSortIcon(PamAccountOrderBy.Name)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
@@ -426,45 +398,48 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{accountView !== AccountView.Flat &&
|
||||
foldersToRender.map((folder) => (
|
||||
<PamFolderRow
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
search={search}
|
||||
onClick={() => handleFolderClick(folder)}
|
||||
onUpdate={(e) => handlePopUpOpen("updateFolder", e)}
|
||||
onDelete={(e) => handlePopUpOpen("deleteFolder", e)}
|
||||
/>
|
||||
))}
|
||||
{currentPageData.map((account) => (
|
||||
<PamAccountRow
|
||||
key={account.id}
|
||||
account={account}
|
||||
search={search}
|
||||
isFlatView={accountView === AccountView.Flat}
|
||||
accountPath={
|
||||
account.folderId ? folderPaths[account.folderId]?.slice(0, -1) : undefined
|
||||
}
|
||||
onAccess={(e) => {
|
||||
handlePopUpOpen("accessAccount", e);
|
||||
}}
|
||||
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
|
||||
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
|
||||
/>
|
||||
))}
|
||||
{isLoading && <TableSkeleton columns={2} innerKey="pam-accounts" />}
|
||||
{!isLoading && (
|
||||
<>
|
||||
{accountView !== PamAccountView.Flat &&
|
||||
foldersToRender.map((folder) => (
|
||||
<PamFolderRow
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
search={search}
|
||||
onClick={() => handleFolderClick(folder)}
|
||||
onUpdate={(e) => handlePopUpOpen("updateFolder", e)}
|
||||
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}
|
||||
onAccess={(e) => {
|
||||
handlePopUpOpen("accessAccount", e);
|
||||
}}
|
||||
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
|
||||
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredAccounts.length) && (
|
||||
{Boolean(totalCount) && !isLoading && (
|
||||
<Pagination
|
||||
count={filteredAccounts.length}
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{isContentEmpty && (
|
||||
{!isLoading && isContentEmpty && (
|
||||
<EmptyState
|
||||
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
|
||||
icon={isSearchEmpty ? faSearch : faCircleXmark}
|
||||
@@ -485,7 +460,7 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
isOpen={popUp.addFolder.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
|
||||
projectId={projectId}
|
||||
currentFolderId={effectiveFolderIdForFiltering}
|
||||
currentFolderId={currentFolderId}
|
||||
/>
|
||||
<PamAccessAccountModal
|
||||
isOpen={popUp.accessAccount.isOpen}
|
||||
@@ -506,7 +481,7 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
isOpen={popUp.addAccount.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addAccount", isOpen)}
|
||||
projectId={projectId}
|
||||
currentFolderId={effectiveFolderIdForFiltering}
|
||||
currentFolderId={currentFolderId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,8 +14,6 @@ type Props = {
|
||||
export const PamAddFolderModal = ({ isOpen, onOpenChange, projectId, currentFolderId }: Props) => {
|
||||
const createPamFolder = useCreatePamFolder();
|
||||
|
||||
console.log({ currentFolderId });
|
||||
|
||||
const onSubmit = async (formData: Pick<TPamFolder, "name" | "description">) => {
|
||||
await createPamFolder.mutateAsync({
|
||||
...formData,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { SingleValue } from "react-select";
|
||||
import { zodResolver } from "@hookform/resolvers/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 { usePopUp } from "@app/hooks";
|
||||
import { useDebounce, usePopUp } from "@app/hooks";
|
||||
import { PamResourceType, useListPamResources } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamAddResourceModal } from "../../PamResourcesPage/components/PamAddResourceModal";
|
||||
@@ -28,7 +29,17 @@ type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
|
||||
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);
|
||||
|
||||
@@ -43,15 +54,6 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
|
||||
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 (
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
@@ -65,6 +67,12 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} label="Resource">
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
inputValue={search}
|
||||
onInputChange={(val, actionMeta) => {
|
||||
if (actionMeta.action === "input-change") {
|
||||
setSearch(val);
|
||||
}
|
||||
}}
|
||||
onChange={(newValue) => {
|
||||
if ((newValue as SingleValue<{ id: string }>)?.id === "_create") {
|
||||
handlePopUpOpen("addResource");
|
||||
|
||||
@@ -2,12 +2,13 @@ import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AccountView } from "./components/AccountViewToggle";
|
||||
import { PamAccountView } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamAccountsPage } from "./PamAccountsPage";
|
||||
|
||||
const PamAccountsPageQueryParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
accountView: z.nativeEnum(AccountView).optional(),
|
||||
accountView: z.nativeEnum(PamAccountView).optional(),
|
||||
accountPath: z.string().catch("/")
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { ContentLoader } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useListPamResources } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamResourcesTable } from "./PamResourcesTable";
|
||||
|
||||
export const PamResourcesSection = () => {
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { data: resources = [], isPending } = useListPamResources(currentProject.id, {
|
||||
refetchInterval: 30000
|
||||
});
|
||||
|
||||
if (isPending) return <ContentLoader />;
|
||||
|
||||
return <PamResourcesTable resources={resources} projectId={currentProject.id} />;
|
||||
return <PamResourcesTable projectId={currentProject.id} />;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
@@ -39,29 +40,34 @@ import {
|
||||
OrgGatewayPermissionActions,
|
||||
OrgPermissionSubjects
|
||||
} from "@app/context/OrgPermissionContext/types";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
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 { PamDeleteResourceModal } from "./PamDeleteResourceModal";
|
||||
import { PamResourceRow } from "./PamResourceRow";
|
||||
import { PamUpdateResourceModal } from "./PamUpdateResourceModal";
|
||||
|
||||
enum OrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
type Filters = {
|
||||
resourceType: PamResourceType[];
|
||||
type PamResourceFilter = {
|
||||
resourceTypes: PamResourceType[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
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 { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
@@ -74,12 +80,13 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
||||
from: ROUTE_PATHS.Pam.ResourcesPage.id
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
resourceType: []
|
||||
const [filter, setFilter] = useState<PamResourceFilter>({
|
||||
resourceTypes: []
|
||||
});
|
||||
|
||||
const {
|
||||
search,
|
||||
debouncedSearch,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
@@ -91,51 +98,55 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<OrderBy>(OrderBy.Name, { initPerPage: 20, initSearch });
|
||||
} = usePagination<PamResourceOrderBy>(PamResourceOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("pamResourcesTable", PreferenceKey.PerPage, 20),
|
||||
initSearch
|
||||
});
|
||||
|
||||
const filteredResources = useMemo(
|
||||
() =>
|
||||
resources
|
||||
.filter((resource) => {
|
||||
const { name, resourceType } = resource;
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("pamResourcesTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
if (filters.resourceType.length && !filters.resourceType.includes(resourceType)) {
|
||||
return false;
|
||||
}
|
||||
const { data, isLoading } = useListPamResources({
|
||||
projectId,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search: debouncedSearch,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
filterResourceTypes: filter.resourceTypes.length ? filter.resourceTypes.join(",") : undefined
|
||||
});
|
||||
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
|
||||
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]
|
||||
);
|
||||
const resources = data?.resources || [];
|
||||
const totalCount = data?.totalCount || 0;
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredResources.length,
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const currentPageData = useMemo(
|
||||
() => filteredResources.slice(offset, perPage * page),
|
||||
[filteredResources, offset, perPage, page]
|
||||
const filteredResources = useMemo(
|
||||
() =>
|
||||
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) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
@@ -145,12 +156,13 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
||||
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;
|
||||
|
||||
const isTableFiltered = Boolean(filters.resourceType.length);
|
||||
const isTableFiltered = Boolean(filter.resourceTypes.length);
|
||||
const isContentEmpty = !filteredResources.length;
|
||||
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
|
||||
|
||||
@@ -187,43 +199,38 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end">
|
||||
<DropdownMenuLabel>Resource Type</DropdownMenuLabel>
|
||||
{resources.length ? (
|
||||
[...new Set(resources.map(({ resourceType }) => resourceType))].map((type) => {
|
||||
const { name, image } = PAM_RESOURCE_TYPE_MAP[type];
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
resourceType: prev.resourceType.includes(type)
|
||||
? prev.resourceType.filter((a) => a !== type)
|
||||
: [...prev.resourceType, type]
|
||||
}));
|
||||
}}
|
||||
key={type}
|
||||
icon={
|
||||
filters.resourceType.includes(type) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${name} resource type`}
|
||||
src={`/images/integrations/${image}`}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DropdownMenuItem isDisabled>No Resources</DropdownMenuItem>
|
||||
)}
|
||||
{Object.entries(PAM_RESOURCE_TYPE_MAP).map(([type, { name, image }]) => {
|
||||
const resourceType = type as PamResourceType;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
resourceTypes: prev.resourceTypes.includes(resourceType)
|
||||
? prev.resourceTypes.filter((a) => a !== resourceType)
|
||||
: [...prev.resourceTypes, resourceType]
|
||||
}));
|
||||
}}
|
||||
key={resourceType}
|
||||
icon={
|
||||
filter.resourceTypes.includes(resourceType) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${name} resource type`}
|
||||
src={`/images/integrations/${image}`}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<OrgPermissionCan
|
||||
@@ -263,11 +270,11 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
||||
Resource
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(OrderBy.Name)}
|
||||
className={getClassName(PamResourceOrderBy.Name)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(OrderBy.Name)}
|
||||
onClick={() => handleSort(PamResourceOrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} />
|
||||
<FontAwesomeIcon icon={getColSortIcon(PamResourceOrderBy.Name)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
@@ -275,27 +282,29 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{currentPageData.map((resource) => (
|
||||
<PamResourceRow
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
onUpdate={(e) => handlePopUpOpen("updateResource", e)}
|
||||
onDelete={(e) => handlePopUpOpen("deleteResource", e)}
|
||||
search={search.trim().toLowerCase()}
|
||||
/>
|
||||
))}
|
||||
{isLoading && <TableSkeleton columns={2} innerKey="pam-resources" />}
|
||||
{!isLoading &&
|
||||
filteredResources.map((resource) => (
|
||||
<PamResourceRow
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
onUpdate={(e) => handlePopUpOpen("updateResource", e)}
|
||||
onDelete={(e) => handlePopUpOpen("deleteResource", e)}
|
||||
search={search.trim().toLowerCase()}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredResources.length) && (
|
||||
{Boolean(totalCount) && !isLoading && (
|
||||
<Pagination
|
||||
count={filteredResources.length}
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{isContentEmpty && (
|
||||
{!isLoading && isContentEmpty && (
|
||||
<EmptyState
|
||||
title={isSearchEmpty ? "No resources match search" : "No resources"}
|
||||
icon={isSearchEmpty ? faSearch : faCircleXmark}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||
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 { aggregateTerminalEvents } from "../../PamSessionsByIDPage/components/terminal-utils";
|
||||
|
||||
Reference in New Issue
Block a user