Allow project approval workflows to set if a reviewer can or can not review their own requests

This commit is contained in:
carlosmonastyrski
2025-03-24 18:08:07 -03:00
parent f9a5b46365
commit 871be7132a
22 changed files with 316 additions and 182 deletions

View File

@@ -0,0 +1,29 @@
import { Knex } from "knex";
import { TableName } from "../schemas/models";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "selfApprovals"))) {
await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => {
t.boolean("selfApprovals").notNullable().defaultTo(true);
});
}
if (!(await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "selfApprovals"))) {
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
t.boolean("selfApprovals").notNullable().defaultTo(true);
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "selfApprovals")) {
await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => {
t.dropColumn("selfApprovals");
});
}
if (await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "selfApprovals")) {
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
t.dropColumn("selfApprovals");
});
}
}

View File

@@ -16,7 +16,8 @@ export const AccessApprovalPoliciesSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
enforcementLevel: z.string().default("hard"),
deletedAt: z.date().nullable().optional()
deletedAt: z.date().nullable().optional(),
selfApprovals: z.boolean().default(true)
});
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;

View File

@@ -16,7 +16,8 @@ export const SecretApprovalPoliciesSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
enforcementLevel: z.string().default("hard"),
deletedAt: z.date().nullable().optional()
deletedAt: z.date().nullable().optional(),
selfApprovals: z.boolean().default(true)
});
export type TSecretApprovalPolicies = z.infer<typeof SecretApprovalPoliciesSchema>;

View File

@@ -29,7 +29,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.array()
.min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).default(1),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard)
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
selfApprovals: z.boolean().default(true)
}),
response: {
200: z.object({
@@ -147,7 +148,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.array()
.min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).optional(),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard)
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
selfApprovals: z.boolean().default(true)
}),
response: {
200: z.object({

View File

@@ -110,7 +110,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
secretPath: z.string().nullish(),
envId: z.string(),
enforcementLevel: z.string(),
deletedAt: z.date().nullish()
deletedAt: z.date().nullish(),
selfApprovals: z.boolean()
}),
reviewers: z
.object({

View File

@@ -35,7 +35,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
.array()
.min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).default(1),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard)
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
selfApprovals: z.boolean().default(true)
}),
response: {
200: z.object({
@@ -85,7 +86,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
.nullable()
.transform((val) => (val ? removeTrailingSlash(val) : val))
.transform((val) => (val === "" ? "/" : val)),
enforcementLevel: z.nativeEnum(EnforcementLevel).optional()
enforcementLevel: z.nativeEnum(EnforcementLevel).optional(),
selfApprovals: z.boolean().default(true)
}),
response: {
200: z.object({

View File

@@ -49,7 +49,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
.array(),
secretPath: z.string().optional().nullable(),
enforcementLevel: z.string(),
deletedAt: z.date().nullish()
deletedAt: z.date().nullish(),
selfApprovals: z.boolean()
}),
committerUser: approvalRequestUser,
commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(),
@@ -267,7 +268,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
approvers: approvalRequestUser.array(),
secretPath: z.string().optional().nullable(),
enforcementLevel: z.string(),
deletedAt: z.date().nullish()
deletedAt: z.date().nullish(),
selfApprovals: z.boolean()
}),
environment: z.string(),
statusChangedByUser: approvalRequestUser.optional(),

View File

@@ -65,7 +65,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvers,
projectSlug,
environment,
enforcementLevel
enforcementLevel,
selfApprovals
}: TCreateAccessApprovalPolicy) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -153,7 +154,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvals,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
},
tx
);
@@ -216,7 +218,8 @@ export const accessApprovalPolicyServiceFactory = ({
actorOrgId,
actorAuthMethod,
approvals,
enforcementLevel
enforcementLevel,
selfApprovals
}: TUpdateAccessApprovalPolicy) => {
const groupApprovers = approvers
.filter((approver) => approver.type === ApproverType.Group)
@@ -262,7 +265,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvals,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
},
tx
);

View File

