Merge remote-tracking branch 'origin' into role-concept

This commit is contained in:
Tuan Dang
2024-07-19 15:27:25 +07:00
74 changed files with 2857 additions and 1348 deletions
@@ -25,7 +25,7 @@ export const DeleteActionModal = ({
deleteKey,
onDeleteApproved,
title,
subTitle = "This action is irreversible!",
subTitle = "This action is irreversible.",
buttonText = "Delete"
}: Props): JSX.Element => {
const [inputData, setInputData] = useState("");
@@ -86,7 +86,7 @@ export const DeleteActionModal = ({
<FormControl
label={
<div className="break-words pb-2 text-sm">
Type <span className="font-bold">{deleteKey}</span> to delete the resource
Type <span className="font-bold">{deleteKey}</span> to perform this action
</div>
}
className="mb-0"
@@ -94,7 +94,7 @@ export const DeleteActionModal = ({
<Input
value={inputData}
onChange={(e) => setInputData(e.target.value)}
placeholder="Type to delete..."
placeholder="Type confirm..."
/>
</FormControl>
</form>
@@ -1,9 +1,11 @@
import { cloneElement, ReactNode } from "react";
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
import { faExclamationTriangle, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as Label from "@radix-ui/react-label";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "../Tooltip";
export type FormLabelProps = {
id?: string;
isRequired?: boolean;
@@ -11,9 +13,10 @@ export type FormLabelProps = {
label?: ReactNode;
icon?: ReactNode;
className?: string;
tooltipText?: string;
};
export const FormLabel = ({ id, label, isRequired, icon, className,isOptional }: FormLabelProps) => (
export const FormLabel = ({ id, label, isRequired, icon, className,isOptional, tooltipText }: FormLabelProps) => (
<Label.Root
className={twMerge(
"mb-0.5 ml-1 flex items-center text-sm font-normal text-mineshaft-400",
@@ -24,11 +27,20 @@ export const FormLabel = ({ id, label, isRequired, icon, className,isOptional }:
{label}
{isRequired && <span className="ml-1 text-red">*</span>}
{isOptional && <span className="ml-1 text-gray-500 italic text-xs">- Optional</span>}
{icon && (
{icon && !tooltipText && (
<span className="ml-2 cursor-default text-mineshaft-300 hover:text-mineshaft-200">
{icon}
</span>
)}
{tooltipText && (
<Tooltip content={tooltipText}>
<FontAwesomeIcon
icon={faQuestionCircle}
size="1x"
className="ml-2"
/>
</Tooltip>
)}
</Label.Root>
);
@@ -64,6 +76,7 @@ export type FormControlProps = {
children: JSX.Element;
className?: string;
icon?: ReactNode;
tooltipText?: string;
};
export const FormControl = ({
@@ -76,7 +89,8 @@ export const FormControl = ({
id,
isError,
icon,
className
className,
tooltipText
}: FormControlProps): JSX.Element => {
return (
<div className={twMerge("mb-4", className)}>
@@ -87,6 +101,7 @@ export const FormControl = ({
isRequired={isRequired}
id={id}
icon={icon}
tooltipText={tooltipText}
/>
) : (
label
+12
View File
@@ -0,0 +1,12 @@
import { PolicyType } from "@app/hooks/api/policies/enums";
export const policyDetails: Record<PolicyType, { name: string; className: string }> = {
[PolicyType.AccessPolicy]: {
className: "bg-lime-900 text-lime-100",
name: "Access Policy"
},
[PolicyType.ChangePolicy]: {
className: "bg-indigo-900 text-indigo-100",
name: "Change Policy"
}
};
@@ -16,14 +16,15 @@ export const useCreateAccessApprovalPolicy = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TCreateAccessPolicyDTO>({
mutationFn: async ({ environment, projectSlug, approvals, approvers, name, secretPath }) => {
mutationFn: async ({ environment, projectSlug, approvals, approvers, name, secretPath, enforcementLevel }) => {
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
environment,
projectSlug,
approvals,
approvers,
secretPath,
name
name,
enforcementLevel
});
return data;
},
@@ -37,12 +38,13 @@ export const useUpdateAccessApprovalPolicy = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateAccessPolicyDTO>({
mutationFn: async ({ id, approvers, approvals, name, secretPath }) => {
mutationFn: async ({ id, approvers, approvals, name, secretPath, enforcementLevel }) => {
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
approvals,
approvers,
secretPath,
name
name,
enforcementLevel
});
return data;
},
@@ -1,3 +1,4 @@
import { EnforcementLevel, PolicyType } from "../policies/enums";
import { TProjectPermission } from "../roles/types";
import { WorkspaceEnv } from "../workspace/types";
@@ -11,6 +12,11 @@ export type TAccessApprovalPolicy = {
environment: WorkspaceEnv;
projectId: string;
approvers: string[];
policyType: PolicyType;
approversRequired: boolean;
enforcementLevel: EnforcementLevel;
updatedAt: Date;
userApprovers?: { userId: string }[];
};
export type TAccessApprovalRequest = {
@@ -47,6 +53,7 @@ export type TAccessApprovalRequest = {
approvers: string[];
secretPath?: string | null;
envId: string;
enforcementLevel: EnforcementLevel;
};
reviewers: {
@@ -119,6 +126,7 @@ export type TCreateAccessPolicyDTO = {
approvers?: string[];
approvals?: number;
secretPath?: string;
enforcementLevel?: EnforcementLevel;
};
export type TUpdateAccessPolicyDTO = {
@@ -128,6 +136,7 @@ export type TUpdateAccessPolicyDTO = {
secretPath?: string;
environment?: string;
approvals?: number;
enforcementLevel?: EnforcementLevel;
// for invalidating list
projectSlug: string;
};
+9
View File
@@ -0,0 +1,9 @@
export enum EnforcementLevel {
Hard = "hard",
Soft = "soft"
}
export enum PolicyType {
ChangePolicy = "change",
AccessPolicy = "access"
}
@@ -9,14 +9,15 @@ export const useCreateSecretApprovalPolicy = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TCreateSecretPolicyDTO>({
mutationFn: async ({ environment, workspaceId, approvals, approverUserIds, secretPath, name }) => {
mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath, name, enforcementLevel }) => {
const { data } = await apiRequest.post("/api/v1/secret-approvals", {
environment,
workspaceId,
approvals,
approverUserIds,
approvers,
secretPath,
name
name,
enforcementLevel
});
return data;
},
@@ -30,12 +31,13 @@ export const useUpdateSecretApprovalPolicy = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateSecretPolicyDTO>({
mutationFn: async ({ id, approverUserIds, approvals, secretPath, name }) => {
mutationFn: async ({ id, approvers, approvals, secretPath, name, enforcementLevel }) => {
const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, {
approvals,
approverUserIds,
approvers,
secretPath,
name
name,
enforcementLevel
});
return data;
},
@@ -1,3 +1,4 @@
import { EnforcementLevel } from "../policies/enums";
import { WorkspaceEnv } from "../workspace/types";
export type TSecretApprovalPolicy = {
@@ -9,6 +10,8 @@ export type TSecretApprovalPolicy = {
secretPath?: string;
approvals: number;
userApprovers: { userId: string }[];
updatedAt: Date;
enforcementLevel: EnforcementLevel;
};
export type TGetSecretApprovalPoliciesDTO = {
@@ -26,16 +29,18 @@ export type TCreateSecretPolicyDTO = {
name?: string;
environment: string;
secretPath?: string | null;
approverUserIds?: string[];
approvers?: string[];
approvals?: number;
enforcementLevel: EnforcementLevel;
};
export type TUpdateSecretPolicyDTO = {
id: string;
name?: string;
approverUserIds?: string[];
approvers?: string[];
secretPath?: string | null;
approvals?: number;
enforcementLevel?: EnforcementLevel;
// for invalidating list
workspaceId: string;
};
+4 -3
View File
@@ -16,6 +16,8 @@ export {
useGetMyIp,
useGetMyOrganizationProjects,
useGetMySessions,
useGetOrgMembership,
useGetOrgMembershipProjectMemberships,
useGetOrgUsers,
useGetUser,
useGetUserAction,
@@ -23,6 +25,5 @@ export {
useRegisterUserAction,
useRevokeMySessions,
useUpdateMfaEnabled,
useUpdateOrgUserRole,
useUpdateUserAuthMethods
} from "./queries";
useUpdateOrgMembership,
useUpdateUserAuthMethods} from "./queries";
+2 -1
View File
@@ -57,8 +57,9 @@ export const useAddUserToWsNonE2EE = () => {
});
return data;
},
onSuccess: (_, { projectId }) => {
onSuccess: (_, { orgId, projectId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(projectId));
queryClient.invalidateQueries(userKeys.allOrgMembershipProjectMemberships(orgId));
}
});
};
+69 -7
View File
@@ -13,7 +13,8 @@ import {
OrgUser,
RenameUserDTO,
TokenVersion,
UpdateOrgUserRoleDTO,
TWorkspaceUser,
UpdateOrgMembershipDTO,
User,
UserEnc
} from "./types";
@@ -23,6 +24,13 @@ export const userKeys = {
getPrivateKey: ["user"] as const,
userAction: ["user-action"] as const,
userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const,
getOrgMembership: (orgId: string, orgMembershipId: string) =>
[{ orgId, orgMembershipId }, "org-membership"] as const,
allOrgMembershipProjectMemberships: (orgId: string) => [orgId, "all-user-memberships"] as const,
forOrgMembershipProjectMemberships: (orgId: string, orgMembershipId: string) =>
[...userKeys.allOrgMembershipProjectMemberships(orgId), { orgMembershipId }] as const,
getOrgMembershipProjectMemberships: (orgId: string, username: string) =>
[{ orgId, username }, "org-membership-project-memberships"] as const,
getOrgUsers: (orgId: string) => [{ orgId }, "user"],
myIp: ["ip"] as const,
myAPIKeys: ["api-keys"] as const,
@@ -167,6 +175,41 @@ export const useAddUserToOrg = () => {
});
};
export const useGetOrgMembership = (organizationId: string, orgMembershipId: string) => {
return useQuery({
queryKey: userKeys.getOrgMembership(organizationId, orgMembershipId),
queryFn: async () => {
const {
data: { membership }
} = await apiRequest.get<{ membership: OrgUser }>(
`/api/v2/organizations/${organizationId}/memberships/${orgMembershipId}`
);
return membership;
},
enabled: Boolean(organizationId) && Boolean(orgMembershipId)
});
};
export const useGetOrgMembershipProjectMemberships = (
organizationId: string,
orgMembershipId: string
) => {
return useQuery({
queryKey: userKeys.forOrgMembershipProjectMemberships(organizationId, orgMembershipId),
queryFn: async () => {
const {
data: { memberships }
} = await apiRequest.get<{ memberships: TWorkspaceUser[] }>(
`/api/v2/organizations/${organizationId}/memberships/${orgMembershipId}/project-memberships`
);
return memberships;
},
enabled: Boolean(organizationId) && Boolean(orgMembershipId)
});
};
export const useDeleteOrgMembership = () => {
const queryClient = useQueryClient();
@@ -180,24 +223,43 @@ export const useDeleteOrgMembership = () => {
});
};
export const useUpdateOrgUserRole = () => {
export const useDeactivateOrgMembership = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, UpdateOrgUserRoleDTO>({
mutationFn: ({ organizationId, membershipId, role }) => {
return useMutation<{}, {}, DeletOrgMembershipDTO>({
mutationFn: ({ membershipId, orgId }) => {
return apiRequest.post(
`/api/v2/organizations/${orgId}/memberships/${membershipId}/deactivate`
);
},
onSuccess: (_, { orgId, membershipId }) => {
queryClient.invalidateQueries(userKeys.getOrgUsers(orgId));
queryClient.invalidateQueries(userKeys.getOrgMembership(orgId, membershipId));
}
});
};
export const useUpdateOrgMembership = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, UpdateOrgMembershipDTO>({
mutationFn: ({ organizationId, membershipId, role, isActive }) => {
return apiRequest.patch(
`/api/v2/organizations/${organizationId}/memberships/${membershipId}`,
{
role
role,
isActive
}
);
},
onSuccess: (_, { organizationId }) => {
onSuccess: (_, { organizationId, membershipId }) => {
queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId));
queryClient.invalidateQueries(userKeys.getOrgMembership(organizationId, membershipId));
},
// to remove old states
onError: (_, { organizationId }) => {
onError: (_, { organizationId, membershipId }) => {
queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId));
queryClient.invalidateQueries(userKeys.getOrgMembership(organizationId, membershipId));
}
});
};
+10 -2
View File
@@ -49,6 +49,7 @@ export type OrgUser = {
user: {
username: string;
email?: string;
isEmailVerified: boolean;
firstName: string;
lastName: string;
id: string;
@@ -82,6 +83,11 @@ export type TWorkspaceUser = {
id: string;
publicKey: string;
};
projectId: string;
project: {
id: string;
name: string;
};
inviteEmail: string;
organization: string;
roles: (
@@ -127,12 +133,14 @@ export type AddUserToWsDTOE2EE = {
export type AddUserToWsDTONonE2EE = {
projectId: string;
usernames: string[];
orgId: string;
};
export type UpdateOrgUserRoleDTO = {
export type UpdateOrgMembershipDTO = {
organizationId: string;
membershipId: string;
role: string;
role?: string;
isActive?: boolean;
};
export type DeletOrgMembershipDTO = {
+4 -1
View File
@@ -11,6 +11,7 @@ import { IdentityMembership } from "../identities/types";
import { IntegrationAuth } from "../integrationAuth/types";
import { TIntegration } from "../integrations/types";
import { EncryptedSecret } from "../secrets/types";
import { userKeys } from "../users/queries";
import { TWorkspaceUser } from "../users/types";
import {
CreateEnvironmentDTO,
@@ -385,6 +386,7 @@ export const useDeleteUserFromWorkspace = () => {
}: {
workspaceId: string;
usernames: string[];
orgId: string;
}) => {
const {
data: { deletedMembership }
@@ -393,8 +395,9 @@ export const useDeleteUserFromWorkspace = () => {
});
return deletedMembership;
},
onSuccess: (_, { workspaceId }) => {
onSuccess: (_, { orgId, workspaceId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
queryClient.invalidateQueries(userKeys.allOrgMembershipProjectMemberships(orgId));
}
});
};
+2 -1
View File
@@ -264,7 +264,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
usernames: orgUsers
.map((member) => member.user.username)
.filter((username) => username !== user.username),
projectId: newProjectId
projectId: newProjectId,
orgId: currentOrg.id
});
}
@@ -0,0 +1,20 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { UserPage } from "@app/views/Org/UserPage";
export default function User() {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<UserPage />
</>
);
}
User.requireAuth = true;
@@ -541,7 +541,8 @@ const OrganizationPage = withPermission(
usernames: orgUsers
.map((member) => member.user.username)
.filter((username) => username !== user.username),
projectId: newProjectId
projectId: newProjectId,
orgId: currentOrg.id
});
}
@@ -1,4 +1,4 @@
import { faKey } from "@fortawesome/free-solid-svg-icons";
import { faFolder } from "@fortawesome/free-solid-svg-icons";
import {
EmptyState,
@@ -37,7 +37,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="identity-project-memberships" />}
{isLoading && <TableSkeleton columns={4} innerKey="identity-project-memberships" />}
{!isLoading &&
projectMemberships?.map((membership) => {
return (
@@ -51,7 +51,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
</TBody>
</Table>
{!isLoading && !projectMemberships?.length && (
<EmptyState title="This identity has not been assigned to any projects" icon={faKey} />
<EmptyState title="This identity has not been assigned to any projects" icon={faFolder} />
)}
</TableContainer>
);
@@ -16,7 +16,7 @@ import {
useOrganization,
useSubscription
} from "@app/context";
import { useDeleteOrgMembership } from "@app/hooks/api";
import { useDeleteOrgMembership, useUpdateOrgMembership } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddOrgMemberModal } from "./AddOrgMemberModal";
@@ -32,11 +32,13 @@ export const OrgMembersSection = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addMember",
"removeMember",
"deactivateMember",
"upgradePlan",
"setUpEmail"
] as const);
const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
const isMoreUsersAllowed = subscription?.memberLimit
? subscription.membersUsed < subscription.memberLimit
@@ -65,6 +67,29 @@ export const OrgMembersSection = () => {
handlePopUpOpen("addMember");
};
const onDeactivateMemberSubmit = async (orgMembershipId: string) => {
try {
await updateOrgMembership({
organizationId: orgId,
membershipId: orgMembershipId,
isActive: false
});
createNotification({
text: "Successfully deactivated user in organization",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to deactivate user in organization",
type: "error"
});
}
handlePopUpClose("deactivateMember");
};
const onRemoveMemberSubmit = async (orgMembershipId: string) => {
try {
await deleteMutateAsync({
@@ -128,6 +153,20 @@ export const OrgMembersSection = () => {
)
}
/>
<DeleteActionModal
isOpen={popUp.deactivateMember.isOpen}
title={`Are you sure want to deactivate member with username ${
(popUp?.deactivateMember?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deactivateMember", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeactivateMemberSubmit(
(popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId
)
}
buttonText="Deactivate"
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
@@ -1,13 +1,18 @@
import { useCallback, useMemo, useState } from "react";
import { faMagnifyingGlass, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useRouter } from "next/router";
import { faEllipsis, faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Select,
SelectItem,
@@ -32,13 +37,13 @@ import {
useFetchServerStatus,
useGetOrgRoles,
useGetOrgUsers,
useUpdateOrgUserRole
useUpdateOrgMembership
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeMember", "upgradePlan"]>,
popUpName: keyof UsePopUpState<["removeMember", "deactivateMember", "upgradePlan"]>,
data?: {
orgMembershipId?: string;
username?: string;
@@ -49,6 +54,7 @@ type Props = {
};
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => {
const router = useRouter();
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const { user } = useUser();
@@ -63,14 +69,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId);
const { mutateAsync: addUserMutateAsync } = useAddUserToOrg();
const { mutateAsync: updateUserOrgRole } = useUpdateOrgUserRole();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
const onRoleChange = async (membershipId: string, role: string) => {
if (!currentOrg?.id) return;
try {
// TODO: replace hardcoding default role
const isCustomRole = !["admin", "member"].includes(role);
const isCustomRole = !["admin", "member", "no-access"].includes(role);
if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", {
@@ -79,7 +85,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
return;
}
await updateUserOrgRole({
await updateOrgMembership({
organizationId: currentOrg?.id,
membershipId,
role
@@ -176,7 +182,11 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
const email = u?.email || inviteEmail;
const username = u?.username ?? inviteEmail ?? "-";
return (
<Tr key={`org-membership-${orgMembershipId}`} className="w-full">
<Tr
key={`org-membership-${orgMembershipId}`}
className="h-10 w-full cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)}
>
<Td className={isActive ? "" : "text-mineshaft-400"}>{name}</Td>
<Td className={isActive ? "" : "text-mineshaft-400"}>{username}</Td>
<Td>
@@ -238,34 +248,117 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
</Td>
<Td>
{userId !== u?.id && (
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<IconButton
onClick={() => {
if (currentOrg?.authEnforced) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
handlePopUpOpen("removeMember", { orgMembershipId, username });
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</OrgPermissionCan>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${orgId}/memberships/${orgMembershipId}`);
}}
disabled={!isAllowed}
>
Edit User
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={
isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
}
onClick={async (e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
if (!isActive) {
// activate user
await updateOrgMembership({
organizationId: orgId,
membershipId: orgMembershipId,
isActive: true
});
return;
}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
{`${isActive ? "Deactivate" : "Activate"} User`}
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
handlePopUpOpen("removeMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
Remove User
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</Td>
</Tr>
@@ -0,0 +1,288 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useRouter } from "next/router";
import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
UpgradePlanModal
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useUser
} from "@app/context";
import { withPermission } from "@app/hoc";
import {
useDeleteOrgMembership,
useGetOrgMembership,
useUpdateOrgMembership
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { UserDetailsSection, UserOrgMembershipModal, UserProjectsSection } from "./components";
export const UserPage = withPermission(
() => {
const router = useRouter();
const membershipId = router.query.membershipId as string;
const { user } = useUser();
const { currentOrg } = useOrganization();
const userId = user?.id || "";
const orgId = currentOrg?.id || "";
const { data: membership } = useGetOrgMembership(orgId, membershipId);
const { mutateAsync: deleteOrgMembership } = useDeleteOrgMembership();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"removeMember",
"orgMembership",
"deactivateMember",
"upgradePlan"
] as const);
const onDeactivateMemberSubmit = async (orgMembershipId: string) => {
try {
await updateOrgMembership({
organizationId: orgId,
membershipId: orgMembershipId,
isActive: false
});
createNotification({
text: "Successfully deactivated user in organization",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to deactivate user in organization",
type: "error"
});
}
handlePopUpClose("deactivateMember");
};
const onRemoveMemberSubmit = async (orgMembershipId: string) => {
try {
await deleteOrgMembership({
orgId,
membershipId: orgMembershipId
});
createNotification({
text: "Successfully removed user from org",
type: "success"
});
handlePopUpClose("removeMember");
router.push(`/org/${orgId}/members`);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to remove user from the organization",
type: "error"
});
}
handlePopUpClose("removeMember");
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{membership && (
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
router.push(`/org/${orgId}/members`);
}}
className="mb-4"
>
Users
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">
{membership.user.firstName || membership.user.lastName
? `${membership.user.firstName} ${membership.user.lastName}`
: "-"}
</p>
{userId !== membership.user.id && (
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role
})
}
disabled={!isAllowed}
>
Edit User
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={
membership.isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
}
onClick={async () => {
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when SCIM is enabled for your organization",
type: "error"
});
return;
}
if (!membership.isActive) {
// activate user
await updateOrgMembership({
organizationId: orgId,
membershipId,
isActive: true
});
return;
}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId: membershipId,
username: membership.user.username
});
}}
disabled={!isAllowed}
>
{`${membership.isActive ? "Deactivate" : "Activate"} User`}
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() => {
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when SCIM is enabled for your organization",
type: "error"
});
return;
}
handlePopUpOpen("removeMember", {
orgMembershipId: membershipId,
username: membership.user.username
});
}}
disabled={!isAllowed}
>
Remove User
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex">
<div className="mr-4 w-96">
<UserDetailsSection membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
</div>
<UserProjectsSection membershipId={membershipId} />
</div>
</div>
)}
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
title={`Are you sure want to remove member with username ${
(popUp?.removeMember?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveMemberSubmit(
(popUp?.removeMember?.data as { orgMembershipId: string })?.orgMembershipId
)
}
/>
<DeleteActionModal
isOpen={popUp.deactivateMember.isOpen}
title={`Are you sure want to deactivate member with username ${
(popUp?.deactivateMember?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deactivateMember", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeactivateMemberSubmit(
(popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId
)
}
buttonText="Deactivate"
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
<UserOrgMembershipModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member }
);
@@ -0,0 +1,195 @@
import {
faCheck,
faCheckCircle,
faCircleXmark,
faCopy,
faPencil} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Tooltip } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useUser
} from "@app/context";
import { useTimedReset } from "@app/hooks";
import {
useAddUserToOrg,
useFetchServerStatus,
useGetOrgMembership,
useGetOrgRoles
} from "@app/hooks/api";
import { OrgUser } from "@app/hooks/api/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
membershipId: string;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["orgMembership"]>, data?: {}) => void;
};
export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => {
const [copyTextUsername, isCopyingUsername, setCopyTextUsername] = useTimedReset<string>({
initialState: "Copy username to clipboard"
});
const { user } = useUser();
const { currentOrg } = useOrganization();
const userId = user?.id || "";
const orgId = currentOrg?.id || "";
const { data: roles } = useGetOrgRoles(orgId);
const { data: serverDetails } = useFetchServerStatus();
const { data: membership } = useGetOrgMembership(orgId, membershipId);
const { mutateAsync: inviteUser, isLoading } = useAddUserToOrg();
const onResendInvite = async (email: string) => {
try {
const { data } = await inviteUser({
organizationId: orgId,
inviteeEmail: email
});
// setCompleteInviteLink(data?.completeInviteLink || "");
if (!data.completeInviteLink) {
createNotification({
text: `Successfully resent invite to ${email}`,
type: "success"
});
}
} catch (err) {
console.error(err);
createNotification({
text: `Failed to resend invite to ${email}`,
type: "error"
});
}
};
const getStatus = (m: OrgUser) => {
if (!m.isActive) {
return "Deactivated";
}
return m.status === "invited" ? "Invited" : "Active";
};
const roleName = roles?.find((r) => r.slug === membership?.role)?.name;
return membership ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Details</h3>
{userId !== membership.user.id && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
{(isAllowed) => {
return (
<Tooltip content="Edit Membership">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</OrgPermissionCan>
)}
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">
{membership.user.firstName || membership.user.lastName
? `${membership.user.firstName} ${membership.user.lastName}`
: "-"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Username</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{membership.user.username}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextUsername}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText("");
setCopyTextUsername("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingUsername ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Email</p>
<div className="flex items-center">
<p className="mr-2 text-sm text-mineshaft-300">{membership.user.email ?? "-"}</p>
<Tooltip
content={
membership.user.isEmailVerified
? "Email has been verified"
: "Email has not been verified"
}
>
<FontAwesomeIcon
size="sm"
icon={membership.user.isEmailVerified ? faCheckCircle : faCircleXmark}
/>
</Tooltip>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{roleName ?? "-"}</p>
</div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Status</p>
<p className="text-sm text-mineshaft-300">{getStatus(membership)}</p>
</div>
{membership.isActive &&
(membership.status === "invited" || membership.status === "verified") &&
membership.user.email &&
serverDetails?.emailConfigured && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
{(isAllowed) => {
return (
<Button
isDisabled={!isAllowed}
className="mt-4 w-full"
colorSchema="primary"
type="submit"
isLoading={isLoading}
onClick={() => {
onResendInvite(membership.user.email as string);
}}
>
Resend Invite
</Button>
);
}}
</OrgPermissionCan>
)}
</div>
</div>
) : (
<div />
);
};
@@ -0,0 +1,160 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
role: z.string()
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["orgMembership"]>;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>, data?: {}) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["orgMembership"]>, state?: boolean) => void;
};
export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { data: roles } = useGetOrgRoles(orgId);
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const popUpData = popUp?.orgMembership?.data as {
membershipId: string;
role: string;
};
useEffect(() => {
if (!roles?.length) return;
if (popUpData) {
reset({
role: popUpData.role
});
} else {
reset({
role: roles[0].slug
});
}
}, [popUp?.orgMembership?.data, roles]);
const onFormSubmit = async ({ role }: FormData) => {
try {
if (!orgId) return;
await updateOrgMembership({
organizationId: orgId,
membershipId: popUpData.membershipId,
role
});
handlePopUpToggle("orgMembership", false);
createNotification({
text: "Successfully updated user organization role",
type: "success"
});
reset();
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to update user organization role";
createNotification({
text,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.orgMembership?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("orgMembership", isOpen);
reset();
}}
>
<ModalContent title="Update Membership">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Update Organization Role"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
const isCustomRole = !["admin", "member", "no-access"].includes(e);
if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", {
description:
"You can assign custom roles to members if you upgrade your Infisical plan."
});
return;
}
onChange(e);
}}
className="w-full"
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`st-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Update
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("orgMembership", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,145 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import { useAddUserToWsNonE2EE, useGetOrgMembershipProjectMemberships } from "@app/hooks/api";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
projectId: z.string()
})
.required();
type FormData = z.infer<typeof schema>;
type Props = {
membershipId: string;
popUp: UsePopUpState<["addUserToProject"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["addUserToProject"]>,
state?: boolean
) => void;
};
export const UserAddToProjectModal = ({ membershipId, popUp, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { workspaces } = useWorkspace();
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
const popupData = popUp.addUserToProject.data as {
username: string;
};
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const { data: projectMemberships } = useGetOrgMembershipProjectMemberships(orgId, membershipId);
const filteredWorkspaces = useMemo(() => {
const wsWorkspaceIds = new Map();
projectMemberships?.forEach((projectMembership) => {
wsWorkspaceIds.set(projectMembership.project.id, true);
});
return (workspaces || []).filter(
({ id, orgId: projectOrgId, version }) =>
!wsWorkspaceIds.has(id) && projectOrgId === currentOrg?.id && version === ProjectVersion.V2
);
}, [workspaces, projectMemberships]);
const onFormSubmit = async ({ projectId }: FormData) => {
try {
await addUserToWorkspaceNonE2EE({
projectId,
usernames: [popupData.username],
orgId
});
createNotification({
text: "Successfully added user to project",
type: "success"
});
reset();
handlePopUpToggle("addUserToProject", false);
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to add identity to project";
createNotification({
text,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.addUserToProject?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addUserToProject", isOpen);
reset();
}}
>
<ModalContent title="Add User to Project">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="projectId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Project" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(filteredWorkspaces || []).map(({ id, name }) => (
<SelectItem value={id} key={`project-${id}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("addUserToProject", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,90 @@
import { useMemo } from "react";
import { useRouter } from "next/router";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
membership: TWorkspaceUser;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void;
};
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Admin) return "Admin";
if (role === ProjectMembershipRole.Member) return "Developer";
if (role === ProjectMembershipRole.Viewer) return "Viewer";
if (role === ProjectMembershipRole.NoAccess) return "No Access";
return role;
};
export const UserProjectRow = ({
membership: { id, project, user, roles },
handlePopUpOpen
}: Props) => {
const { workspaces } = useWorkspace();
const router = useRouter();
const isAccessible = useMemo(() => {
const workspaceIds = new Map();
workspaces?.forEach((workspace) => {
workspaceIds.set(workspace.id, true);
});
return workspaceIds.has(project.id);
}, [workspaces, project]);
return (
<Tr
className="group h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
key={`user-project-membership-${id}`}
onClick={() => {
if (isAccessible) {
router.push(`/project/${project.id}/members`);
return;
}
createNotification({
text: "Unable to access project",
type: "error"
});
}}
>
<Td>{project.name}</Td>
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
roles.length > 1 ? ` (+${roles.length - 1})` : ""
}`}</Td>
<Td>
{isAccessible && (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Remove">
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeUserFromProject", {
username: user.username,
projectId: project.id,
projectName: project.name
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</Td>
</Tr>
);
};
@@ -0,0 +1,98 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal, IconButton } from "@app/components/v2";
import { useOrganization, useUser } from "@app/context";
import { useDeleteUserFromWorkspace, useGetOrgMembership } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { UserAddToProjectModal } from "./UserAddToProjectModal";
import { UserProjectsTable } from "./UserProjectsTable";
type Props = {
membershipId: string;
};
export const UserProjectsSection = ({ membershipId }: Props) => {
const { user } = useUser();
const { currentOrg } = useOrganization();
const userId = user?.id || "";
const orgId = currentOrg?.id || "";
const { data: membership } = useGetOrgMembership(orgId, membershipId);
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addUserToProject",
"removeUserFromProject"
] as const);
const handleRemoveUser = async (projectId: string, username: string) => {
try {
await removeUserFromWorkspace({ workspaceId: projectId, usernames: [username], orgId });
createNotification({
text: "Successfully removed user from project",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove user from the project",
type: "error"
});
}
handlePopUpClose("removeUserFromProject");
};
return membership ? (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Projects</h3>
{userId !== membership.user.id && membership.status !== "invited" && (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addUserToProject", {
username: membership.user.username
});
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</div>
<div className="py-4">
<UserProjectsTable membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
</div>
<UserAddToProjectModal
membershipId={membershipId}
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.removeUserFromProject.isOpen}
deleteKey="remove"
title={`Do you want to remove this user from ${
(popUp?.removeUserFromProject?.data as { projectName: string })?.projectName || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeUserFromProject", isOpen)}
onDeleteApproved={() => {
const popupData = popUp?.removeUserFromProject?.data as {
username: string;
projectId: string;
projectName: string;
};
return handleRemoveUser(popupData.projectId, popupData.username);
}}
/>
</div>
) : (
<div />
);
};
@@ -0,0 +1,62 @@
import { faFolder } from "@fortawesome/free-solid-svg-icons";
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { UserProjectRow } from "./UserProjectRow";
type Props = {
membershipId: string;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void;
};
export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { data: projectMemberships, isLoading } = useGetOrgMembershipProjectMemberships(
orgId,
membershipId
);
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Role</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
{!isLoading &&
projectMemberships?.map((membership) => {
return (
<UserProjectRow
key={`user-project-membership-${membership.id}`}
membership={membership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{!isLoading && !projectMemberships?.length && (
<EmptyState title="This user has not been assigned to any projects" icon={faFolder} />
)}
</TableContainer>
);
};
@@ -0,0 +1 @@
export { UserProjectsSection } from "./UserProjectsSection";
@@ -0,0 +1,3 @@
export { UserDetailsSection } from "./UserDetailsSection";
export { UserOrgMembershipModal } from "./UserOrgMembershipModal";
export { UserProjectsSection } from "./UserProjectsSection";
@@ -0,0 +1 @@
export { UserPage } from "./UserPage";
@@ -6,14 +6,15 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button,FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useAddUserToWsE2EE,
useAddUserToWsNonE2EE,
useGetOrgUsers,
useGetUserWsKey,
useGetWorkspaceUsers} from "@app/hooks/api";
useGetWorkspaceUsers
} from "@app/hooks/api";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -76,7 +77,8 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
} else if (currentWorkspace.version === ProjectVersion.V2) {
await addUserToWorkspaceNonE2EE({
projectId: workspaceId,
usernames: [orgUser.user.username]
usernames: [orgUser.user.username],
orgId
});
} else {
createNotification({
@@ -4,7 +4,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub , useOrganization, useWorkspace } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteUserFromWorkspace } from "@app/hooks/api";
@@ -30,7 +35,11 @@ export const MembersSection = () => {
if (!currentWorkspace?.id) return;
try {
await removeUserFromWorkspace({ workspaceId: currentWorkspace.id, usernames: [username] });
await removeUserFromWorkspace({
workspaceId: currentWorkspace.id,
usernames: [username],
orgId: currentOrg.id
});
createNotification({
text: "Successfully removed user from project",
type: "success"
@@ -3,25 +3,31 @@ import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { Divider } from "@app/components/v2/Divider";
import { Badge } from "@app/components/v2/Badge";
import { useWorkspace } from "@app/context";
import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api";
import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList";
import { AccessApprovalRequest } from "./components/AccessApprovalRequest";
import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList";
import { ApprovalPolicyList } from "./components/ApprovalPolicyList";
import { SecretApprovalRequest } from "./components/SecretApprovalRequest";
enum TabSection {
SecretApprovalRequests = "approval-requests",
SecretPolicies = "approval-rules",
ResourcePolicies = "resource-rules",
ResourceApprovalRequests = "resource-requests"
ResourceApprovalRequests = "resource-requests",
Policies = "policies"
}
export const SecretApprovalPage = () => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || "";
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId: projectId });
const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug });
const defaultTab = (accessApprovalRequestCount?.pendingCount || 0) > (secretApprovalReqCount?.open || 0)
? TabSection.ResourceApprovalRequests
: TabSection.SecretApprovalRequests;
return (
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 px-6 text-white">
@@ -45,25 +51,26 @@ export const SecretApprovalPage = () => {
</Link>
</div>
</div>
<Tabs defaultValue={TabSection.SecretApprovalRequests}>
<Tabs defaultValue={defaultTab}>
<TabList>
<Tab value={TabSection.SecretApprovalRequests}>Secret Requests</Tab>
<Tab value={TabSection.SecretPolicies}>Secret Policies</Tab>
<Divider />
<Tab value={TabSection.ResourceApprovalRequests}>Access Requests</Tab>
<Tab value={TabSection.ResourcePolicies}>Access Request Policies</Tab>
<Tab value={TabSection.SecretApprovalRequests}>
Secret Requests
{Boolean(secretApprovalReqCount?.open) && (<Badge className="ml-2">{secretApprovalReqCount?.open}</Badge>)}
</Tab>
<Tab value={TabSection.ResourceApprovalRequests}>
Access Requests
{Boolean(accessApprovalRequestCount?.pendingCount) && <Badge className="ml-2">{accessApprovalRequestCount?.pendingCount}</Badge>}
</Tab>
<Tab value={TabSection.Policies}>Policies</Tab>
</TabList>
<TabPanel value={TabSection.SecretPolicies}>
<SecretApprovalPolicyList workspaceId={projectId} />
</TabPanel>
<TabPanel value={TabSection.SecretApprovalRequests}>
<SecretApprovalRequest />
</TabPanel>
<TabPanel value={TabSection.ResourceApprovalRequests}>
<AccessApprovalRequest projectId={projectId} projectSlug={projectSlug} />
</TabPanel>
<TabPanel value={TabSection.ResourcePolicies}>
<AccessApprovalPolicyList workspaceId={projectId} />
<TabPanel value={TabSection.Policies}>
<ApprovalPolicyList workspaceId={projectId} />
</TabPanel>
</Tabs>
</div>
@@ -1,174 +0,0 @@
import { faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr,
UpgradePlanModal
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteAccessApprovalPolicy, useGetWorkspaceUsers } from "@app/hooks/api";
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
import { AccessApprovalPolicyRow } from "./components/AccessApprovalPolicyRow";
import { AccessPolicyForm } from "./components/AccessPolicyModal";
interface IProps {
workspaceId: string;
}
export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => {
const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([
"secretPolicyForm",
"deletePolicy",
"upgradePlan"
] as const);
const { permission } = useProjectPermission();
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const { data: members } = useGetWorkspaceUsers(workspaceId);
const { data: policies, isLoading: isPoliciesLoading } = useGetAccessApprovalPolicies({
projectSlug: currentWorkspace?.slug as string,
options: {
enabled:
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
!!currentWorkspace?.slug
}
});
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteAccessApprovalPolicy();
const handleDeletePolicy = async () => {
const { id } = popUp.deletePolicy.data as TAccessApprovalPolicy;
if (!currentWorkspace?.slug) return;
try {
await deleteSecretApprovalPolicy({
projectSlug: currentWorkspace?.slug,
id
});
createNotification({
type: "success",
text: "Successfully deleted policy"
});
handlePopUpClose("deletePolicy");
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to delete policy"
});
}
};
return (
<div>
<div className="mb-6 flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl font-semibold text-mineshaft-100">Access Request Policies</span>
<div className="mt-2 text-sm text-bunker-300">
Implement secret request policies for specific secrets and environments.
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("secretPolicyForm");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Create policy
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th>Eligible Approvers</Th>
<Th>Approval Required</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPoliciesLoading && (
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" />
)}
{!isPoliciesLoading && !policies?.length && (
<Tr>
<Td colSpan={6}>
<EmptyState title="No policies found" icon={faFileShield} />
</Td>
</Tr>
)}
{!!currentWorkspace &&
policies?.map((policy) => (
<AccessApprovalPolicyRow
projectSlug={currentWorkspace.slug}
policy={policy}
key={policy.id}
members={members}
onEdit={() => handlePopUpOpen("secretPolicyForm", policy)}
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
/>
))}
</TBody>
</Table>
</TableContainer>
<AccessPolicyForm
projectSlug={currentWorkspace?.slug!}
isOpen={popUp.secretPolicyForm.isOpen}
onToggle={(isOpen) => handlePopUpToggle("secretPolicyForm", isOpen)}
members={members}
editValues={popUp.secretPolicyForm.data as TAccessApprovalPolicy}
/>
<DeleteActionModal
isOpen={popUp.deletePolicy.isOpen}
deleteKey="remove"
title="Do you want to remove this policy?"
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
onDeleteApproved={handleDeletePolicy}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
/>
</div>
);
};
@@ -1,146 +0,0 @@
import { useState } from "react";
import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
IconButton,
Input,
Td,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { useUpdateAccessApprovalPolicy } from "@app/hooks/api";
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
import { TWorkspaceUser } from "@app/hooks/api/users/types";
type Props = {
policy: TAccessApprovalPolicy;
members?: TWorkspaceUser[];
projectSlug: string;
onEdit: () => void;
onDelete: () => void;
};
export const AccessApprovalPolicyRow = ({
policy,
members = [],
projectSlug,
onEdit,
onDelete
}: Props) => {
const [selectedApprovers, setSelectedApprovers] = useState<string[]>([]);
const { mutate: updateAccessApprovalPolicy, isLoading } = useUpdateAccessApprovalPolicy();
const { permission } = useProjectPermission();
return (
<Tr>
<Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td>
<DropdownMenu
onOpenChange={(isOpen) => {
if (!isOpen) {
updateAccessApprovalPolicy(
{
projectSlug,
id: policy.id,
approvers: selectedApprovers
},
{
onSettled: () => {
setSelectedApprovers([]);
}
}
);
} else {
setSelectedApprovers(policy.approvers);
}
}}
>
<DropdownMenuTrigger
asChild
disabled={
isLoading ||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval)
}
>
<Input
isReadOnly
value={policy.approvers?.length ? `${policy.approvers.length} selected` : "None"}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select members that are allowed to approve changes
</DropdownMenuLabel>
{members?.map(({ id, user }) => {
const isChecked = selectedApprovers.includes(id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
setSelectedApprovers((state) =>
isChecked ? state.filter((el) => el !== id) : [...state, id]
);
}}
key={`create-policy-members-${id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.username}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</Td>
<Td>{policy.approvals}</Td>
<Td>
<div className="flex items-center justify-end space-x-4">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton variant="plain" ariaLabel="edit" onClick={onEdit} isDisabled={!isAllowed}>
<FontAwesomeIcon icon={faPencil} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretApproval}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="danger"
size="lg"
ariaLabel="edit"
onClick={onDelete}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
};
@@ -1,266 +0,0 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
useCreateAccessApprovalPolicy,
useUpdateAccessApprovalPolicy
} from "@app/hooks/api/accessApproval";
import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types";
import { TWorkspaceUser } from "@app/hooks/api/users/types";
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
members?: TWorkspaceUser[];
projectSlug: string;
editValues?: TAccessApprovalPolicy;
};
const formSchema = z
.object({
environment: z.string(),
name: z.string().optional(),
secretPath: z.string().optional(),
approvals: z.number().min(1),
approvers: z.string().array().min(1)
})
.refine((data) => data.approvals <= data.approvers.length, {
path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers."
});
type TFormSchema = z.infer<typeof formSchema>;
export const AccessPolicyForm = ({
isOpen,
onToggle,
members = [],
projectSlug,
editValues
}: Props) => {
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined
});
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
useEffect(() => {
if (!isOpen) reset({});
}, [isOpen]);
const isEditMode = Boolean(editValues);
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
const handleCreatePolicy = async (data: TFormSchema) => {
if (!projectSlug) return;
try {
await createAccessApprovalPolicy({
...data,
projectSlug
});
createNotification({
type: "success",
text: "Successfully created policy"
});
onToggle(false);
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to create policy"
});
}
};
const handleUpdatePolicy = async (data: TFormSchema) => {
if (!projectSlug) return;
if (!editValues?.id) return;
try {
await updateAccessApprovalPolicy({
id: editValues?.id,
...data,
projectSlug
});
createNotification({
type: "success",
text: "Successfully updated policy"
});
onToggle(false);
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "failed to update policy"
});
}
};
const handleFormSubmit = async (data: TFormSchema) => {
if (isEditMode) {
await handleUpdatePolicy(data);
} else {
await handleCreatePolicy(data);
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent title={isEditMode ? "Edit Access Policy" : "Create Access Policy"}>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Policy Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
className="mt-4"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
isDisabled={isEditMode}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
>
{environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`azure-key-vault-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
render={({ field, fieldState: { error } }) => (
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="approvers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Approvers Required"
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value?.length ? `${value.length} selected` : "None"}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select members that are allowed to approve changes
</DropdownMenuLabel>
{members.map(({ id, user }) => {
const isChecked = value?.includes(id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked ? value?.filter((el) => el !== id) : [...(value || []), id]
);
}}
key={`create-policy-members-${id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.username}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="number"
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save
</Button>
<Button onClick={() => onToggle(false)} variant="outline_bg">
Close
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -1 +0,0 @@
export { AccessApprovalPolicyList } from "./AccessApprovalPolicyList";
@@ -40,6 +40,7 @@ import {
useGetAccessRequestsCount
} from "@app/hooks/api/accessApproval/queries";
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { queryClient } from "@app/reactQuery";
@@ -80,7 +81,12 @@ export const AccessApprovalRequest = ({
projectId: string;
}) => {
const [selectedRequest, setSelectedRequest] = useState<
(TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null }) | null
(TAccessApprovalRequest & {
user: TWorkspaceUser["user"] | null;
isRequestedByCurrentUser: boolean;
isApprover: boolean;
})
| null
>(null);
const { handlePopUpOpen, popUp, handlePopUpClose } = usePopUp([
@@ -141,6 +147,8 @@ export const AccessApprovalRequest = ({
);
const isApprover = request.policy.approvers.indexOf(membership.id || "") !== -1;
const isAccepted = request.isApproved;
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const isRequestedByCurrentUser = request.requestedBy === membership.id;
const userReviewStatus = request.reviewers.find(
({ member }) => member === membership.id
@@ -178,7 +186,9 @@ export const AccessApprovalRequest = ({
isRejectedByAnyone,
isApprover,
userReviewStatus,
isAccepted
isAccepted,
isSoftEnforcement,
isRequestedByCurrentUser
};
};
@@ -331,16 +341,24 @@ export const AccessApprovalRequest = ({
tabIndex={0}
onClick={() => {
if (
!details.isApprover ||
details.isReviewedByUser ||
details.isRejectedByAnyone ||
details.isAccepted
(
!details.isApprover
|| details.isReviewedByUser
|| details.isRejectedByAnyone
|| details.isAccepted
) && !(
details.isSoftEnforcement
&& details.isRequestedByCurrentUser
&& !details.isAccepted
)
)
return;
setSelectedRequest({
...request,
user: membersGroupById?.[request.requestedBy].user!
user: membersGroupById?.[request.requestedBy].user!,
isRequestedByCurrentUser: details.isRequestedByCurrentUser,
isApprover: details.isApprover
});
handlePopUpOpen("reviewRequest");
}}
@@ -355,7 +373,9 @@ export const AccessApprovalRequest = ({
if (evt.key === "Enter") {
setSelectedRequest({
...request,
user: membersGroupById?.[request.requestedBy].user!
user: membersGroupById?.[request.requestedBy].user!,
isRequestedByCurrentUser: details.isRequestedByCurrentUser,
isApprover: details.isApprover
});
handlePopUpOpen("reviewRequest");
}
@@ -2,11 +2,12 @@ import { useCallback, useMemo, useState } from "react";
import ms from "ms";
import { createNotification } from "@app/components/notifications";
import { Button, Modal, ModalContent } from "@app/components/v2";
import { Button, Checkbox, Modal, ModalContent } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions } from "@app/context";
import { useReviewAccessRequest } from "@app/hooks/api";
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
import { TWorkspaceUser } from "@app/hooks/api/types";
export const ReviewAccessRequestModal = ({
@@ -19,12 +20,18 @@ export const ReviewAccessRequestModal = ({
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
request: TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null };
request: TAccessApprovalRequest & {
user: TWorkspaceUser["user"] | null;
isRequestedByCurrentUser: boolean;
isApprover: boolean;
};
projectSlug: string;
selectedRequester: string | undefined;
selectedEnvSlug: string | undefined;
}) => {
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
const [byPassApproval, setByPassApproval] = useState(false);
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const accessDetails = {
env: request.environmentName,
@@ -134,10 +141,14 @@ export const ReviewAccessRequestModal = ({
<div className="space-x-2">
<Button
isLoading={isLoading === "approved"}
isDisabled={!!isLoading}
isDisabled={
!!isLoading ||
(!request.isApprover && !byPassApproval && isSoftEnforcement)
}
onClick={() => handleReview("approved")}
className="mt-4"
size="sm"
colorSchema={!request.isApprover && isSoftEnforcement ? "danger" : "primary"}
>
Approve Request
</Button>
@@ -151,6 +162,21 @@ export const ReviewAccessRequestModal = ({
Reject Request
</Button>
</div>
{isSoftEnforcement && request.isRequestedByCurrentUser && !request.isApprover && (
<div className="mt-4">
<Checkbox
onCheckedChange={(checked) => setByPassApproval(checked === true)}
isChecked={byPassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={byPassApproval ? "bg-red hover:bg-red-600 border-red" : ""}
>
<span className="text-red text-sm">
Approve without waiting for requirements to be met (bypass policy protection)
</span>
</Checkbox>
</div>
)}
</div>
</ModalContent>
</Modal>
@@ -0,0 +1,262 @@
import { useMemo,useState } from "react";
import { faCheckCircle,faChevronDown, faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr,
UpgradePlanModal
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
TProjectPermission,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteAccessApprovalPolicy, useDeleteSecretApprovalPolicy, useGetSecretApprovalPolicies, useGetWorkspaceUsers } from "@app/hooks/api";
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
import { PolicyType } from "@app/hooks/api/policies/enums";
import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types";
import { AccessPolicyForm } from "./components/AccessPolicyModal";
import { ApprovalPolicyRow } from "./components/ApprovalPolicyRow";
interface IProps {
workspaceId: string;
}
const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => {
const { data: accessPolicies, isLoading: isAccessPoliciesLoading } = useGetAccessApprovalPolicies({
projectSlug: currentWorkspace?.slug as string,
options: {
enabled:
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
!!currentWorkspace?.slug
}
});
const { data: secretPolicies, isLoading: isSecretPoliciesLoading } = useGetSecretApprovalPolicies({
workspaceId: currentWorkspace?.id as string,
options: {
enabled:
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
!!currentWorkspace?.id
}
});
// merge data sorted by updatedAt
const policies = [
...(accessPolicies?.map(policy => ({ ...policy, policyType: PolicyType.AccessPolicy })) || []),
...(secretPolicies?.map(policy => ({ ...policy, policyType: PolicyType.ChangePolicy })) || [])
].sort((a, b) => {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
return {
policies,
isLoading: isAccessPoliciesLoading || isSecretPoliciesLoading
};
};
export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([
"policyForm",
"deletePolicy",
"upgradePlan"
] as const);
const { permission } = useProjectPermission();
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const { data: members } = useGetWorkspaceUsers(workspaceId);
const { policies, isLoading: isPoliciesLoading } = useApprovalPolicies(permission, currentWorkspace);
const [filterType, setFilterType] = useState<string | null>(null);
const filteredPolicies = useMemo(() => {
return filterType
? policies.filter(policy => policy.policyType === filterType)
: policies;
}, [policies, filterType]);
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy();
const handleDeletePolicy = async () => {
const { id, policyType } = popUp.deletePolicy.data as TAccessApprovalPolicy;
if (!currentWorkspace?.slug) return;
try {
if (policyType === PolicyType.ChangePolicy) {
await deleteSecretApprovalPolicy({
workspaceId,
id
});
} else {
await deleteAccessApprovalPolicy({
projectSlug: currentWorkspace?.slug,
id
});
}
createNotification({
type: "success",
text: "Successfully deleted policy"
});
handlePopUpClose("deletePolicy");
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to delete policy"
});
}
};
return (
<div>
<div className="mb-6 flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl font-semibold text-mineshaft-100">Policies</span>
<div className="mt-2 text-sm text-bunker-300">
Implement granular policies for access requests and secrets management.
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("policyForm");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Create policy
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th>Eligible Approvers</Th>
<Th>Approval Required</Th>
<Th>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className="text-bunker-300 uppercase text-xs font-semibold"
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
>
Type
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select a type</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setFilterType(null)}
icon={!filterType && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
All
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setFilterType(PolicyType.AccessPolicy)}
icon={filterType === PolicyType.AccessPolicy && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
Access Policy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setFilterType(PolicyType.ChangePolicy)}
icon={filterType === PolicyType.ChangePolicy && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
Change Policy
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPoliciesLoading && (
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" />
)}
{!isPoliciesLoading && !filteredPolicies?.length && (
<Tr>
<Td colSpan={6}>
<EmptyState title="No policies found" icon={faFileShield} />
</Td>
</Tr>
)}
{!!currentWorkspace &&
filteredPolicies?.map((policy) => (
<ApprovalPolicyRow
projectSlug={currentWorkspace.slug}
policy={policy}
workspaceId={workspaceId}
key={policy.id}
members={members}
onEdit={() => handlePopUpOpen("policyForm", policy)}
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
/>
))}
</TBody>
</Table>
</TableContainer>
<AccessPolicyForm
projectSlug={currentWorkspace?.slug!}
isOpen={popUp.policyForm.isOpen}
onToggle={(isOpen) => handlePopUpToggle("policyForm", isOpen)}
members={members}
editValues={popUp.policyForm.data as TAccessApprovalPolicy}
/>
<DeleteActionModal
isOpen={popUp.deletePolicy.isOpen}
deleteKey="remove"
title="Do you want to remove this policy?"
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
onDeleteApproved={handleDeletePolicy}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
/>
</div>
);
};
@@ -0,0 +1,369 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { policyDetails } from "@app/helpers/policies";
import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
import {
useCreateAccessApprovalPolicy,
useUpdateAccessApprovalPolicy
} from "@app/hooks/api/accessApproval";
import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types";
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
import { TWorkspaceUser } from "@app/hooks/api/users/types";
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
members?: TWorkspaceUser[];
projectSlug: string;
editValues?: TAccessApprovalPolicy;
};
const formSchema = z
.object({
environment: z.string(),
name: z.string().optional(),
secretPath: z.string().optional(),
approvals: z.number().min(1),
approvers: z.string().array().min(1),
policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel)
})
.refine((data) => data.approvals <= data.approvers.length, {
path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers."
});
type TFormSchema = z.infer<typeof formSchema>;
export const AccessPolicyForm = ({
isOpen,
onToggle,
members = [],
projectSlug,
editValues
}: Props) => {
const {
control,
handleSubmit,
reset,
watch,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
values: editValues ? {
...editValues,
environment: editValues.environment.slug,
approvers: editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers
} : undefined
});
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues);
useEffect(() => {
if (!isOpen || !isEditMode) reset({});
}, [isOpen, isEditMode]);
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy();
const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy();
const policyName = policyDetails[watch("policyType")]?.name || "Policy";
const handleCreatePolicy = async (data: TFormSchema) => {
if (!projectSlug) return;
try {
if (data.policyType === PolicyType.ChangePolicy) {
await createSecretApprovalPolicy({
...data,
workspaceId: currentWorkspace?.id || ""
});
} else {
await createAccessApprovalPolicy({
...data,
projectSlug
});
}
createNotification({
type: "success",
text: "Successfully created policy"
});
onToggle(false);
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to create policy"
});
}
};
const handleUpdatePolicy = async (data: TFormSchema) => {
if (!projectSlug) return;
if (!editValues?.id) return;
try {
if (data.policyType === PolicyType.ChangePolicy) {
await updateSecretApprovalPolicy({
id: editValues?.id,
...data,
workspaceId: currentWorkspace?.id || ""
});
} else {
await updateAccessApprovalPolicy({
id: editValues?.id,
...data,
projectSlug
});
}
createNotification({
type: "success",
text: "Successfully updated policy"
});
onToggle(false);
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "failed to update policy"
});
}
};
const handleFormSubmit = async (data: TFormSchema) => {
if (isEditMode) {
await handleUpdatePolicy(data);
} else {
await handleCreatePolicy(data);
}
};
const formatEnforcementLevel = (level: EnforcementLevel) => {
if (level === EnforcementLevel.Hard) return "Hard";
if (level === EnforcementLevel.Soft) return "Soft";
return level;
};
return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent title={isEditMode ? `Edit ${policyName}` : "Create Policy"}>
<div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller
control={control}
name="policyType"
defaultValue={PolicyType.ChangePolicy}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Policy Type"
isRequired
isError={Boolean(error)}
tooltipText="Change polices govern secret changes within a given environment and secret path. Access polices allow underprivileged user to request access to environment/secret path."
errorText={error?.message}
>
<Select
isDisabled={isEditMode}
value={value}
onValueChange={(val) => onChange(val as PolicyType)}
className="w-full border border-mineshaft-500"
>
{Object.values(PolicyType).map((policyType) => {
return (
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
{policyDetails[policyType].name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Policy Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
defaultValue={environments[0]?.slug}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
className="mt-4"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
isDisabled={isEditMode}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
>
{environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`azure-key-vault-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
render={({ field, fieldState: { error } }) => (
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="approvers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Required Approvers"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value?.length ? `${value.length} selected` : "None"}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select members that are allowed to approve requests
</DropdownMenuLabel>
{members.map(({ id, user }) => {
const userId = watch("policyType") === PolicyType.ChangePolicy ? user.id : id;
const isChecked = value?.includes(userId);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked ? value?.filter((el: string) => el !== userId) : [...(value || []), userId]
);
}}
key={`create-policy-members-${userId}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.username}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Minimum Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="number"
min={1}
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Enforcement Level"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="Determines the level of enforcement for required approvers of a request"
helperText={
field.value === EnforcementLevel.Hard
? "All approvers must approve the request."
: "All approvers must approve the request; however, the requester can bypass approval requirements in emergencies."
}
>
<Select
value={field.value}
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
className="w-full border border-mineshaft-500"
>
{Object.values(EnforcementLevel).map((level) => {
return (
<SelectItem value={level} key={`enforcement-level-${level}`} className="text-xs">
{formatEnforcementLevel(level)}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save
</Button>
<Button onClick={() => onToggle(false)} variant="outline_bg">
Close
</Button>
</div>
</form>
</div>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,200 @@
import { useState } from "react";
import { faCheckCircle, faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
Input,
Td,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { policyDetails } from "@app/helpers/policies";
import { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { TWorkspaceUser } from "@app/hooks/api/users/types";
interface IPolicy {
id: string;
name: string;
environment: WorkspaceEnv;
projectId?: string;
secretPath?: string;
approvals: number;
approvers?: string[];
userApprovers?: { userId: string }[];
updatedAt: Date;
policyType: PolicyType;
enforcementLevel: EnforcementLevel;
};
type Props = {
policy: IPolicy;
members?: TWorkspaceUser[];
projectSlug: string;
workspaceId: string;
onEdit: () => void;
onDelete: () => void;
};
export const ApprovalPolicyRow = ({
policy,
members = [],
projectSlug,
workspaceId,
onEdit,
onDelete
}: Props) => {
const [selectedApprovers, setSelectedApprovers] = useState<string[]>(policy.userApprovers?.map(({ userId }) => userId) || policy.approvers || []);
const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy();
const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy();
const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading;
const { permission } = useProjectPermission();
return (
<Tr>
<Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td>
<DropdownMenu
onOpenChange={(isOpen) => {
if (!isOpen) {
if (policy.policyType === PolicyType.AccessPolicy) {
updateAccessApprovalPolicy(
{
projectSlug,
id: policy.id,
approvers: selectedApprovers
},
{ onSettled: () => {} }
);
} else {
updateSecretApprovalPolicy(
{
workspaceId,
id: policy.id,
approvers: selectedApprovers
},
{ onSettled: () => {} }
);
}
} else {
setSelectedApprovers(policy.policyType === PolicyType.ChangePolicy
? policy?.userApprovers?.map(({ userId }) => userId) || []
: policy?.approvers || []
);
}
}}
>
<DropdownMenuTrigger
asChild
disabled={
isLoading ||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval)
}
>
<Input
isReadOnly
value={selectedApprovers.length ? `${selectedApprovers.length} selected` : "None"}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select members that are allowed to approve changes
</DropdownMenuLabel>
{members?.map(({ id, user }) => {
const userId = policy.policyType === PolicyType.ChangePolicy ? user.id : id;
const isChecked = selectedApprovers.includes(userId);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
setSelectedApprovers((state) =>
isChecked ? state.filter((el) => el !== userId) : [...state, userId]
);
}}
key={`create-policy-members-${userId}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.username}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</Td>
<Td>{policy.approvals}</Td>
<Td>
<Badge className={policyDetails[policy.policyType].className}>
{policyDetails[policy.policyType].name}
</Badge>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg cursor-pointer">
<div className="flex justify-center items-center hover:text-primary-400 data-[state=open]:text-primary-400 hover:scale-125 data-[state=open]:scale-125 transition-transform duration-300 ease-in-out">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="p-1 min-w-[100%]">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
disabled={!isAllowed}
>
Edit Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
disabled={!isAllowed}
>
Delete Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
};
@@ -0,0 +1 @@
export { ApprovalPolicyList } from "./ApprovalPolicyList";
@@ -1,178 +0,0 @@
import { faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
EmptyState,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr,
UpgradePlanModal
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useDeleteSecretApprovalPolicy,
useGetSecretApprovalPolicies,
useGetWorkspaceUsers
} from "@app/hooks/api";
import { TSecretApprovalPolicy } from "@app/hooks/api/types";
import { SecretApprovalPolicyRow } from "./components/SecretApprovalPolicyRow";
import { SecretPolicyForm } from "./components/SecretPolicyForm";
type Props = {
workspaceId: string;
};
export const SecretApprovalPolicyList = ({ workspaceId }: Props) => {
const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([
"secretPolicyForm",
"deletePolicy",
"upgradePlan"
] as const);
const { permission } = useProjectPermission();
const { subscription } = useSubscription();
const { data: members } = useGetWorkspaceUsers(workspaceId);
const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({
workspaceId,
options: {
enabled: permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval)
}
});
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
const handleDeletePolicy = async () => {
const { id } = popUp.deletePolicy.data as TSecretApprovalPolicy;
try {
await deleteSecretApprovalPolicy({
workspaceId,
id
});
createNotification({
type: "success",
text: "Successfully deleted policy"
});
handlePopUpClose("deletePolicy");
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to delete policy"
});
}
};
return (
<div>
<div className="mb-6 flex justify-between">
<div className="flex flex-col">
<span className="text-xl font-semibold text-mineshaft-100">Approval Policies</span>
<div className="mt-2 text-sm text-bunker-300">
Implement policies to prevent unauthorized secret changes.
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("secretPolicyForm");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Create policy
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th>Eligible Approvers</Th>
<Th>Approval Required</Th>
</Tr>
</THead>
<TBody>
{isPoliciesLoading && (
<TableSkeleton columns={4} innerKey="secret-policies" className="bg-mineshaft-700" />
)}
{!isPoliciesLoading && !policies?.length && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No policies found" icon={faFileShield} />
</Td>
</Tr>
)}
{policies?.map((policy) => (
<SecretApprovalPolicyRow
workspaceId={workspaceId}
policy={policy}
key={policy.id}
members={members}
onEdit={() => handlePopUpOpen("secretPolicyForm", policy)}
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
/>
))}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp.secretPolicyForm.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("secretPolicyForm", isOpen)}
>
<ModalContent title={popUp.secretPolicyForm.data ? "Edit policy" : "Create policy"}>
<SecretPolicyForm
workspaceId={workspaceId}
isOpen={popUp.secretPolicyForm.isOpen}
onToggle={(isOpen) => handlePopUpToggle("secretPolicyForm", isOpen)}
members={members}
editValues={popUp.secretPolicyForm.data as TSecretApprovalPolicy}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deletePolicy.isOpen}
deleteKey="remove"
title="Do you want to remove this polciy?"
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
onDeleteApproved={handleDeletePolicy}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
/>
</div>
);
};
@@ -1,148 +0,0 @@
import { useState } from "react";
import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
IconButton,
Input,
Td,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { useUpdateSecretApprovalPolicy } from "@app/hooks/api";
import { TSecretApprovalPolicy } from "@app/hooks/api/types";
import { TWorkspaceUser } from "@app/hooks/api/users/types";
type Props = {
policy: TSecretApprovalPolicy;
members?: TWorkspaceUser[];
workspaceId: string;
onEdit: () => void;
onDelete: () => void;
};
export const SecretApprovalPolicyRow = ({
policy,
members = [],
workspaceId,
onEdit,
onDelete
}: Props) => {
const [selectedApprovers, setSelectedApprovers] = useState<string[]>([]);
const { mutate: updateSecretApprovalPolicy, isLoading } = useUpdateSecretApprovalPolicy();
const { permission } = useProjectPermission();
return (
<Tr>
<Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td>
<DropdownMenu
onOpenChange={(isOpen) => {
if (!isOpen) {
updateSecretApprovalPolicy(
{
workspaceId,
id: policy.id,
approverUserIds: selectedApprovers
},
{
onSettled: () => {
setSelectedApprovers([]);
}
}
);
} else {
setSelectedApprovers(policy.userApprovers.map(({ userId }) => userId));
}
}}
>
<DropdownMenuTrigger
asChild
disabled={
isLoading ||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval)
}
>
<Input
isReadOnly
value={
policy?.userApprovers.length ? `${policy.userApprovers.length} selected` : "None"
}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select members that are allowed to approve changes
</DropdownMenuLabel>
{members?.map(({ user }) => {
const isChecked = selectedApprovers.includes(user.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
setSelectedApprovers((state) =>
isChecked ? state.filter((el) => el !== user.id) : [...state, user.id]
);
}}
key={`create-policy-members-${user.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.username}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</Td>
<Td>{policy.approvals}</Td>
<Td>
<div className="flex items-center justify-end space-x-4">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton variant="plain" ariaLabel="edit" onClick={onEdit} isDisabled={!isAllowed}>
<FontAwesomeIcon icon={faPencil} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretApproval}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="danger"
size="lg"
ariaLabel="edit"
onClick={onDelete}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
};
@@ -1,262 +0,0 @@
import { Controller, useForm } from "react-hook-form";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
FormControl,
Input,
Select,
SelectItem
} from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useWorkspace } from "@app/context";
import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
import { TSecretApprovalPolicy } from "@app/hooks/api/types";
import { TWorkspaceUser } from "@app/hooks/api/users/types";
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
members?: TWorkspaceUser[];
workspaceId: string;
editValues?: TSecretApprovalPolicy;
};
const formSchema = z
.object({
environment: z.string(),
name: z.string().optional(),
secretPath: z.string().optional().nullable(),
approvals: z.number().min(1),
approverUserIds: z.string().array().min(1)
})
.refine((data) => data.approvals <= data.approverUserIds.length, {
path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers."
});
type TFormSchema = z.infer<typeof formSchema>;
export const SecretPolicyForm = ({
onToggle,
members = [],
workspaceId,
editValues
}: Props) => {
const {
control,
handleSubmit,
watch,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
values: editValues
? {
...editValues,
approverUserIds: editValues.userApprovers.map(({ userId }) => userId),
environment: editValues.environment.slug
}
: undefined
});
const { currentWorkspace } = useWorkspace();
const selectedEnvironment = watch("environment");
const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues);
const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy();
const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy();
const handleCreatePolicy = async (data: TFormSchema) => {
try {
await createSecretApprovalPolicy({
...data,
workspaceId
});
createNotification({
type: "success",
text: "Successfully created policy"
});
onToggle(false);
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to create policy"
});
}
};
const handleUpdatePolicy = async (data: TFormSchema) => {
if (!editValues?.id) return;
try {
await updateSecretApprovalPolicy({
id: editValues?.id,
...data,
secretPath: data.secretPath || null,
workspaceId
});
createNotification({
type: "success",
text: "Successfully updated policy"
});
onToggle(false);
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "failed to update policy"
});
}
};
const handleFormSubmit = async (data: TFormSchema) => {
if (isEditMode) {
await handleUpdatePolicy(data);
} else {
await handleCreatePolicy(data);
}
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Policy Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
className="mt-4"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
isDisabled={isEditMode}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
>
{environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`azure-key-vault-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
render={({ field, fieldState: { error } }) => (
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
<SecretPathInput
{...field}
value={field.value || ""}
environment={selectedEnvironment}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="approverUserIds"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Approvers Required"
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value?.length ? `${value.length} selected` : "None"}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select members that are allowed to approve changes
</DropdownMenuLabel>
{members.map(({ user }) => {
const isChecked = value?.includes(user.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el) => el !== user.id)
: [...(value || []), user.id]
);
}}
key={`create-policy-members-${user.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.username}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="number"
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save
</Button>
<Button onClick={() => onToggle(false)} variant="outline_bg">
Close
</Button>
</div>
</form>
);
};
@@ -1 +0,0 @@
export { SecretApprovalPolicyList } from "./SecretApprovalPolicyList";
@@ -1,20 +1,22 @@
import React, { useState } from "react";
import {
faCheck,
faClose,
faLandMineOn,
faLockOpen,
faSquareCheck,
faSquareXmark,
faUserLock
} from "@fortawesome/free-solid-svg-icons";
faUserLock} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { Button, Checkbox } from "@app/components/v2";
import {
usePerformSecretApprovalRequestMerge,
useUpdateSecretApprovalRequestStatus
} from "@app/hooks/api";
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
type Props = {
approvalRequestId: string;
@@ -25,6 +27,7 @@ type Props = {
canApprove?: boolean;
statusChangeByEmail?: string;
workspaceId: string;
enforcementLevel: EnforcementLevel;
};
export const SecretApprovalRequestAction = ({
@@ -34,7 +37,8 @@ export const SecretApprovalRequestAction = ({
isMergable,
approvals,
statusChangeByEmail,
workspaceId,
workspaceId,
enforcementLevel,
canApprove
}: Props) => {
const { mutateAsync: performSecretApprovalMerge, isLoading: isMerging } =
@@ -43,6 +47,8 @@ export const SecretApprovalRequestAction = ({
const { mutateAsync: updateSecretStatusChange, isLoading: isStatusChanging } =
useUpdateSecretApprovalRequestStatus();
const [byPassApproval, setByPassApproval] = useState(false);
const handleSecretApprovalRequestMerge = async () => {
try {
await performSecretApprovalMerge({
@@ -82,6 +88,8 @@ export const SecretApprovalRequestAction = ({
}
};
const isSoftEnforcement = enforcementLevel === EnforcementLevel.Soft;
if (!hasMerged && status === "open") {
return (
<div className="flex w-full items-center justify-between">
@@ -96,10 +104,25 @@ export const SecretApprovalRequestAction = ({
At least {approvals} approving review required
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
</span>
{!canApprove && isSoftEnforcement && (
<div className="mt-1">
<Checkbox
onCheckedChange={(checked) => setByPassApproval(checked === true)}
isChecked={byPassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={byPassApproval ? "bg-red hover:bg-red-600 border-red" : ""}
>
<span className="text-red text-sm">
Merge without waiting for approval (bypass secret change policy)
</span>
</Checkbox>
</div>
)}
</span>
</div>
<div className="flex items-center space-x-2">
{canApprove ? (
{canApprove || isSoftEnforcement ? (
<>
<Button
onClick={() => handleSecretApprovalStatusChange("close")}
@@ -111,11 +134,14 @@ export const SecretApprovalRequestAction = ({
Close request
</Button>
<Button
leftIcon={<FontAwesomeIcon icon={faCheck} />}
isDisabled={!isMergable}
leftIcon={<FontAwesomeIcon icon={!canApprove ? faLandMineOn : faCheck} />}
isDisabled={
(!isMergable && canApprove)
|| (!canApprove && isSoftEnforcement && !byPassApproval)
}
isLoading={isMerging}
onClick={handleSecretApprovalRequestMerge}
colorSchema="primary"
colorSchema={isSoftEnforcement && !canApprove ? "danger" : "primary"}
variant="solid"
>
Merge
@@ -252,6 +252,7 @@ export const SecretApprovalRequestChanges = ({
status={secretApprovalRequestDetails.status}
isMergable={isMergable}
statusChangeByEmail={secretApprovalRequestDetails.statusChangedByUser?.email}
enforcementLevel={secretApprovalRequestDetails.policy.enforcementLevel}
workspaceId={workspaceId}
/>
</div>