From 22b6e304d8487561986462af4b37ebbd69a4252c Mon Sep 17 00:00:00 2001 From: = Date: Sat, 6 Dec 2025 11:54:48 +0530 Subject: [PATCH] feat: completed policy management ui --- frontend/src/layouts/PamLayout/PamLayout.tsx | 17 + .../pages/pam/ApprovalsPage/ApprovalsPage.tsx | 73 ++++ .../components/PolicyTab/PolicyTab.tsx | 5 + .../PolicyTab/components/PoliciesTable.tsx | 317 ++++++++++++++++++ .../PolicyTab/components/PolicyModal.tsx | 248 ++++++++++++++ .../PolicyTab/components/PolicySchema.tsx | 37 ++ .../PolicyTab/components/PolicySection.tsx | 65 ++++ .../PolicySteps/PolicyApprovalSteps.tsx | 234 +++++++++++++ .../PolicySteps/PolicyConstraintsStep.tsx | 130 +++++++ .../PolicySteps/PolicyDetailsStep.tsx | 102 ++++++ .../PolicySteps/PolicyReviewStep.tsx | 218 ++++++++++++ .../components/PolicySteps/index.tsx | 4 + .../components/PolicyTab/components/index.tsx | 3 + .../components/PolicyTab/index.tsx | 1 + .../src/pages/pam/ApprovalsPage/route.tsx | 31 ++ .../components/ReviewAccessModal.tsx | 2 +- frontend/src/routeTree.gen.ts | 27 ++ frontend/src/routes.ts | 1 + frontend/src/types/project.ts | 6 + 19 files changed, 1520 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/pam/ApprovalsPage/ApprovalsPage.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/PolicyTab.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PoliciesTable.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicyModal.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySchema.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySection.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyApprovalSteps.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyConstraintsStep.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyDetailsStep.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyReviewStep.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/index.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/index.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/index.tsx create mode 100644 frontend/src/pages/pam/ApprovalsPage/route.tsx diff --git a/frontend/src/layouts/PamLayout/PamLayout.tsx b/frontend/src/layouts/PamLayout/PamLayout.tsx index 365d3c3eb..743c02f7b 100644 --- a/frontend/src/layouts/PamLayout/PamLayout.tsx +++ b/frontend/src/layouts/PamLayout/PamLayout.tsx @@ -69,6 +69,23 @@ export const PamLayout = () => { > {({ isActive }) => Sessions} + + {({ isActive }) => ( + + Approvals + + )} + { + const navigate = useNavigate(); + const { currentOrg } = useOrganization(); + const { currentProject } = useProject(); + const selectedTab = useSearch({ + strict: false, + select: (el) => el.selectedTab + }); + + const updateSelectedTab = (tab: string) => { + navigate({ + to: "/organizations/$orgId/projects/pam/$projectId/approvals", + search: (prev) => ({ ...prev, selectedTab: tab }), + params: { + orgId: currentOrg.id, + projectId: currentProject.id + } + }); + }; + + return ( +
+
+ + + + + Requests + + + Policies + + + Grants + + + +
Hello
+
+ + + + +
Hello
+
+
+
+
+ ); +}; + +export const ApprovalsPage = () => { + return ( + <> + + + + + + ); +}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/PolicyTab.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/PolicyTab.tsx new file mode 100644 index 000000000..cdafe6805 --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/PolicyTab.tsx @@ -0,0 +1,5 @@ +import { PolicySection } from "./components"; + +export const PolicyTab = () => { + return ; +}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PoliciesTable.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PoliciesTable.tsx new file mode 100644 index 000000000..1666456a5 --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PoliciesTable.tsx @@ -0,0 +1,317 @@ +import { Fragment, useState } from "react"; +import { + faChevronDown, + faChevronRight, + faEllipsisV, + faPencil, + faTrash, + faUsers +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { getMemberLabel } from "@app/helpers/members"; +import { useGetWorkspaceUsers, useListWorkspaceGroups } from "@app/hooks/api"; +import { + approvalPolicyQuery, + ApprovalPolicyType, + ApproverType +} from "@app/hooks/api/approvalPolicies"; +import { UsePopUpState } from "@app/hooks/usePopUp"; +import { useQuery } from "@tanstack/react-query"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["policy", "deletePolicy"]>, + data?: object + ) => void; +}; + +export const PoliciesTable = ({ handlePopUpOpen }: Props) => { + const { currentProject } = useProject(); + const [expandedRows, setExpandedRows] = useState>(new Set()); + + const projectId = currentProject?.id || ""; + + const { data: policies = [], isPending: isPoliciesLoading } = useQuery( + approvalPolicyQuery.list({ + policyType: ApprovalPolicyType.PamAccess, + projectId + }) + ); + + const { data: members = [] } = useGetWorkspaceUsers(projectId); + const { data: groups = [] } = useListWorkspaceGroups(projectId); + + const getApproverLabel = (approverId: string, approverType: ApproverType) => { + if (approverType === ApproverType.User) { + const member = members?.find((m) => m.user.id === approverId); + if (member) { + return getMemberLabel(member); + } + } else if (approverType === ApproverType.Group) { + const group = groups?.find(({ group: g }) => g.id === approverId); + if (group) { + return group.group.name; + } + } + return approverId; + }; + + const toggleRowExpansion = (policyId: string) => { + setExpandedRows((prev) => { + const newSet = new Set(prev); + if (newSet.has(policyId)) { + newSet.delete(policyId); + } else { + newSet.add(policyId); + } + return newSet; + }); + }; + + return ( +
+ + + + + + + + + + + {isPoliciesLoading && } + {!isPoliciesLoading && policies.length === 0 && ( + + + + )} + {!isPoliciesLoading && + policies.map((policy) => { + const isExpanded = expandedRows.has(policy.id); + const maxTtl = policy.maxRequestTtlSeconds + ? `${Math.floor(policy.maxRequestTtlSeconds / 3600)}h` + : "No limit"; + const conditionsCount = policy.conditions.conditions.length; + + return ( + <> + toggleRowExpansion(policy.id)} + > + + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
+ Policy NameMax Request TTLConditions +
+ +
+ { + e.stopPropagation(); + toggleRowExpansion(policy.id); + }} + > + + + {policy.name}{maxTtl} + {conditionsCount} condition{conditionsCount !== 1 ? "s" : ""} + { + e.stopPropagation(); + }} + > + + +
+ + + + + +
+
+ + { + e.stopPropagation(); + handlePopUpOpen("policy", { + policyId: policy.id, + policy + }); + }} + icon={} + > + Edit Policy + + { + e.stopPropagation(); + handlePopUpOpen("deletePolicy", { + policyId: policy.id, + policyName: policy.name + }); + }} + icon={} + > + Delete Policy + + +
+
+
+
+
+ Approval Contraints +
+ {policy.conditions.conditions.map((step, index) => ( + +
+
+
+ + Resources: + +

+ {step.resourceIds.join(", ")} +

+
+
+
+ + AND + +
+
+
+ + Account Paths: + +

+ {step.accountPaths.join(", ")} +

+
+
+
+ {index < policy.conditions.conditions.length - 1 && ( +
+
+
+ + OR + +
+
+
+ )} + + ))} +
+
+
+ Approval Sequence +
+ {policy.steps.map((step, index) => ( +
+
+
+ Step {index + 1} + {step.name && ( + + ({step.name}) + + )} +
+
+ Requires {step.requiredApprovals} approval + {step.requiredApprovals !== 1 ? "s" : ""} +
+
+
+ {step.approvers.map((approver, approverIndex) => ( +
+ + {approver.type}:{" "} + + {getApproverLabel(approver.id, approver.type)} + +
+ ))} +
+
+ ))} +
+
+
+
+
+ ); +}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicyModal.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicyModal.tsx new file mode 100644 index 000000000..f81de5e49 --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicyModal.tsx @@ -0,0 +1,248 @@ +import { useEffect, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { Tab } from "@headlessui/react"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalContent } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { + ApprovalPolicyType, + TApprovalPolicy, + useCreateApprovalPolicy, + useUpdateApprovalPolicy +} from "@app/hooks/api/approvalPolicies"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { PolicyApprovalSteps } from "./PolicySteps/PolicyApprovalSteps"; +import { PolicyConstraintsStep } from "./PolicySteps/PolicyConstraintsStep"; +import { PolicyDetailsStep } from "./PolicySteps/PolicyDetailsStep"; +import { PolicyReviewStep } from "./PolicySteps/PolicyReviewStep"; +import { PolicyFormSchema, TPolicyForm } from "./PolicySchema"; + +type Props = { + popUp: UsePopUpState<["policy"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["policy"]>, state?: boolean) => void; +}; + +const FORM_STEPS: { name: string; key: string; fields: (keyof TPolicyForm)[] }[] = [ + { name: "Details", key: "details", fields: ["name", "maxRequestTtlSeconds"] }, + { name: "Constraints", key: "constraints", fields: ["conditions", "constraints"] }, + { name: "Approvals", key: "approvals", fields: ["steps"] }, + { name: "Review", key: "review", fields: [] } +]; + +export const PolicyModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentProject } = useProject(); + const isOpen = popUp?.policy?.isOpen; + const policyData = popUp?.policy?.data as + | { policyId: string; policy: TApprovalPolicy } + | undefined; + + const [selectedStepIndex, setSelectedStepIndex] = useState(0); + + const formMethods = useForm({ + resolver: zodResolver(PolicyFormSchema), + defaultValues: { + name: "", + maxRequestTtlSeconds: null, + conditions: [{ resourceIds: [], accountPaths: [] }], + constraints: { + requestDurationHours: { + min: 1, + max: 24 + } + }, + steps: [ + { + name: "", + requiredApprovals: 1, + notifyApprovers: true, + approvers: [] + } + ] + }, + mode: "onChange" + }); + + const { handleSubmit, trigger, reset } = formMethods; + + const { mutateAsync: createPolicy, isPending: isCreating } = useCreateApprovalPolicy(); + const { mutateAsync: updatePolicy, isPending: isUpdating } = useUpdateApprovalPolicy(); + + useEffect(() => { + if (policyData?.policy) { + reset({ + name: policyData.policy.name, + maxRequestTtlSeconds: policyData.policy.maxRequestTtlSeconds, + conditions: policyData.policy.conditions.conditions, + constraints: policyData.policy.constraints.constraints, + steps: policyData.policy.steps.map((step) => ({ + ...step, + name: step.name || "" + })) + }); + } else { + reset({ + name: "", + maxRequestTtlSeconds: null, + conditions: [{ resourceIds: [], accountPaths: [] }], + constraints: { + requestDurationHours: { + min: 1, + max: 24 + } + }, + steps: [ + { + name: "", + requiredApprovals: 1, + notifyApprovers: true, + approvers: [] + } + ] + }); + } + setSelectedStepIndex(0); + }, [policyData, reset, isOpen]); + + const onSubmit = async (data: TPolicyForm) => { + if (!currentProject?.id) return; + + try { + if (policyData?.policyId) { + await updatePolicy({ + policyType: ApprovalPolicyType.PamAccess, + policyId: policyData.policyId, + ...data + }); + createNotification({ + text: "Successfully updated policy", + type: "success" + }); + } else { + await createPolicy({ + policyType: ApprovalPolicyType.PamAccess, + projectId: currentProject.id, + ...data + }); + createNotification({ + text: "Successfully created policy", + type: "success" + }); + } + handlePopUpToggle("policy", false); + } catch (error) { + console.error(error); + createNotification({ + text: `Failed to ${policyData?.policyId ? "update" : "create"} policy`, + type: "error" + }); + } + }; + + const isStepValid = async (index: number) => { + const { fields } = FORM_STEPS[index]; + if (fields.length === 0) return true; + return trigger(fields); + }; + + const isFinalStep = selectedStepIndex === FORM_STEPS.length - 1; + + const handleNext = async () => { + if (isFinalStep) { + await handleSubmit(onSubmit)(); + return; + } + + const isValid = await isStepValid(selectedStepIndex); + + if (!isValid) return; + + setSelectedStepIndex((prev) => prev + 1); + }; + + const handlePrev = () => { + if (selectedStepIndex === 0) { + handlePopUpToggle("policy", false); + return; + } + + setSelectedStepIndex((prev) => prev - 1); + }; + + const isTabEnabled = async (index: number) => { + let isEnabled = true; + for (let i = index - 1; i >= 0; i -= 1) { + // eslint-disable-next-line no-await-in-loop + isEnabled = isEnabled && (await isStepValid(i)); + } + + return isEnabled; + }; + + return ( + handlePopUpToggle("policy", open)}> + + +
+ + + {FORM_STEPS.map((step, index) => ( + { + e.preventDefault(); + const isEnabled = await isTabEnabled(index); + setSelectedStepIndex((prev) => (isEnabled ? index : prev)); + }} + className={({ selected }) => + `-mb-[0.14rem] whitespace-nowrap ${index > selectedStepIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-hidden disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + key={step.key} + > + {index + 1}. {step.name} + + ))} + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+ ); +}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySchema.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySchema.tsx new file mode 100644 index 000000000..f1edbfbcf --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySchema.tsx @@ -0,0 +1,37 @@ +import { ApproverType } from "@app/hooks/api/approvalPolicies"; +import { z } from "zod"; + +export const PolicyFormSchema = z.object({ + name: z.string().min(1, "Policy name is required").max(128), + maxRequestTtlSeconds: z.number().min(3600).max(2592000).nullable().optional(), + conditions: z + .object({ + resourceIds: z.array(z.string().uuid()), + accountPaths: z.array(z.string().min(1)) + }) + .array() + .min(1, "At least one condition is required"), + constraints: z.object({ + requestDurationHours: z.object({ + min: z.number().min(0).max(168), + max: z.number().min(1).max(168) + }) + }), + steps: z + .object({ + name: z.string().max(128).nullable().optional(), + requiredApprovals: z.number().min(1).max(100), + notifyApprovers: z.boolean().optional(), + approvers: z + .object({ + type: z.nativeEnum(ApproverType), + id: z.string().uuid() + }) + .array() + .min(1, "At least one approver is required") + }) + .array() + .min(1, "At least one approval step is required") +}); + +export type TPolicyForm = z.infer; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySection.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySection.tsx new file mode 100644 index 000000000..b23927998 --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySection.tsx @@ -0,0 +1,65 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteApprovalPolicy, ApprovalPolicyType } from "@app/hooks/api/approvalPolicies"; + +import { PolicyModal } from "./PolicyModal"; +import { PoliciesTable } from "./PoliciesTable"; + +export const PolicySection = () => { + const { currentProject } = useProject(); + + const { mutateAsync: deleteApprovalPolicy } = useDeleteApprovalPolicy(); + + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "policy", + "deletePolicy" + ] as const); + + const handleDeletePolicy = async () => { + const policyId = (popUp?.deletePolicy?.data as { policyId: string })?.policyId; + if (!currentProject?.id) return; + if (!policyId) return; + + await deleteApprovalPolicy({ + policyType: ApprovalPolicyType.PamAccess, + policyId + }); + createNotification({ + text: "Successfully deleted policy", + type: "success" + }); + handlePopUpClose("deletePolicy"); + }; + + return ( +
+
+
+

Approval Policies

+
+ +
+ + + handlePopUpToggle("deletePolicy", isOpen)} + onDeleteApproved={handleDeletePolicy} + /> +
+ ); +}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyApprovalSteps.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyApprovalSteps.tsx new file mode 100644 index 000000000..5196c22bb --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyApprovalSteps.tsx @@ -0,0 +1,234 @@ +import { useMemo } from "react"; +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, FilterableSelect, FormControl, IconButton, Input } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { getMemberLabel } from "@app/helpers/members"; +import { useGetWorkspaceUsers, useListWorkspaceGroups } from "@app/hooks/api"; +import { ApproverType } from "@app/hooks/api/approvalPolicies"; + +import { TPolicyForm } from "../PolicySchema"; + +export const PolicyApprovalSteps = () => { + const { + control, + formState: { errors } + } = useFormContext(); + + const { currentProject } = useProject(); + const projectId = currentProject?.id || ""; + + const { data: members = [] } = useGetWorkspaceUsers(projectId); + const { data: groups = [] } = useListWorkspaceGroups(projectId); + + const { + fields: stepFields, + append: appendStep, + remove: removeStep + } = useFieldArray({ + control, + name: "steps" + }); + + const memberOptions = useMemo( + () => + members.map((member) => ({ + id: member.user.id, + type: ApproverType.User, + isOrgMembershipActive: member.user.isOrgMembershipActive + })), + [members] + ); + + const groupOptions = useMemo( + () => + groups?.map(({ group }) => ({ + id: group.id, + type: ApproverType.Group + })), + [groups] + ); + + return ( +
+
+
+ +

+ Define the approval workflow with sequential steps +

+
+ +
+ +
+ {stepFields.map((field, index) => ( +
+
+
+ + {index + 1} + + + Approval Step {index + 1} + +
+ {stepFields.length > 1 && ( + removeStep(index)} + > + + + )} +
+ +
+ ( + + + + )} + /> + + ( + + approvalsField.onChange(parseInt(e.target.value, 10))} + /> + + )} + /> + +
+
Approvers
+ { + const userApprovers = value.filter((a) => a.type === ApproverType.User); + const groupApprovers = value.filter((a) => a.type === ApproverType.Group); + + return ( + <> + + option.id} + getOptionLabel={(option) => { + const member = members?.find((m) => m.user.id === option.id); + if (!member) return option.id; + return getMemberLabel(member); + }} + value={userApprovers} + onChange={(selected) => { + const newApprovers = [ + ...(selected || []), + ...groupApprovers + ]; + onChange(newApprovers); + }} + /> + + + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? + option.id + } + value={groupApprovers} + onChange={(selected) => { + const newApprovers = [ + ...userApprovers, + ...(selected || []) + ]; + onChange(newApprovers); + }} + /> + + + ); + }} + /> +
+
+
+ ))} +
+ + {stepFields.length === 0 && ( +
+

No approval steps defined

+

+ Click "Add Step" to create your first approval step +

+
+ )} +
+ ); +}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyConstraintsStep.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyConstraintsStep.tsx new file mode 100644 index 000000000..cf4f27ef0 --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyConstraintsStep.tsx @@ -0,0 +1,130 @@ +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, FormControl, IconButton, Input } from "@app/components/v2"; +import { TPolicyForm } from "../PolicySchema"; + +export const PolicyConstraintsStep = () => { + const { control } = useFormContext(); + + const { + fields: conditionFields, + append: appendCondition, + remove: removeCondition + } = useFieldArray({ + control, + name: "conditions" + }); + + return ( +
+
+
+
+

Conditions

+

+ Define which resources and account paths this policy applies to +

+
+ +
+
+ {conditionFields.map((field, index) => ( +
+
+
+ + Condition {index + 1} + + {conditionFields.length > 1 && ( + removeCondition(index)} + > + + + )} +
+
+ ( + + { + const ids = e.target.value + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + resourceField.onChange(ids); + }} + placeholder="e.g., 550e8400-e29b-41d4-a716-446655440000, ..." + /> + + )} + /> +
+
+ AND +
+
+ ( + + { + const paths = e.target.value + .split(",") + .map((path) => path.trim()) + .filter(Boolean); + pathField.onChange(paths); + }} + placeholder="e.g., /admin/*, /users/john" + /> + + )} + /> +
+
+ {index < conditionFields.length - 1 && ( +
+
+
+ OR +
+
+
+ )} +
+ ))} +
+
+
+ ); +}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyDetailsStep.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyDetailsStep.tsx new file mode 100644 index 000000000..65f3a3415 --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyDetailsStep.tsx @@ -0,0 +1,102 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input } from "@app/components/v2"; +import { TPolicyForm } from "../PolicySchema"; + +export const PolicyDetailsStep = () => { + const { control } = useFormContext(); + + return ( +
+ ( + + + + )} + /> + ( + + { + const val = e.target.value; + field.onChange(val === "" ? null : parseInt(val, 10)); + }} + placeholder="e.g., 86400 (24 hours)" + /> + + )} + /> +
+
+

+ Request Duration Constraints +

+

+ Set minimum and maximum duration (in hours) for access requests +

+
+
+ ( + + field.onChange(parseInt(e.target.value, 10))} + /> + + )} + /> + ( + + field.onChange(parseInt(e.target.value, 10))} + /> + + )} + /> +
+
+
+ ); +}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyReviewStep.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyReviewStep.tsx new file mode 100644 index 000000000..899f8efba --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/PolicyReviewStep.tsx @@ -0,0 +1,218 @@ +import { useFormContext } from "react-hook-form"; +import { faUsers } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useProject } from "@app/context"; +import { getMemberLabel } from "@app/helpers/members"; +import { useGetWorkspaceUsers, useListWorkspaceGroups } from "@app/hooks/api"; +import { ApproverType } from "@app/hooks/api/approvalPolicies"; + +import { TPolicyForm } from "../PolicySchema"; + +const ReviewField = ({ label, value }: { label: string; value: string | number }) => ( +
+ {label} + {value} +
+); + +export const PolicyReviewStep = () => { + const { watch } = useFormContext(); + const { currentProject } = useProject(); + const projectId = currentProject?.id || ""; + + const { data: members = [] } = useGetWorkspaceUsers(projectId); + const { data: groups = [] } = useListWorkspaceGroups(projectId); + + const { name, maxRequestTtlSeconds, conditions, constraints, steps } = watch(); + + const formatTtl = (seconds: number | null | undefined) => { + if (!seconds) return "No limit"; + const hours = Math.floor(seconds / 3600); + const days = Math.floor(hours / 24); + if (days > 0) { + return `${days} day${days !== 1 ? "s" : ""} (${seconds}s)`; + } + return `${hours} hour${hours !== 1 ? "s" : ""} (${seconds}s)`; + }; + + const getApproverLabel = (approverId: string, approverType: ApproverType) => { + if (approverType === ApproverType.User) { + const member = members?.find((m) => m.user.id === approverId); + if (member) { + return getMemberLabel(member); + } + } else if (approverType === ApproverType.Group) { + const group = groups?.find(({ group: g }) => g.id === approverId); + if (group) { + return group.group.name; + } + } + return approverId; + }; + + return ( +
+
+
+

Policy Details

+
+
+ + +
+
+ +
+
+

Request Duration Constraints

+
+
+ + +
+
+ +
+
+

+ Conditions ({conditions.length}) +

+
+
+ {conditions.map((condition, index) => ( +
+
+ Condition {index + 1} +
+
+
+ Resource IDs: + + {condition.resourceIds.length > 0 + ? condition.resourceIds.join(", ") + : "None specified"} + +
+
+ Account Paths: + + {condition.accountPaths.length > 0 + ? condition.accountPaths.join(", ") + : "None specified"} + +
+
+
+ ))} +
+
+ +
+
+

+ Approval Workflow ({steps.length} step{steps.length !== 1 ? "s" : ""}) +

+
+
+ {steps.map((step, index) => { + const userApprovers = step.approvers.filter((a) => a.type === ApproverType.User); + const groupApprovers = step.approvers.filter((a) => a.type === ApproverType.Group); + + return ( +
+
+
+ + {index + 1} + +
+ + Step {index + 1} + {step.name && ( + ({step.name}) + )} + +
+
+
+ Requires {step.requiredApprovals} approval + {step.requiredApprovals !== 1 ? "s" : ""} +
+
+ +
+ {userApprovers.length > 0 && ( +
+
+ User Approvers ({userApprovers.length}): +
+
+ {userApprovers.map((approver, approverIndex) => ( +
+ + + {getApproverLabel(approver.id, ApproverType.User)} + +
+ ))} +
+
+ )} + + {groupApprovers.length > 0 && ( +
+
+ Group Approvers ({groupApprovers.length}): +
+
+ {groupApprovers.map((approver, approverIndex) => ( +
+ + + {getApproverLabel(approver.id, ApproverType.Group)} + +
+ ))} +
+
+ )} + + {step.approvers.length === 0 && ( + No approvers defined + )} +
+
+ ); + })} +
+
+ + {/* Summary Notice */} +
+

+ Please review all the details above. Click "Create" to save this policy or + "Back" to make changes. +

+
+
+ ); +}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/index.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/index.tsx new file mode 100644 index 000000000..3a59d2b78 --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySteps/index.tsx @@ -0,0 +1,4 @@ +export { PolicyApprovalSteps } from "./PolicyApprovalSteps"; +export { PolicyConstraintsStep } from "./PolicyConstraintsStep"; +export { PolicyDetailsStep } from "./PolicyDetailsStep"; +export { PolicyReviewStep } from "./PolicyReviewStep"; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/index.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/index.tsx new file mode 100644 index 000000000..9e7b4659f --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/index.tsx @@ -0,0 +1,3 @@ +export { PolicyModal } from "./PolicyModal"; +export { PoliciesTable } from "./PoliciesTable"; +export { PolicySection } from "./PolicySection"; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/index.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/index.tsx new file mode 100644 index 000000000..5a3f85b4f --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/index.tsx @@ -0,0 +1 @@ +export { PolicyTab } from "./PolicyTab"; diff --git a/frontend/src/pages/pam/ApprovalsPage/route.tsx b/frontend/src/pages/pam/ApprovalsPage/route.tsx new file mode 100644 index 000000000..ce59de485 --- /dev/null +++ b/frontend/src/pages/pam/ApprovalsPage/route.tsx @@ -0,0 +1,31 @@ +import { createFileRoute, stripSearchParams } from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; + +import { ApprovalControlTabs } from "@app/types/project"; + +import { ApprovalsPage } from "./ApprovalsPage"; + +const ApprovalPagePageQuerySchema = z.object({ + selectedTab: z.nativeEnum(ApprovalControlTabs).catch(ApprovalControlTabs.Requests) +}); + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals" +)({ + component: ApprovalsPage, + validateSearch: zodValidator(ApprovalPagePageQuerySchema), + search: { + middlewares: [stripSearchParams({})] + }, + beforeLoad: ({ context }) => { + return { + breadcrumbs: [ + ...context.breadcrumbs, + { + label: "Approvals" + } + ] + }; + } +}); diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx index a86ca8ebb..403530ce8 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -291,7 +291,7 @@ export const ReviewAccessRequestModal = ({ {request.user && (request.user.firstName || request.user.lastName) && request.user.email ? ( - + {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) ) : ( diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index ca413ef21..aef323bdc 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -111,6 +111,7 @@ import { Route as secretManagerSecretApprovalsPageRouteImport } from './pages/se import { Route as secretManagerIPAllowlistPageRouteImport } from './pages/secret-manager/IPAllowlistPage/route' import { Route as pamSettingsPageRouteImport } from './pages/pam/SettingsPage/route' import { Route as pamPamResourcesPageRouteImport } from './pages/pam/PamResourcesPage/route' +import { Route as pamApprovalsPageRouteImport } from './pages/pam/ApprovalsPage/route' import { Route as pamPamAccountsPageRouteImport } from './pages/pam/PamAccountsPage/route' import { Route as kmsSettingsPageRouteImport } from './pages/kms/SettingsPage/route' import { Route as kmsOverviewPageRouteImport } from './pages/kms/OverviewPage/route' @@ -1226,6 +1227,12 @@ const pamPamResourcesPageRouteRoute = pamPamResourcesPageRouteImport.update({ getParentRoute: () => pamLayoutRoute, } as any) +const pamApprovalsPageRouteRoute = pamApprovalsPageRouteImport.update({ + id: '/approvals', + path: '/approvals', + getParentRoute: () => pamLayoutRoute, +} as any) + const pamPamAccountsPageRouteRoute = pamPamAccountsPageRouteImport.update({ id: '/accounts', path: '/accounts', @@ -2902,6 +2909,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof pamPamAccountsPageRouteImport parentRoute: typeof pamLayoutImport } + '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals': { + id: '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals' + path: '/approvals' + fullPath: '/organizations/$orgId/projects/pam/$projectId/approvals' + preLoaderRoute: typeof pamApprovalsPageRouteImport + parentRoute: typeof pamLayoutImport + } '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources': { id: '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources' path: '/resources' @@ -4214,6 +4228,7 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsPamProjectI interface pamLayoutRouteChildren { pamPamAccountsPageRouteRoute: typeof pamPamAccountsPageRouteRoute + pamApprovalsPageRouteRoute: typeof pamApprovalsPageRouteRoute pamPamResourcesPageRouteRoute: typeof pamPamResourcesPageRouteRoute pamSettingsPageRouteRoute: typeof pamSettingsPageRouteRoute projectAccessControlPageRoutePamRoute: typeof projectAccessControlPageRoutePamRoute @@ -4227,6 +4242,7 @@ interface pamLayoutRouteChildren { const pamLayoutRouteChildren: pamLayoutRouteChildren = { pamPamAccountsPageRouteRoute: pamPamAccountsPageRouteRoute, + pamApprovalsPageRouteRoute: pamApprovalsPageRouteRoute, pamPamResourcesPageRouteRoute: pamPamResourcesPageRouteRoute, pamSettingsPageRouteRoute: pamSettingsPageRouteRoute, projectAccessControlPageRoutePamRoute: projectAccessControlPageRoutePamRoute, @@ -5159,6 +5175,7 @@ export interface FileRoutesByFullPath { '/organizations/$orgId/projects/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute '/organizations/$orgId/projects/kms/$projectId/settings': typeof kmsSettingsPageRouteRoute '/organizations/$orgId/projects/pam/$projectId/accounts': typeof pamPamAccountsPageRouteRoute + '/organizations/$orgId/projects/pam/$projectId/approvals': typeof pamApprovalsPageRouteRoute '/organizations/$orgId/projects/pam/$projectId/resources': typeof pamPamResourcesPageRouteRoute '/organizations/$orgId/projects/pam/$projectId/settings': typeof pamSettingsPageRouteRoute '/organizations/$orgId/projects/secret-management/$projectId/allowlist': typeof secretManagerIPAllowlistPageRouteRoute @@ -5395,6 +5412,7 @@ export interface FileRoutesByTo { '/organizations/$orgId/projects/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute '/organizations/$orgId/projects/kms/$projectId/settings': typeof kmsSettingsPageRouteRoute '/organizations/$orgId/projects/pam/$projectId/accounts': typeof pamPamAccountsPageRouteRoute + '/organizations/$orgId/projects/pam/$projectId/approvals': typeof pamApprovalsPageRouteRoute '/organizations/$orgId/projects/pam/$projectId/resources': typeof pamPamResourcesPageRouteRoute '/organizations/$orgId/projects/pam/$projectId/settings': typeof pamSettingsPageRouteRoute '/organizations/$orgId/projects/secret-management/$projectId/allowlist': typeof secretManagerIPAllowlistPageRouteRoute @@ -5642,6 +5660,7 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/kms/$projectId/_kms-layout/overview': typeof kmsOverviewPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/kms/$projectId/_kms-layout/settings': typeof kmsSettingsPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/accounts': typeof pamPamAccountsPageRouteRoute + '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals': typeof pamApprovalsPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources': typeof pamPamResourcesPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/settings': typeof pamSettingsPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/allowlist': typeof secretManagerIPAllowlistPageRouteRoute @@ -5887,6 +5906,7 @@ export interface FileRouteTypes { | '/organizations/$orgId/projects/kms/$projectId/overview' | '/organizations/$orgId/projects/kms/$projectId/settings' | '/organizations/$orgId/projects/pam/$projectId/accounts' + | '/organizations/$orgId/projects/pam/$projectId/approvals' | '/organizations/$orgId/projects/pam/$projectId/resources' | '/organizations/$orgId/projects/pam/$projectId/settings' | '/organizations/$orgId/projects/secret-management/$projectId/allowlist' @@ -6122,6 +6142,7 @@ export interface FileRouteTypes { | '/organizations/$orgId/projects/kms/$projectId/overview' | '/organizations/$orgId/projects/kms/$projectId/settings' | '/organizations/$orgId/projects/pam/$projectId/accounts' + | '/organizations/$orgId/projects/pam/$projectId/approvals' | '/organizations/$orgId/projects/pam/$projectId/resources' | '/organizations/$orgId/projects/pam/$projectId/settings' | '/organizations/$orgId/projects/secret-management/$projectId/allowlist' @@ -6367,6 +6388,7 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/kms/$projectId/_kms-layout/overview' | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/kms/$projectId/_kms-layout/settings' | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/accounts' + | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals' | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources' | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/settings' | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/allowlist' @@ -7058,6 +7080,7 @@ export const routeTree = rootRoute "parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId", "children": [ "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/accounts", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals", "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources", "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/settings", "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/access-management", @@ -7156,6 +7179,10 @@ export const routeTree = rootRoute "filePath": "pam/PamAccountsPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout" }, + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approvals": { + "filePath": "pam/ApprovalsPage/route.tsx", + "parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout" + }, "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/resources": { "filePath": "pam/PamResourcesPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index b0b52c4d3..0da91ba60 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -361,6 +361,7 @@ const pamRoutes = route("/organizations/$orgId/projects/pam/$projectId", [ // Access Management route("/access-management", "project/AccessControlPage/route-pam.tsx"), + route("/approvals", "pam/ApprovalsPage/route.tsx"), route("/roles/$roleSlug", "project/RoleDetailsBySlugPage/route-pam.tsx"), route("/identities/$identityId", "project/IdentityDetailsByIDPage/route-pam.tsx"), route("/members/$membershipId", "project/MemberDetailsByIDPage/route-pam.tsx"), diff --git a/frontend/src/types/project.ts b/frontend/src/types/project.ts index 92b0e5ae5..069fa2ce3 100644 --- a/frontend/src/types/project.ts +++ b/frontend/src/types/project.ts @@ -5,3 +5,9 @@ export enum ProjectAccessControlTabs { Identities = "identities", ServiceTokens = "service-tokens" } + +export enum ApprovalControlTabs { + Requests = "requests", + Policies = "policies", + Grants = "grants" +}