mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: completed policy management ui
This commit is contained in:
@@ -69,6 +69,23 @@ export const PamLayout = () => {
|
||||
>
|
||||
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Sessions</Tab>}
|
||||
</Link>
|
||||
<Link
|
||||
to="/organizations/$orgId/projects/pam/$projectId/approvals"
|
||||
params={{
|
||||
orgId: currentOrg.id,
|
||||
projectId: currentProject.id
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<Tab
|
||||
value={
|
||||
isActive || location.pathname.match(/\/approvals\/|\/i/) ? "selected" : ""
|
||||
}
|
||||
>
|
||||
Approvals
|
||||
</Tab>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/organizations/$orgId/projects/pam/$projectId/access-management"
|
||||
params={{
|
||||
|
||||
73
frontend/src/pages/pam/ApprovalsPage/ApprovalsPage.tsx
Normal file
73
frontend/src/pages/pam/ApprovalsPage/ApprovalsPage.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { useOrganization, useProject } from "@app/context";
|
||||
import { ApprovalControlTabs } from "@app/types/project";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { PolicyTab } from "./components/PolicyTab";
|
||||
|
||||
const Page = () => {
|
||||
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 (
|
||||
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-8xl">
|
||||
<PageHeader
|
||||
scope={currentProject.type}
|
||||
title="PAM Approvals"
|
||||
description="Manage approval workflows, update policy settings, and monitor request statuses."
|
||||
/>
|
||||
<Tabs orientation="vertical" value={selectedTab} onValueChange={updateSelectedTab}>
|
||||
<TabList>
|
||||
<Tab variant="project" value={ApprovalControlTabs.Requests}>
|
||||
Requests
|
||||
</Tab>
|
||||
<Tab variant="project" value={ApprovalControlTabs.Policies}>
|
||||
Policies
|
||||
</Tab>
|
||||
<Tab variant="project" value={ApprovalControlTabs.Grants}>
|
||||
Grants
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={ApprovalControlTabs.Requests}>
|
||||
<div>Hello</div>
|
||||
</TabPanel>
|
||||
<TabPanel value={ApprovalControlTabs.Policies}>
|
||||
<PolicyTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={ApprovalControlTabs.Grants}>
|
||||
<div>Hello</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ApprovalsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<Page />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PolicySection } from "./components";
|
||||
|
||||
export const PolicyTab = () => {
|
||||
return <PolicySection />;
|
||||
};
|
||||
@@ -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<Set<string>>(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 (
|
||||
<div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-10" />
|
||||
<Th>Policy Name</Th>
|
||||
<Th>Max Request TTL</Th>
|
||||
<Th>Conditions</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPoliciesLoading && <TableSkeleton columns={5} innerKey="approval-policies" />}
|
||||
{!isPoliciesLoading && policies.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={5}>
|
||||
<EmptyState title="No policies found" icon={faUsers} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!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 (
|
||||
<>
|
||||
<Tr
|
||||
key={policy.id}
|
||||
className="group cursor-pointer hover:bg-mineshaft-700"
|
||||
onClick={() => toggleRowExpansion(policy.id)}
|
||||
>
|
||||
<Td>
|
||||
<IconButton
|
||||
ariaLabel="expand"
|
||||
variant="plain"
|
||||
className="p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleRowExpansion(policy.id);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isExpanded ? faChevronDown : faChevronRight}
|
||||
className="text-mineshaft-400"
|
||||
/>
|
||||
</IconButton>
|
||||
</Td>
|
||||
<Td>{policy.name}</Td>
|
||||
<Td>{maxTtl}</Td>
|
||||
<Td>
|
||||
{conditionsCount} condition{conditionsCount !== 1 ? "s" : ""}
|
||||
</Td>
|
||||
<Td
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<Tooltip content="More options">
|
||||
<IconButton
|
||||
ariaLabel="More options"
|
||||
variant="plain"
|
||||
className="w-4 p-0"
|
||||
size="md"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("policy", {
|
||||
policyId: policy.id,
|
||||
policy
|
||||
});
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faPencil} />}
|
||||
>
|
||||
Edit Policy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deletePolicy", {
|
||||
policyId: policy.id,
|
||||
policyName: policy.name
|
||||
});
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete Policy
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
{isExpanded && (
|
||||
<Tr className="bg-mineshaft-800">
|
||||
<Td colSpan={5} className="p-0">
|
||||
<div className="max-h-80 overflow-auto overflow-x-hidden">
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-sm font-medium text-mineshaft-300">
|
||||
Approval Contraints
|
||||
</div>
|
||||
{policy.conditions.conditions.map((step, index) => (
|
||||
<Fragment key={`${policy.id}--${index + 1}`}>
|
||||
<div
|
||||
className={twMerge(
|
||||
"rounded border border-mineshaft-600 bg-mineshaft-900 p-3"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-mineshaft-300">
|
||||
Resources:
|
||||
</span>
|
||||
<p className="text-sm text-mineshaft-100">
|
||||
{step.resourceIds.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div
|
||||
style={{ height: "1px" }}
|
||||
className="w-1/5 bg-mineshaft-500"
|
||||
/>
|
||||
<span className="px-2 text-xs font-medium text-mineshaft-400">
|
||||
AND
|
||||
</span>
|
||||
<div
|
||||
style={{ height: "1px" }}
|
||||
className="w-1/5 bg-mineshaft-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-mineshaft-300">
|
||||
Account Paths:
|
||||
</span>
|
||||
<p className="text-sm text-mineshaft-100">
|
||||
{step.accountPaths.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{index < policy.conditions.conditions.length - 1 && (
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-3 w-px bg-mineshaft-500" />
|
||||
<span className="px-2 text-xs font-medium text-mineshaft-400">
|
||||
OR
|
||||
</span>
|
||||
<div className="h-3 w-px bg-mineshaft-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-sm font-medium text-mineshaft-300">
|
||||
Approval Sequence
|
||||
</div>
|
||||
{policy.steps.map((step, index) => (
|
||||
<div
|
||||
key={`${policy.id}-step-${index + 1}`}
|
||||
className={twMerge(
|
||||
"mb-3 rounded border border-mineshaft-600 bg-mineshaft-900 p-3",
|
||||
index === policy.steps.length - 1 && "mb-0"
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-mineshaft-200">
|
||||
Step {index + 1}
|
||||
{step.name && (
|
||||
<span className="ml-2 text-mineshaft-400">
|
||||
({step.name})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-mineshaft-400">
|
||||
Requires {step.requiredApprovals} approval
|
||||
{step.requiredApprovals !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{step.approvers.map((approver, approverIndex) => (
|
||||
<div
|
||||
key={`${policy.id}-step-${index + 1}-approver-${approverIndex + 1}`}
|
||||
className="rounded bg-mineshaft-700 px-2 py-1 text-xs text-mineshaft-300"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faUsers}
|
||||
className="mr-1.5 text-mineshaft-400"
|
||||
/>
|
||||
<span className="capitalize">{approver.type}:</span>{" "}
|
||||
<span className="text-mineshaft-200">
|
||||
{getApproverLabel(approver.id, approver.type)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<TPolicyForm>({
|
||||
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 (
|
||||
<Modal isOpen={isOpen} onOpenChange={(open) => handlePopUpToggle("policy", open)}>
|
||||
<ModalContent
|
||||
title={policyData?.policyId ? "Edit Policy" : "Create Policy"}
|
||||
className="max-w-3xl"
|
||||
>
|
||||
<FormProvider {...formMethods}>
|
||||
<form>
|
||||
<Tab.Group selectedIndex={selectedStepIndex} onChange={setSelectedStepIndex}>
|
||||
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
|
||||
{FORM_STEPS.map((step, index) => (
|
||||
<Tab
|
||||
onClick={async (e) => {
|
||||
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}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<PolicyDetailsStep />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<PolicyConstraintsStep />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<PolicyApprovalSteps />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<PolicyReviewStep />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
|
||||
<div className="mt-6 flex justify-between border-t border-mineshaft-600 pt-4">
|
||||
<Button type="button" variant="outline_bg" onClick={handlePrev}>
|
||||
{selectedStepIndex === 0 ? "Cancel" : "Back"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
isLoading={isCreating || isUpdating}
|
||||
isDisabled={isCreating || isUpdating}
|
||||
>
|
||||
{policyData?.policyId ? "Update" : "Create"} {isFinalStep ? "" : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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<typeof PolicyFormSchema>;
|
||||
@@ -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 (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-xl font-medium text-mineshaft-100">Approval Policies</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("policy")}
|
||||
>
|
||||
Create Policy
|
||||
</Button>
|
||||
</div>
|
||||
<PoliciesTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<PolicyModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePolicy.isOpen}
|
||||
deleteKey="delete"
|
||||
title="Are you sure you want to delete this policy?"
|
||||
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
|
||||
onDeleteApproved={handleDeletePolicy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<TPolicyForm>();
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-mineshaft-200">Approval Steps</label>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
Define the approval workflow with sequential steps
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
appendStep({
|
||||
name: "",
|
||||
requiredApprovals: 1,
|
||||
notifyApprovers: true,
|
||||
approvers: []
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Step
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{stepFields.map((field, index) => (
|
||||
<div key={field.id} className="rounded border border-mineshaft-600 bg-mineshaft-800 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/20 text-xs font-semibold text-primary">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-mineshaft-300">
|
||||
Approval Step {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
{stepFields.length > 1 && (
|
||||
<IconButton
|
||||
ariaLabel="Remove step"
|
||||
variant="plain"
|
||||
size="xs"
|
||||
onClick={() => removeStep(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="text-red-500" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`steps.${index}.name`}
|
||||
render={({ field: nameField }) => (
|
||||
<FormControl
|
||||
label="Step Name (Optional)"
|
||||
helperText="A descriptive name for this approval step"
|
||||
>
|
||||
<Input
|
||||
{...nameField}
|
||||
value={nameField.value || ""}
|
||||
placeholder="e.g., Security Team Review"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={`steps.${index}.requiredApprovals`}
|
||||
render={({ field: approvalsField, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Required Approvals"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Number of approvers that must approve (1-100)"
|
||||
>
|
||||
<Input
|
||||
{...approvalsField}
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
onChange={(e) => approvalsField.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-mineshaft-200">Approvers</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`steps.${index}.approvers`}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => {
|
||||
const userApprovers = value.filter((a) => a.type === ApproverType.User);
|
||||
const groupApprovers = value.filter((a) => a.type === ApproverType.Group);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl
|
||||
label="User Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={
|
||||
error?.message && userApprovers.length === 0 && groupApprovers.length === 0
|
||||
? error?.message
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterableSelect
|
||||
isMulti
|
||||
placeholder="Select users..."
|
||||
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={userApprovers}
|
||||
onChange={(selected) => {
|
||||
const newApprovers = [
|
||||
...(selected || []),
|
||||
...groupApprovers
|
||||
];
|
||||
onChange(newApprovers);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Group Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={
|
||||
error?.message && userApprovers.length === 0 && groupApprovers.length === 0
|
||||
? error?.message
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterableSelect
|
||||
isMulti
|
||||
placeholder="Select groups..."
|
||||
options={groupOptions}
|
||||
getOptionValue={(option) => 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);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{stepFields.length === 0 && (
|
||||
<div className="rounded border border-dashed border-mineshaft-600 bg-mineshaft-800/50 p-8 text-center">
|
||||
<p className="text-sm text-mineshaft-400">No approval steps defined</p>
|
||||
<p className="mt-1 text-xs text-mineshaft-500">
|
||||
Click "Add Step" to create your first approval step
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<TPolicyForm>();
|
||||
|
||||
const {
|
||||
fields: conditionFields,
|
||||
append: appendCondition,
|
||||
remove: removeCondition
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: "conditions"
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="pb-0.5 text-sm font-medium text-mineshaft-200">Conditions</p>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
Define which resources and account paths this policy applies to
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => appendCondition({ resourceIds: [], accountPaths: [] })}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{conditionFields.map((field, index) => (
|
||||
<div key={field.id}>
|
||||
<div className="rounded border border-mineshaft-600 bg-mineshaft-800 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-mineshaft-300">
|
||||
Condition {index + 1}
|
||||
</span>
|
||||
{conditionFields.length > 1 && (
|
||||
<IconButton
|
||||
ariaLabel="Remove condition"
|
||||
variant="plain"
|
||||
size="xs"
|
||||
onClick={() => removeCondition(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="text-red-500" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`conditions.${index}.resourceIds`}
|
||||
render={({ field: resourceField, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Resource IDs"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Comma-separated UUIDs of resources this condition applies to"
|
||||
>
|
||||
<Input
|
||||
value={resourceField.value.join(", ")}
|
||||
onChange={(e) => {
|
||||
const ids = e.target.value
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
resourceField.onChange(ids);
|
||||
}}
|
||||
placeholder="e.g., 550e8400-e29b-41d4-a716-446655440000, ..."
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-center">
|
||||
<div style={{ height: "1px" }} className="w-1/5 bg-mineshaft-500" />
|
||||
<span className="px-2 text-xs font-medium text-mineshaft-400">AND</span>
|
||||
<div style={{ height: "1px" }} className="w-1/5 bg-mineshaft-500" />
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`conditions.${index}.accountPaths`}
|
||||
render={({ field: pathField, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Account Paths"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Comma-separated account paths this condition applies to"
|
||||
>
|
||||
<Input
|
||||
value={pathField.value.join(", ")}
|
||||
onChange={(e) => {
|
||||
const paths = e.target.value
|
||||
.split(",")
|
||||
.map((path) => path.trim())
|
||||
.filter(Boolean);
|
||||
pathField.onChange(paths);
|
||||
}}
|
||||
placeholder="e.g., /admin/*, /users/john"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{index < conditionFields.length - 1 && (
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-3 w-px bg-mineshaft-500" />
|
||||
<span className="px-2 text-xs font-medium text-mineshaft-400">OR</span>
|
||||
<div className="h-3 w-px bg-mineshaft-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<TPolicyForm>();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Name"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="Enter policy name" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxRequestTtlSeconds"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Max Request TTL (seconds)"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Maximum time-to-live for requests. Must be between 1 hour (3600s) and 30 days (2592000s). Leave empty for no limit."
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
field.onChange(val === "" ? null : parseInt(val, 10));
|
||||
}}
|
||||
placeholder="e.g., 86400 (24 hours)"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="border-t border-mineshaft-600 pt-4">
|
||||
<div className="mb-3">
|
||||
<p className="pb-0.5 text-sm font-medium text-mineshaft-200">
|
||||
Request Duration Constraints
|
||||
</p>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
Set minimum and maximum duration (in hours) for access requests
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="constraints.requestDurationHours.min"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Minimum Hours"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="0-168 hours"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
max={168}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="constraints.requestDurationHours.max"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Maximum Hours"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="1-168 hours (7 days)"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 }) => (
|
||||
<div className="flex gap-x-8">
|
||||
<span className="min-w-[140px] text-sm text-mineshaft-400">{label}</span>
|
||||
<span className="text-sm text-mineshaft-200">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PolicyReviewStep = () => {
|
||||
const { watch } = useFormContext<TPolicyForm>();
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-3 border-b border-mineshaft-600 pb-2">
|
||||
<h3 className="text-sm font-medium text-mineshaft-200">Policy Details</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ReviewField label="Policy Name" value={name || "Not set"} />
|
||||
<ReviewField label="Max Request TTL" value={formatTtl(maxRequestTtlSeconds)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 border-b border-mineshaft-600 pb-2">
|
||||
<h3 className="text-sm font-medium text-mineshaft-200">Request Duration Constraints</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ReviewField
|
||||
label="Minimum Duration"
|
||||
value={`${constraints.requestDurationHours.min} hour${constraints.requestDurationHours.min !== 1 ? "s" : ""}`}
|
||||
/>
|
||||
<ReviewField
|
||||
label="Maximum Duration"
|
||||
value={`${constraints.requestDurationHours.max} hour${constraints.requestDurationHours.max !== 1 ? "s" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 border-b border-mineshaft-600 pb-2">
|
||||
<h3 className="text-sm font-medium text-mineshaft-200">
|
||||
Conditions ({conditions.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{conditions.map((condition, index) => (
|
||||
<div
|
||||
key={`condition-${index + 1}`}
|
||||
className="rounded border border-mineshaft-600 bg-mineshaft-800/50 p-3"
|
||||
>
|
||||
<div className="mb-2 text-xs font-medium text-mineshaft-300">
|
||||
Condition {index + 1}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-xs text-mineshaft-400">Resource IDs: </span>
|
||||
<span className="text-xs text-mineshaft-200">
|
||||
{condition.resourceIds.length > 0
|
||||
? condition.resourceIds.join(", ")
|
||||
: "None specified"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-mineshaft-400">Account Paths: </span>
|
||||
<span className="text-xs text-mineshaft-200">
|
||||
{condition.accountPaths.length > 0
|
||||
? condition.accountPaths.join(", ")
|
||||
: "None specified"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 border-b border-mineshaft-600 pb-2">
|
||||
<h3 className="text-sm font-medium text-mineshaft-200">
|
||||
Approval Workflow ({steps.length} step{steps.length !== 1 ? "s" : ""})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div
|
||||
key={`step-${index + 1}`}
|
||||
className="rounded border border-mineshaft-600 bg-mineshaft-800/50 p-3"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/20 text-xs font-medium text-primary">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-mineshaft-200">
|
||||
Step {index + 1}
|
||||
{step.name && (
|
||||
<span className="ml-2 text-xs text-mineshaft-400">({step.name})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-mineshaft-400">
|
||||
Requires {step.requiredApprovals} approval
|
||||
{step.requiredApprovals !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{userApprovers.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-mineshaft-400">
|
||||
User Approvers ({userApprovers.length}):
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{userApprovers.map((approver, approverIndex) => (
|
||||
<div
|
||||
key={`step-${index + 1}-user-${approverIndex + 1}`}
|
||||
className="flex items-center gap-1.5 rounded bg-mineshaft-700 px-2 py-1 text-xs text-mineshaft-300"
|
||||
>
|
||||
<FontAwesomeIcon icon={faUsers} className="text-mineshaft-400" />
|
||||
<span className="text-mineshaft-200">
|
||||
{getApproverLabel(approver.id, ApproverType.User)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupApprovers.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-mineshaft-400">
|
||||
Group Approvers ({groupApprovers.length}):
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groupApprovers.map((approver, approverIndex) => (
|
||||
<div
|
||||
key={`step-${index + 1}-group-${approverIndex + 1}`}
|
||||
className="flex items-center gap-1.5 rounded bg-mineshaft-700 px-2 py-1 text-xs text-mineshaft-300"
|
||||
>
|
||||
<FontAwesomeIcon icon={faUsers} className="text-mineshaft-400" />
|
||||
<span className="text-mineshaft-200">
|
||||
{getApproverLabel(approver.id, ApproverType.Group)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.approvers.length === 0 && (
|
||||
<span className="text-xs text-mineshaft-500">No approvers defined</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Notice */}
|
||||
<div className="rounded-md border border-primary/30 bg-primary/5 p-3">
|
||||
<p className="text-xs text-mineshaft-300">
|
||||
Please review all the details above. Click "Create" to save this policy or
|
||||
"Back" to make changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { PolicyApprovalSteps } from "./PolicyApprovalSteps";
|
||||
export { PolicyConstraintsStep } from "./PolicyConstraintsStep";
|
||||
export { PolicyDetailsStep } from "./PolicyDetailsStep";
|
||||
export { PolicyReviewStep } from "./PolicyReviewStep";
|
||||
@@ -0,0 +1,3 @@
|
||||
export { PolicyModal } from "./PolicyModal";
|
||||
export { PoliciesTable } from "./PoliciesTable";
|
||||
export { PolicySection } from "./PolicySection";
|
||||
@@ -0,0 +1 @@
|
||||
export { PolicyTab } from "./PolicyTab";
|
||||
31
frontend/src/pages/pam/ApprovalsPage/route.tsx
Normal file
31
frontend/src/pages/pam/ApprovalsPage/route.tsx
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -291,7 +291,7 @@ export const ReviewAccessRequestModal = ({
|
||||
{request.user &&
|
||||
(request.user.firstName || request.user.lastName) &&
|
||||
request.user.email ? (
|
||||
<span className="inline font-bold">
|
||||
<span className="inline font-medium">
|
||||
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -5,3 +5,9 @@ export enum ProjectAccessControlTabs {
|
||||
Identities = "identities",
|
||||
ServiceTokens = "service-tokens"
|
||||
}
|
||||
|
||||
export enum ApprovalControlTabs {
|
||||
Requests = "requests",
|
||||
Policies = "policies",
|
||||
Grants = "grants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user