feat: completed policy management ui

This commit is contained in:
=
2025-12-06 11:54:48 +05:30
parent d192f1e3e1
commit 22b6e304d8
19 changed files with 1520 additions and 1 deletions

View File

@@ -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={{

View 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 />
</>
);
};

View File

@@ -0,0 +1,5 @@
import { PolicySection } from "./components";
export const PolicyTab = () => {
return <PolicySection />;
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 &quot;Create&quot; to save this policy or
&quot;Back&quot; to make changes.
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,4 @@
export { PolicyApprovalSteps } from "./PolicyApprovalSteps";
export { PolicyConstraintsStep } from "./PolicyConstraintsStep";
export { PolicyDetailsStep } from "./PolicyDetailsStep";
export { PolicyReviewStep } from "./PolicyReviewStep";

View File

@@ -0,0 +1,3 @@
export { PolicyModal } from "./PolicyModal";
export { PoliciesTable } from "./PoliciesTable";
export { PolicySection } from "./PolicySection";

View File

@@ -0,0 +1 @@
export { PolicyTab } from "./PolicyTab";

View 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"
}
]
};
}
});

View File

@@ -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>
) : (

View File

@@ -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"

View File

@@ -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"),

View File

@@ -5,3 +5,9 @@ export enum ProjectAccessControlTabs {
Identities = "identities",
ServiceTokens = "service-tokens"
}
export enum ApprovalControlTabs {
Requests = "requests",
Policies = "policies",
Grants = "grants"
}