diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index d3897ce3e..6b3ae3679 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -47,12 +47,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .max(100, "Cannot have more than 100 bypassers") .optional(), approvalsRequired: z - .record( - z.number().int(), - z.object({ - numberOfApprovals: z.number().int() - }) - ) + .object({ + numberOfApprovals: z.number().int(), + stepNumber: z.number().int() + }) + .array() .optional(), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), @@ -95,7 +94,12 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi approvals: sapPubSchema .extend({ approvers: z - .object({ type: z.nativeEnum(ApproverType), id: z.string().nullable().optional() }) + .object({ + type: z.nativeEnum(ApproverType), + id: z.string().nullable().optional(), + sequence: z.number().nullable().optional(), + approvalsRequired: z.number().nullable().optional() + }) .array() .nullable() .optional(), @@ -169,8 +173,17 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .transform((val) => (val === "" ? "/" : val)), approvers: z .discriminatedUnion("type", [ - z.object({ type: z.literal(ApproverType.Group), id: z.string() }), - z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) + z.object({ + type: z.literal(ApproverType.Group), + id: z.string(), + sequence: z.number().int().default(1) + }), + z.object({ + type: z.literal(ApproverType.User), + id: z.string().optional(), + username: z.string().optional(), + sequence: z.number().int().default(1) + }) ]) .array() .min(1, { message: "At least one approver should be provided" }) @@ -187,12 +200,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true), approvalsRequired: z - .record( - z.number().int(), - z.object({ - numberOfApprovals: z.number().int() - }) - ) + .object({ + numberOfApprovals: z.number().int(), + stepNumber: z.number().int() + }) + .array() .optional() }), response: { @@ -260,7 +272,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .object({ type: z.nativeEnum(ApproverType), id: z.string().nullable().optional(), - name: z.string().nullable().optional() + name: z.string().nullable().optional(), + approvalsRequired: z.number().nullable().optional() }) .array() .nullable() 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 5b3a08b4b..65fcf3c86 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -112,7 +112,15 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv id: z.string(), name: z.string(), approvals: z.number(), - approvers: z.string().array(), + approvers: z + .object({ + userId: z.string().nullable().optional(), + sequence: z.number().nullable().optional(), + approvalsRequired: z.number().nullable().optional(), + email: z.string().nullable().optional(), + username: z.string().nullable().optional() + }) + .array(), bypassers: z.string().array(), secretPath: z.string().nullish(), envId: z.string(), diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts index c61d209c3..d08d9dbf6 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -48,6 +48,9 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { .select(tx.ref("username").withSchema("bypasserUsers").as("bypasserUsername")) .select(tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(tx.ref("approverGroupId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(tx.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence")) + .select(tx.ref("approvalsRequired").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(tx.ref("approverGroupId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(tx.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser)) .select(tx.ref("bypasserGroupId").withSchema(TableName.AccessApprovalPolicyBypasser)) .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) @@ -80,17 +83,21 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { { key: "approverUserId", label: "approvers" as const, - mapper: ({ approverUserId: id }) => ({ + mapper: ({ approverUserId: id, approverSequence, approvalsRequired }) => ({ id, - type: "user" + type: "user", + sequence: approverSequence, + approvalsRequired }) }, { key: "approverGroupId", label: "approvers" as const, - mapper: ({ approverGroupId: id }) => ({ + mapper: ({ approverGroupId: id, approverSequence, approvalsRequired }) => ({ id, - type: "group" + type: "group", + sequence: approverSequence, + approvalsRequired }) } ] @@ -129,18 +136,22 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { { key: "approverUserId", label: "approvers" as const, - mapper: ({ approverUserId: id, approverUsername }) => ({ + mapper: ({ approverUserId: id, approverUsername, approverSequence, approvalsRequired }) => ({ id, type: ApproverType.User, - name: approverUsername + name: approverUsername, + sequence: approverSequence, + approvalsRequired }) }, { key: "approverGroupId", label: "approvers" as const, - mapper: ({ approverGroupId: id }) => ({ + mapper: ({ approverGroupId: id, approverSequence, approvalsRequired }) => ({ id, - type: ApproverType.Group + type: ApproverType.Group, + sequence: approverSequence, + approvalsRequired }) }, { diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index f194f8248..70fe2d04c 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -4,6 +4,7 @@ import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; @@ -30,7 +31,6 @@ import { TListAccessApprovalPoliciesDTO, TUpdateAccessApprovalPolicy } from "./access-approval-policy-types"; -import { groupBy } from "@app/lib/fn"; type TAccessApprovalPolicyServiceFactoryDep = { projectDAL: TProjectDALFactory; @@ -44,7 +44,7 @@ type TAccessApprovalPolicyServiceFactoryDep = { userDAL: Pick; accessApprovalRequestDAL: Pick; additionalPrivilegeDAL: Pick; - accessApprovalRequestReviewerDAL: Pick; + accessApprovalRequestReviewerDAL: Pick; orgMembershipDAL: Pick; }; @@ -176,6 +176,7 @@ export const accessApprovalPolicyServiceFactory = ({ } } + const approvalsRequiredGroupByStepNumber = groupBy(approvalsRequired || [], (i) => i.stepNumber); const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.create( { @@ -195,7 +196,9 @@ export const accessApprovalPolicyServiceFactory = ({ approverUserId: el.id, policyId: doc.id, sequence: el.sequence, - approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals + approvalsRequired: el.sequence + ? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals + : approvals })), tx ); @@ -207,7 +210,9 @@ export const accessApprovalPolicyServiceFactory = ({ approverGroupId: el.id, policyId: doc.id, sequence: el.sequence, - approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals + approvalsRequired: el.sequence + ? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals + : approvals })), tx ); @@ -284,7 +289,6 @@ export const accessApprovalPolicyServiceFactory = ({ id: string; sequence?: number; }[]; - const userApproverNames = approvers.filter( (approver) => approver.type === ApproverType.User && approver.username ) as { username: string; sequence?: number }[]; @@ -385,6 +389,7 @@ export const accessApprovalPolicyServiceFactory = ({ } } + const approvalsRequiredGroupByStepNumber = groupBy(approvalsRequired || [], (i) => i.stepNumber); const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.updateById( accessApprovalPolicy.id, @@ -427,13 +432,14 @@ export const accessApprovalPolicyServiceFactory = ({ })) ); } - await accessApprovalPolicyApproverDAL.insertMany( approverUserIds.map((el) => ({ approverUserId: el.id, policyId: doc.id, sequence: el.sequence, - approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals + approvalsRequired: el.sequence + ? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals + : approvals })), tx ); @@ -445,7 +451,9 @@ export const accessApprovalPolicyServiceFactory = ({ approverGroupId: el.id, policyId: doc.id, sequence: el.sequence, - approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals + approvalsRequired: el.sequence + ? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals + : approvals })), tx ); diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index 1fe2d4c34..bdb50dde1 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -39,7 +39,7 @@ export type TCreateAccessApprovalPolicy = { name: string; enforcementLevel: EnforcementLevel; allowedSelfApprovals: boolean; - approvalsRequired?: Record; + approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; } & Omit; export type TUpdateAccessApprovalPolicy = { @@ -57,7 +57,7 @@ export type TUpdateAccessApprovalPolicy = { name?: string; enforcementLevel?: EnforcementLevel; allowedSelfApprovals: boolean; - approvalsRequired?: Record; + approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; } & Omit; export type TDeleteAccessApprovalPolicy = { 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 bfd07bdcf..78dfddcc6 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 @@ -39,12 +39,16 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalRequest}.id`, `${TableName.AccessApprovalRequestReviewer}.requestId` ) - .leftJoin( TableName.AccessApprovalPolicyApprover, `${TableName.AccessApprovalPolicy}.id`, `${TableName.AccessApprovalPolicyApprover}.policyId` ) + .leftJoin( + db(TableName.Users).as("accessApprovalPolicyApproverUser"), + `${TableName.AccessApprovalPolicyApprover}.approverUserId`, + "accessApprovalPolicyApproverUser.id" + ) .leftJoin( TableName.UserGroupMembership, `${TableName.AccessApprovalPolicyApprover}.approverGroupId`, @@ -82,13 +86,18 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") ) - .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(db.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence")) + .select(db.ref("approvalsRequired").withSchema(TableName.AccessApprovalPolicyApprover)) .select(db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId")) - .select(db.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser)) .select(db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId")) - + .select( + db.ref("email").withSchema("accessApprovalPolicyApproverUser").as("approverEmail"), + db.ref("email").withSchema(TableName.Users).as("approverGroupEmail"), + db.ref("username").withSchema("accessApprovalPolicyApproverUser").as("approverUsername"), + db.ref("username").withSchema(TableName.Users).as("approverGroupUsername") + ) .select( db.ref("projectId").withSchema(TableName.Environment), db.ref("slug").withSchema(TableName.Environment).as("envSlug"), @@ -173,11 +182,33 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { label: "reviewers" as const, mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined) }, - { key: "approverUserId", label: "approvers" as const, mapper: ({ approverUserId }) => approverUserId }, + { + key: "approverUserId", + label: "approvers" as const, + mapper: ({ approverUserId, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({ + userId: approverUserId, + sequence: approverSequence, + approvalsRequired, + email: approverEmail, + username: approverUsername + }) + }, { key: "approverGroupUserId", label: "approvers" as const, - mapper: ({ approverGroupUserId }) => approverGroupUserId + mapper: ({ + approverGroupUserId, + approverSequence, + approvalsRequired, + approverGroupEmail, + approverGroupUsername + }) => ({ + userId: approverGroupUserId, + sequence: approverSequence, + approvalsRequired, + email: approverGroupEmail, + username: approverGroupUsername + }) }, { key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId }, { @@ -192,7 +223,11 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { return formattedDocs.map((doc) => ({ ...doc, - policy: { ...doc.policy, approvers: doc.approvers, bypassers: doc.bypassers } + policy: { + ...doc.policy, + approvers: doc.approvers.filter((el) => el.userId).sort((a, b) => (a.sequence || 0) - (b.sequence || 0)), + bypassers: doc.bypassers + } })); } catch (error) { throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" }); @@ -272,6 +307,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.AccessApprovalRequest)) .select( tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover), + tx.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence"), + tx.ref("approvalsRequired").withSchema(TableName.AccessApprovalPolicyApprover), tx.ref("userId").withSchema(TableName.UserGroupMembership), tx.ref("email").withSchema("accessApprovalPolicyApproverUser").as("approverEmail"), tx.ref("email").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupEmail"), @@ -367,13 +404,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { approverEmail: email, approverUsername: username, approverLastName: lastName, - approverFirstName: firstName + approverFirstName: firstName, + approverSequence, + approvalsRequired }) => ({ userId: approverUserId, email, firstName, lastName, - username + username, + sequence: approverSequence, + approvalsRequired }) }, { @@ -384,13 +425,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { approverGroupEmail: email, approverGroupUsername: username, approverGroupLastName: lastName, - approverFirstName: firstName + approverFirstName: firstName, + approverSequence, + approvalsRequired }) => ({ userId, email, firstName, lastName, - username + username, + sequence: approverSequence, + approvalsRequired }) }, { @@ -434,7 +479,9 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ...formattedDoc[0], policy: { ...formattedDoc[0].policy, - approvers: formattedDoc[0].approvers, + approvers: formattedDoc[0].approvers + .filter((el) => el.userId) + .sort((a, b) => (a.sequence || 0) - (b.sequence || 0)), bypassers: formattedDoc[0].bypassers } }; 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 d03cc64c0..3747af252 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 @@ -4,6 +4,7 @@ import msFn from "ms"; import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { EnforcementLevel } from "@app/lib/types"; @@ -358,7 +359,6 @@ export const accessApprovalRequestServiceFactory = ({ const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypass); const isApprover = policy.approvers.find((approver) => approver.userId === actorId); - // If user is (not an approver OR cant self approve) AND can't bypass policy if ((!isApprover || (!policy.allowedSelfApprovals && isSelfApproval)) && cannotBypassUnderSoftEnforcement) { throw new BadRequestError({ @@ -383,6 +383,41 @@ export const accessApprovalRequestServiceFactory = ({ if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) { throw new BadRequestError({ message: "The request has already been rejected by another reviewer" }); } + const reviewsGroupById = groupBy( + existingReviews.filter((review) => review.status === ApprovalStatus.APPROVED), + (i) => i.reviewerUserId + ); + + const approvedSequences = policy.approvers.reduce( + (acc, curr) => { + const hasApproved = reviewsGroupById?.[curr.userId as string]?.[0]; + if (acc?.[acc.length - 1]?.step === curr.sequence) { + if (hasApproved) { + acc[acc.length - 1].approvals += 1; + } + return acc; + } + + acc.push({ + step: curr.sequence || 1, + approvals: hasApproved ? 1 : 0, + requiredApprovals: curr.approvalsRequired || 1 + }); + return acc; + }, + [] as { step: number; approvals: number; requiredApprovals: number }[] + ); + const presentSequence = approvedSequences.find((el) => el.approvals < el.requiredApprovals) || { + step: 1, + approvals: 0, + requiredApprovals: 1 + }; + if (presentSequence) { + const isApproverOfTheSequence = policy.approvers.find( + (el) => el.sequence === presentSequence.step && el.userId === actorId + ); + if (!isApproverOfTheSequence) throw new BadRequestError({ message: "You are not reviewer in this step" }); + } const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => { const isBreakGlassApprovalAttempt = @@ -426,11 +461,14 @@ export const accessApprovalRequestServiceFactory = ({ ); } - const otherReviews = existingReviews.filter((er) => er.reviewerUserId !== actorId); - const allUniqueReviews = [...otherReviews, reviewForThisActorProcessing]; + if (status === ApprovalStatus.REJECTED) { + await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { status: ApprovalStatus.REJECTED }, tx); + return reviewForThisActorProcessing; + } - const approvedReviews = allUniqueReviews.filter((r) => r.status === ApprovalStatus.APPROVED); - const meetsStandardApprovalThreshold = approvedReviews.length >= policy.approvals; + const meetsStandardApprovalThreshold = + (presentSequence?.approvals || 0) + 1 >= presentSequence.requiredApprovals && + approvedSequences.at(-1)?.step === presentSequence?.step; if ( reviewForThisActorProcessing.status === ApprovalStatus.APPROVED && diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index c2db3e6e7..c07e37fcb 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -36,12 +36,12 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ oidcSSO: false, scim: false, ldap: false, - groups: false, + groups: true, status: null, trial_end: null, has_used_trial: true, - secretApproval: false, - secretRotation: false, + secretApproval: true, + secretRotation: true, caCrl: false, instanceUserManagement: false, externalKms: false, diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index 8f51c2867..47a5acd56 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -64,6 +64,10 @@ export const FilterableSelect = ({ control: (base) => ({ ...base, transition: "none" + }), + menuPortal: (provided) => ({ + ...provided, + zIndex: 9999 }) }} tabSelectsValue={tabSelectsValue} diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index e4224e110..3b05ff61b 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -25,7 +25,8 @@ export const useCreateAccessApprovalPolicy = () => { name, secretPath, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + approvalsRequired }) => { const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { environment, @@ -36,7 +37,8 @@ export const useCreateAccessApprovalPolicy = () => { secretPath, name, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + approvalsRequired }); return data; }, @@ -60,7 +62,8 @@ export const useUpdateAccessApprovalPolicy = () => { name, secretPath, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + approvalsRequired }) => { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { approvals, @@ -69,7 +72,8 @@ export const useUpdateAccessApprovalPolicy = () => { secretPath, name, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + approvalsRequired }); return data; }, diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 54a53b2ab..ac94e0468 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -33,6 +33,8 @@ export enum BypasserType { export type Approver = { id: string; type: ApproverType; + sequence?: number; + approvals?: number; }; export type Bypasser = { @@ -78,7 +80,13 @@ export type TAccessApprovalRequest = { id: string; name: string; approvals: number; - approvers: string[]; + approvers: { + userId: string; + sequence?: number; + approvalsRequired?: number; + username: string; + email: string; + }[]; bypassers: string[]; secretPath?: string | null; envId: string; @@ -88,7 +96,7 @@ export type TAccessApprovalRequest = { }; reviewers: { - member: string; + userId: string; status: string; }[]; @@ -163,6 +171,7 @@ export type TCreateAccessPolicyDTO = { secretPath?: string; enforcementLevel?: EnforcementLevel; allowedSelfApprovals: boolean; + approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; }; export type TUpdateAccessPolicyDTO = { @@ -177,6 +186,7 @@ export type TUpdateAccessPolicyDTO = { allowedSelfApprovals: boolean; // for invalidating list projectSlug: string; + approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; }; export type TDeleteSecretPolicyDTO = { diff --git a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx index 39fa1058a..e6ba62a6b 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx @@ -225,7 +225,7 @@ export const useGetSecretApprovalRequestCount = ({ }) => useQuery({ queryKey: secretApprovalRequestKeys.count({ workspaceId }), - refetchInterval: 5000, + refetchInterval: 15000, queryFn: () => fetchSecretApprovalRequestCount({ workspaceId }), enabled: Boolean(workspaceId) && (options?.enabled ?? true) }); diff --git a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx index 9cb739736..fe27e5d7d 100644 --- a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx +++ b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx @@ -27,8 +27,8 @@ import { } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { ProjectType } from "@app/hooks/api/workspace/types"; import { isLoggedIn } from "@app/hooks/api/reactQuery"; +import { ProjectType } from "@app/hooks/api/workspace/types"; // eslint-disable-next-line new-cap const client = new jsrp.client(); 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 ee7f93d87..5bcc12f18 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -87,6 +87,7 @@ export const AccessApprovalRequest = ({ isRequestedByCurrentUser: boolean; isSelfApproveAllowed: boolean; isApprover: boolean; + isDisabled?: boolean; }) | null >(null); @@ -147,16 +148,17 @@ export const AccessApprovalRequest = ({ const generateRequestDetails = useCallback( (request: TAccessApprovalRequest) => { const isReviewedByUser = - request.reviewers.findIndex(({ member }) => member === user.id) !== -1; + request.reviewers.findIndex(({ userId }) => userId === user.id) !== -1; const isRejectedByAnyone = request.reviewers.some( ({ status }) => status === ApprovalStatus.REJECTED ); - const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1; + const isApprover = + request.policy.approvers.findIndex((el) => el.userId === user.id || "") !== -1; const isAccepted = request.isApproved; const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; const isRequestedByCurrentUser = request.requestedByUserId === user.id; const isSelfApproveAllowed = request.policy.allowedSelfApprovals; - const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status; + const userReviewStatus = request.reviewers.find(({ userId }) => userId === user.id)?.status; const canBypass = !request.policy.bypassers.length || request.policy.bypassers.includes(user.id); @@ -205,21 +207,6 @@ export const AccessApprovalRequest = ({ const handleSelectRequest = useCallback( (request: TAccessApprovalRequest) => { const details = generateRequestDetails(request); - - // Whether the request has already been approved / rejected / reviewed - const isInactive = - details.isAccepted || details.isReviewedByUser || details.isRejectedByAnyone; - - // Whether the current user can bypass policy - const canBypass = - details.isSoftEnforcement && details.isRequestedByCurrentUser && details.canBypass; - - // Whether the current user can approve - const canApprove = - details.isApprover && (!details.isRequestedByCurrentUser || details.isSelfApproveAllowed); - - if (isInactive || (!canApprove && !canBypass)) return; - if (membersGroupById?.[request.requestedByUserId].user || details.isRequestedByCurrentUser) { setSelectedRequest({ ...request, @@ -381,9 +368,6 @@ export const AccessApprovalRequest = ({ return (
{ handlePopUpClose("reviewRequest"); diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx index 0d15e016f..04656e11e 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -1,16 +1,48 @@ import { useCallback, useMemo, useState } from "react"; -import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { + faCheckCircle, + faCircle, + faTriangleExclamation, + faUsers, + faXmarkCircle +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import ms from "ms"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { Button, Checkbox, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { + Button, + Checkbox, + FormControl, + Input, + Modal, + ModalContent, + Popover, + PopoverContent, + PopoverTrigger, + Tooltip +} from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; -import { ProjectPermissionActions } from "@app/context"; -import { useReviewAccessRequest } from "@app/hooks/api"; -import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; +import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context"; +import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api"; +import { + Approver, + ApproverType, + TAccessApprovalPolicy, + TAccessApprovalRequest +} from "@app/hooks/api/accessApproval/types"; import { EnforcementLevel } from "@app/hooks/api/policies/enums"; +import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types"; +import { groupBy } from "@app/lib/fn/array"; + +const getReviewedStatusSymbol = (status?: ApprovalStatus) => { + if (status === ApprovalStatus.APPROVED) + return ; + if (status === ApprovalStatus.REJECTED) + return ; + return ; +}; export const ReviewAccessRequestModal = ({ isOpen, @@ -19,7 +51,9 @@ export const ReviewAccessRequestModal = ({ projectSlug, selectedRequester, selectedEnvSlug, - canBypass + canBypass, + policies = [], + members = [] }: { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; @@ -33,10 +67,15 @@ export const ReviewAccessRequestModal = ({ selectedRequester: string | undefined; selectedEnvSlug: string | undefined; canBypass: boolean; + policies: TAccessApprovalPolicy[]; + members: TWorkspaceUser[]; }) => { const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); const [bypassApproval, setBypassApproval] = useState(false); const [bypassReason, setBypassReason] = useState(""); + const { currentWorkspace } = useWorkspace(); + const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || ""); + const { user } = useUser(); const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; @@ -134,6 +173,54 @@ export const ReviewAccessRequestModal = ({ ] ); + const approverSequence = useMemo(() => { + const policy = policies.find((el) => el.id === request.policy.id); + const reviewesGroupById = groupBy(request.reviewers, (i) => i.userId); + const membersGroupById = groupBy(members, (i) => i.user.id); + const projectGroupsGroupById = groupBy(groupMemberships, (i) => i.group.id); + const approversBySequence = policy?.approvers?.reduce( + (acc, curr) => { + if (acc.length > 1 && acc[acc.length - 1].sequence === curr.sequence) { + acc[acc.length - 1][curr.type]?.push(curr); + return acc; + } + + const approvals = curr.approvals || policy.approvals; + const sequence = curr.sequence || 1; + + acc.push( + curr.type === ApproverType.User + ? { user: [curr], group: [], sequence, approvals } + : { group: [curr], user: [], sequence, approvals } + ); + return acc; + }, + [] as { + user: Approver[]; + group: Approver[]; + sequence?: number; + approvals?: number; + }[] + ); + + const approvers = approversBySequence?.map((approverChain) => { + const reviewers = request.policy.approvers + .filter((el) => (el.sequence || 1) === approverChain.sequence) + .map((el) => ({ ...el, status: reviewesGroupById?.[el.userId]?.[0]?.status })); + const hasApproved = + reviewers.filter((el) => el.status === "approved").length >= + (approverChain?.approvals || 1); + + const hasRejected = reviewers.filter((el) => el.status === ApprovalStatus.REJECTED).length; + + return { ...approverChain, reviewers, hasApproved, hasRejected }; + }); + return { approvers, membersGroupById, projectGroupsGroupById }; + }, [request, policies]); + + const hasRejected = request.reviewers.find((el) => el.status === ApprovalStatus.REJECTED); + const isReviewedByMe = request.reviewers.find((i) => i.userId === user.id); + return ( -
- - {request.user && - (request.user.firstName || request.user.lastName) && - request.user.email ? ( - - {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) - - ) : ( - A user - )}{" "} - is requesting access to the following resource: - -
-
- Requested path: - {accessDetails.env + accessDetails.secretPath || ""} -
- -
- Permissions: - {requestedAccess} -
- -
- Access Type: - {getAccessLabel()} -
- - {request.note && ( -
- User Note: - {request.note} +
+ {request.user && + (request.user.firstName || request.user.lastName) && + request.user.email ? ( + + {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) + + ) : ( + A user + )}{" "} + is requesting access to the following resource: +
+
+
+
+
+
Environment
+
{accessDetails.env || "-"}
- )} +
+
Secret Path
+
{accessDetails.secretPath || "-"}
+
+
+
Access Type
+
{getAccessLabel()}
+
+
+
Permission
+
{requestedAccess}
+
+
+
Note
+
{request.note || "-"}
+
+
-
- - -
- {isSoftEnforcement && - request.isRequestedByCurrentUser && - !(request.isApprover && request.isSelfApproveAllowed) && - canBypass && ( -
- setBypassApproval(checked === true)} - isChecked={bypassApproval} - id="byPassApproval" - checkIndicatorBg="text-white" - className={twMerge( - "mr-2", - bypassApproval ? "border-red bg-red hover:bg-red-600" : "" - )} - > - - Approve without waiting for requirements to be met (bypass policy protection) - - - {bypassApproval && ( - Approvers
+
+ {approverSequence?.approvers?.map((approver, index) => ( +
+
+
- setBypassReason(e.currentTarget.value)} - placeholder="Enter reason for bypass (min 10 chars)" - leftIcon={} +
{index + 1}
+
+ {index !== (approverSequence?.approvers?.length || 0) - 1 && ( +
- - )} + )} + {index !== 0 && ( +
+ )} +
+
+
+
Users
+
+ {approver?.user + ?.map( + (el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username + ) + .join(",") || "-"} +
+
+
+
Groups
+
+ {approver?.group + ?.map( + (el) => + approverSequence?.projectGroupsGroupById?.[el.id]?.[0]?.group?.name + ) + .join(",") || "-"} +
+
+
+
+
Approvals Required
+
{approver.approvals || "-"}
+
+
+ + + + + +
+
Reviewers
+
+ {approver.reviewers.map((el) => ( +
+
{el.username}
+ + {getReviewedStatusSymbol(el?.status as ApprovalStatus)} + +
+ ))} +
+
+
+
+
+
+
- )} + ))} +
+ {hasRejected || isReviewedByMe ? ( +
+ {isReviewedByMe + ? "You have reviewed this request." + : "This request has been rejected."} +
+ ) : ( + <> +
+ + +
+ {isSoftEnforcement && + request.isRequestedByCurrentUser && + !(request.isApprover && request.isSelfApproveAllowed) && + canBypass && ( +
+ setBypassApproval(checked === true)} + isChecked={bypassApproval} + id="byPassApproval" + checkIndicatorBg="text-white" + className={twMerge( + "mr-2", + bypassApproval ? "border-red bg-red hover:bg-red-600" : "" + )} + > + + Approve without waiting for requirements to be met (bypass policy + protection) + + + {bypassApproval && ( + + setBypassReason(e.currentTarget.value)} + placeholder="Enter reason for bypass (min 10 chars)" + leftIcon={} + /> + + )} +
+ )} + + )}
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 ee837adbb..3d1ba70ef 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -188,9 +188,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { Name Environment Secret Path - Eligible Approvers - Eligible Group Approvers - Approval Required 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 057504696..00ba857cb 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,6 +1,9 @@ -import { useEffect, useMemo } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { useEffect, 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"; import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -8,12 +11,15 @@ import { Button, FilterableSelect, FormControl, + IconButton, Input, Modal, ModalContent, Select, SelectItem, - Switch + Switch, + Tag, + Tooltip } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { getMemberLabel } from "@app/helpers/members"; @@ -28,6 +34,7 @@ import { useUpdateAccessApprovalPolicy } from "@app/hooks/api/accessApproval"; import { + Approver, ApproverType, BypasserType, TAccessApprovalPolicy @@ -68,10 +75,28 @@ const formSchema = z .default([]), policyType: z.nativeEnum(PolicyType), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), - allowedSelfApprovals: z.boolean().default(true) + allowedSelfApprovals: z.boolean().default(true), + sequenceApprovers: z + .object({ + user: z + .object({ type: z.literal(ApproverType.User), id: z.string() }) + .array() + .default([]), + group: z + .object({ type: z.literal(ApproverType.Group), id: z.string() }) + .array() + .default([]), + approvals: z.number().min(1).default(1) + }) + .array() + .default([]) + .optional() }) .superRefine((data, ctx) => { - if (!(data.groupApprovers.length || data.userApprovers.length)) { + if ( + data.policyType === PolicyType.ChangePolicy && + !(data.groupApprovers.length || data.userApprovers.length) + ) { ctx.addIssue({ path: ["userApprovers"], code: z.ZodIssueCode.custom, @@ -95,6 +120,9 @@ export const AccessPolicyForm = ({ projectSlug, editValues }: Props) => { + const [draggedItem, setDraggedItem] = useState(null); + const [dragOverItem, setDragOverItem] = useState(null); + const modalContainer = useRef(null); const { control, handleSubmit, @@ -103,6 +131,7 @@ export const AccessPolicyForm = ({ formState: { isSubmitting } } = useForm({ resolver: zodResolver(formSchema), + // @ts-expect-error due to collision of approver type values: editValues ? { ...editValues, @@ -124,15 +153,47 @@ export const AccessPolicyForm = ({ ?.filter((bypasser) => bypasser.type === BypasserType.Group) .map(({ id, type }) => ({ id, type: type as BypasserType.Group })) || [], approvals: editValues?.approvals, - allowedSelfApprovals: editValues?.allowedSelfApprovals + allowedSelfApprovals: editValues?.allowedSelfApprovals, + sequenceApprovers: editValues.approvers + ?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0)) + .reduce( + (acc, curr) => { + if (acc.length > 1 && acc[acc.length - 1].sequence === curr.sequence) { + acc[acc.length - 1][curr.type]?.push(curr); + return acc; + } + const approvals = curr.approvals || editValues.approvals; + acc.push( + curr.type === ApproverType.User + ? { + user: [curr], + group: [], + sequence: 1, + approvals + } + : { group: [curr], user: [], sequence: 1, approvals } + ); + return acc; + }, + [] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[] + ) } - : undefined + : undefined, + defaultValues: { + sequenceApprovers: [{ approvals: 1 }] + } }); + const sequenceApproversFieldArray = useFieldArray({ + control, + name: "sequenceApprovers" + }); + const { currentWorkspace } = useWorkspace(); const { data: groups } = useListWorkspaceGroups(projectId); const environments = currentWorkspace?.environments || []; const isEditMode = Boolean(editValues); + const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy; useEffect(() => { if (!isOpen || !isEditMode) reset({}); @@ -157,6 +218,7 @@ export const AccessPolicyForm = ({ userApprovers, groupBypassers, userBypassers, + sequenceApprovers, ...data }: TFormSchema) => { if (!projectId) return; @@ -175,7 +237,15 @@ export const AccessPolicyForm = ({ } else { await createAccessApprovalPolicy({ ...data, - approvers: [...userApprovers, ...groupApprovers], + approvers: sequenceApprovers?.flatMap((approvers, index) => + approvers.user + .map((el) => ({ ...el, sequence: index + 1 }) as Approver) + .concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 }))) + ), + approvalsRequired: sequenceApprovers?.map((el, index) => ({ + stepNumber: index + 1, + numberOfApprovals: el.approvals + })), bypassers: bypassers.length > 0 ? bypassers : undefined, environment: environment.slug, projectSlug @@ -201,6 +271,7 @@ export const AccessPolicyForm = ({ groupApprovers, userBypassers, groupBypassers, + sequenceApprovers, ...data }: TFormSchema) => { if (!projectId || !projectSlug) return; @@ -221,7 +292,15 @@ export const AccessPolicyForm = ({ await updateAccessApprovalPolicy({ id: editValues?.id, ...data, - approvers: [...userApprovers, ...groupApprovers], + approvers: sequenceApprovers?.flatMap((approvers, index) => + approvers.user + .map((el) => ({ ...el, sequence: index + 1 }) as Approver) + .concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 }))) + ), + approvalsRequired: sequenceApprovers?.map((el, index) => ({ + stepNumber: index + 1, + numberOfApprovals: el.approvals + })), bypassers: bypassers.length > 0 ? bypassers : undefined, environment: environment.slug, projectSlug @@ -285,16 +364,46 @@ export const AccessPolicyForm = ({ [groups] ); + const handleDragStart = (_: React.DragEvent, index: number) => { + setDraggedItem(index); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + setDragOverItem(index); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + + if (draggedItem === null || dragOverItem === null || draggedItem === dragOverItem) { + setDraggedItem(null); + setDragOverItem(null); + return; + } + + sequenceApproversFieldArray.move(draggedItem, dragOverItem); + + setDraggedItem(null); + setDragOverItem(null); + }; + + const handleDragEnd = () => { + setDraggedItem(null); + setDragOverItem(null); + }; + return (
-
+
field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> + {!isAccessPolicyType && ( + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> + )} +
+
@@ -366,6 +482,7 @@ export const AccessPolicyForm = ({ label="Secret Path" isError={Boolean(error)} errorText={error?.message} + className="flex-grow" > @@ -400,62 +517,199 @@ export const AccessPolicyForm = ({ Select members or groups that are allowed to approve requests from this policy.

-
- ( - - option.id} - getOptionLabel={(option) => { - const member = members?.find((m) => m.user.id === option.id); + {isAccessPolicyType ? ( + <> +
+ {sequenceApproversFieldArray.fields.map((el, index) => ( +
handleDragOver(e, index)} + onDrop={handleDrop} + > +
+ Step {index + 1} +
+
Min. Approvals
+
+ ( + field.onChange(parseInt(val.target.value, 10))} + /> + )} + /> +
+ + sequenceApproversFieldArray.remove(index)} + className="text-red-500 hover:text-gray-200" + > + + + + +
handleDragStart(e, index)} + onDragEnd={handleDragEnd} + className="mr-2 cursor-move text-gray-400 hover:text-gray-200" + > + +
+
+
+
+
+ ( + + option.id} + getOptionLabel={(option) => { + const member = members?.find((m) => m.user.id === option.id); - if (!member) return option.id; + if (!member) return option.id; - return getMemberLabel(member); - }} - value={value} - onChange={onChange} - /> - - )} - /> - ( - + + )} + /> + ( + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? + option.id + } + value={value} + onChange={onChange} + /> + + )} + /> +
+
+ ))} +
+
+
+ Add Step + +
+ + ) : ( +
+ ( + + option.id} + getOptionLabel={(option) => { + const member = members?.find((m) => m.user.id === option.id); + + if (!member) return option.id; + + return getMemberLabel(member); + }} + value={value} + onChange={onChange} + /> + + )} + /> + ( + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? + option.id + } + value={value} + onChange={onChange} + /> + + )} + /> +
+ )} { + const [isExpanded, setIsExpanded] = useToggle(); + const labels = useMemo(() => { - const usersInPolicy = policy.approvers - ?.filter((approver) => approver.type === ApproverType.User) - .map((approver) => approver.id); + const sortedSteps = policy.approvers?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0)); + const entityInSameSequence = sortedSteps?.reduce( + (acc, curr) => { + if (acc.length > 1 && acc[acc.length - 1].sequence === curr.sequence) { + acc[acc.length - 1][curr.type]?.push(curr); + return acc; + } + const approvals = curr.approvals || policy.approvals; + acc.push( + curr.type === ApproverType.User + ? { user: [curr], group: [], sequence: 1, approvals } + : { group: [curr], user: [], sequence: 1, approvals } + ); + return acc; + }, + [] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[] + ); - const groupsInPolicy = policy.approvers - ?.filter((approver) => approver.type === ApproverType.Group) - .map((approver) => approver.id); - - const memberLabels = usersInPolicy?.length - ? members - .filter((member) => usersInPolicy?.includes(member.user.id)) + return entityInSameSequence?.map((el) => { + return { + sequence: el.sequence || policy.approvals, + userLabels: members + ?.filter((member) => el.user.find((i) => i.id === member.user.id)) .map((member) => getMemberLabel(member)) - .join(", ") - : null; - - const groupLabels = groupsInPolicy?.length - ? groups - .filter(({ group }) => groupsInPolicy?.includes(group.id)) + .join(","), + groupLabels: groups + ?.filter(({ group }) => el.group.find((i) => i.id === group.id)) .map(({ group }) => group.name) - .join(", ") - : null; - - return { - members: memberLabels, - groups: groupLabels - }; + .join(","), + approvals: el.approvals + }; + }); }, [policy, members, groups]); return ( - - {policy.name} - {policy.environment.slug} - {policy.secretPath || "*"} - - -

{labels.members ?? "-"}

-
- - - -

{labels.groups ?? "-"}

-
- - {policy.approvals} - - - {policyDetails[policy.policyType].name} - - - - - -
- -
-
- - - {(isAllowed) => ( - + { + if (evt.key === "Enter") setIsExpanded.toggle(); + }} + onClick={() => setIsExpanded.toggle()} + > + {policy.name} + {policy.environment.slug} + {policy.secretPath || "*"} + + + {policyDetails[policy.policyType].name} + + + + + +
+ +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + onEdit(); + }} + disabled={!isAllowed} + > + Edit Policy + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + onDelete(); + }} + disabled={!isAllowed} + > + Delete Policy + + )} + + +
+ + + {isExpanded && ( + + +
Approvers
+ {labels?.map((el, index) => ( +
+
+
+
{index + 1}
+
+ {index !== labels.length - 1 && ( +
)} - onClick={(e) => { - e.stopPropagation(); - onEdit(); - }} - disabled={!isAllowed} - > - Edit Policy - - )} - - - {(isAllowed) => ( - )} - onClick={(e) => { - e.stopPropagation(); - onDelete(); - }} - disabled={!isAllowed} - > - Delete Policy - - )} - - - - - +
+
+
+
Users
+
{el.userLabels || "-"}
+
+
+
Groups
+
{el.groupLabels || "-"}
+
+
+
Approvals Required
+
{el.approvals || "-"}
+
+
+
+ ))} + + + )} + ); };