feature: allow editing of access request by admin reviewers

This commit is contained in:
Scott Wilson
2025-08-12 23:25:31 -07:00
parent f5a0d8be78
commit 0d3cb2d41a
15 changed files with 610 additions and 16 deletions
@@ -1,4 +1,4 @@
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { faArrowUpRightFromSquare, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormLabel, Tooltip } from "../v2";
@@ -10,15 +10,18 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
label={label}
icon={
<Tooltip
className="max-w-lg"
content={
<span>
Examples: 30m, 1h, 3d, etc.{" "}
<a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
className="text-primary-500 hover:text-mineshaft-100"
>
More
See More Examples{" "}
<FontAwesomeIcon size="xs" className="mt-0.5" icon={faArrowUpRightFromSquare} />
</a>
</span>
}
@@ -26,7 +29,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-px right-1"
className="relative right-1 mt-0.5 text-mineshaft-300"
/>
</Tooltip>
}
@@ -6,10 +6,12 @@ import { apiRequest } from "@app/config/request";
import { accessApprovalKeys } from "./queries";
import {
TAccessApproval,
TAccessApprovalRequest,
TCreateAccessPolicyDTO,
TCreateAccessRequestDTO,
TDeleteSecretPolicyDTO,
TUpdateAccessPolicyDTO
TUpdateAccessPolicyDTO,
TUpdateAccessRequestDTO
} from "./types";
export const useCreateAccessApprovalPolicy = () => {
@@ -134,6 +136,25 @@ export const useCreateAccessRequest = () => {
});
};
export const useUpdateAccessRequest = () => {
const queryClient = useQueryClient();
return useMutation<TAccessApprovalRequest, object, TUpdateAccessRequestDTO>({
mutationFn: async ({ requestId, ...payload }) => {
const { data } = await apiRequest.patch<{ approval: TAccessApprovalRequest }>(
`/api/v1/access-approvals/requests/${requestId}`,
payload
);
return data.approval;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug)
});
}
});
};
export const useReviewAccessRequest = () => {
const queryClient = useQueryClient();
return useMutation<
@@ -103,6 +103,8 @@ export type TAccessApprovalRequest = {
}[];
note?: string;
editNote?: string;
editedByUserId: string;
};
export type TAccessApproval = {
@@ -146,6 +148,13 @@ export type TCreateAccessRequestDTO = {
note?: string;
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">;
export type TUpdateAccessRequestDTO = {
requestId: string;
editNote: string;
temporaryRange: string;
projectSlug: string;
};
export type TGetAccessApprovalRequestsDTO = {
projectSlug: string;
policyId?: string;
@@ -592,6 +592,16 @@ export const AccessApprovalRequest = ({
setSelectedRequest(null);
refetchRequests();
}}
onUpdate={(request) => {
// scott: this isn't ideal but our current use of state makes this complicated...
// we shouldn't be using state like this...
handleSelectRequest({
...selectedRequest,
isTemporary: true,
temporaryRange: request.temporaryRange,
reviewers: []
});
}}
canBypass={generateRequestDetails(selectedRequest).canBypass}
/>
)}
@@ -0,0 +1,169 @@
import { Controller, useForm } from "react-hook-form";
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalClose,
ModalContent,
TextArea
} from "@app/components/v2";
import { useUpdateAccessRequest } from "@app/hooks/api/accessApproval/mutation";
import { accessApprovalKeys } from "@app/hooks/api/accessApproval/queries";
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
type ContentProps = {
accessRequest: TAccessApprovalRequest;
onComplete: (request: TAccessApprovalRequest) => void;
projectSlug: string;
};
const EditSchema = z.object({
temporaryRange: z.string().nonempty("Required"),
editNote: z.string().nonempty("Required")
});
type FormData = z.infer<typeof EditSchema>;
const Content = ({ accessRequest, onComplete, projectSlug }: ContentProps) => {
const update = useUpdateAccessRequest();
const queryClient = useQueryClient();
const {
handleSubmit,
control,
formState: { isSubmitting }
} = useForm({
resolver: zodResolver(EditSchema),
defaultValues: {
temporaryRange: accessRequest.temporaryRange ?? "1h",
editNote: ""
}
});
const onSubmit = async (form: FormData) => {
try {
const request = await update.mutateAsync({
requestId: accessRequest.id,
projectSlug,
...form
});
await queryClient.refetchQueries({
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug)
});
createNotification({
type: "success",
text: "Access request updated successfully."
});
onComplete(request);
} catch (e) {
console.error(e);
createNotification({
type: "error",
text: "Failed to update access request"
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4 flex w-full items-start rounded-md border border-yellow/50 bg-yellow/30 px-4 py-2 text-sm text-yellow-200">
<FontAwesomeIcon icon={faWarning} className="mr-2.5 mt-1 text-base text-yellow" />
Updating this access request will restart the review process and require all approvers to
re-approve it.
</div>
<Controller
control={control}
defaultValue="1h"
name="temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Access Duration" />}
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={`Must be less than current access duration: ${accessRequest.isTemporary ? accessRequest.temporaryRange : "Permanent"}`}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="editNote"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Reason for Editing"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
className="!resize-none"
rows={4}
{...field}
placeholder="Provide a reason for updating this request..."
/>
</FormControl>
)}
/>
<div className="mt-4 flex gap-x-2">
<Button
type="submit"
variant="outline_bg"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Update Request
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
accessRequest?: TAccessApprovalRequest;
onComplete: (request: TAccessApprovalRequest) => void;
projectSlug: string;
};
export const EditAccessRequestModal = ({
isOpen,
onOpenChange,
accessRequest,
onComplete,
projectSlug
}: Props) => {
if (!accessRequest) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Edit Access Request"
subTitle="Modify this access request for re-approval."
>
<Content
projectSlug={projectSlug}
accessRequest={accessRequest}
onComplete={(request) => {
onComplete(request);
onOpenChange(false);
}}
/>
</ModalContent>
</Modal>
);
};
@@ -2,6 +2,7 @@ import { ReactNode, useCallback, useMemo, useState } from "react";
import {
faBan,
faCheck,
faEdit,
faHourglass,
faTriangleExclamation
} from "@fortawesome/free-solid-svg-icons";
@@ -15,6 +16,7 @@ import {
Checkbox,
FormControl,
GenericFieldLabel,
IconButton,
Input,
Modal,
ModalContent,
@@ -22,6 +24,7 @@ import {
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api";
import {
Approver,
@@ -32,6 +35,7 @@ import {
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { groupBy } from "@app/lib/fn/array";
import { EditAccessRequestModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal";
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
if (status === ApprovalStatus.APPROVED)
@@ -62,7 +66,8 @@ export const ReviewAccessRequestModal = ({
selectedEnvSlug,
canBypass,
policies = [],
members = []
members = [],
onUpdate
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
@@ -78,6 +83,7 @@ export const ReviewAccessRequestModal = ({
canBypass: boolean;
policies: TAccessApprovalPolicy[];
members: TWorkspaceUser[];
onUpdate: (request: TAccessApprovalRequest) => void;
}) => {
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
const [bypassApproval, setBypassApproval] = useState(false);
@@ -86,6 +92,8 @@ export const ReviewAccessRequestModal = ({
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
const { user } = useUser();
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["editRequest"] as const);
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const accessDetails = {
@@ -124,13 +132,9 @@ export const ReviewAccessRequestModal = ({
// convert the range to human readable format
ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true });
return (
<Badge>
{`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
long: true
})} after approval`}
</Badge>
);
return `Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
long: true
})} after approval`;
};
const reviewAccessRequest = useReviewAccessRequest();
@@ -286,7 +290,33 @@ export const ReviewAccessRequestModal = ({
<GenericFieldLabel truncate label="Secret Path">
{accessDetails.secretPath}
</GenericFieldLabel>
<GenericFieldLabel label="Access Type">{getAccessLabel()}</GenericFieldLabel>
<GenericFieldLabel label="Access Duration">
<div className="flex h-min gap-1">
{getAccessLabel()}
{request.isApprover && (
<>
<EditAccessRequestModal
isOpen={popUp.editRequest.isOpen}
onOpenChange={(open) => handlePopUpToggle("editRequest", open)}
accessRequest={request}
onComplete={onUpdate}
projectSlug={projectSlug}
/>
<Tooltip content="Edit Access Duration">
<IconButton
onClick={() => handlePopUpOpen("editRequest")}
variant="plain"
size="xs"
tabIndex={-1}
ariaLabel="Edit access duration"
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
</>
)}
</div>
</GenericFieldLabel>
<GenericFieldLabel label="Permission">{requestedAccess}</GenericFieldLabel>
{request.note && (
<GenericFieldLabel className="col-span-full" label="Note">