mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-09 18:28:27 +00:00
feat: ui changes for approval to work
This commit is contained in:
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+7
-21
@@ -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");
|
||||||
|
|||||||
+239
-30
@@ -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>
|
||||||
|
|||||||
-3
@@ -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>
|
||||||
|
|||||||
+267
-13
@@ -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"
|
||||||
|
|||||||
+83
-42
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user