@@ -26,6 +26,7 @@ export type TCreateAccessApprovalPolicy = {
projectSlug: string;
name: string;
enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalPolicy = {
@@ -35,6 +36,7 @@ export type TUpdateAccessApprovalPolicy = {
secretPath?: string;
name?: string;
enforcementLevel?: EnforcementLevel;
selfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteAccessApprovalPolicy = {

View File

@@ -61,6 +61,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
db.ref("selfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policySelfApprovals"),
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
)
@@ -119,6 +120,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approvals: doc.policyApprovals,
secretPath: doc.policySecretPath,
enforcementLevel: doc.policyEnforcementLevel,
selfApprovals: doc.policySelfApprovals,
envId: doc.policyEnvId,
deletedAt: doc.policyDeletedAt
},
@@ -254,6 +256,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("slug").withSchema(TableName.Environment).as("environment"),
tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
tx.ref("selfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policySelfApprovals"),
tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
);
@@ -275,6 +278,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approvals: el.policyApprovals,
secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel,
selfApprovals: el.policySelfApprovals,
deletedAt: el.policyDeletedAt
},
requestedByUser: {

View File

@@ -320,6 +320,11 @@ export const accessApprovalRequestServiceFactory = ({
message: "The policy associated with this access request has been deleted."
});
}
if (!policy.selfApprovals && actorId === accessApprovalRequest.requestedByUserId) {
throw new BadRequestError({
message: "Failed to review access approval request. Users are not authorized to review their own request."
});
}
const { membership, hasRole } = await permissionService.getProjectPermission({
actor,

View File

@@ -62,7 +62,8 @@ export const secretApprovalPolicyServiceFactory = ({
projectId,
secretPath,
environment,
enforcementLevel
enforcementLevel,
selfApprovals
}: TCreateSapDTO) => {
const groupApprovers = approvers
?.filter((approver) => approver.type === ApproverType.Group)
@@ -113,7 +114,8 @@ export const secretApprovalPolicyServiceFactory = ({
approvals,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
},
tx
);
@@ -172,7 +174,8 @@ export const secretApprovalPolicyServiceFactory = ({
actorAuthMethod,
approvals,
secretPolicyId,
enforcementLevel
enforcementLevel,
selfApprovals
}: TUpdateSapDTO) => {
const groupApprovers = approvers
?.filter((approver) => approver.type === ApproverType.Group)
@@ -218,7 +221,8 @@ export const secretApprovalPolicyServiceFactory = ({
approvals,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
},
tx
);

View File

@@ -10,6 +10,7 @@ export type TCreateSapDTO = {
projectId: string;
name: string;
enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateSapDTO = {
@@ -19,6 +20,7 @@ export type TUpdateSapDTO = {
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
name?: string;
enforcementLevel?: EnforcementLevel;
selfApprovals?: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteSapDTO = {

View File

@@ -112,6 +112,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"),
tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
tx.ref("selfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policySelfApprovals"),
tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt")
);
@@ -150,7 +151,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel,
envId: el.policyEnvId,
deletedAt: el.policyDeletedAt
deletedAt: el.policyDeletedAt,
selfApprovals: el.policySelfApprovals
}
}),
childrenMapper: [
@@ -336,6 +338,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
),
db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
db.ref("selfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policySelfApprovals"),
db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"),
@@ -364,7 +367,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
name: el.policyName,
approvals: el.policyApprovals,
secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel
enforcementLevel: el.policyEnforcementLevel,
selfApprovals: el.policySelfApprovals
},
committerUser: {
userId: el.committerUserId,
@@ -482,6 +486,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
`DENSE_RANK() OVER (partition by ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."id" DESC) as rank`
),
db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
db.ref("selfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policySelfApprovals"),
db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
@@ -511,7 +516,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
name: el.policyName,
approvals: el.policyApprovals,
secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel
enforcementLevel: el.policyEnforcementLevel,
selfApprovals: el.policySelfApprovals
},
committerUser: {
userId: el.committerUserId,

View File

@@ -352,6 +352,11 @@ export const secretApprovalRequestServiceFactory = ({
message: "The policy associated with this secret approval request has been deleted."
});
}
if (!policy.selfApprovals && actorId === secretApprovalRequest.committerUserId) {
throw new BadRequestError({
message: "Failed to review secret approval request. Users are not authorized to review their own request."
});
}
const { hasRole } = await permissionService.getProjectPermission({
actor: ActorType.USER,

View File

@@ -23,7 +23,8 @@ export const useCreateAccessApprovalPolicy = () => {
approvers,
name,
secretPath,
enforcementLevel
enforcementLevel,
selfApprovals
}) => {
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
environment,
@@ -32,7 +33,8 @@ export const useCreateAccessApprovalPolicy = () => {
approvers,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
});
return data;
},
@@ -48,13 +50,22 @@ export const useUpdateAccessApprovalPolicy = () => {
const queryClient = useQueryClient();
return useMutation<object, object, TUpdateAccessPolicyDTO>({
mutationFn: async ({ id, approvers, approvals, name, secretPath, enforcementLevel }) => {
mutationFn: async ({
id,
approvers,
approvals,
name,
secretPath,
enforcementLevel,
selfApprovals
}) => {
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
approvals,
approvers,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
});
return data;
},

View File

@@ -16,6 +16,7 @@ export type TAccessApprovalPolicy = {
enforcementLevel: EnforcementLevel;
updatedAt: Date;
approvers?: Approver[];
selfApprovals: boolean;
};
export enum ApproverType {
@@ -71,6 +72,7 @@ export type TAccessApprovalRequest = {
envId: string;
enforcementLevel: EnforcementLevel;
deletedAt: Date | null;
selfApprovals: boolean;
};
reviewers: {
@@ -144,6 +146,7 @@ export type TCreateAccessPolicyDTO = {
approvals?: number;
secretPath?: string;
enforcementLevel?: EnforcementLevel;
selfApprovals: boolean;
};
export type TUpdateAccessPolicyDTO = {
@@ -154,6 +157,7 @@ export type TUpdateAccessPolicyDTO = {
environment?: string;
approvals?: number;
enforcementLevel?: EnforcementLevel;
selfApprovals: boolean;
// for invalidating list
projectSlug: string;
};

View File

@@ -16,7 +16,8 @@ export const useCreateSecretApprovalPolicy = () => {
approvers,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
}) => {
const { data } = await apiRequest.post("/api/v1/secret-approvals", {
environment,
@@ -25,7 +26,8 @@ export const useCreateSecretApprovalPolicy = () => {
approvers,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
});
return data;
},
@@ -41,13 +43,22 @@ export const useUpdateSecretApprovalPolicy = () => {
const queryClient = useQueryClient();
return useMutation<object, object, TUpdateSecretPolicyDTO>({
mutationFn: async ({ id, approvers, approvals, secretPath, name, enforcementLevel }) => {
mutationFn: async ({
id,
approvers,
approvals,
secretPath,
name,
enforcementLevel,
selfApprovals
}) => {
const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, {
approvals,
approvers,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
});
return data;
},

View File

@@ -12,6 +12,7 @@ export type TSecretApprovalPolicy = {
approvers: Approver[];
updatedAt: Date;
enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
};
export enum ApproverType {
@@ -42,6 +43,7 @@ export type TCreateSecretPolicyDTO = {
approvers?: Approver[];
approvals?: number;
enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
};
export type TUpdateSecretPolicyDTO = {
@@ -50,6 +52,7 @@ export type TUpdateSecretPolicyDTO = {
approvers?: Approver[];
secretPath?: string | null;
approvals?: number;
selfApprovals?: boolean;
enforcementLevel?: EnforcementLevel;
// for invalidating list
workspaceId: string;

View File

@@ -152,7 +152,7 @@ export const AccessApprovalRequest = ({
const isAccepted = request.isApproved;
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const isRequestedByCurrentUser = request.requestedByUserId === user.id;
const isSelfApproveAllowed = request.policy.selfApprovals;
const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status;
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
@@ -189,7 +189,8 @@ export const AccessApprovalRequest = ({
userReviewStatus,
isAccepted,
isSoftEnforcement,
isRequestedByCurrentUser
isRequestedByCurrentUser,
isSelfApproveAllowed
};
};
@@ -342,15 +343,16 @@ export const AccessApprovalRequest = ({
tabIndex={0}
onClick={() => {
if (
(!details.isApprover ||
((!details.isApprover ||
details.isReviewedByUser ||
details.isRejectedByAnyone ||
details.isAccepted) &&
!(
details.isSoftEnforcement &&
details.isRequestedByCurrentUser &&
!details.isAccepted
)
!(
details.isSoftEnforcement &&
details.isRequestedByCurrentUser &&
!details.isAccepted
)) ||
(request.requestedByUserId === user.id && !details.isSelfApproveAllowed)
)
return;
if (membersGroupById?.[request.requestedByUserId].user) {

View File

@@ -12,7 +12,8 @@ import {
Modal,
ModalContent,
Select,
SelectItem
SelectItem,
Switch
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { getMemberLabel } from "@app/helpers/members";
@@ -54,7 +55,8 @@ const formSchema = z
.array()
.default([]),
policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel)
enforcementLevel: z.nativeEnum(EnforcementLevel),
selfApprovals: z.boolean().default(true)
})
.superRefine((data, ctx) => {
if (!(data.groupApprovers.length || data.userApprovers.length)) {
@@ -101,7 +103,8 @@ export const AccessPolicyForm = ({
editValues?.approvers
?.filter((approver) => approver.type === ApproverType.Group)
.map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [],
approvals: editValues?.approvals
approvals: editValues?.approvals,
selfApprovals: editValues?.selfApprovals
}
: undefined
});
@@ -441,6 +444,27 @@ export const AccessPolicyForm = ({
</FormControl>
)}
/>
<Controller
control={control}
name="selfApprovals"
defaultValue
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Self Approvals"
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
id="self-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Allow approvers to review their own requests
</Switch>
</FormControl>
)}
/>
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save

View File

@@ -127,7 +127,9 @@ export const SecretApprovalRequestChanges = ({
} = useForm<TReviewFormSchema>({
resolver: zodResolver(reviewFormSchema)
});
const shouldBlockSelfReview =
secretApprovalRequestDetails?.policy?.selfApprovals === false &&
secretApprovalRequestDetails?.committerUserId === userSession.id;
const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus;
const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus;
@@ -245,117 +247,119 @@ export const SecretApprovalRequestChanges = ({
</div>
</div>
</div>
{!hasMerged && secretApprovalRequestDetails.status === "open" && (
<DropdownMenu
open={popUp.reviewChanges.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)}
>
<DropdownMenuTrigger asChild>
<Button
variant="outline_bg"
rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}
>
Review
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" asChild className="mt-3">
<form onSubmit={handleSubmit(handleSubmitReview)}>
<div className="flex w-[400px] flex-col space-y-2 p-5">
<div className="text-lg font-medium">Finish your review</div>
<Controller
control={control}
name="comment"
render={({ field, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<TextArea
{...field}
placeholder="Leave a comment..."
reSize="none"
className="text-md mt-2 h-48 border border-mineshaft-600 bg-bunker-800"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="status"
defaultValue={ApprovalStatus.APPROVED}
render={({ field, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="mb-4 space-y-2"
aria-label="Status"
>
<div className="flex items-center gap-2">
<RadioGroupItem
id="approve"
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.APPROVED}
aria-labelledby="approve-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="approve-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.APPROVED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.APPROVED);
}
}}
tabIndex={0}
role="button"
>
Approve
</span>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
id="reject"
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.REJECTED}
aria-labelledby="reject-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="reject-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.REJECTED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.REJECTED);
}
}}
tabIndex={0}
role="button"
>
Reject
</span>
</div>
</RadioGroup>
</FormControl>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
isLoading={isApproving || isRejecting || isSubmitting}
variant="outline_bg"
>
Submit Review
</Button>
{!hasMerged &&
secretApprovalRequestDetails.status === "open" &&
!shouldBlockSelfReview && (
<DropdownMenu
open={popUp.reviewChanges.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)}
>
<DropdownMenuTrigger asChild>
<Button
variant="outline_bg"
rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}
>
Review
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" asChild className="mt-3">
<form onSubmit={handleSubmit(handleSubmitReview)}>
<div className="flex w-[400px] flex-col space-y-2 p-5">
<div className="text-lg font-medium">Finish your review</div>
<Controller
control={control}
name="comment"
render={({ field, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<TextArea
{...field}
placeholder="Leave a comment..."
reSize="none"
className="text-md mt-2 h-48 border border-mineshaft-600 bg-bunker-800"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="status"
defaultValue={ApprovalStatus.APPROVED}
render={({ field, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="mb-4 space-y-2"
aria-label="Status"
>
<div className="flex items-center gap-2">
<RadioGroupItem
id="approve"
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.APPROVED}
aria-labelledby="approve-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="approve-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.APPROVED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.APPROVED);
}
}}
tabIndex={0}
role="button"
>
Approve
</span>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
id="reject"
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.REJECTED}
aria-labelledby="reject-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="reject-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.REJECTED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.REJECTED);
}
}}
tabIndex={0}
role="button"
>
Reject
</span>
</div>
</RadioGroup>
</FormControl>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
isLoading={isApproving || isRejecting || isSubmitting}
variant="outline_bg"
>
Submit Review
</Button>
</div>
</div>
</div>
</form>
</DropdownMenuContent>
</DropdownMenu>
)}
</form>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex flex-col space-y-4">
{secretApprovalRequestDetails.commits.map(
@@ -422,40 +426,45 @@ export const SecretApprovalRequestChanges = ({
<div className="sticky top-0 w-1/5 pt-4" style={{ minWidth: "240px" }}>
<div className="text-sm text-bunker-300">Reviewers</div>
<div className="mt-2 flex flex-col space-y-2 text-sm">
{secretApprovalRequestDetails?.policy?.approvers.map((requiredApprover) => {
const reviewer = reviewedUsers?.[requiredApprover.userId];
return (
<div
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
key={`required-approver-${requiredApprover.userId}`}
>
<div className="flex-grow text-sm">
<Tooltip
content={`${requiredApprover.firstName || ""} ${
requiredApprover.lastName || ""
}`}
>
<span>{requiredApprover?.email} </span>
</Tooltip>
<span className="text-red">*</span>
</div>
<div>
{reviewer?.comment && (
<Tooltip content={reviewer.comment}>
<FontAwesomeIcon
icon={faComment}
size="xs"
className="mr-1 text-mineshaft-300"
/>
{secretApprovalRequestDetails?.policy?.approvers
.filter(
(requiredApprover) =>
!(shouldBlockSelfReview && requiredApprover.userId === userSession.id)
)
.map((requiredApprover) => {
const reviewer = reviewedUsers?.[requiredApprover.userId];
return (
<div
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
key={`required-approver-${requiredApprover.userId}`}
>
<div className="flex-grow text-sm">
<Tooltip
content={`${requiredApprover.firstName || ""} ${
requiredApprover.lastName || ""
}`}
>
<span>{requiredApprover?.email} </span>
</Tooltip>
)}
<Tooltip content={reviewer?.status || ApprovalStatus.PENDING}>
{getReviewedStatusSymbol(reviewer?.status)}
</Tooltip>
<span className="text-red">*</span>
</div>
<div>
{reviewer?.comment && (
<Tooltip content={reviewer.comment}>
<FontAwesomeIcon
icon={faComment}
size="xs"
className="mr-1 text-mineshaft-300"
/>
</Tooltip>
)}
<Tooltip content={reviewer?.status || ApprovalStatus.PENDING}>
{getReviewedStatusSymbol(reviewer?.status)}
</Tooltip>
</div>
</div>
</div>
);
})}
);
})}
{secretApprovalRequestDetails?.reviewers
.filter(
(reviewer) =>