feat: ui changes for approval to work

This commit is contained in:
=
2025-06-11 15:20:46 +05:30
parent d32b6ad41d
commit f9c00cf442
18 changed files with 1001 additions and 371 deletions
@@ -47,12 +47,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.max(100, "Cannot have more than 100 bypassers") .max(100, "Cannot have more than 100 bypassers")
.optional(), .optional(),
approvalsRequired: z approvalsRequired: z
.record( .object({
z.number().int(), numberOfApprovals: z.number().int(),
z.object({ stepNumber: z.number().int()
numberOfApprovals: z.number().int()
}) })
) .array()
.optional(), .optional(),
approvals: z.number().min(1).default(1), approvals: z.number().min(1).default(1),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
@@ -95,7 +94,12 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
approvals: sapPubSchema approvals: sapPubSchema
.extend({ .extend({
approvers: z 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() .array()
.nullable() .nullable()
.optional(), .optional(),
@@ -169,8 +173,17 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.transform((val) => (val === "" ? "/" : val)), .transform((val) => (val === "" ? "/" : val)),
approvers: z approvers: z
.discriminatedUnion("type", [ .discriminatedUnion("type", [
z.object({ type: z.literal(ApproverType.Group), id: z.string() }), z.object({
z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) 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() .array()
.min(1, { message: "At least one approver should be provided" }) .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), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
allowedSelfApprovals: z.boolean().default(true), allowedSelfApprovals: z.boolean().default(true),
approvalsRequired: z approvalsRequired: z
.record( .object({
z.number().int(), numberOfApprovals: z.number().int(),
z.object({ stepNumber: z.number().int()
numberOfApprovals: z.number().int()
}) })
) .array()
.optional() .optional()
}), }),
response: { response: {
@@ -260,7 +272,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.object({ .object({
type: z.nativeEnum(ApproverType), type: z.nativeEnum(ApproverType),
id: z.string().nullable().optional(), id: z.string().nullable().optional(),
name: z.string().nullable().optional() name: z.string().nullable().optional(),
approvalsRequired: z.number().nullable().optional()
}) })
.array() .array()
.nullable() .nullable()
@@ -112,7 +112,15 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
approvals: z.number(), 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(), bypassers: z.string().array(),
secretPath: z.string().nullish(), secretPath: z.string().nullish(),
envId: z.string(), envId: z.string(),
@@ -48,6 +48,9 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
.select(tx.ref("username").withSchema("bypasserUsers").as("bypasserUsername")) .select(tx.ref("username").withSchema("bypasserUsers").as("bypasserUsername"))
.select(tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(tx.ref("approverGroupId").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("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser))
.select(tx.ref("bypasserGroupId").withSchema(TableName.AccessApprovalPolicyBypasser)) .select(tx.ref("bypasserGroupId").withSchema(TableName.AccessApprovalPolicyBypasser))
.select(tx.ref("name").withSchema(TableName.Environment).as("envName")) .select(tx.ref("name").withSchema(TableName.Environment).as("envName"))
@@ -80,17 +83,21 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
{ {
key: "approverUserId", key: "approverUserId",
label: "approvers" as const, label: "approvers" as const,
mapper: ({ approverUserId: id }) => ({ mapper: ({ approverUserId: id, approverSequence, approvalsRequired }) => ({
id, id,
type: "user" type: "user",
sequence: approverSequence,
approvalsRequired
}) })
}, },
{ {
key: "approverGroupId", key: "approverGroupId",
label: "approvers" as const, label: "approvers" as const,
mapper: ({ approverGroupId: id }) => ({ mapper: ({ approverGroupId: id, approverSequence, approvalsRequired }) => ({
id, id,
type: "group" type: "group",
sequence: approverSequence,
approvalsRequired
}) })
} }
] ]
@@ -129,18 +136,22 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
{ {
key: "approverUserId", key: "approverUserId",
label: "approvers" as const, label: "approvers" as const,
mapper: ({ approverUserId: id, approverUsername }) => ({ mapper: ({ approverUserId: id, approverUsername, approverSequence, approvalsRequired }) => ({
id, id,
type: ApproverType.User, type: ApproverType.User,
name: approverUsername name: approverUsername,
sequence: approverSequence,
approvalsRequired
}) })
}, },
{ {
key: "approverGroupId", key: "approverGroupId",
label: "approvers" as const, label: "approvers" as const,
mapper: ({ approverGroupId: id }) => ({ mapper: ({ approverGroupId: id, approverSequence, approvalsRequired }) => ({
id, id,
type: ApproverType.Group type: ApproverType.Group,
sequence: approverSequence,
approvalsRequired
}) })
}, },
{ {
@@ -4,6 +4,7 @@ import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; 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 { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
@@ -30,7 +31,6 @@ import {
TListAccessApprovalPoliciesDTO, TListAccessApprovalPoliciesDTO,
TUpdateAccessApprovalPolicy TUpdateAccessApprovalPolicy
} from "./access-approval-policy-types"; } from "./access-approval-policy-types";
import { groupBy } from "@app/lib/fn";
type TAccessApprovalPolicyServiceFactoryDep = { type TAccessApprovalPolicyServiceFactoryDep = {
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
@@ -44,7 +44,7 @@ type TAccessApprovalPolicyServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "find">; userDAL: Pick<TUserDALFactory, "find">;
accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update" | "find">; accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update" | "find">;
additionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">; additionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update">; accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update" | "delete">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find">; orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find">;
}; };
@@ -176,6 +176,7 @@ export const accessApprovalPolicyServiceFactory = ({
} }
} }
const approvalsRequiredGroupByStepNumber = groupBy(approvalsRequired || [], (i) => i.stepNumber);
const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => {
const doc = await accessApprovalPolicyDAL.create( const doc = await accessApprovalPolicyDAL.create(
{ {
@@ -195,7 +196,9 @@ export const accessApprovalPolicyServiceFactory = ({
approverUserId: el.id, approverUserId: el.id,
policyId: doc.id, policyId: doc.id,
sequence: el.sequence, sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})), })),
tx tx
); );
@@ -207,7 +210,9 @@ export const accessApprovalPolicyServiceFactory = ({
approverGroupId: el.id, approverGroupId: el.id,
policyId: doc.id, policyId: doc.id,
sequence: el.sequence, sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})), })),
tx tx
); );
@@ -284,7 +289,6 @@ export const accessApprovalPolicyServiceFactory = ({
id: string; id: string;
sequence?: number; sequence?: number;
}[]; }[];
const userApproverNames = approvers.filter( const userApproverNames = approvers.filter(
(approver) => approver.type === ApproverType.User && approver.username (approver) => approver.type === ApproverType.User && approver.username
) as { username: string; sequence?: number }[]; ) 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 updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => {
const doc = await accessApprovalPolicyDAL.updateById( const doc = await accessApprovalPolicyDAL.updateById(
accessApprovalPolicy.id, accessApprovalPolicy.id,
@@ -427,13 +432,14 @@ export const accessApprovalPolicyServiceFactory = ({
})) }))
); );
} }
await accessApprovalPolicyApproverDAL.insertMany( await accessApprovalPolicyApproverDAL.insertMany(
approverUserIds.map((el) => ({ approverUserIds.map((el) => ({
approverUserId: el.id, approverUserId: el.id,
policyId: doc.id, policyId: doc.id,
sequence: el.sequence, sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})), })),
tx tx
); );
@@ -445,7 +451,9 @@ export const accessApprovalPolicyServiceFactory = ({
approverGroupId: el.id, approverGroupId: el.id,
policyId: doc.id, policyId: doc.id,
sequence: el.sequence, sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})), })),
tx tx
); );
@@ -39,7 +39,7 @@ export type TCreateAccessApprovalPolicy = {
name: string; name: string;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
approvalsRequired?: Record<number, { numberOfApprovals: number }>; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalPolicy = { export type TUpdateAccessApprovalPolicy = {
@@ -57,7 +57,7 @@ export type TUpdateAccessApprovalPolicy = {
name?: string; name?: string;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
approvalsRequired?: Record<number, { numberOfApprovals: number }>; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TDeleteAccessApprovalPolicy = { export type TDeleteAccessApprovalPolicy = {
@@ -39,12 +39,16 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
`${TableName.AccessApprovalRequest}.id`, `${TableName.AccessApprovalRequest}.id`,
`${TableName.AccessApprovalRequestReviewer}.requestId` `${TableName.AccessApprovalRequestReviewer}.requestId`
) )
.leftJoin( .leftJoin(
TableName.AccessApprovalPolicyApprover, TableName.AccessApprovalPolicyApprover,
`${TableName.AccessApprovalPolicy}.id`, `${TableName.AccessApprovalPolicy}.id`,
`${TableName.AccessApprovalPolicyApprover}.policyId` `${TableName.AccessApprovalPolicyApprover}.policyId`
) )
.leftJoin<TUsers>(
db(TableName.Users).as("accessApprovalPolicyApproverUser"),
`${TableName.AccessApprovalPolicyApprover}.approverUserId`,
"accessApprovalPolicyApproverUser.id"
)
.leftJoin( .leftJoin(
TableName.UserGroupMembership, TableName.UserGroupMembership,
`${TableName.AccessApprovalPolicyApprover}.approverGroupId`, `${TableName.AccessApprovalPolicyApprover}.approverGroupId`,
@@ -82,13 +86,18 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
) )
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .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("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"))
.select(db.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser)) .select(db.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser))
.select(db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId")) .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( .select(
db.ref("projectId").withSchema(TableName.Environment), db.ref("projectId").withSchema(TableName.Environment),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"), db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
@@ -173,11 +182,33 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
label: "reviewers" as const, label: "reviewers" as const,
mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined) 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", key: "approverGroupUserId",
label: "approvers" as const, 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 }, { key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId },
{ {
@@ -192,7 +223,11 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
return formattedDocs.map((doc) => ({ return formattedDocs.map((doc) => ({
...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) { } catch (error) {
throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" }); throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" });
@@ -272,6 +307,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.AccessApprovalRequest)) .select(selectAllTableCols(TableName.AccessApprovalRequest))
.select( .select(
tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover), 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("userId").withSchema(TableName.UserGroupMembership),
tx.ref("email").withSchema("accessApprovalPolicyApproverUser").as("approverEmail"), tx.ref("email").withSchema("accessApprovalPolicyApproverUser").as("approverEmail"),
tx.ref("email").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupEmail"), tx.ref("email").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupEmail"),
@@ -367,13 +404,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approverEmail: email, approverEmail: email,
approverUsername: username, approverUsername: username,
approverLastName: lastName, approverLastName: lastName,
approverFirstName: firstName approverFirstName: firstName,
approverSequence,
approvalsRequired
}) => ({ }) => ({
userId: approverUserId, userId: approverUserId,
email, email,
firstName, firstName,
lastName, lastName,
username username,
sequence: approverSequence,
approvalsRequired
}) })
}, },
{ {
@@ -384,13 +425,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approverGroupEmail: email, approverGroupEmail: email,
approverGroupUsername: username, approverGroupUsername: username,
approverGroupLastName: lastName, approverGroupLastName: lastName,
approverFirstName: firstName approverFirstName: firstName,
approverSequence,
approvalsRequired
}) => ({ }) => ({
userId, userId,
email, email,
firstName, firstName,
lastName, lastName,
username username,
sequence: approverSequence,
approvalsRequired
}) })
}, },
{ {
@@ -434,7 +479,9 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
...formattedDoc[0], ...formattedDoc[0],
policy: { policy: {
...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 bypassers: formattedDoc[0].bypassers
} }
}; };
@@ -4,6 +4,7 @@ import msFn from "ms";
import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { EnforcementLevel } from "@app/lib/types"; import { EnforcementLevel } from "@app/lib/types";
@@ -358,7 +359,6 @@ export const accessApprovalRequestServiceFactory = ({
const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypass); const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypass);
const isApprover = policy.approvers.find((approver) => approver.userId === actorId); 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 user is (not an approver OR cant self approve) AND can't bypass policy
if ((!isApprover || (!policy.allowedSelfApprovals && isSelfApproval)) && cannotBypassUnderSoftEnforcement) { if ((!isApprover || (!policy.allowedSelfApprovals && isSelfApproval)) && cannotBypassUnderSoftEnforcement) {
throw new BadRequestError({ throw new BadRequestError({
@@ -383,6 +383,41 @@ export const accessApprovalRequestServiceFactory = ({
if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) { if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) {
throw new BadRequestError({ message: "The request has already been rejected by another reviewer" }); 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 reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => {
const isBreakGlassApprovalAttempt = const isBreakGlassApprovalAttempt =
@@ -426,11 +461,14 @@ export const accessApprovalRequestServiceFactory = ({
); );
} }
const otherReviews = existingReviews.filter((er) => er.reviewerUserId !== actorId); if (status === ApprovalStatus.REJECTED) {
const allUniqueReviews = [...otherReviews, reviewForThisActorProcessing]; await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { status: ApprovalStatus.REJECTED }, tx);
return reviewForThisActorProcessing;
}
const approvedReviews = allUniqueReviews.filter((r) => r.status === ApprovalStatus.APPROVED); const meetsStandardApprovalThreshold =
const meetsStandardApprovalThreshold = approvedReviews.length >= policy.approvals; (presentSequence?.approvals || 0) + 1 >= presentSequence.requiredApprovals &&
approvedSequences.at(-1)?.step === presentSequence?.step;
if ( if (
reviewForThisActorProcessing.status === ApprovalStatus.APPROVED && reviewForThisActorProcessing.status === ApprovalStatus.APPROVED &&
@@ -36,12 +36,12 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
oidcSSO: false, oidcSSO: false,
scim: false, scim: false,
ldap: false, ldap: false,
groups: false, groups: true,
status: null, status: null,
trial_end: null, trial_end: null,
has_used_trial: true, has_used_trial: true,
secretApproval: false, secretApproval: true,
secretRotation: false, secretRotation: true,
caCrl: false, caCrl: false,
instanceUserManagement: false, instanceUserManagement: false,
externalKms: false, externalKms: false,
@@ -64,6 +64,10 @@ export const FilterableSelect = <T,>({
control: (base) => ({ control: (base) => ({
...base, ...base,
transition: "none" transition: "none"
}),
menuPortal: (provided) => ({
...provided,
zIndex: 9999
}) })
}} }}
tabSelectsValue={tabSelectsValue} tabSelectsValue={tabSelectsValue}
@@ -25,7 +25,8 @@ export const useCreateAccessApprovalPolicy = () => {
name, name,
secretPath, secretPath,
enforcementLevel, enforcementLevel,
allowedSelfApprovals allowedSelfApprovals,
approvalsRequired
}) => { }) => {
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
environment, environment,
@@ -36,7 +37,8 @@ export const useCreateAccessApprovalPolicy = () => {
secretPath, secretPath,
name, name,
enforcementLevel, enforcementLevel,
allowedSelfApprovals allowedSelfApprovals,
approvalsRequired
}); });
return data; return data;
}, },
@@ -60,7 +62,8 @@ export const useUpdateAccessApprovalPolicy = () => {
name, name,
secretPath, secretPath,
enforcementLevel, enforcementLevel,
allowedSelfApprovals allowedSelfApprovals,
approvalsRequired
}) => { }) => {
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
approvals, approvals,
@@ -69,7 +72,8 @@ export const useUpdateAccessApprovalPolicy = () => {
secretPath, secretPath,
name, name,
enforcementLevel, enforcementLevel,
allowedSelfApprovals allowedSelfApprovals,
approvalsRequired
}); });
return data; return data;
}, },
+12 -2
View File
@@ -33,6 +33,8 @@ export enum BypasserType {
export type Approver = { export type Approver = {
id: string; id: string;
type: ApproverType; type: ApproverType;
sequence?: number;
approvals?: number;
}; };
export type Bypasser = { export type Bypasser = {
@@ -78,7 +80,13 @@ export type TAccessApprovalRequest = {
id: string; id: string;
name: string; name: string;
approvals: number; approvals: number;
approvers: string[]; approvers: {
userId: string;
sequence?: number;
approvalsRequired?: number;
username: string;
email: string;
}[];
bypassers: string[]; bypassers: string[];
secretPath?: string | null; secretPath?: string | null;
envId: string; envId: string;
@@ -88,7 +96,7 @@ export type TAccessApprovalRequest = {
}; };
reviewers: { reviewers: {
member: string; userId: string;
status: string; status: string;
}[]; }[];
@@ -163,6 +171,7 @@ export type TCreateAccessPolicyDTO = {
secretPath?: string; secretPath?: string;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
}; };
export type TUpdateAccessPolicyDTO = { export type TUpdateAccessPolicyDTO = {
@@ -177,6 +186,7 @@ export type TUpdateAccessPolicyDTO = {
allowedSelfApprovals: boolean; allowedSelfApprovals: boolean;
// for invalidating list // for invalidating list
projectSlug: string; projectSlug: string;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
}; };
export type TDeleteSecretPolicyDTO = { export type TDeleteSecretPolicyDTO = {
@@ -225,7 +225,7 @@ export const useGetSecretApprovalRequestCount = ({
}) => }) =>
useQuery({ useQuery({
queryKey: secretApprovalRequestKeys.count({ workspaceId }), queryKey: secretApprovalRequestKeys.count({ workspaceId }),
refetchInterval: 5000, refetchInterval: 15000,
queryFn: () => fetchSecretApprovalRequestCount({ workspaceId }), queryFn: () => fetchSecretApprovalRequestCount({ workspaceId }),
enabled: Boolean(workspaceId) && (options?.enabled ?? true) enabled: Boolean(workspaceId) && (options?.enabled ?? true)
}); });
@@ -27,8 +27,8 @@ import {
} from "@app/hooks/api/auth/queries"; } from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types"; import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { isLoggedIn } from "@app/hooks/api/reactQuery"; import { isLoggedIn } from "@app/hooks/api/reactQuery";
import { ProjectType } from "@app/hooks/api/workspace/types";
// eslint-disable-next-line new-cap // eslint-disable-next-line new-cap
const client = new jsrp.client(); const client = new jsrp.client();
@@ -87,6 +87,7 @@ export const AccessApprovalRequest = ({
isRequestedByCurrentUser: boolean; isRequestedByCurrentUser: boolean;
isSelfApproveAllowed: boolean; isSelfApproveAllowed: boolean;
isApprover: boolean; isApprover: boolean;
isDisabled?: boolean;
}) })
| null | null
>(null); >(null);
@@ -147,16 +148,17 @@ export const AccessApprovalRequest = ({
const generateRequestDetails = useCallback( const generateRequestDetails = useCallback(
(request: TAccessApprovalRequest) => { (request: TAccessApprovalRequest) => {
const isReviewedByUser = const isReviewedByUser =
request.reviewers.findIndex(({ member }) => member === user.id) !== -1; request.reviewers.findIndex(({ userId }) => userId === user.id) !== -1;
const isRejectedByAnyone = request.reviewers.some( const isRejectedByAnyone = request.reviewers.some(
({ status }) => status === ApprovalStatus.REJECTED ({ 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 isAccepted = request.isApproved;
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const isRequestedByCurrentUser = request.requestedByUserId === user.id; const isRequestedByCurrentUser = request.requestedByUserId === user.id;
const isSelfApproveAllowed = request.policy.allowedSelfApprovals; 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 = const canBypass =
!request.policy.bypassers.length || request.policy.bypassers.includes(user.id); !request.policy.bypassers.length || request.policy.bypassers.includes(user.id);
@@ -205,21 +207,6 @@ export const AccessApprovalRequest = ({
const handleSelectRequest = useCallback( const handleSelectRequest = useCallback(
(request: TAccessApprovalRequest) => { (request: TAccessApprovalRequest) => {
const details = generateRequestDetails(request); 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) { if (membersGroupById?.[request.requestedByUserId].user || details.isRequestedByCurrentUser) {
setSelectedRequest({ setSelectedRequest({
...request, ...request,
@@ -381,9 +368,6 @@ export const AccessApprovalRequest = ({
return ( return (
<div <div
aria-disabled={
details.isReviewedByUser || details.isRejectedByAnyone || details.isAccepted
}
key={request.id} key={request.id}
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80" className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
role="button" role="button"
@@ -450,9 +434,11 @@ export const AccessApprovalRequest = ({
{!!selectedRequest && ( {!!selectedRequest && (
<ReviewAccessRequestModal <ReviewAccessRequestModal
selectedEnvSlug={envFilter} selectedEnvSlug={envFilter}
policies={policies || []}
selectedRequester={requestedByFilter} selectedRequester={requestedByFilter}
projectSlug={projectSlug} projectSlug={projectSlug}
request={selectedRequest} request={selectedRequest}
members={members || []}
isOpen={popUp.reviewRequest.isOpen} isOpen={popUp.reviewRequest.isOpen}
onOpenChange={() => { onOpenChange={() => {
handlePopUpClose("reviewRequest"); handlePopUpClose("reviewRequest");
@@ -1,16 +1,48 @@
import { useCallback, useMemo, useState } from "react"; 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms"; import ms from "ms";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; 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 { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions } from "@app/context"; import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context";
import { useReviewAccessRequest } from "@app/hooks/api"; import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api";
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; import {
Approver,
ApproverType,
TAccessApprovalPolicy,
TAccessApprovalRequest
} from "@app/hooks/api/accessApproval/types";
import { EnforcementLevel } from "@app/hooks/api/policies/enums"; 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 <FontAwesomeIcon icon={faCheckCircle} size="xs" style={{ color: "#15803d" }} />;
if (status === ApprovalStatus.REJECTED)
return <FontAwesomeIcon icon={faXmarkCircle} size="xs" style={{ color: "#b91c1c" }} />;
return <FontAwesomeIcon icon={faCircle} size="xs" style={{ color: "#c2410c" }} />;
};
export const ReviewAccessRequestModal = ({ export const ReviewAccessRequestModal = ({
isOpen, isOpen,
@@ -19,7 +51,9 @@ export const ReviewAccessRequestModal = ({
projectSlug, projectSlug,
selectedRequester, selectedRequester,
selectedEnvSlug, selectedEnvSlug,
canBypass canBypass,
policies = [],
members = []
}: { }: {
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
@@ -33,10 +67,15 @@ export const ReviewAccessRequestModal = ({
selectedRequester: string | undefined; selectedRequester: string | undefined;
selectedEnvSlug: string | undefined; selectedEnvSlug: string | undefined;
canBypass: boolean; canBypass: boolean;
policies: TAccessApprovalPolicy[];
members: TWorkspaceUser[];
}) => { }) => {
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
const [bypassApproval, setBypassApproval] = useState(false); const [bypassApproval, setBypassApproval] = useState(false);
const [bypassReason, setBypassReason] = useState(""); const [bypassReason, setBypassReason] = useState("");
const { currentWorkspace } = useWorkspace();
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
const { user } = useUser();
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; 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 ( return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}> <Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent <ModalContent
@@ -141,47 +228,159 @@ export const ReviewAccessRequestModal = ({
title="Review Request" title="Review Request"
subTitle="Review the request and approve or deny access." subTitle="Review the request and approve or deny access."
> >
<div className="text-sm"> <div className="mb-4 rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5 text-sm">
<span>
{request.user && {request.user &&
(request.user.firstName || request.user.lastName) && (request.user.firstName || request.user.lastName) &&
request.user.email ? ( request.user.email ? (
<span className="font-bold"> <span className="inline font-bold">
{request.user?.firstName} {request.user?.lastName} ({request.user?.email}) {request.user?.firstName} {request.user?.lastName} ({request.user?.email})
</span> </span>
) : ( ) : (
<span>A user</span> <span>A user</span>
)}{" "} )}{" "}
is requesting access to the following resource: is requesting access to the following resource:
</span>
<div className="mb-2 mt-4 border-l border-blue-500 bg-blue-500/20 px-3 py-2 text-mineshaft-200">
<div className="mb-1 lowercase">
<span className="font-bold capitalize">Requested path: </span>
<Badge>{accessDetails.env + accessDetails.secretPath || ""}</Badge>
</div> </div>
<div className="">
<div className="mb-1"> <div className="mb-2 mt-4 text-mineshaft-200">
<span className="font-bold">Permissions: </span> <div className="grid grid-cols-2 gap-4">
<Badge>{requestedAccess}</Badge>
</div>
<div> <div>
<span className="font-bold">Access Type: </span> <div className="mb-1 text-xs font-semibold uppercase">Environment</div>
<span>{getAccessLabel()}</span> <div>{accessDetails.env || "-"}</div>
</div> </div>
<div>
{request.note && ( <div className="mb-1 text-xs font-semibold uppercase">Secret Path</div>
<div className="mt-1"> <div>{accessDetails.secretPath || "-"}</div>
<span className="font-bold">User Note: </span>
<span>{request.note}</span>
</div> </div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Access Type</div>
<div>{getAccessLabel()}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Permission</div>
<div>{requestedAccess}</div>
</div>
<div className="col-span-2">
<div className="mb-1 text-xs font-semibold uppercase">Note</div>
<div>{request.note || "-"}</div>
</div>
</div>
</div>
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
<div className="thin-scrollbar max-h-64 overflow-y-auto rounded p-2">
{approverSequence?.approvers?.map((approver, index) => (
<div
key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4"
>
<div>
<div
className={twMerge(
"mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white",
approver.hasApproved && "border-green-400 text-green-400",
approver.hasRejected && "border-red-500 text-red-500"
)}
>
<div className="text-lg">{index + 1}</div>
</div>
{index !== (approverSequence?.approvers?.length || 0) - 1 && (
<div
className={twMerge(
"absolute bottom-0 left-8 h-6 border-r border-gray-400",
approver.hasApproved && "border-green-400",
approver.hasRejected && "border-red-500"
)}
/>
)}
{index !== 0 && (
<div
className={twMerge(
"absolute left-8 top-0 h-4 border-r border-gray-400",
approver.hasApproved && "border-green-400",
approver.hasRejected && "border-red-500"
)}
/>
)} )}
</div> </div>
<div className="grid flex-grow grid-cols-3">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
<div>
{approver?.user
?.map(
(el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username
)
.join(",") || "-"}
</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>
{approver?.group
?.map(
(el) =>
approverSequence?.projectGroupsGroupById?.[el.id]?.[0]?.group?.name
)
.join(",") || "-"}
</div>
</div>
<div className="flex items-center">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{approver.approvals || "-"}</div>
</div>
<div className="ml-16">
<Popover>
<PopoverTrigger>
<FontAwesomeIcon
icon={faUsers}
className={twMerge(
approver.hasApproved && "border-green-400 text-green-400",
approver.hasRejected && "border-red-500 text-red-500"
)}
/>
</PopoverTrigger>
<PopoverContent hideCloseBtn className="pt-3">
<div>
<div className="mb-1 text-sm text-bunker-300">Reviewers</div>
<div className="thin-scrollbar flex max-h-64 flex-col gap-1 overflow-y-auto rounded">
{approver.reviewers.map((el) => (
<div className="flex items-center gap-2 bg-mineshaft-700 p-1 text-sm">
<div className="flex-grow">{el.username}</div>
<Tooltip
content={`Status: ${el?.status || ApprovalStatus.PENDING}`}
>
{getReviewedStatusSymbol(el?.status as ApprovalStatus)}
</Tooltip>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
))}
</div>
{hasRejected || isReviewedByMe ? (
<div
className={twMerge(
"mb-4 rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm",
isReviewedByMe && "border-l-green-400"
)}
>
{isReviewedByMe
? "You have reviewed this request."
: "This request has been rejected."}
</div>
) : (
<>
<div className="space-x-2"> <div className="space-x-2">
<Button <Button
isLoading={isLoading === "approved"} isLoading={isLoading === "approved"}
isDisabled={ isDisabled={
!!isLoading || Boolean(isLoading) ||
(!( (!(
request.isApprover && request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed) (!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
@@ -197,7 +396,14 @@ export const ReviewAccessRequestModal = ({
</Button> </Button>
<Button <Button
isLoading={isLoading === "rejected"} isLoading={isLoading === "rejected"}
isDisabled={!!isLoading} isDisabled={
!!isLoading ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("rejected")} onClick={() => handleReview("rejected")}
className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200" className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200"
size="sm" size="sm"
@@ -221,7 +427,8 @@ export const ReviewAccessRequestModal = ({
)} )}
> >
<span className="text-xs text-red"> <span className="text-xs text-red">
Approve without waiting for requirements to be met (bypass policy protection) Approve without waiting for requirements to be met (bypass policy
protection)
</span> </span>
</Checkbox> </Checkbox>
{bypassApproval && ( {bypassApproval && (
@@ -241,6 +448,8 @@ export const ReviewAccessRequestModal = ({
)} )}
</div> </div>
)} )}
</>
)}
</div> </div>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -188,9 +188,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
<Th>Name</Th> <Th>Name</Th>
<Th>Environment</Th> <Th>Environment</Th>
<Th>Secret Path</Th> <Th>Secret Path</Th>
<Th className="w-[18%]">Eligible Approvers</Th>
<Th className="w-[18%]">Eligible Group Approvers</Th>
<Th>Approval Required</Th>
<Th> <Th>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
@@ -1,6 +1,9 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form"; 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 { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -8,12 +11,15 @@ import {
Button, Button,
FilterableSelect, FilterableSelect,
FormControl, FormControl,
IconButton,
Input, Input,
Modal, Modal,
ModalContent, ModalContent,
Select, Select,
SelectItem, SelectItem,
Switch Switch,
Tag,
Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { getMemberLabel } from "@app/helpers/members"; import { getMemberLabel } from "@app/helpers/members";
@@ -28,6 +34,7 @@ import {
useUpdateAccessApprovalPolicy useUpdateAccessApprovalPolicy
} from "@app/hooks/api/accessApproval"; } from "@app/hooks/api/accessApproval";
import { import {
Approver,
ApproverType, ApproverType,
BypasserType, BypasserType,
TAccessApprovalPolicy TAccessApprovalPolicy
@@ -68,10 +75,28 @@ const formSchema = z
.default([]), .default([]),
policyType: z.nativeEnum(PolicyType), policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), 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) => { .superRefine((data, ctx) => {
if (!(data.groupApprovers.length || data.userApprovers.length)) { if (
data.policyType === PolicyType.ChangePolicy &&
!(data.groupApprovers.length || data.userApprovers.length)
) {
ctx.addIssue({ ctx.addIssue({
path: ["userApprovers"], path: ["userApprovers"],
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@@ -95,6 +120,9 @@ export const AccessPolicyForm = ({
projectSlug, projectSlug,
editValues editValues
}: Props) => { }: Props) => {
const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
const modalContainer = useRef<HTMLDivElement>(null);
const { const {
control, control,
handleSubmit, handleSubmit,
@@ -103,6 +131,7 @@ export const AccessPolicyForm = ({
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<TFormSchema>({ } = useForm<TFormSchema>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
// @ts-expect-error due to collision of approver type
values: editValues values: editValues
? { ? {
...editValues, ...editValues,
@@ -124,15 +153,47 @@ export const AccessPolicyForm = ({
?.filter((bypasser) => bypasser.type === BypasserType.Group) ?.filter((bypasser) => bypasser.type === BypasserType.Group)
.map(({ id, type }) => ({ id, type: type as BypasserType.Group })) || [], .map(({ id, type }) => ({ id, type: type as BypasserType.Group })) || [],
approvals: editValues?.approvals, 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,
defaultValues: {
sequenceApprovers: [{ approvals: 1 }]
} }
: undefined
}); });
const sequenceApproversFieldArray = useFieldArray({
control,
name: "sequenceApprovers"
});
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { data: groups } = useListWorkspaceGroups(projectId); const { data: groups } = useListWorkspaceGroups(projectId);
const environments = currentWorkspace?.environments || []; const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues); const isEditMode = Boolean(editValues);
const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy;
useEffect(() => { useEffect(() => {
if (!isOpen || !isEditMode) reset({}); if (!isOpen || !isEditMode) reset({});
@@ -157,6 +218,7 @@ export const AccessPolicyForm = ({
userApprovers, userApprovers,
groupBypassers, groupBypassers,
userBypassers, userBypassers,
sequenceApprovers,
...data ...data
}: TFormSchema) => { }: TFormSchema) => {
if (!projectId) return; if (!projectId) return;
@@ -175,7 +237,15 @@ export const AccessPolicyForm = ({
} else { } else {
await createAccessApprovalPolicy({ await createAccessApprovalPolicy({
...data, ...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, bypassers: bypassers.length > 0 ? bypassers : undefined,
environment: environment.slug, environment: environment.slug,
projectSlug projectSlug
@@ -201,6 +271,7 @@ export const AccessPolicyForm = ({
groupApprovers, groupApprovers,
userBypassers, userBypassers,
groupBypassers, groupBypassers,
sequenceApprovers,
...data ...data
}: TFormSchema) => { }: TFormSchema) => {
if (!projectId || !projectSlug) return; if (!projectId || !projectSlug) return;
@@ -221,7 +292,15 @@ export const AccessPolicyForm = ({
await updateAccessApprovalPolicy({ await updateAccessApprovalPolicy({
id: editValues?.id, id: editValues?.id,
...data, ...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, bypassers: bypassers.length > 0 ? bypassers : undefined,
environment: environment.slug, environment: environment.slug,
projectSlug projectSlug
@@ -285,16 +364,46 @@ export const AccessPolicyForm = ({
[groups] [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 ( return (
<Modal isOpen={isOpen} onOpenChange={onToggle}> <Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent <ModalContent
className="max-w-2xl" className="max-w-3xl"
bodyClassName="overflow-visible" bodyClassName="overflow-visible"
ref={modalContainer}
title={isEditMode ? `Edit ${policyName}` : "Create Policy"} title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
> >
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="grid grid-cols-2 gap-x-3"> <div className="flex items-center gap-x-3">
<Controller <Controller
control={control} control={control}
name="policyType" name="policyType"
@@ -306,6 +415,7 @@ export const AccessPolicyForm = ({
isError={Boolean(error)} isError={Boolean(error)}
tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path." tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path."
errorText={error?.message} errorText={error?.message}
className="flex-grow"
> >
<Select <Select
isDisabled={isEditMode} isDisabled={isEditMode}
@@ -324,15 +434,17 @@ export const AccessPolicyForm = ({
</FormControl> </FormControl>
)} )}
/> />
{!isAccessPolicyType && (
<Controller <Controller
control={control} control={control}
name="approvals" name="approvals"
defaultValue={1} defaultValue={1}
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Minimum Approvals Required" label="Min. Approvals Required"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
className="flex-grow"
> >
<Input <Input
{...field} {...field}
@@ -343,6 +455,9 @@ export const AccessPolicyForm = ({
</FormControl> </FormControl>
)} )}
/> />
)}
</div>
<div className="flex items-center gap-x-3">
<Controller <Controller
control={control} control={control}
name="name" name="name"
@@ -351,6 +466,7 @@ export const AccessPolicyForm = ({
label="Policy Name" label="Policy Name"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
className="flex-grow"
> >
<Input {...field} value={field.value || ""} /> <Input {...field} value={field.value || ""} />
</FormControl> </FormControl>
@@ -366,6 +482,7 @@ export const AccessPolicyForm = ({
label="Secret Path" label="Secret Path"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
className="flex-grow"
> >
<Input {...field} value={field.value || ""} /> <Input {...field} value={field.value || ""} />
</FormControl> </FormControl>
@@ -400,6 +517,141 @@ export const AccessPolicyForm = ({
Select members or groups that are allowed to approve requests from this policy. Select members or groups that are allowed to approve requests from this policy.
</p> </p>
</div> </div>
{isAccessPolicyType ? (
<>
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded">
{sequenceApproversFieldArray.fields.map((el, index) => (
<div
className={twMerge(
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0",
dragOverItem === index ? "border-2 border-blue-400" : "",
draggedItem === index ? "opacity-50" : ""
)}
key={el.id}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
>
<div className="mb-3 flex items-center justify-between">
<Tag>Step {index + 1}</Tag>
<div className="flex items-center gap-3">
<div className="inline text-xs text-mineshaft-400">Min. Approvals</div>
<div className="mr-2 w-20 border-r border-mineshaft-400 pr-3">
<Controller
control={control}
name={`sequenceApprovers.${index}.approvals` as const}
defaultValue={1}
render={({ field }) => (
<Input
{...field}
type="number"
size="xs"
min={1}
onChange={(val) => field.onChange(parseInt(val.target.value, 10))}
/>
)}
/>
</div>
<Tooltip content="Remove step">
<IconButton
ariaLabel="delete"
variant="plain"
onClick={() => sequenceApproversFieldArray.remove(index)}
className="text-red-500 hover:text-gray-200"
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
<Tooltip content="Drag to reorder permission">
<div
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
className="mr-2 cursor-move text-gray-400 hover:text-gray-200"
>
<FontAwesomeIcon icon={faGripVertical} />
</div>
</Tooltip>
</div>
</div>
<div className="flex gap-2">
<Controller
control={control}
name={`sequenceApprovers.${index}.user` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => 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}
/>
</FormControl>
)}
/>
<Controller
control={control}
name={`sequenceApprovers.${index}.group` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
</div>
))}
</div>
<div className="my-2">
<Button
size="xs"
variant="outline_bg"
onClick={() =>
sequenceApproversFieldArray.append({
approvals: 1,
user: [],
group: []
})
}
>
Add Step
</Button>
</div>
</>
) : (
<div className="flex gap-2"> <div className="flex gap-2">
<Controller <Controller
control={control} control={control}
@@ -447,7 +699,8 @@ export const AccessPolicyForm = ({
options={groupOptions} options={groupOptions}
getOptionValue={(option) => option.id} getOptionValue={(option) => option.id}
getOptionLabel={(option) => getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
} }
value={value} value={value}
onChange={onChange} onChange={onChange}
@@ -456,6 +709,7 @@ export const AccessPolicyForm = ({
)} )}
/> />
</div> </div>
)}
<Controller <Controller
control={control} control={control}
name="allowedSelfApprovals" name="allowedSelfApprovals"
@@ -10,7 +10,6 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
Td, Td,
Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
@@ -18,6 +17,7 @@ import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types";
import { getMemberLabel } from "@app/helpers/members"; import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies"; import { policyDetails } from "@app/helpers/policies";
import { useToggle } from "@app/hooks";
import { Approver } from "@app/hooks/api/accessApproval/types"; import { Approver } from "@app/hooks/api/accessApproval/types";
import { TGroupMembership } from "@app/hooks/api/groups/types"; import { TGroupMembership } from "@app/hooks/api/groups/types";
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
@@ -53,57 +53,58 @@ export const ApprovalPolicyRow = ({
onEdit, onEdit,
onDelete onDelete
}: Props) => { }: Props) => {
const [isExpanded, setIsExpanded] = useToggle();
const labels = useMemo(() => { const labels = useMemo(() => {
const usersInPolicy = policy.approvers const sortedSteps = policy.approvers?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0));
?.filter((approver) => approver.type === ApproverType.User) const entityInSameSequence = sortedSteps?.reduce(
.map((approver) => approver.id); (acc, curr) => {
if (acc.length > 1 && acc[acc.length - 1].sequence === curr.sequence) {
const groupsInPolicy = policy.approvers acc[acc.length - 1][curr.type]?.push(curr);
?.filter((approver) => approver.type === ApproverType.Group) return acc;
.map((approver) => approver.id); }
const approvals = curr.approvals || policy.approvals;
const memberLabels = usersInPolicy?.length acc.push(
? members curr.type === ApproverType.User
.filter((member) => usersInPolicy?.includes(member.user.id)) ? { user: [curr], group: [], sequence: 1, approvals }
.map((member) => getMemberLabel(member)) : { group: [curr], user: [], sequence: 1, approvals }
.join(", ") );
: null; return acc;
},
const groupLabels = groupsInPolicy?.length [] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[]
? groups );
.filter(({ group }) => groupsInPolicy?.includes(group.id))
.map(({ group }) => group.name)
.join(", ")
: null;
return entityInSameSequence?.map((el) => {
return { return {
members: memberLabels, sequence: el.sequence || policy.approvals,
groups: groupLabels userLabels: members
?.filter((member) => el.user.find((i) => i.id === member.user.id))
.map((member) => getMemberLabel(member))
.join(","),
groupLabels: groups
?.filter(({ group }) => el.group.find((i) => i.id === group.id))
.map(({ group }) => group.name)
.join(","),
approvals: el.approvals
}; };
});
}, [policy, members, groups]); }, [policy, members, groups]);
return ( return (
<Tr> <>
<Tr
isHoverable
isSelectable
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") setIsExpanded.toggle();
}}
onClick={() => setIsExpanded.toggle()}
>
<Td>{policy.name}</Td> <Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td> <Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td> <Td>{policy.secretPath || "*"}</Td>
<Td className="max-w-0">
<Tooltip
side="left"
content={labels.members ?? "No users are assigned as approvers for this policy"}
>
<p className="truncate">{labels.members ?? "-"}</p>
</Tooltip>
</Td>
<Td className="max-w-0">
<Tooltip
side="left"
content={labels.groups ?? "No groups are assigned as approvers for this policy"}
>
<p className="truncate">{labels.groups ?? "-"}</p>
</Tooltip>
</Td>
<Td>{policy.approvals}</Td>
<Td> <Td>
<Badge className={policyDetails[policy.policyType].className}> <Badge className={policyDetails[policy.policyType].className}>
{policyDetails[policy.policyType].name} {policyDetails[policy.policyType].name}
@@ -161,5 +162,45 @@ export const ApprovalPolicyRow = ({
</DropdownMenu> </DropdownMenu>
</Td> </Td>
</Tr> </Tr>
{isExpanded && (
<Tr>
<Td colSpan={5} className="rounded bg-mineshaft-900">
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
{labels?.map((el, index) => (
<div
key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4"
>
<div>
<div className="mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white">
<div className="text-lg">{index + 1}</div>
</div>
{index !== labels.length - 1 && (
<div className="absolute bottom-0 left-8 h-6 border-r border-gray-400" />
)}
{index !== 0 && (
<div className="absolute left-8 top-0 h-4 border-r border-gray-400" />
)}
</div>
<div className="grid flex-grow grid-cols-3">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
<div>{el.userLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>{el.groupLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{el.approvals || "-"}</div>
</div>
</div>
</div>
))}
</Td>
</Tr>
)}
</>
); );
}; };