diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 65fcf3c86..8a6b2be88 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -89,7 +89,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv schema: { querystring: z.object({ projectSlug: z.string().trim(), - authorProjectMembershipId: z.string().trim().optional(), + authorUserId: z.string().trim().optional(), envSlug: z.string().trim().optional() }), response: { @@ -143,7 +143,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv handler: async (req) => { const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({ projectSlug: req.query.projectSlug, - authorProjectMembershipId: req.query.authorProjectMembershipId, + authorUserId: req.query.authorUserId, envSlug: req.query.envSlug, actor: req.permission.type, actorId: req.permission.id, diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index ce745245f..e558062b1 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -30,6 +30,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv workspaceId: z.string().trim(), environment: z.string().trim().optional(), committer: z.string().trim().optional(), + search: z.string().trim().optional(), status: z.nativeEnum(RequestState).optional(), limit: z.coerce.number().default(20), offset: z.coerce.number().default(0) @@ -66,13 +67,14 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv userId: z.string().nullable().optional() }) .array() - }).array() + }).array(), + totalCount: z.number() }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const approvals = await server.services.secretApprovalRequest.getSecretApprovals({ + const { approvals, totalCount } = await server.services.secretApprovalRequest.getSecretApprovals({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -80,7 +82,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv ...req.query, projectId: req.query.workspaceId }); - return { approvals }; + return { approvals, totalCount }; } }); diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index c69c55041..33e9f7a32 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -725,16 +725,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR ) .where(`${TableName.Environment}.projectId`, projectId) - .where(`${TableName.AccessApprovalPolicy}.deletedAt`, null) .select(selectAllTableCols(TableName.AccessApprovalRequest)) .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")) - .select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId")); + .select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId")) + .select(db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")); const formattedRequests = sqlNestRelationships({ data: accessRequests, key: "id", parentMapper: (doc) => ({ - ...AccessApprovalRequestsSchema.parse(doc) + ...AccessApprovalRequestsSchema.parse(doc), + isPolicyDeleted: Boolean(doc.policyDeletedAt) }), childrenMapper: [ { @@ -751,7 +752,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR (req) => !req.privilegeId && !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) && - req.status === ApprovalStatus.PENDING + req.status === ApprovalStatus.PENDING && + !req.isPolicyDeleted ); // an approval is finalized if there are any rejections, a privilege ID is set or the number of approvals is equal to the number of approvals required. @@ -759,7 +761,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR (req) => req.privilegeId || req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) || - req.status !== ApprovalStatus.PENDING + req.status !== ApprovalStatus.PENDING || + req.isPolicyDeleted ); return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length }; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 70d491bf0..5a3af5aa5 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -275,7 +275,7 @@ export const accessApprovalRequestServiceFactory = ({ const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({ projectSlug, - authorProjectMembershipId, + authorUserId, envSlug, actor, actorOrgId, @@ -300,8 +300,8 @@ export const accessApprovalRequestServiceFactory = ({ const policies = await accessApprovalPolicyDAL.find({ projectId: project.id }); let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id)); - if (authorProjectMembershipId) { - requests = requests.filter((request) => request.requestedByUserId === actorId); + if (authorUserId) { + requests = requests.filter((request) => request.requestedByUserId === authorUserId); } if (envSlug) { diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index fb3e78de0..2550f2a96 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -31,7 +31,7 @@ export type TCreateAccessApprovalRequestDTO = { export type TListApprovalRequestsDTO = { projectSlug: string; - authorProjectMembershipId?: string; + authorUserId?: string; envSlug?: string; } & Omit; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 3bd35c3c8..5e1e546d6 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -24,6 +24,7 @@ type TFindQueryFilter = { committer?: string; limit?: number; offset?: number; + search?: string; }; export const secretApprovalRequestDALFactory = (db: TDbClient) => { @@ -314,7 +315,6 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId) .orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId) ) - .andWhere((bd) => void bd.where(`${TableName.SecretApprovalPolicy}.deletedAt`, null)) .select("status", `${TableName.SecretApprovalRequest}.id`) .groupBy(`${TableName.SecretApprovalRequest}.id`, "status") .count("status") @@ -340,13 +340,13 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { }; const findByProjectId = async ( - { status, limit = 20, offset = 0, projectId, committer, environment, userId }: TFindQueryFilter, + { status, limit = 20, offset = 0, projectId, committer, environment, userId, search }: TFindQueryFilter, tx?: Knex ) => { try { // akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination // this is the place u wanna look at. - const query = (tx || db.replicaNode())(TableName.SecretApprovalRequest) + const innerQuery = (tx || db.replicaNode())(TableName.SecretApprovalRequest) .join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`) .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .join( @@ -435,7 +435,30 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), db.ref("lastName").withSchema("committerUser").as("committerUserLastName") ) - .orderBy("createdAt", "desc"); + .distinctOn(`${TableName.SecretApprovalRequest}.id`) + .as("inner"); + + const query = (tx || db) + .select("*") + .select(db.raw("count(*) OVER() as total_count")) + .from(innerQuery) + .orderBy("createdAt", "desc") as typeof innerQuery; + + if (search) { + void query.where((qb) => { + void qb + .whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [ + db.ref("firstName").withSchema("committerUser"), + db.ref("lastName").withSchema("committerUser"), + `%${search}%` + ]) + .orWhereRaw(`?? ilike ?`, [db.ref("username").withSchema("committerUser"), `%${search}%`]) + .orWhereRaw(`?? ilike ?`, [db.ref("email").withSchema("committerUser"), `%${search}%`]) + .orWhereILike(`${TableName.Environment}.name`, `%${search}%`) + .orWhereILike(`${TableName.Environment}.slug`, `%${search}%`) + .orWhereILike(`${TableName.SecretApprovalPolicy}.secretPath`, `%${search}%`); + }); + } const docs = await (tx || db) .with("w", query) @@ -443,6 +466,10 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .from[number]>("w") .where("w.rank", ">=", offset) .andWhere("w.rank", "<", offset + limit); + + // @ts-expect-error knex does not infer + const totalCount = Number(docs[0]?.total_count || 0); + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", @@ -504,23 +531,26 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { } ] }); - return formattedDoc.map((el) => ({ - ...el, - policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } - })); + return { + approvals: formattedDoc.map((el) => ({ + ...el, + policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } + })), + totalCount + }; } catch (error) { throw new DatabaseError({ error, name: "FindSAR" }); } }; const findByProjectIdBridgeSecretV2 = async ( - { status, limit = 20, offset = 0, projectId, committer, environment, userId }: TFindQueryFilter, + { status, limit = 20, offset = 0, projectId, committer, environment, userId, search }: TFindQueryFilter, tx?: Knex ) => { try { // akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination // this is the place u wanna look at. - const query = (tx || db.replicaNode())(TableName.SecretApprovalRequest) + const innerQuery = (tx || db.replicaNode())(TableName.SecretApprovalRequest) .join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`) .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .join( @@ -609,14 +639,42 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), db.ref("lastName").withSchema("committerUser").as("committerUserLastName") ) - .orderBy("createdAt", "desc"); + .distinctOn(`${TableName.SecretApprovalRequest}.id`) + .as("inner"); + const query = (tx || db) + .select("*") + .select(db.raw("count(*) OVER() as total_count")) + .from(innerQuery) + .orderBy("createdAt", "desc") as typeof innerQuery; + + if (search) { + void query.where((qb) => { + void qb + .whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [ + db.ref("firstName").withSchema("committerUser"), + db.ref("lastName").withSchema("committerUser"), + `%${search}%` + ]) + .orWhereRaw(`?? ilike ?`, [db.ref("username").withSchema("committerUser"), `%${search}%`]) + .orWhereRaw(`?? ilike ?`, [db.ref("email").withSchema("committerUser"), `%${search}%`]) + .orWhereILike(`${TableName.Environment}.name`, `%${search}%`) + .orWhereILike(`${TableName.Environment}.slug`, `%${search}%`) + .orWhereILike(`${TableName.SecretApprovalPolicy}.secretPath`, `%${search}%`); + }); + } + + const rankOffset = offset + 1; const docs = await (tx || db) .with("w", query) .select("*") .from[number]>("w") - .where("w.rank", ">=", offset) - .andWhere("w.rank", "<", offset + limit); + .where("w.rank", ">=", rankOffset) + .andWhere("w.rank", "<", rankOffset + limit); + + // @ts-expect-error knex does not infer + const totalCount = Number(docs[0]?.total_count || 0); + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", @@ -682,10 +740,13 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { } ] }); - return formattedDoc.map((el) => ({ - ...el, - policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } - })); + return { + approvals: formattedDoc.map((el) => ({ + ...el, + policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } + })), + totalCount + }; } catch (error) { throw new DatabaseError({ error, name: "FindSAR" }); } diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index e70d0af00..49f336111 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -194,7 +194,8 @@ export const secretApprovalRequestServiceFactory = ({ environment, committer, limit, - offset + offset, + search }: TListApprovalsDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); @@ -208,6 +209,7 @@ export const secretApprovalRequestServiceFactory = ({ }); const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { return secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({ projectId, @@ -216,19 +218,21 @@ export const secretApprovalRequestServiceFactory = ({ status, userId: actorId, limit, - offset + offset, + search }); } - const approvals = await secretApprovalRequestDAL.findByProjectId({ + + return secretApprovalRequestDAL.findByProjectId({ projectId, committer, environment, status, userId: actorId, limit, - offset + offset, + search }); - return approvals; }; const getSecretApprovalDetails = async ({ diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts index 839833a9c..2fdb0bb9d 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts @@ -93,6 +93,7 @@ export type TListApprovalsDTO = { committer?: string; limit?: number; offset?: number; + search?: string; } & TProjectPermission; export type TSecretApprovalDetailsDTO = { diff --git a/frontend/src/components/v2/Dropdown/Dropdown.tsx b/frontend/src/components/v2/Dropdown/Dropdown.tsx index c4cc95429..b1831187a 100644 --- a/frontend/src/components/v2/Dropdown/Dropdown.tsx +++ b/frontend/src/components/v2/Dropdown/Dropdown.tsx @@ -94,7 +94,7 @@ export const DropdownMenuItem = ({ className={twMerge( "block cursor-pointer rounded-sm px-4 py-2 font-inter text-xs text-mineshaft-200 outline-none data-[highlighted]:bg-mineshaft-700", className, - isDisabled ? "pointer-events-none opacity-50" : "" + isDisabled ? "pointer-events-none cursor-not-allowed opacity-50" : "" )} > diff --git a/frontend/src/helpers/policies.ts b/frontend/src/helpers/policies.ts index 7828807dc..d798d3c10 100644 --- a/frontend/src/helpers/policies.ts +++ b/frontend/src/helpers/policies.ts @@ -1,12 +1,20 @@ +import { IconDefinition } from "@fortawesome/free-brands-svg-icons"; +import { faArrowRightToBracket, faEdit } from "@fortawesome/free-solid-svg-icons"; + import { PolicyType } from "@app/hooks/api/policies/enums"; -export const policyDetails: Record = { +export const policyDetails: Record< + PolicyType, + { name: string; className: string; icon: IconDefinition } +> = { [PolicyType.AccessPolicy]: { - className: "bg-lime-900 text-lime-100", - name: "Access Policy" + className: "bg-green/20 text-green", + name: "Access Policy", + icon: faArrowRightToBracket }, [PolicyType.ChangePolicy]: { - className: "bg-indigo-900 text-indigo-100", - name: "Change Policy" + className: "bg-yellow/20 text-yellow", + name: "Change Policy", + icon: faEdit } }; diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx index 6370f4a59..f5478cd1b 100644 --- a/frontend/src/hooks/api/accessApproval/queries.tsx +++ b/frontend/src/hooks/api/accessApproval/queries.tsx @@ -65,11 +65,11 @@ const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequests const fetchApprovalRequests = async ({ projectSlug, envSlug, - authorProjectMembershipId + authorUserId }: TGetAccessApprovalRequestsDTO) => { const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>( "/api/v1/access-approvals/requests", - { params: { projectSlug, envSlug, authorProjectMembershipId } } + { params: { projectSlug, envSlug, authorUserId } } ); return data.requests.map((request) => ({ @@ -109,12 +109,12 @@ export const useGetAccessRequestsCount = ({ export const useGetAccessApprovalPolicies = ({ projectSlug, envSlug, - authorProjectMembershipId, + authorUserId, options = {} }: TGetAccessApprovalRequestsDTO & TReactQueryOptions) => useQuery({ queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug), - queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorProjectMembershipId }), + queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorUserId }), ...options, enabled: Boolean(projectSlug) && (options?.enabled ?? true) }); @@ -122,16 +122,13 @@ export const useGetAccessApprovalPolicies = ({ export const useGetAccessApprovalRequests = ({ projectSlug, envSlug, - authorProjectMembershipId, + authorUserId, options = {} }: TGetAccessApprovalRequestsDTO & TReactQueryOptions) => useQuery({ - queryKey: accessApprovalKeys.getAccessApprovalRequests( - projectSlug, - envSlug, - authorProjectMembershipId - ), - queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorProjectMembershipId }), + queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, authorUserId), + queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorUserId }), ...options, - enabled: Boolean(projectSlug) && (options?.enabled ?? true) + enabled: Boolean(projectSlug) && (options?.enabled ?? true), + placeholderData: (previousData) => previousData }); diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index b0080ce8d..32baa3c62 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -148,7 +148,7 @@ export type TCreateAccessRequestDTO = { export type TGetAccessApprovalRequestsDTO = { projectSlug: string; envSlug?: string; - authorProjectMembershipId?: string; + authorUserId?: string; }; export type TGetAccessPolicyApprovalCountDTO = { diff --git a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx index e6ba62a6b..e96dcf34f 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx @@ -1,5 +1,5 @@ /* eslint-disable no-param-reassign */ -import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { decryptAssymmetric, @@ -25,10 +25,11 @@ export const secretApprovalRequestKeys = { status, committer, offset, - limit + limit, + search }: TGetSecretApprovalRequestList) => [ - { workspaceId, environment, status, committer, offset, limit }, + { workspaceId, environment, status, committer, offset, limit, search }, "secret-approval-requests" ] as const, detail: ({ id }: Omit) => @@ -118,23 +119,25 @@ const fetchSecretApprovalRequestList = async ({ committer, status = "open", limit = 20, - offset + offset = 0, + search = "" }: TGetSecretApprovalRequestList) => { - const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequest[] }>( - "/api/v1/secret-approval-requests", - { - params: { - workspaceId, - environment, - committer, - status, - limit, - offset - } + const { data } = await apiRequest.get<{ + approvals: TSecretApprovalRequest[]; + totalCount: number; + }>("/api/v1/secret-approval-requests", { + params: { + workspaceId, + environment, + committer, + status, + limit, + offset, + search } - ); + }); - return data.approvals; + return data; }; export const useGetSecretApprovalRequests = ({ @@ -143,31 +146,32 @@ export const useGetSecretApprovalRequests = ({ options = {}, status, limit = 20, + offset = 0, + search, committer }: TGetSecretApprovalRequestList & TReactQueryOptions) => - useInfiniteQuery({ - initialPageParam: 0, + useQuery({ queryKey: secretApprovalRequestKeys.list({ workspaceId, environment, committer, - status + status, + limit, + search, + offset }), - queryFn: ({ pageParam }) => + queryFn: () => fetchSecretApprovalRequestList({ workspaceId, environment, status, committer, limit, - offset: pageParam + offset, + search }), enabled: Boolean(workspaceId) && (options?.enabled ?? true), - getNextPageParam: (lastPage, pages) => { - if (lastPage.length && lastPage.length < limit) return undefined; - - return lastPage?.length !== 0 ? pages.length * limit : undefined; - } + placeholderData: (previousData) => previousData }); const fetchSecretApprovalRequestDetails = async ({ diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index 18360377f..3983d325a 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -113,6 +113,7 @@ export type TGetSecretApprovalRequestList = { committer?: string; limit?: number; offset?: number; + search?: string; }; export type TGetSecretApprovalRequestCount = { diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index 3711b7632..86fc39300 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -352,9 +352,9 @@ export const ProjectLayout = () => { secretApprovalReqCount?.open || accessApprovalRequestCount?.pendingCount ) && ( - + {pendingRequestsCount} - + )} )} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx index a70a6a901..bc82e9b90 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx @@ -1,7 +1,5 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; @@ -45,21 +43,7 @@ export const SecretApprovalsPage = () => { - - - Documentation - - - - + /> diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 86483e2fd..f8b935e29 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -2,15 +2,25 @@ /* eslint-disable react/jsx-no-useless-fragment */ import { useCallback, useMemo, useState } from "react"; import { + faArrowUpRightFromSquare, + faBan, + faBookOpen, faCheck, faCheckCircle, faChevronDown, + faClipboardCheck, faLock, - faPlus + faMagnifyingGlass, + faPlus, + faSearch, + faStopwatch, + faUser, + IconDefinition } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { formatDistance } from "date-fns"; +import { format, formatDistance } from "date-fns"; import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { @@ -21,6 +31,8 @@ import { DropdownMenuLabel, DropdownMenuTrigger, EmptyState, + Input, + Pagination, Tooltip } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; @@ -32,7 +44,12 @@ import { useUser, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; +import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useGetWorkspaceUsers } from "@app/hooks/api"; import { accessApprovalKeys, @@ -48,28 +65,21 @@ import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types"; import { RequestAccessModal } from "./components/RequestAccessModal"; import { ReviewAccessRequestModal } from "./components/ReviewAccessModal"; -const generateRequestText = (request: TAccessApprovalRequest, userId: string) => { +const generateRequestText = (request: TAccessApprovalRequest) => { const { isTemporary } = request; return ( -
+
Requested {isTemporary ? "temporary" : "permanent"} access to{" "} - + {request.policy.secretPath} - - in - + {" "} + in{" "} + {request.environmentName}
-
- {request.requestedByUserId === userId && ( - - Requested By You - - )} -
); }; @@ -120,30 +130,64 @@ export const AccessApprovalRequest = ({ projectSlug }); - const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({ + const { + data: requests, + refetch: refetchRequests, + isPending: areRequestsPending + } = useGetAccessApprovalRequests({ projectSlug, - authorProjectMembershipId: requestedByFilter, + authorUserId: requestedByFilter, envSlug: envFilter }); + const { search, setSearch, setPage, page, perPage, setPerPage, offset } = usePagination("", { + initPerPage: getUserTablePreference("accessRequestsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("accessRequestsTable", PreferenceKey.PerPage, newPerPage); + }; + const filteredRequests = useMemo(() => { + let accessRequests: typeof requests; + if (statusFilter === "open") - return requests?.filter( + accessRequests = requests?.filter( (request) => !request.policy.deletedAt && !request.isApproved && !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) ); if (statusFilter === "close") - return requests?.filter( + accessRequests = requests?.filter( (request) => request.policy.deletedAt || request.isApproved || request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) ); - return requests; - }, [requests, statusFilter, requestedByFilter, envFilter]); + return ( + accessRequests?.filter((request) => { + const { environmentName, requestedByUser } = request; + + const searchValue = search.trim().toLowerCase(); + + return ( + environmentName?.toLowerCase().includes(searchValue) || + `${requestedByUser?.email ?? ""} ${requestedByUser?.firstName ?? ""} ${requestedByUser?.lastName ?? ""}` + .toLowerCase() + .includes(searchValue) + ); + }) ?? [] + ); + }, [requests, statusFilter, requestedByFilter, envFilter, search]); + + useResetPageHelper({ + totalCount: filteredRequests.length, + offset, + setPage + }); const generateRequestDetails = useCallback( (request: TAccessApprovalRequest) => { @@ -162,9 +206,15 @@ export const AccessApprovalRequest = ({ const canBypass = !request.policy.bypassers.length || request.policy.bypassers.includes(user.id); - let displayData: { label: string; type: "primary" | "danger" | "success" } = { + let displayData: { + label: string; + type: "primary" | "danger" | "success"; + tooltipContent?: string; + icon: IconDefinition | null; + } = { label: "", - type: "primary" + type: "primary", + icon: null }; const isExpired = @@ -172,20 +222,42 @@ export const AccessApprovalRequest = ({ request.isApproved && new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string)); - if (isExpired) displayData = { label: "Access Expired", type: "danger" }; - else if (isAccepted) displayData = { label: "Access Granted", type: "success" }; - else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" }; + if (isExpired) + displayData = { + label: "Access Expired", + type: "danger", + icon: faStopwatch, + tooltipContent: request.privilege?.temporaryAccessEndTime + ? `Expired ${format(request.privilege.temporaryAccessEndTime, "M/d/yyyy h:mm aa")}` + : undefined + }; + else if (isAccepted) + displayData = { + label: "Access Granted", + type: "success", + icon: faCheck, + tooltipContent: `Granted ${format(request.updatedAt, "M/d/yyyy h:mm aa")}` + }; + else if (isRejectedByAnyone) + displayData = { + label: "Rejected", + type: "danger", + icon: faBan, + tooltipContent: `Rejected ${format(request.updatedAt, "M/d/yyyy h:mm aa")}` + }; else if (userReviewStatus === ApprovalStatus.APPROVED) { displayData = { label: `Pending ${request.policy.approvals - request.reviewers.length} review${ request.policy.approvals - request.reviewers.length > 1 ? "s" : "" }`, - type: "primary" + type: "primary", + icon: faClipboardCheck }; } else if (!isReviewedByUser) displayData = { label: "Review Required", - type: "primary" + type: "primary", + icon: faClipboardCheck }; return { @@ -225,47 +297,71 @@ export const AccessApprovalRequest = ({ [generateRequestDetails, membersGroupById, user, setSelectedRequest, handlePopUpOpen] ); - return ( -
-
-
- Access Requests -
- Request access to secrets in sensitive environments and folders. -
-
-
- - - -
-
+ const isFiltered = Boolean(search || envFilter || requestedByFilter); - - -
+ return ( + + +
+
+
+
+

Access Requests

+ +
+ + Docs + +
+
+
+

+ Request and review access to secrets in sensitive environments and folders +

+
+ + + +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search approval requests by requesting user or environment..." + className="flex-1" + containerClassName="mb-4" + /> +
{ if (evt.key === "Enter") setStatusFilter("open"); }} - className={ - statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" - } + className={twMerge( + "font-medium", + statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400" + )} > {!!requestCount && requestCount?.pendingCount} Pending
setStatusFilter("close")} @@ -292,7 +390,7 @@ export const AccessApprovalRequest = ({ }} > - {!!requestCount && requestCount.finalizedCount} Completed + {!!requestCount && requestCount.finalizedCount} Closed
@@ -300,14 +398,20 @@ export const AccessApprovalRequest = ({ - - Select an environment + + + Select an Environment + {currentWorkspace?.environments.map(({ slug, name }) => ( setEnvFilter((state) => (state === slug ? undefined : slug))} @@ -337,15 +441,27 @@ export const AccessApprovalRequest = ({ Requested By - - Select an author + + + Select Requesting User + {members?.map(({ user: membershipUser, id }) => ( - setRequestedByFilter((state) => (state === id ? undefined : id)) + setRequestedByFilter((state) => + state === membershipUser.id ? undefined : membershipUser.id + ) } key={`request-filter-member-${id}`} - icon={requestedByFilter === id && } + icon={ + requestedByFilter === membershipUser.id && ( + + ) + } iconPos="right" > {membershipUser.username} @@ -357,19 +473,26 @@ export const AccessApprovalRequest = ({
- {filteredRequests?.length === 0 && ( + {filteredRequests?.length === 0 && !isFiltered && (
- + +
+ )} + {Boolean(!filteredRequests?.length && isFiltered && !areRequestsPending) && ( +
+
)} {!!filteredRequests?.length && - filteredRequests?.map((request) => { + filteredRequests?.slice(offset, perPage * page).map((request) => { const details = generateRequestDetails(request); return (
handleSelectRequest(request)} @@ -379,14 +502,18 @@ export const AccessApprovalRequest = ({ } }} > -
+
- - {generateRequestText(request, user.id)} + + {generateRequestText(request)}
-
+
{membersGroupById?.[request.requestedByUserId]?.user && ( <> Requested {formatDistance(new Date(request.createdAt), new Date())}{" "} @@ -397,61 +524,86 @@ export const AccessApprovalRequest = ({ )}
+
+
+
+ {request.requestedByUserId === user.id && ( +
+ + Requested By You +
+ )} +
- - {details.displayData.label} + + {details.displayData.icon && ( + + )} + {details.displayData.label}
-
+
); })} + {Boolean(filteredRequests.length) && ( + + )}
- - +
+ {!!policies && ( + { + queryClient.invalidateQueries({ + queryKey: accessApprovalKeys.getAccessApprovalRequests( + projectSlug, + envFilter, + requestedByFilter + ) + }); + handlePopUpClose("requestAccess"); + }} + /> + )} - {!!policies && ( - { - queryClient.invalidateQueries({ - queryKey: accessApprovalKeys.getAccessApprovalRequests( - projectSlug, - envFilter, - requestedByFilter - ) - }); - handlePopUpClose("requestAccess"); - }} + {!!selectedRequest && ( + { + handlePopUpClose("reviewRequest"); + setSelectedRequest(null); + refetchRequests(); + }} + canBypass={generateRequestDetails(selectedRequest).canBypass} + /> + )} + + handlePopUpClose("upgradePlan")} /> - )} - - {!!selectedRequest && ( - { - handlePopUpClose("reviewRequest"); - setSelectedRequest(null); - refetchRequests(); - }} - canBypass={generateRequestDetails(selectedRequest).canBypass} - /> - )} - - handlePopUpClose("upgradePlan")} - /> -
+
+
); }; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index 5dc89fe20..e2ef64d3e 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -1,11 +1,19 @@ import { useMemo, useState } from "react"; import { + faArrowDown, + faArrowUp, + faArrowUpRightFromSquare, + faBookOpen, faCheckCircle, - faChevronDown, faFileShield, - faPlus + faFilter, + faMagnifyingGlass, + faPlus, + faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; @@ -19,8 +27,9 @@ import { DropdownMenuLabel, DropdownMenuTrigger, EmptyState, - Modal, - ModalContent, + IconButton, + Input, + Pagination, Table, TableContainer, TableSkeleton, @@ -38,7 +47,12 @@ import { useWorkspace } from "@app/context"; import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types"; -import { usePopUp } from "@app/hooks"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; +import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useDeleteAccessApprovalPolicy, useDeleteSecretApprovalPolicy, @@ -47,6 +61,7 @@ import { useListWorkspaceGroups } from "@app/hooks/api"; import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; import { PolicyType } from "@app/hooks/api/policies/enums"; import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types"; @@ -57,6 +72,18 @@ interface IProps { workspaceId: string; } +enum PolicyOrderBy { + Name = "name", + Environment = "environment", + SecretPath = "secret-path", + Type = "type" +} + +type PolicyFilters = { + type: null | PolicyType; + environmentIds: string[]; +}; + const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => { const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies( { @@ -112,11 +139,79 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { currentWorkspace ); - const [filterType, setFilterType] = useState(null); + const [filters, setFilters] = useState({ + type: null, + environmentIds: [] + }); - const filteredPolicies = useMemo(() => { - return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies; - }, [policies, filterType]); + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + orderBy, + setOrderBy, + setOrderDirection, + toggleOrderDirection + } = usePagination(PolicyOrderBy.Name, { + initPerPage: getUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, newPerPage); + }; + + const filteredPolicies = useMemo( + () => + policies + .filter(({ policyType, environment, name, secretPath }) => { + if (filters.type && policyType !== filters.type) return false; + + if (filters.environmentIds.length && !filters.environmentIds.includes(environment.id)) + return false; + + const searchValue = search.trim().toLowerCase(); + + return ( + name.toLowerCase().includes(searchValue) || + environment.name.toLowerCase().includes(searchValue) || + (secretPath ?? "*").toLowerCase().includes(searchValue) + ); + }) + .sort((a, b) => { + const [policyOne, policyTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + switch (orderBy) { + case PolicyOrderBy.Type: + return policyOne.policyType + .toLowerCase() + .localeCompare(policyTwo.policyType.toLowerCase()); + case PolicyOrderBy.Environment: + return policyOne.environment.name + .toLowerCase() + .localeCompare(policyTwo.environment.name.toLowerCase()); + case PolicyOrderBy.SecretPath: + return (policyOne.secretPath ?? "*") + .toLowerCase() + .localeCompare((policyTwo.secretPath ?? "*").toLowerCase()); + case PolicyOrderBy.Name: + default: + return policyOne.name.toLowerCase().localeCompare(policyTwo.name.toLowerCase()); + } + }), + [policies, filters, search, orderBy, orderDirection] + ); + + useResetPageHelper({ + totalCount: filteredPolicies.length, + offset, + setPage + }); const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy(); @@ -151,144 +246,288 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { } }; + const isTableFiltered = filters.type !== null || Boolean(filters.environmentIds.length); + + const handleSort = (column: PolicyOrderBy) => { + if (column === orderBy) { + toggleOrderDirection(); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + const getClassName = (col: PolicyOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30"); + + const getColSortIcon = (col: PolicyOrderBy) => + orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; + return ( -
-
-
- Policies -
- Implement granular policies for access requests and secrets management. -
-
-
- - {(isAllowed) => ( - - )} - -
-
- - - - - - - - - - - - {isPoliciesLoading && ( - - )} - {!isPoliciesLoading && !filteredPolicies?.length && ( - - - - )} - {!!currentWorkspace && - filteredPolicies?.map((policy) => ( - handlePopUpOpen("policyForm", policy)} - onDelete={() => handlePopUpOpen("deletePolicy", policy)} - /> - ))} - -
NameEnvironmentSecret Path - - - - - - Select a type - setFilterType(null)} - icon={!filterType && } - iconPos="right" - > - All - - setFilterType(PolicyType.AccessPolicy)} - icon={ - filterType === PolicyType.AccessPolicy && ( - - ) - } - iconPos="right" - > - Access Policy - - setFilterType(PolicyType.ChangePolicy)} - icon={ - filterType === PolicyType.ChangePolicy && ( - - ) - } - iconPos="right" - > - Change Policy - - - - -
- -
-
- handlePopUpToggle("policyForm", isOpen)} + + - - handlePopUpToggle("policyForm", isOpen)} - members={members} - editValues={popUp.policyForm.data as TAccessApprovalPolicy} - /> - - +
+
+
+
+

Policies

+ +
+ + Docs + +
+
+
+

+ Implement granular policies for access requests and secrets management +

+
+ + {(isAllowed) => ( + + )} + +
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search policies by name, type, environment or secret path..." + className="flex-1" + /> + + + + + + + + Policy Type + + setFilters((prev) => ({ + ...prev, + type: null + })) + } + icon={!filters && } + iconPos="right" + > + All + + + setFilters((prev) => ({ + ...prev, + type: PolicyType.AccessPolicy + })) + } + icon={ + filters.type === PolicyType.AccessPolicy && ( + + ) + } + iconPos="right" + > + Access Policy + + + setFilters((prev) => ({ + ...prev, + type: PolicyType.ChangePolicy + })) + } + icon={ + filters.type === PolicyType.ChangePolicy && ( + + ) + } + iconPos="right" + > + Change Policy + + Environment + {currentWorkspace.environments.map((env) => ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + environmentIds: prev.environmentIds.includes(env.id) + ? prev.environmentIds.filter((i) => i !== env.id) + : [...prev.environmentIds, env.id] + })); + }} + key={env.id} + icon={ + filters.environmentIds.includes(env.id) && ( + + ) + } + iconPos="right" + > + {env.name} + + ))} + + +
+ + + + + + + + + + + + {isPoliciesLoading && ( + + )} + {!isPoliciesLoading && !policies?.length && ( + + + + )} + {!!currentWorkspace && + filteredPolicies + ?.slice(offset, perPage * page) + .map((policy) => ( + handlePopUpOpen("policyForm", policy)} + onDelete={() => handlePopUpOpen("deletePolicy", policy)} + /> + ))} + +
+
+ Name + handleSort(PolicyOrderBy.Name)} + > + + +
+
+
+ Environment + handleSort(PolicyOrderBy.Environment)} + > + + +
+
+
+ Secret Path + handleSort(PolicyOrderBy.SecretPath)} + > + + +
+
+
+ Type + handleSort(PolicyOrderBy.Type)} + > + + +
+
+
+ +
+ {Boolean(!filteredPolicies.length && policies.length && !isPoliciesLoading) && ( + + )} + {Boolean(filteredPolicies.length) && ( + + )} +
+
+ + handlePopUpToggle("policyForm", isOpen)} + members={members} + editValues={popUp.policyForm.data as TAccessApprovalPolicy} + /> { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text="You can add secret approval policy if you switch to Infisical's Enterprise plan." /> -
+ ); }; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index 3b5a832da..42c85f9d1 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { RefObject, useMemo, useRef, useState } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -13,6 +13,8 @@ import { FormControl, IconButton, Input, + Modal, + ModalContent, Select, SelectItem, Switch, @@ -110,20 +112,20 @@ const formSchema = z type TFormSchema = z.infer; -export const AccessPolicyForm = ({ - isOpen, +const Form = ({ onToggle, members = [], projectId, projectSlug, - editValues -}: Props) => { + editValues, + modalContainer, + isEditMode +}: Props & { modalContainer: RefObject; isEditMode: boolean }) => { const [draggedItem, setDraggedItem] = useState(null); const [dragOverItem, setDragOverItem] = useState(null); const { control, handleSubmit, - reset, watch, formState: { isSubmitting } } = useForm({ @@ -188,13 +190,8 @@ export const AccessPolicyForm = ({ const { data: groups } = useListWorkspaceGroups(projectId); const environments = currentWorkspace?.environments || []; - const isEditMode = Boolean(editValues); const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy; - useEffect(() => { - if (!isOpen || !isEditMode) reset({}); - }, [isOpen, isEditMode]); - const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); @@ -387,6 +384,7 @@ export const AccessPolicyForm = ({ setDraggedItem(null); setDragOverItem(null); }; + return (
@@ -572,7 +570,7 @@ export const AccessPolicyForm = ({ className="flex-grow" > ); }; + +export const AccessPolicyForm = ({ isOpen, onToggle, editValues, ...props }: Props) => { + const modalContainer = useRef(null); + const isEditMode = Boolean(editValues); + + return ( + + + + + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx index 7bb42de1c..0a674f5b4 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { faEdit, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -9,6 +9,8 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + GenericFieldLabel, + IconButton, Td, Tr } from "@app/components/v2"; @@ -80,11 +82,11 @@ export const ApprovalPolicyRow = ({ userLabels: members ?.filter((member) => el.user.find((i) => i.id === member.user.id)) .map((member) => getMemberLabel(member)) - .join(","), + .join(", "), groupLabels: groups ?.filter(({ group }) => el.group.find((i) => i.id === group.id)) .map(({ group }) => group.name) - .join(","), + .join(", "), approvals: el.approvals }; }); @@ -102,36 +104,47 @@ export const ApprovalPolicyRow = ({ }} onClick={() => setIsExpanded.toggle()} > - {policy.name} - {policy.environment.slug} + {policy.name || Unnamed Policy} + {policy.environment.name} {policy.secretPath || "*"} - - {policyDetails[policy.policyType].name} + + + {policyDetails[policy.policyType].name} -
- -
+ + + + +
- + {(isAllowed) => ( { e.stopPropagation(); onEdit(); }} - disabled={!isAllowed} + isDisabled={!isAllowed} + icon={} > Edit Policy @@ -143,16 +156,12 @@ export const ApprovalPolicyRow = ({ > {(isAllowed) => ( { e.stopPropagation(); onDelete(); }} - disabled={!isAllowed} + isDisabled={!isAllowed} + icon={} > Delete Policy @@ -162,45 +171,41 @@ export const ApprovalPolicyRow = ({
- {isExpanded && ( - - -
Approvers
- {labels?.map((el, index) => ( -
-
-
-
{index + 1}
+ + +
+
+
Approvers
+ {labels?.map((el, index) => ( +
+
+
{index + 1}
{index !== labels.length - 1 && ( -
+
)} {index !== 0 && ( -
+
)} -
-
-
-
Users
-
{el.userLabels || "-"}
-
-
-
Groups
-
{el.groupLabels || "-"}
-
-
-
Approvals Required
-
{el.approvals || "-"}
+ +
+ {el.userLabels} + {el.groupLabels} + {el.approvals}
-
- ))} - - - )} + ))} +
+
+ + ); }; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index ca0e598a2..eafb2ac9d 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -1,14 +1,19 @@ -import { Fragment, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { + faArrowUpRightFromSquare, + faBookOpen, faCheck, faCheckCircle, faChevronDown, - faCodeBranch + faCodeBranch, + faMagnifyingGlass, + faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useSearch } from "@tanstack/react-router"; import { formatDistance } from "date-fns"; import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { Button, @@ -18,6 +23,8 @@ import { DropdownMenuLabel, DropdownMenuTrigger, EmptyState, + Input, + Pagination, Skeleton } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; @@ -28,6 +35,12 @@ import { useUser, useWorkspace } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; +import { usePagination } from "@app/hooks"; import { useGetSecretApprovalRequestCount, useGetSecretApprovalRequests, @@ -52,18 +65,41 @@ export const SecretApprovalRequest = () => { const [usingUrlRequestId, setUsingUrlRequestId] = useState(false); const { - data: secretApprovalRequests, - isFetchingNextPage: isFetchingNextApprovalRequest, - fetchNextPage: fetchNextApprovalRequest, - hasNextPage: hasNextApprovalPage, + debouncedSearch: debouncedSearchFilter, + search: searchFilter, + setSearch: setSearchFilter, + setPage, + page, + perPage, + setPerPage, + offset, + limit + } = usePagination("", { + initPerPage: getUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, newPerPage); + }; + + const { + data, isPending: isApprovalRequestLoading, refetch } = useGetSecretApprovalRequests({ workspaceId, status: statusFilter, environment: envFilter, - committer: committerFilter + committer: committerFilter, + search: debouncedSearchFilter, + limit, + offset }); + + const totalApprovalCount = data?.totalCount ?? 0; + const secretApprovalRequests = data?.approvals ?? []; + const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } = useGetSecretApprovalRequestCount({ workspaceId }); const { user: userSession } = useUser(); @@ -88,8 +124,9 @@ export const SecretApprovalRequest = () => { refetch(); }; - const isRequestListEmpty = - !isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0; + const isRequestListEmpty = !isApprovalRequestLoading && secretApprovalRequests?.length === 0; + + const isFiltered = Boolean(searchFilter || envFilter || committerFilter); return ( @@ -116,178 +153,233 @@ export const SecretApprovalRequest = () => { exit={{ opacity: 0, translateX: 30 }} className="rounded-md text-gray-300" > -
-
setStatusFilter("open")} - onKeyDown={(evt) => { - if (evt.key === "Enter") setStatusFilter("open"); - }} - className={ - statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" - } - > - - {isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open -
-
setStatusFilter("close")} - onKeyDown={(evt) => { - if (evt.key === "Enter") setStatusFilter("close"); - }} - > - - {isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed -
- +

Review pending and closed change requests

+
+
+ setSearchFilter(e.target.value)} + leftIcon={} + placeholder="Search change requests by author, environment or policy path..." + className="flex-1" + containerClassName="mb-4" + /> +
+
setStatusFilter("open")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("open"); + }} + className={twMerge( + "font-medium", + statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400" + )} + > + + {isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open +
+
setStatusFilter("close")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("close"); + }} + > + + {isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed +
+
- + - - Select an author - {members?.map(({ user, id }) => ( + + + Select an Environment + + {currentWorkspace?.environments.map(({ slug, name }) => ( - setCommitterFilter((state) => (state === user.id ? undefined : user.id)) - } - key={`request-filter-member-${id}`} - icon={ - committerFilter === user.id && - } + onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))} + key={`request-filter-${slug}`} + icon={envFilter === slug && } iconPos="right" > - {user.username} + {name} ))} + {!!permission.can( + ProjectPermissionMemberActions.Read, + ProjectPermissionSub.Member + ) && ( + + + + + + + Select an Author + + {members?.map(({ user, id }) => ( + + setCommitterFilter((state) => (state === user.id ? undefined : user.id)) + } + key={`request-filter-member-${id}`} + icon={ + committerFilter === user.id && + } + iconPos="right" + > + {user.username} + + ))} + + + )} +
+
+
+ {isRequestListEmpty && !isFiltered && ( +
+ +
+ )} + {secretApprovalRequests.map((secretApproval) => { + const { + id: reqId, + commits, + createdAt, + reviewers, + status, + committerUser + } = secretApproval; + const isReviewed = reviewers.some( + ({ status: reviewStatus, userId }) => + userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED + ); + return ( +
setSelectedApprovalId(secretApproval.id)} + onKeyDown={(evt) => { + if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id); + }} + > +
+ + {secretApproval.isReplicated + ? `${commits.length} secret pending import` + : generateCommitText(commits)} + #{secretApproval.slug} +
+ + Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} + {committerUser?.firstName || ""} {committerUser?.lastName || ""} ( + {committerUser?.email}) + {!isReviewed && status === "open" && " - Review required"} + +
+ ); + })} + {Boolean( + !secretApprovalRequests.length && isFiltered && !isApprovalRequestLoading + ) && ( +
+ +
+ )} + {Boolean(totalApprovalCount) && ( + + )} + {isApprovalRequestLoading && ( +
+ {Array.apply(0, Array(3)).map((_x, index) => ( +
+
+ + +
+ +
+ ))} +
)}
-
- {isRequestListEmpty && ( -
- -
- )} - {secretApprovalRequests?.pages?.map((group, i) => ( - - {group?.map((secretApproval) => { - const { - id: reqId, - commits, - createdAt, - reviewers, - status, - committerUser - } = secretApproval; - const isReviewed = reviewers.some( - ({ status: reviewStatus, userId }) => - userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED - ); - return ( -
setSelectedApprovalId(secretApproval.id)} - onKeyDown={(evt) => { - if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id); - }} - > -
- - {secretApproval.isReplicated - ? `${commits.length} secret pending import` - : generateCommitText(commits)} - #{secretApproval.slug} -
- - Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} - {committerUser?.firstName || ""} {committerUser?.lastName || ""} ( - {committerUser?.email}) - {!isReviewed && status === "open" && " - Review required"} - -
- ); - })} -
- ))} - {(isFetchingNextApprovalRequest || isApprovalRequestLoading) && ( -
- {Array.apply(0, Array(3)).map((_x, index) => ( -
-
- - -
- -
- ))} -
- )} -
- {hasNextApprovalPage && ( - - )} )} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index 89b427bb3..466a06c84 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -56,27 +56,24 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica if (score[CommitType.CREATE]) text.push( - {score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"} - created + {score[CommitType.CREATE]} Secret{score[CommitType.CREATE] !== 1 && "s"} + Created ); if (score[CommitType.UPDATE]) text.push( - {Boolean(text.length) && ","} - {score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"} - - {" "} - updated - + {Boolean(text.length) && ", "} + {score[CommitType.UPDATE]} Secret{score[CommitType.UPDATE] !== 1 && "s"} + Updated ); if (score[CommitType.DELETE]) text.push( {Boolean(text.length) && "and"} - {score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"} - deleted + {score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"} + Deleted ); return text; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx index 48e942d0a..03db38935 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx @@ -36,10 +36,13 @@ const formSchema = z.object({ userGroups: z.string().trim().optional(), policyArns: z.string().trim().optional(), tags: z - .array( - z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) }) - ) - .optional() + .array( + z.object({ + key: z.string().trim().min(1).max(128), + value: z.string().trim().min(1).max(256) + }) + ) + .optional() }), z.object({ method: z.literal(DynamicSecretAwsIamAuth.AssumeRole), @@ -51,10 +54,13 @@ const formSchema = z.object({ userGroups: z.string().trim().optional(), policyArns: z.string().trim().optional(), tags: z - .array( - z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) }) - ) - .optional() + .array( + z.object({ + key: z.string().trim().min(1).max(128), + value: z.string().trim().min(1).max(256) + }) + ) + .optional() }) ]), defaultTTL: z.string().superRefine((val, ctx) => { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAwsIamForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAwsIamForm.tsx index d28f983da..8572b1a0c 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAwsIamForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAwsIamForm.tsx @@ -25,8 +25,8 @@ const formSchema = z.object({ userGroups: z.string().trim().optional(), policyArns: z.string().trim().optional(), tags: z - .array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) })) - .optional(), + .array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) })) + .optional() }), z.object({ method: z.literal(DynamicSecretAwsIamAuth.AssumeRole), @@ -38,8 +38,8 @@ const formSchema = z.object({ userGroups: z.string().trim().optional(), policyArns: z.string().trim().optional(), tags: z - .array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) })) - .optional() + .array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) })) + .optional() }) ]), defaultTTL: z.string().superRefine((val, ctx) => { @@ -97,7 +97,7 @@ export const EditDynamicSecretAwsIamForm = ({ usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}", inputs: { ...(dynamicSecret.inputs as TForm["inputs"]) - }, + } } }); const isAccessKeyMethod = watch("inputs.method") === DynamicSecretAwsIamAuth.AccessKey; @@ -125,8 +125,7 @@ export const EditDynamicSecretAwsIamForm = ({ defaultTTL, inputs, newName: newName === dynamicSecret.name ? undefined : newName, - usernameTemplate: - !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate + usernameTemplate: !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate } }); onClose();