mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 19:44:52 +00:00
Fix merge conflict
This commit is contained in:
@@ -17,23 +17,20 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-end space-x-12 rounded-md bg-mineshaft-800 p-16 text-bunker-300",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faLock} size="6x" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-4xl font-medium">Access Restricted</div>
|
||||
{children || (
|
||||
<div className="text-sm">
|
||||
Your role has limited permissions, please <br /> contact your administrator to gain
|
||||
access
|
||||
</div>
|
||||
)}
|
||||
<div className={twMerge("rounded-md bg-mineshaft-800 p-16 text-bunker-300", className)}>
|
||||
<div className="flex items-end space-x-12">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faLock} size="6x" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-4xl font-medium">Access Restricted</div>
|
||||
{children || (
|
||||
<div className="text-sm">
|
||||
Your role has limited permissions, please <br /> contact your administrator to gain
|
||||
access
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cva, VariantProps } from "cva";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface IProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const badgeVariants = cva(
|
||||
[
|
||||
"inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-xs text-yellow opacity-80 hover:opacity-100"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-yellow/20 text-yellow",
|
||||
danger: "bg-red/20 text-red",
|
||||
success: "bg-green/20 text-green"
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export type BadgeProps = VariantProps<typeof badgeVariants> & IProps;
|
||||
|
||||
export const Badge = ({ children, className, variant }: BadgeProps) => {
|
||||
return (
|
||||
<div className={twMerge(badgeVariants({ variant: variant || "primary" }), className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Badge } from "./Badge";
|
||||
@@ -29,7 +29,7 @@ const buttonVariants = cva(
|
||||
colorSchema: {
|
||||
primary: ["bg-primary", "text-black", "border-primary bg-opacity-90 hover:bg-opacity-100"],
|
||||
secondary: ["bg-mineshaft", "text-gray-300", "border-mineshaft hover:bg-opacity-80"],
|
||||
danger: ["bg-red", "text-white", "border-red hover:bg-opacity-90"],
|
||||
danger: ["!bg-red", "!text-white", "!border-red hover:!bg-opacity-90"],
|
||||
gray: ["bg-bunker-500", "text-bunker-200"]
|
||||
},
|
||||
variant: {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Divider = ({ className }: IProps): JSX.Element => {
|
||||
return (
|
||||
<div className={twMerge("flex items-center px-2 opacity-50", className)}>
|
||||
<div aria-hidden="true" className="h-5 w-full grow border border-t border-mineshaft-200" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Divider } from "./Divider";
|
||||
@@ -41,18 +41,22 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
`inline-flex items-center justify-between rounded-md
|
||||
bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200 focus:bg-mineshaft-700/80`,
|
||||
className
|
||||
bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none focus:bg-mineshaft-700/80 data-[placeholder]:text-mineshaft-200`,
|
||||
className,
|
||||
isDisabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value placeholder={placeholder}>
|
||||
{props.icon ? <FontAwesomeIcon icon={props.icon} /> : placeholder}
|
||||
</SelectPrimitive.Value>
|
||||
{!isDisabled && (
|
||||
<SelectPrimitive.Icon className="ml-3">
|
||||
<FontAwesomeIcon icon={faCaretDown} size="sm" />
|
||||
</SelectPrimitive.Icon>
|
||||
)}
|
||||
|
||||
<SelectPrimitive.Icon className="ml-3">
|
||||
<FontAwesomeIcon
|
||||
icon={faCaretDown}
|
||||
size="sm"
|
||||
className={twMerge(isDisabled && "opacity-30")}
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
useCreateAccessApprovalPolicy,
|
||||
useCreateAccessRequest,
|
||||
useDeleteAccessApprovalPolicy,
|
||||
useReviewAccessRequest,
|
||||
useUpdateAccessApprovalPolicy
|
||||
} from "./mutation";
|
||||
export {
|
||||
useGetAccessApprovalPolicies,
|
||||
useGetAccessApprovalRequests,
|
||||
useGetAccessPolicyApprovalCount,
|
||||
useGetAccessRequestsCount
|
||||
} from "./queries";
|
||||
@@ -0,0 +1,123 @@
|
||||
import { packRules } from "@casl/ability/extra";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { accessApprovalKeys } from "./queries";
|
||||
import {
|
||||
TAccessApproval,
|
||||
TCreateAccessPolicyDTO,
|
||||
TCreateAccessRequestDTO,
|
||||
TDeleteSecretPolicyDTO,
|
||||
TUpdateAccessPolicyDTO
|
||||
} from "./types";
|
||||
|
||||
export const useCreateAccessApprovalPolicy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, TCreateAccessPolicyDTO>({
|
||||
mutationFn: async ({ environment, projectSlug, approvals, approvers, name, secretPath }) => {
|
||||
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
|
||||
environment,
|
||||
projectSlug,
|
||||
approvals,
|
||||
approvers,
|
||||
secretPath,
|
||||
name
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateAccessApprovalPolicy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, TUpdateAccessPolicyDTO>({
|
||||
mutationFn: async ({ id, approvers, approvals, name, secretPath }) => {
|
||||
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
|
||||
approvals,
|
||||
approvers,
|
||||
secretPath,
|
||||
name
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteAccessApprovalPolicy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, TDeleteSecretPolicyDTO>({
|
||||
mutationFn: async ({ id }) => {
|
||||
const { data } = await apiRequest.delete(`/api/v1/access-approvals/policies/${id}`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateAccessRequest = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<{}, {}, TCreateAccessRequestDTO>({
|
||||
mutationFn: async ({ projectSlug, ...request }) => {
|
||||
const { data } = await apiRequest.post<TAccessApproval>(
|
||||
"/api/v1/access-approvals/requests",
|
||||
{
|
||||
...request,
|
||||
permissions: request.permissions ? packRules(request.permissions) : undefined
|
||||
},
|
||||
{
|
||||
params: {
|
||||
projectSlug
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequestCount(projectSlug));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useReviewAccessRequest = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
{},
|
||||
{},
|
||||
{
|
||||
requestId: string;
|
||||
status: "approved" | "rejected";
|
||||
projectSlug: string;
|
||||
envSlug?: string;
|
||||
requestedBy?: string;
|
||||
}
|
||||
>({
|
||||
mutationFn: async ({ requestId, status }) => {
|
||||
const { data } = await apiRequest.post(
|
||||
`/api/v1/access-approvals/requests/${requestId}/review`,
|
||||
{
|
||||
status
|
||||
}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectSlug, envSlug, requestedBy }) => {
|
||||
queryClient.invalidateQueries(
|
||||
accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, requestedBy)
|
||||
);
|
||||
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequestCount(projectSlug));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { PackRule, unpackRules } from "@casl/ability/extra";
|
||||
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TProjectPermission } from "../roles/types";
|
||||
import {
|
||||
TAccessApprovalPolicy,
|
||||
TAccessApprovalRequest,
|
||||
TAccessRequestCount,
|
||||
TGetAccessApprovalRequestsDTO,
|
||||
TGetAccessPolicyApprovalCountDTO
|
||||
} from "./types";
|
||||
|
||||
export const accessApprovalKeys = {
|
||||
getAccessApprovalPolicies: (projectSlug: string) =>
|
||||
[{ projectSlug }, "access-approval-policies"] as const,
|
||||
getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) =>
|
||||
[{ workspaceId, environment }, "access-approval-policy"] as const,
|
||||
|
||||
getAccessApprovalRequests: (projectSlug: string, envSlug?: string, requestedBy?: string) =>
|
||||
[{ projectSlug, envSlug, requestedBy }, "access-approvals-requests"] as const,
|
||||
getAccessApprovalRequestCount: (projectSlug: string) =>
|
||||
[{ projectSlug }, "access-approval-request-count"] as const
|
||||
};
|
||||
|
||||
export const fetchPolicyApprovalCount = async ({
|
||||
projectSlug,
|
||||
envSlug
|
||||
}: TGetAccessPolicyApprovalCountDTO) => {
|
||||
const { data } = await apiRequest.get<{ count: number }>(
|
||||
"/api/v1/access-approvals/policies/count",
|
||||
{
|
||||
params: { projectSlug, envSlug }
|
||||
}
|
||||
);
|
||||
return data.count;
|
||||
};
|
||||
|
||||
export const useGetAccessPolicyApprovalCount = ({
|
||||
projectSlug,
|
||||
envSlug,
|
||||
options = {}
|
||||
}: TGetAccessPolicyApprovalCountDTO & {
|
||||
options?: UseQueryOptions<
|
||||
number,
|
||||
unknown,
|
||||
number,
|
||||
ReturnType<typeof accessApprovalKeys.getAccessApprovalPolicies>
|
||||
>;
|
||||
}) =>
|
||||
useQuery({
|
||||
queryFn: () => fetchPolicyApprovalCount({ projectSlug, envSlug }),
|
||||
...options,
|
||||
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
|
||||
});
|
||||
|
||||
const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequestsDTO) => {
|
||||
const { data } = await apiRequest.get<{ approvals: TAccessApprovalPolicy[] }>(
|
||||
"/api/v1/access-approvals/policies",
|
||||
{ params: { projectSlug } }
|
||||
);
|
||||
return data.approvals;
|
||||
};
|
||||
|
||||
const fetchApprovalRequests = async ({
|
||||
projectSlug,
|
||||
envSlug,
|
||||
authorProjectMembershipId
|
||||
}: TGetAccessApprovalRequestsDTO) => {
|
||||
const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>(
|
||||
"/api/v1/access-approvals/requests",
|
||||
{ params: { projectSlug, envSlug, authorProjectMembershipId } }
|
||||
);
|
||||
|
||||
return data.requests.map((request) => ({
|
||||
...request,
|
||||
|
||||
privilege: request.privilege
|
||||
? {
|
||||
...request.privilege,
|
||||
permissions: unpackRules(
|
||||
request.privilege.permissions as unknown as PackRule<TProjectPermission>[]
|
||||
)
|
||||
}
|
||||
: null,
|
||||
permissions: unpackRules(request.permissions as unknown as PackRule<TProjectPermission>[])
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchAccessRequestsCount = async (projectSlug: string) => {
|
||||
const { data } = await apiRequest.get<TAccessRequestCount>(
|
||||
"/api/v1/access-approvals/requests/count",
|
||||
{ params: { projectSlug } }
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const useGetAccessRequestsCount = ({
|
||||
projectSlug,
|
||||
options = {}
|
||||
}: TGetAccessApprovalRequestsDTO & {
|
||||
options?: UseQueryOptions<
|
||||
TAccessRequestCount,
|
||||
unknown,
|
||||
{ pendingCount: number; finalizedCount: number },
|
||||
ReturnType<typeof accessApprovalKeys.getAccessApprovalRequestCount>
|
||||
>;
|
||||
}) =>
|
||||
useQuery({
|
||||
queryKey: accessApprovalKeys.getAccessApprovalRequestCount(projectSlug),
|
||||
queryFn: () => fetchAccessRequestsCount(projectSlug),
|
||||
...options,
|
||||
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
|
||||
});
|
||||
|
||||
export const useGetAccessApprovalPolicies = ({
|
||||
projectSlug,
|
||||
envSlug,
|
||||
authorProjectMembershipId,
|
||||
options = {}
|
||||
}: TGetAccessApprovalRequestsDTO & {
|
||||
options?: UseQueryOptions<
|
||||
TAccessApprovalPolicy[],
|
||||
unknown,
|
||||
TAccessApprovalPolicy[],
|
||||
ReturnType<typeof accessApprovalKeys.getAccessApprovalPolicies>
|
||||
>;
|
||||
}) =>
|
||||
useQuery({
|
||||
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug),
|
||||
queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorProjectMembershipId }),
|
||||
...options,
|
||||
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
|
||||
});
|
||||
|
||||
export const useGetAccessApprovalRequests = ({
|
||||
projectSlug,
|
||||
envSlug,
|
||||
authorProjectMembershipId,
|
||||
options = {}
|
||||
}: TGetAccessApprovalRequestsDTO & {
|
||||
options?: UseQueryOptions<
|
||||
TAccessApprovalRequest[],
|
||||
unknown,
|
||||
TAccessApprovalRequest[],
|
||||
ReturnType<typeof accessApprovalKeys.getAccessApprovalRequests>
|
||||
>;
|
||||
}) =>
|
||||
useQuery({
|
||||
queryKey: accessApprovalKeys.getAccessApprovalRequests(
|
||||
projectSlug,
|
||||
envSlug,
|
||||
authorProjectMembershipId
|
||||
),
|
||||
queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorProjectMembershipId }),
|
||||
...options,
|
||||
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { TProjectPermission } from "../roles/types";
|
||||
import { WorkspaceEnv } from "../workspace/types";
|
||||
|
||||
export type TAccessApprovalPolicy = {
|
||||
id: string;
|
||||
name: string;
|
||||
approvals: number;
|
||||
secretPath: string;
|
||||
envId: string;
|
||||
workspace: string;
|
||||
environment: WorkspaceEnv;
|
||||
projectId: string;
|
||||
approvers: string[];
|
||||
};
|
||||
|
||||
export type TAccessApprovalRequest = {
|
||||
id: string;
|
||||
policyId: string;
|
||||
privilegeId: string | null;
|
||||
requestedBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
isTemporary: boolean;
|
||||
temporaryRange: string | null | undefined;
|
||||
|
||||
permissions: TProjectPermission[] | null;
|
||||
|
||||
// Computed
|
||||
environmentName: string;
|
||||
isApproved: boolean;
|
||||
|
||||
privilege: {
|
||||
membershipId: string;
|
||||
isTemporary: boolean;
|
||||
temporaryMode?: string | null;
|
||||
temporaryRange?: string | null;
|
||||
temporaryAccessStartTime?: Date | null;
|
||||
temporaryAccessEndTime?: Date | null;
|
||||
permissions: TProjectPermission[];
|
||||
isApproved: boolean;
|
||||
} | null;
|
||||
|
||||
policy: {
|
||||
id: string;
|
||||
name: string;
|
||||
approvals: number;
|
||||
approvers: string[];
|
||||
secretPath?: string | null;
|
||||
envId: string;
|
||||
};
|
||||
|
||||
reviewers: {
|
||||
member: string;
|
||||
status: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TAccessApproval = {
|
||||
id: string;
|
||||
policyId: string;
|
||||
privilegeId: string;
|
||||
requestedBy: string;
|
||||
};
|
||||
|
||||
export type TAccessRequestCount = {
|
||||
pendingCount: number;
|
||||
finalizedCount: number;
|
||||
};
|
||||
|
||||
export type TProjectUserPrivilege = {
|
||||
projectMembershipId: string;
|
||||
slug: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
permissions?: TProjectPermission[];
|
||||
} & (
|
||||
| {
|
||||
isTemporary: true;
|
||||
temporaryMode: string;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
temporaryAccessEndTime?: string;
|
||||
}
|
||||
| {
|
||||
isTemporary: false;
|
||||
temporaryMode?: null;
|
||||
temporaryRange?: null;
|
||||
temporaryAccessStartTime?: null;
|
||||
temporaryAccessEndTime?: null;
|
||||
}
|
||||
);
|
||||
|
||||
export type TCreateAccessRequestDTO = {
|
||||
projectSlug: string;
|
||||
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">;
|
||||
|
||||
export type TGetAccessApprovalRequestsDTO = {
|
||||
projectSlug: string;
|
||||
envSlug?: string;
|
||||
authorProjectMembershipId?: string;
|
||||
};
|
||||
|
||||
export type TGetAccessPolicyApprovalCountDTO = {
|
||||
projectSlug: string;
|
||||
envSlug: string;
|
||||
};
|
||||
|
||||
export type TGetSecretApprovalPolicyOfBoardDTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export type TCreateAccessPolicyDTO = {
|
||||
projectSlug: string;
|
||||
name?: string;
|
||||
environment: string;
|
||||
approvers?: string[];
|
||||
approvals?: number;
|
||||
secretPath?: string;
|
||||
};
|
||||
|
||||
export type TUpdateAccessPolicyDTO = {
|
||||
id: string;
|
||||
name?: string;
|
||||
approvers?: string[];
|
||||
secretPath?: string;
|
||||
environment?: string;
|
||||
approvals?: number;
|
||||
// for invalidating list
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export type TDeleteSecretPolicyDTO = {
|
||||
id: string;
|
||||
// for invalidating list
|
||||
projectSlug: string;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./accessApproval";
|
||||
export * from "./admin";
|
||||
export * from "./apiKeys";
|
||||
export * from "./auditLogs";
|
||||
|
||||
@@ -94,7 +94,21 @@ export const useGetFoldersByEnv = ({
|
||||
[(folders || []).map((folder) => folder.data)]
|
||||
);
|
||||
|
||||
return { folders, folderNames, isFolderPresentInEnv };
|
||||
const getFolderByNameAndEnv = useCallback(
|
||||
(name: string, env: string) => {
|
||||
const selectedEnvIndex = environments.indexOf(env);
|
||||
if (selectedEnvIndex !== -1) {
|
||||
return folders?.[selectedEnvIndex]?.data?.find(
|
||||
({ name: folderName }) => folderName === name
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
[(folders || []).map((folder) => folder.data)]
|
||||
);
|
||||
|
||||
return { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv };
|
||||
};
|
||||
|
||||
export const useCreateFolder = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ZodIssue } from "zod";
|
||||
|
||||
export type { TAccessApprovalPolicy } from "./accessApproval/types";
|
||||
export type { TAuditLogStream } from "./auditLogStreams/types";
|
||||
export type { GetAuthTokenAPI } from "./auth/types";
|
||||
export type { IncidentContact } from "./incidentContacts/types";
|
||||
@@ -49,13 +50,13 @@ export enum ApiErrorTypes {
|
||||
|
||||
export type TApiErrors =
|
||||
| {
|
||||
error: ApiErrorTypes.ValidationError;
|
||||
message: ZodIssue[];
|
||||
statusCode: 403;
|
||||
}
|
||||
error: ApiErrorTypes.ValidationError;
|
||||
message: ZodIssue[];
|
||||
statusCode: 403;
|
||||
}
|
||||
| { error: ApiErrorTypes.ForbiddenError; message: string; statusCode: 401 }
|
||||
| {
|
||||
statusCode: 400;
|
||||
message: string;
|
||||
error: ApiErrorTypes.BadRequestError;
|
||||
};
|
||||
statusCode: 400;
|
||||
message: string;
|
||||
error: ApiErrorTypes.BadRequestError;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/* eslint-disable no-var */
|
||||
/* eslint-disable func-names */
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Image from "next/image";
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
fetchOrgUsers,
|
||||
useAddUserToWsNonE2EE,
|
||||
useCreateWorkspace,
|
||||
useGetAccessRequestsCount,
|
||||
useGetOrgTrialUrl,
|
||||
useGetSecretApprovalRequestCount,
|
||||
useGetUserAction,
|
||||
@@ -115,7 +116,7 @@ type TAddProjectFormData = yup.InferType<typeof formSchema>;
|
||||
|
||||
export const AppLayout = ({ children }: LayoutProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
const { mutateAsync } = useGetOrgTrialUrl();
|
||||
|
||||
const { workspaces, currentWorkspace } = useWorkspace();
|
||||
@@ -124,9 +125,15 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
const { user } = useUser();
|
||||
const { subscription } = useSubscription();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const projectSlug = currentWorkspace?.slug || "";
|
||||
const { data: updateClosed } = useGetUserAction("december_update_closed");
|
||||
|
||||
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId });
|
||||
const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug });
|
||||
|
||||
const pendingRequestsCount = useMemo(() => {
|
||||
return (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
|
||||
}, [secretApprovalReqCount, accessApprovalRequestCount]);
|
||||
|
||||
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
||||
? subscription.workspacesUsed < subscription.workspaceLimit
|
||||
@@ -554,10 +561,13 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
}
|
||||
icon="system-outline-189-domain-verification"
|
||||
>
|
||||
Secret Approvals
|
||||
{Boolean(secretApprovalReqCount?.open) && (
|
||||
Approvals
|
||||
{Boolean(
|
||||
secretApprovalReqCount?.open ||
|
||||
accessApprovalRequestCount?.pendingCount
|
||||
) && (
|
||||
<span className="ml-2 rounded border border-primary-400 bg-primary-600 py-0.5 px-1 text-xs font-semibold text-black">
|
||||
{secretApprovalReqCount?.open}
|
||||
{pendingRequestsCount}
|
||||
</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
|
||||
@@ -35,8 +35,6 @@ export default function LoginPage() {
|
||||
const selectOrg = useSelectOrganization();
|
||||
const { user, isLoading: userLoading } = useUser();
|
||||
|
||||
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const logout = useLogoutUser(true);
|
||||
@@ -153,7 +151,7 @@ export default function LoginPage() {
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-md text-center text-gray-500">
|
||||
You‘re currently logged in as <strong>{user.email}</strong>
|
||||
You‘re currently logged in as <strong>{user.username}</strong>
|
||||
</p>
|
||||
<p className="text-md text-center text-gray-500">
|
||||
Not you?{" "}
|
||||
|
||||
+228
-47
@@ -1,9 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
faArrowRotateLeft,
|
||||
faCaretDown,
|
||||
faCheck,
|
||||
faClock,
|
||||
faLockOpen,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -44,11 +46,13 @@ import {
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
TProjectUserPrivilege,
|
||||
useCreateAccessRequest,
|
||||
useCreateProjectUserAdditionalPrivilege,
|
||||
useDeleteProjectUserAdditionalPrivilege,
|
||||
useListProjectUserPrivileges,
|
||||
useUpdateProjectUserAdditionalPrivilege
|
||||
} from "@app/hooks/api";
|
||||
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
|
||||
|
||||
const secretPermissionSchema = z.object({
|
||||
secretPath: z.string().optional(),
|
||||
@@ -70,51 +74,105 @@ const secretPermissionSchema = z.object({
|
||||
])
|
||||
});
|
||||
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
|
||||
const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => {
|
||||
export const SpecificPrivilegeSecretForm = ({
|
||||
privilege,
|
||||
policies,
|
||||
onClose
|
||||
}: {
|
||||
privilege?: TProjectUserPrivilege;
|
||||
policies?: TAccessApprovalPolicy[];
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"deletePrivilege"
|
||||
"deletePrivilege",
|
||||
"requestAccess"
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Member
|
||||
);
|
||||
const isMemberEditDisabled =
|
||||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege;
|
||||
|
||||
const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege();
|
||||
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
|
||||
const requestAccess = useCreateAccessRequest();
|
||||
|
||||
const privilegeForm = useForm<TSecretPermissionForm>({
|
||||
resolver: zodResolver(secretPermissionSchema),
|
||||
values: {
|
||||
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
|
||||
// secret path will be inside $glob operator
|
||||
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
|
||||
read: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Read)
|
||||
),
|
||||
edit: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Edit)
|
||||
),
|
||||
create: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Create)
|
||||
),
|
||||
delete: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Delete)
|
||||
),
|
||||
// zod will pick it
|
||||
temporaryAccess: privilege
|
||||
...(privilege
|
||||
? {
|
||||
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
|
||||
// secret path will be inside $glob operator
|
||||
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
|
||||
read: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Read)
|
||||
),
|
||||
edit: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Edit)
|
||||
),
|
||||
create: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Create)
|
||||
),
|
||||
delete: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Delete)
|
||||
),
|
||||
// zod will pick it
|
||||
temporaryAccess: privilege
|
||||
}
|
||||
: {
|
||||
environmentSlug: currentWorkspace?.environments?.[0].slug!,
|
||||
read: false,
|
||||
edit: false,
|
||||
create: false,
|
||||
delete: false,
|
||||
temporaryAccess: {
|
||||
isTemporary: false
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
|
||||
const selectedEnvironmentSlug = privilegeForm.watch("environmentSlug");
|
||||
const selectedEnvironment = privilegeForm.watch("environmentSlug");
|
||||
const secretPath = privilegeForm.watch("secretPath");
|
||||
|
||||
const readAccess = privilegeForm.watch("read");
|
||||
const createAccess = privilegeForm.watch("create");
|
||||
const editAccess = privilegeForm.watch("edit");
|
||||
const deleteAccess = privilegeForm.watch("delete");
|
||||
|
||||
const accessSelected = readAccess || createAccess || editAccess || deleteAccess;
|
||||
|
||||
const selectablePaths = useMemo(() => {
|
||||
if (!policies) return [];
|
||||
const environmentPolicies = policies.filter(
|
||||
(policy) => policy.environment.slug === selectedEnvironment
|
||||
);
|
||||
|
||||
privilegeForm.setValue("secretPath", "", {
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
return [...environmentPolicies.map((policy) => policy.secretPath)];
|
||||
}, [policies, selectedEnvironment]);
|
||||
|
||||
const isTemporary = temporaryAccessField?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccessField.isTemporary &&
|
||||
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
|
||||
|
||||
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
|
||||
if (!privilege) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "No privilege to update found.",
|
||||
title: "Error"
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateUserPrivilege.isLoading) return;
|
||||
try {
|
||||
const actions = [
|
||||
@@ -152,6 +210,15 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
||||
};
|
||||
|
||||
const handleDeletePrivilege = async () => {
|
||||
if (!privilege) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "No privilege to delete found.",
|
||||
title: "Error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteUserPrivilege.isLoading) return;
|
||||
try {
|
||||
await deleteUserPrivilege.mutateAsync({
|
||||
@@ -170,35 +237,100 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
||||
}
|
||||
};
|
||||
|
||||
// This is used for requesting access additional privileges, not directly creating a privilege!
|
||||
const handleRequestAccess = async (data: TSecretPermissionForm) => {
|
||||
if (!policies) return;
|
||||
if (!currentWorkspace) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "No workspace found.",
|
||||
title: "Error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.secretPath) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Please select a secret path",
|
||||
title: "Error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{ action: ProjectPermissionActions.Read, allowed: data.read },
|
||||
{ action: ProjectPermissionActions.Create, allowed: data.create },
|
||||
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
|
||||
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
|
||||
];
|
||||
const conditions: Record<string, any> = { environment: data.environmentSlug };
|
||||
if (data.secretPath) {
|
||||
conditions.secretPath = { $glob: data.secretPath };
|
||||
}
|
||||
await requestAccess.mutateAsync({
|
||||
...data,
|
||||
...(data.temporaryAccess.isTemporary && {
|
||||
temporaryRange: data.temporaryAccess.temporaryRange
|
||||
}),
|
||||
projectSlug: currentWorkspace.slug,
|
||||
isTemporary: data.temporaryAccess.isTemporary,
|
||||
permissions: actions
|
||||
.filter(({ allowed }) => allowed)
|
||||
.map(({ action }) => ({
|
||||
action,
|
||||
subject: [ProjectPermissionSub.Secrets],
|
||||
conditions
|
||||
}))
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully requested access"
|
||||
});
|
||||
privilegeForm.reset();
|
||||
if (onClose) onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: TSecretPermissionForm) => {
|
||||
if (privilege) {
|
||||
handleUpdatePrivilege(data);
|
||||
} else {
|
||||
handleRequestAccess(data);
|
||||
}
|
||||
};
|
||||
|
||||
const getAccessLabel = (exactTime = false) => {
|
||||
if (isExpired) return "Access expired";
|
||||
if (!temporaryAccessField?.isTemporary) return "Permanent";
|
||||
if (exactTime)
|
||||
|
||||
if (exactTime && !policies) {
|
||||
return `Until ${format(
|
||||
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`;
|
||||
}
|
||||
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="mt-4 w-full">
|
||||
<form onSubmit={privilegeForm.handleSubmit(handleSubmit)}>
|
||||
<div className={twMerge("flex items-start gap-4", !privilege && "flex-wrap")}>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="environmentSlug"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormControl label="Env">
|
||||
<FormControl label="Environment">
|
||||
<Select
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className="bg-mineshaft-600 hover:bg-mineshaft-500"
|
||||
onValueChange={(e) => onChange(e)}
|
||||
>
|
||||
{currentWorkspace?.environments?.map(({ slug, id }) => (
|
||||
{currentWorkspace?.environments?.map(({ slug, id, name }) => (
|
||||
<SelectItem value={slug} key={id}>
|
||||
{slug}
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
@@ -208,16 +340,43 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="secretPath"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Secret Path">
|
||||
<SecretPathInput
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
containerClassName="w-48"
|
||||
environment={selectedEnvironmentSlug}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
render={({ field }) => {
|
||||
if (policies) {
|
||||
return (
|
||||
<Tooltip
|
||||
isDisabled={!!selectablePaths.length}
|
||||
content="The selected environment doesn't have any policies."
|
||||
>
|
||||
<div>
|
||||
<FormControl label="Secret Path">
|
||||
<Select
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled || !selectablePaths.length}
|
||||
className="w-48"
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
>
|
||||
{selectablePaths.map((path) => (
|
||||
<SelectItem value={path} key={path}>
|
||||
{path}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormControl label="Secret Path">
|
||||
<SecretPathInput
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
containerClassName="w-48"
|
||||
environment={selectedEnvironment}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-grow justify-between">
|
||||
<Controller
|
||||
@@ -285,7 +444,7 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-7 flex items-center space-x-2">
|
||||
<div className="mt-6 flex items-center space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||
<div>
|
||||
@@ -301,7 +460,7 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{getAccessLabel()}
|
||||
{getAccessLabel(false)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -362,8 +521,9 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccessField.isTemporary ? "Restart" : "Grant"}
|
||||
{temporaryAccessField.isTemporary && !policies ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
|
||||
{temporaryAccessField.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -375,14 +535,15 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{privilegeForm.formState.isDirty ? (
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{privilegeForm.formState.isDirty && privilege ? (
|
||||
<>
|
||||
<Tooltip content="Cancel" className="mr-4">
|
||||
<IconButton
|
||||
@@ -413,7 +574,8 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
) : // eslint-disable-next-line no-nested-ternary
|
||||
privilege ? (
|
||||
<Tooltip
|
||||
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
|
||||
className="mr-4"
|
||||
@@ -428,9 +590,28 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!!policies && (
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={privilegeForm.formState.isSubmitting || requestAccess.isLoading}
|
||||
isDisabled={
|
||||
isMemberEditDisabled ||
|
||||
!policies.length ||
|
||||
!privilegeForm.formState.isValid ||
|
||||
!secretPath ||
|
||||
!accessSelected
|
||||
}
|
||||
className="mt-4"
|
||||
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
|
||||
>
|
||||
Request access
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePrivilege.isOpen}
|
||||
|
||||
@@ -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 { useWorkspace } from "@app/context";
|
||||
|
||||
import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList";
|
||||
import { AccessApprovalRequest } from "./components/AccessApprovalRequest";
|
||||
import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList";
|
||||
import { SecretApprovalRequest } from "./components/SecretApprovalRequest";
|
||||
|
||||
enum TabSection {
|
||||
ApprovalRequests = "approval-requests",
|
||||
Rules = "approval-rules"
|
||||
SecretApprovalRequests = "approval-requests",
|
||||
SecretPolicies = "approval-rules",
|
||||
ResourcePolicies = "resource-rules",
|
||||
ResourceApprovalRequests = "resource-requests"
|
||||
}
|
||||
|
||||
export const SecretApprovalPage = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const projectSlug = currentWorkspace?.slug || "";
|
||||
|
||||
return (
|
||||
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 px-6 text-white">
|
||||
<div className="flex items-center justify-between py-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h2 className="text-3xl font-semibold text-gray-200">Secret Approval Workflows</h2>
|
||||
<h2 className="text-3xl font-semibold text-gray-200">Approval Workflows</h2>
|
||||
<p className="text-bunker-300">
|
||||
Create approval policies for any modifications to secrets in sensitive environments and
|
||||
folders.
|
||||
@@ -39,16 +45,25 @@ export const SecretApprovalPage = () => {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultValue={TabSection.ApprovalRequests}>
|
||||
<Tabs defaultValue={TabSection.SecretApprovalRequests}>
|
||||
<TabList>
|
||||
<Tab value={TabSection.ApprovalRequests}>Secret PRs</Tab>
|
||||
<Tab value={TabSection.Rules}>Policies</Tab>
|
||||
<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>
|
||||
</TabList>
|
||||
<TabPanel value={TabSection.ApprovalRequests}>
|
||||
<TabPanel value={TabSection.SecretPolicies}>
|
||||
<SecretApprovalPolicyList workspaceId={projectId} />
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSection.SecretApprovalRequests}>
|
||||
<SecretApprovalRequest />
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSection.Rules}>
|
||||
<SecretApprovalPolicyList workspaceId={workspaceId} />
|
||||
<TabPanel value={TabSection.ResourceApprovalRequests}>
|
||||
<AccessApprovalRequest projectId={projectId} projectSlug={projectSlug} />
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSection.ResourcePolicies}>
|
||||
<AccessApprovalPolicyList workspaceId={projectId} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { AccessApprovalPolicyList } from "./AccessApprovalPolicyList";
|
||||
+436
@@ -0,0 +1,436 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
faChevronDown,
|
||||
faLock,
|
||||
faPlus
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Tooltip,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetWorkspaceUsers } from "@app/hooks/api";
|
||||
import {
|
||||
accessApprovalKeys,
|
||||
useGetAccessApprovalPolicies,
|
||||
useGetAccessApprovalRequests,
|
||||
useGetAccessRequestsCount
|
||||
} from "@app/hooks/api/accessApproval/queries";
|
||||
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
|
||||
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { queryClient } from "@app/reactQuery";
|
||||
|
||||
import { RequestAccessModal } from "./components/RequestAccessModal";
|
||||
import { ReviewAccessRequestModal } from "./components/ReviewAccessModal";
|
||||
|
||||
const generateRequestText = (request: TAccessApprovalRequest, membershipId: string) => {
|
||||
const { isTemporary } = request;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between text-sm">
|
||||
<div>
|
||||
Requested {isTemporary ? "temporary" : "permanent"} access to{" "}
|
||||
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
|
||||
{request.policy.secretPath}
|
||||
</code>
|
||||
in
|
||||
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
|
||||
{request.environmentName}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
{request.requestedBy === membershipId && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<Badge className="ml-1">Requested By You</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccessApprovalRequest = ({
|
||||
projectSlug,
|
||||
projectId
|
||||
}: {
|
||||
projectSlug: string;
|
||||
projectId: string;
|
||||
}) => {
|
||||
const [selectedRequest, setSelectedRequest] = useState<
|
||||
(TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null }) | null
|
||||
>(null);
|
||||
|
||||
const { handlePopUpOpen, popUp, handlePopUpClose } = usePopUp([
|
||||
"requestAccess",
|
||||
"reviewRequest",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
const { membership, permission } = useProjectPermission();
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { data: members } = useGetWorkspaceUsers(projectId);
|
||||
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
|
||||
(prev, curr) => ({ ...prev, [curr.id]: curr }),
|
||||
{}
|
||||
);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<"open" | "close">("open");
|
||||
const [requestedByFilter, setRequestedByFilter] = useState<string | undefined>(undefined);
|
||||
const [envFilter, setEnvFilter] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data: requestCount } = useGetAccessRequestsCount({
|
||||
projectSlug
|
||||
});
|
||||
|
||||
const { data: policies, isLoading: policiesLoading } = useGetAccessApprovalPolicies({
|
||||
projectSlug
|
||||
});
|
||||
|
||||
const { data: requests } = useGetAccessApprovalRequests({
|
||||
projectSlug,
|
||||
authorProjectMembershipId: requestedByFilter,
|
||||
envSlug: envFilter
|
||||
});
|
||||
|
||||
const filteredRequests = useMemo(() => {
|
||||
if (statusFilter === "open")
|
||||
return requests?.filter(
|
||||
(request) =>
|
||||
!request.isApproved &&
|
||||
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||
);
|
||||
if (statusFilter === "close")
|
||||
return requests?.filter(
|
||||
(request) =>
|
||||
request.isApproved ||
|
||||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||
);
|
||||
|
||||
return requests;
|
||||
}, [requests, statusFilter, requestedByFilter, envFilter]);
|
||||
|
||||
const generateRequestDetails = (request: TAccessApprovalRequest) => {
|
||||
const isReviewedByUser =
|
||||
request.reviewers.findIndex(({ member }) => member === membership.id) !== -1;
|
||||
const isRejectedByAnyone = request.reviewers.some(
|
||||
({ status }) => status === ApprovalStatus.REJECTED
|
||||
);
|
||||
const isApprover = request.policy.approvers.indexOf(membership.id || "") !== -1;
|
||||
const isAccepted = request.isApproved;
|
||||
|
||||
const userReviewStatus = request.reviewers.find(
|
||||
({ member }) => member === membership.id
|
||||
)?.status;
|
||||
|
||||
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
|
||||
label: "",
|
||||
type: "primary"
|
||||
};
|
||||
|
||||
const isExpired =
|
||||
request.privilege &&
|
||||
request.isApproved &&
|
||||
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
|
||||
|
||||
if (isExpired) displayData = { label: "Access Expired", type: "danger" };
|
||||
else if (isAccepted) displayData = { label: "Access Granted", type: "success" };
|
||||
else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" };
|
||||
else if (userReviewStatus === ApprovalStatus.APPROVED) {
|
||||
displayData = {
|
||||
label: `Pending ${request.policy.approvals - request.reviewers.length} review${
|
||||
request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
|
||||
}`,
|
||||
type: "primary"
|
||||
};
|
||||
} else if (!isReviewedByUser)
|
||||
displayData = {
|
||||
label: "Review Required",
|
||||
type: "primary"
|
||||
};
|
||||
|
||||
return {
|
||||
displayData,
|
||||
isReviewedByUser,
|
||||
isRejectedByAnyone,
|
||||
isApprover,
|
||||
userReviewStatus,
|
||||
isAccepted
|
||||
};
|
||||
};
|
||||
|
||||
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 Requests</span>
|
||||
<div className="mt-2 text-sm text-bunker-300">
|
||||
Request access to secrets in sensitive environments and folders.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
content="To submit Access Requests, your project needs to create Access Request policies first."
|
||||
isDisabled={policiesLoading || !!policies?.length}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.secretApproval) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("requestAccess");
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={policiesLoading || !policies?.length}
|
||||
>
|
||||
Request access
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="approval-changes-list"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="rounded-md text-gray-300"
|
||||
>
|
||||
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("open")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("open");
|
||||
}}
|
||||
className={
|
||||
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||
{!!requestCount && requestCount?.pendingCount} Pending
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("close")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("close");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||
{!!requestCount && requestCount.finalizedCount} Completed
|
||||
</div>
|
||||
<div className="flex flex-grow justify-end space-x-8">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className="text-bunker-300"
|
||||
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
|
||||
>
|
||||
Environments
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
|
||||
{currentWorkspace?.environments.map(({ slug, name }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
|
||||
key={`request-filter-${slug}`}
|
||||
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
{name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={requestedByFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||
}
|
||||
>
|
||||
Requested By
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
||||
{members?.map(({ user, id }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setRequestedByFilter((state) => (state === id ? undefined : id))
|
||||
}
|
||||
key={`request-filter-member-${id}`}
|
||||
icon={requestedByFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
{user.username}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-b-md border-x border-t border-b border-mineshaft-600 bg-mineshaft-800">
|
||||
{filteredRequests?.length === 0 && (
|
||||
<div className="py-12">
|
||||
<EmptyState title="No more access requests pending." />
|
||||
</div>
|
||||
)}
|
||||
{!!filteredRequests?.length &&
|
||||
filteredRequests?.map((request) => {
|
||||
const details = generateRequestDetails(request);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-disabled={
|
||||
details.isReviewedByUser || details.isRejectedByAnyone || details.isAccepted
|
||||
}
|
||||
key={request.id}
|
||||
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (
|
||||
!details.isApprover ||
|
||||
details.isReviewedByUser ||
|
||||
details.isRejectedByAnyone ||
|
||||
details.isAccepted
|
||||
)
|
||||
return;
|
||||
|
||||
setSelectedRequest({
|
||||
...request,
|
||||
user: membersGroupById?.[request.requestedBy].user!
|
||||
});
|
||||
handlePopUpOpen("reviewRequest");
|
||||
}}
|
||||
onKeyDown={(evt) => {
|
||||
if (
|
||||
!details.isApprover ||
|
||||
details.isAccepted ||
|
||||
details.isReviewedByUser ||
|
||||
details.isRejectedByAnyone
|
||||
)
|
||||
return;
|
||||
if (evt.key === "Enter") {
|
||||
setSelectedRequest({
|
||||
...request,
|
||||
user: membersGroupById?.[request.requestedBy].user!
|
||||
});
|
||||
handlePopUpOpen("reviewRequest");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex w-full flex-col justify-between">
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||
{generateRequestText(request, membership.id)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500">
|
||||
{membersGroupById?.[request.requestedBy]?.user && (
|
||||
<>
|
||||
Requested {formatDistance(new Date(request.createdAt), new Date())}{" "}
|
||||
ago by {membersGroupById?.[request.requestedBy]?.user?.firstName}{" "}
|
||||
{membersGroupById?.[request.requestedBy]?.user?.lastName} (
|
||||
{membersGroupById?.[request.requestedBy]?.user?.email}){" "}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{details.isApprover && (
|
||||
<Badge variant={details.displayData.type}>
|
||||
{details.displayData.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{!!policies && (
|
||||
<RequestAccessModal
|
||||
policies={policies}
|
||||
isOpen={popUp.requestAccess.isOpen}
|
||||
onOpenChange={() => {
|
||||
queryClient.invalidateQueries(
|
||||
accessApprovalKeys.getAccessApprovalRequests(
|
||||
projectSlug,
|
||||
envFilter,
|
||||
requestedByFilter
|
||||
)
|
||||
);
|
||||
handlePopUpClose("requestAccess");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!selectedRequest && (
|
||||
<ReviewAccessRequestModal
|
||||
selectedEnvSlug={envFilter}
|
||||
selectedRequester={requestedByFilter}
|
||||
projectSlug={projectSlug}
|
||||
request={selectedRequest}
|
||||
isOpen={popUp.reviewRequest.isOpen}
|
||||
onOpenChange={() => {
|
||||
handlePopUpClose("reviewRequest");
|
||||
setSelectedRequest(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpgradePlanModal
|
||||
text="You need to upgrade your plan to access this feature"
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={() => handlePopUpClose("upgradePlan")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
|
||||
import { SpecificPrivilegeSecretForm } from "@app/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection";
|
||||
|
||||
export const RequestAccessModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
policies
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
policies: TAccessApprovalPolicy[];
|
||||
}) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-4xl"
|
||||
title="Request Access"
|
||||
subTitle="Your role has limited permissions, please contact your administrator to gain access"
|
||||
>
|
||||
<SpecificPrivilegeSecretForm onClose={() => onOpenChange(false)} policies={policies} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
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 { 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 { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
|
||||
export const ReviewAccessRequestModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
request,
|
||||
projectSlug,
|
||||
selectedRequester,
|
||||
selectedEnvSlug
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
request: TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null };
|
||||
projectSlug: string;
|
||||
selectedRequester: string | undefined;
|
||||
selectedEnvSlug: string | undefined;
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
|
||||
|
||||
const accessDetails = {
|
||||
env: request.environmentName,
|
||||
// secret path will be inside $glob operator
|
||||
secretPath: request.policy.secretPath,
|
||||
read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)),
|
||||
edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)),
|
||||
create: request.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Create)
|
||||
),
|
||||
delete: request.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Delete)
|
||||
),
|
||||
|
||||
temporaryAccess: {
|
||||
isTemporary: request.isTemporary,
|
||||
temporaryRange: request.temporaryRange
|
||||
}
|
||||
};
|
||||
|
||||
const requestedAccess = useMemo(() => {
|
||||
const access: string[] = [];
|
||||
if (accessDetails.read) access.push("Read");
|
||||
if (accessDetails.edit) access.push("Edit");
|
||||
if (accessDetails.create) access.push("Create");
|
||||
if (accessDetails.delete) access.push("Delete");
|
||||
|
||||
return access.join(", ");
|
||||
}, [accessDetails]);
|
||||
|
||||
const getAccessLabel = () => {
|
||||
if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange)
|
||||
return "Permanent";
|
||||
|
||||
// convert the range to human readable format
|
||||
ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true });
|
||||
|
||||
return (
|
||||
<Badge>
|
||||
{`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
|
||||
long: true
|
||||
})} after approval`}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const reviewAccessRequest = useReviewAccessRequest();
|
||||
|
||||
const handleReview = useCallback(async (status: "approved" | "rejected") => {
|
||||
setIsLoading(status);
|
||||
try {
|
||||
await reviewAccessRequest.mutateAsync({
|
||||
requestId: request.id,
|
||||
status,
|
||||
projectSlug,
|
||||
envSlug: selectedEnvSlug,
|
||||
requestedBy: selectedRequester
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setIsLoading(null);
|
||||
return;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
title: `Request ${status}`,
|
||||
text: `The request has been ${status}`,
|
||||
type: status === "approved" ? "success" : "info"
|
||||
});
|
||||
|
||||
setIsLoading(null);
|
||||
onOpenChange(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-4xl"
|
||||
title="Review Request"
|
||||
subTitle="Review the request and approve or deny access."
|
||||
>
|
||||
<div className="text-sm">
|
||||
<span>
|
||||
<span className="font-bold">
|
||||
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
|
||||
</span>{" "}
|
||||
is requesting access to the following resource:
|
||||
</span>
|
||||
|
||||
<div className="mt-4 mb-2 border-l border-blue-500 bg-blue-500/20 px-3 py-2 text-mineshaft-200">
|
||||
<div className="mb-1 lowercase">
|
||||
<span className="font-bold capitalize">Requested path: </span>
|
||||
<Badge>{accessDetails.env + accessDetails.secretPath || ""}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mb-1">
|
||||
<span className="font-bold">Permissions: </span>
|
||||
<Badge>{requestedAccess}</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-bold">Access Type: </span>
|
||||
<span>{getAccessLabel()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
isLoading={isLoading === "approved"}
|
||||
isDisabled={!!isLoading}
|
||||
onClick={() => handleReview("approved")}
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
>
|
||||
Approve Request
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading === "rejected"}
|
||||
isDisabled={!!isLoading}
|
||||
onClick={() => handleReview("rejected")}
|
||||
className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200"
|
||||
size="sm"
|
||||
>
|
||||
Reject Request
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { AccessApprovalRequest } from "./AccessApprovalRequest";
|
||||
-2
@@ -46,7 +46,6 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => {
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
|
||||
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
||||
const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({
|
||||
@@ -120,7 +119,6 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => {
|
||||
<Th>Secret Path</Th>
|
||||
<Th>Eligible Approvers</Th>
|
||||
<Th>Approval Required</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ export const SecretApprovalPolicyRow = ({
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{user.email}
|
||||
{user.username}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
+1
-1
@@ -222,7 +222,7 @@ export const SecretPolicyForm = ({
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{user.email}
|
||||
{user.username}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
+39
-26
@@ -19,7 +19,13 @@ import {
|
||||
EmptyState,
|
||||
Skeleton
|
||||
} from "@app/components/v2";
|
||||
import { useUser, useWorkspace } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useUser,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import {
|
||||
useGetSecretApprovalRequestCount,
|
||||
useGetSecretApprovalRequests,
|
||||
@@ -58,6 +64,7 @@ export const SecretApprovalRequest = () => {
|
||||
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
|
||||
useGetSecretApprovalRequestCount({ workspaceId });
|
||||
const { user: presentUser } = useUser();
|
||||
const { permission } = useProjectPermission();
|
||||
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
||||
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
|
||||
(prev, curr) => ({ ...prev, [curr.id]: curr }),
|
||||
@@ -156,34 +163,40 @@ export const SecretApprovalRequest = () => {
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={committerFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
|
||||
>
|
||||
Author
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
||||
{members?.map(({ user, id }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setCommitterFilter((state) => (state === id ? undefined : id))}
|
||||
key={`request-filter-member-${id}`}
|
||||
icon={committerFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={committerFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||
}
|
||||
>
|
||||
{user.email}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
Author
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
||||
{members?.map(({ user, id }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setCommitterFilter((state) => (state === id ? undefined : id))
|
||||
}
|
||||
key={`request-filter-member-${id}`}
|
||||
icon={committerFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
{user.username}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-b-md border-x border-t border-b border-mineshaft-600 border-mineshaft-600 bg-mineshaft-800">
|
||||
<div className="flex flex-col rounded-b-md border-x border-t border-b border-mineshaft-600 bg-mineshaft-800">
|
||||
{isRequestListEmpty && (
|
||||
<div className="py-12">
|
||||
<EmptyState title="No more requests pending." />
|
||||
|
||||
+2
-3
@@ -37,7 +37,6 @@ export const SecretApprovalRequestAction = ({
|
||||
workspaceId,
|
||||
canApprove
|
||||
}: Props) => {
|
||||
|
||||
const { mutateAsync: performSecretApprovalMerge, isLoading: isMerging } =
|
||||
usePerformSecretApprovalRequestMerge();
|
||||
|
||||
@@ -136,7 +135,7 @@ export const SecretApprovalRequestAction = ({
|
||||
<div className="flex items-start space-x-4">
|
||||
<FontAwesomeIcon icon={faCheck} className="pt-1 text-2xl text-primary" />
|
||||
<span className="flex flex-col">
|
||||
Change request merged
|
||||
Secret approval merged
|
||||
<span className="inline-block text-xs text-bunker-200">
|
||||
Merged by {statusChangeByEmail}
|
||||
</span>
|
||||
@@ -150,7 +149,7 @@ export const SecretApprovalRequestAction = ({
|
||||
<div className="flex items-start space-x-4">
|
||||
<FontAwesomeIcon icon={faUserLock} className="pt-1 text-2xl text-primary" />
|
||||
<span className="flex flex-col">
|
||||
Change request has been closed
|
||||
Secret approval has been closed
|
||||
<span className="inline-block text-xs text-bunker-200">
|
||||
Closed by {statusChangeByEmail}
|
||||
</span>
|
||||
|
||||
-2
@@ -83,7 +83,6 @@ export const SecretApprovalRequestChanges = ({
|
||||
workspaceId,
|
||||
members = {}
|
||||
}: Props) => {
|
||||
|
||||
const { user } = useUser();
|
||||
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
|
||||
const {
|
||||
@@ -94,7 +93,6 @@ export const SecretApprovalRequestChanges = ({
|
||||
id: approvalRequestId,
|
||||
decryptKey: decryptFileKey!
|
||||
});
|
||||
console.log(secretApprovalRequestDetails);
|
||||
|
||||
const {
|
||||
mutateAsync: updateSecretApprovalRequestStatus,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -70,6 +70,12 @@ import { ProjectIndexSecretsSection } from "./components/ProjectIndexSecretsSect
|
||||
import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow";
|
||||
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
|
||||
import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow";
|
||||
import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel";
|
||||
|
||||
export enum EntryType {
|
||||
FOLDER = "folder",
|
||||
SECRET = "secret"
|
||||
}
|
||||
|
||||
export const SecretOverviewPage = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -82,15 +88,6 @@ export const SecretOverviewPage = () => {
|
||||
const [expandableTableWidth, setExpandableTableWidth] = useState(0);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
useEffect(() => {
|
||||
const handleParentTableWidthResize = () => {
|
||||
setExpandableTableWidth(parentTableRef.current?.clientWidth || 0);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleParentTableWidthResize);
|
||||
return () => window.removeEventListener("resize", handleParentTableWidthResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (parentTableRef.current) {
|
||||
setExpandableTableWidth(parentTableRef.current.clientWidth);
|
||||
@@ -105,6 +102,56 @@ export const SecretOverviewPage = () => {
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const secretPath = (router.query?.secretPath as string) || "/";
|
||||
|
||||
const [selectedEntries, setSelectedEntries] = useState<{
|
||||
[EntryType.FOLDER]: Record<string, boolean>;
|
||||
[EntryType.SECRET]: Record<string, boolean>;
|
||||
}>({
|
||||
[EntryType.FOLDER]: {},
|
||||
[EntryType.SECRET]: {}
|
||||
});
|
||||
|
||||
const toggleSelectedEntry = useCallback(
|
||||
(type: EntryType, key: string) => {
|
||||
const isChecked = Boolean(selectedEntries[type]?.[key]);
|
||||
const newChecks = { ...selectedEntries };
|
||||
|
||||
// remove selection if its present else add it
|
||||
if (isChecked) {
|
||||
delete newChecks[type][key];
|
||||
} else {
|
||||
newChecks[type][key] = true;
|
||||
}
|
||||
|
||||
setSelectedEntries(newChecks);
|
||||
},
|
||||
[selectedEntries]
|
||||
);
|
||||
|
||||
const resetSelectedEntries = useCallback(() => {
|
||||
setSelectedEntries({
|
||||
[EntryType.FOLDER]: {},
|
||||
[EntryType.SECRET]: {}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleParentTableWidthResize = () => {
|
||||
setExpandableTableWidth(parentTableRef.current?.clientWidth || 0);
|
||||
};
|
||||
|
||||
const onRouteChangeStart = () => {
|
||||
resetSelectedEntries();
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", onRouteChangeStart);
|
||||
|
||||
window.addEventListener("resize", handleParentTableWidthResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleParentTableWidthResize);
|
||||
router.events.off("routeChangeStart", onRouteChangeStart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWorkspaceLoading && !workspaceId && router.isReady) {
|
||||
router.push(`/org/${currentOrg?.id}/overview`);
|
||||
@@ -129,7 +176,8 @@ export const SecretOverviewPage = () => {
|
||||
secretPath,
|
||||
decryptFileKey: latestFileKey!
|
||||
});
|
||||
const { folders, folderNames, isFolderPresentInEnv } = useGetFoldersByEnv({
|
||||
|
||||
const { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv } = useGetFoldersByEnv({
|
||||
projectId: workspaceId,
|
||||
path: secretPath,
|
||||
environments: userAvailableEnvs.map(({ slug }) => slug)
|
||||
@@ -543,6 +591,13 @@ export const SecretOverviewPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SelectionPanel
|
||||
secretPath={secretPath}
|
||||
getSecretByKey={getSecretByKey}
|
||||
getFolderByNameAndEnv={getFolderByNameAndEnv}
|
||||
selectedEntries={selectedEntries}
|
||||
resetSelectedEntries={resetSelectedEntries}
|
||||
/>
|
||||
<div className="thin-scrollbar mt-4" ref={parentTableRef}>
|
||||
<TableContainer className="max-h-[calc(100vh-250px)] overflow-y-auto">
|
||||
<Table>
|
||||
@@ -666,6 +721,8 @@ export const SecretOverviewPage = () => {
|
||||
<SecretOverviewFolderRow
|
||||
folderName={folderName}
|
||||
isFolderPresentInEnv={isFolderPresentInEnv}
|
||||
isSelected={selectedEntries.folder[folderName]}
|
||||
onToggleFolderSelect={() => toggleSelectedEntry(EntryType.FOLDER, folderName)}
|
||||
environments={visibleEnvs}
|
||||
key={`overview-${folderName}-${index + 1}`}
|
||||
onClick={handleFolderClick}
|
||||
@@ -684,6 +741,8 @@ export const SecretOverviewPage = () => {
|
||||
visibleEnvs?.length > 0 &&
|
||||
filteredSecretNames.map((key, index) => (
|
||||
<SecretOverviewTableRow
|
||||
isSelected={selectedEntries.secret[key]}
|
||||
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)}
|
||||
secretPath={secretPath}
|
||||
isImportedSecretPresentInEnv={isImportedSecretPresentInEnv}
|
||||
onSecretCreate={handleSecretCreate}
|
||||
|
||||
+20
-3
@@ -2,20 +2,23 @@ import { faCheck, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Td, Tr } from "@app/components/v2";
|
||||
import { Checkbox, Td, Tr } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
folderName: string;
|
||||
environments: { name: string; slug: string }[];
|
||||
isFolderPresentInEnv: (name: string, env: string) => boolean;
|
||||
onClick: (path: string) => void;
|
||||
isSelected: boolean;
|
||||
onToggleFolderSelect: (folderName: string) => void;
|
||||
};
|
||||
|
||||
export const SecretOverviewFolderRow = ({
|
||||
folderName,
|
||||
environments = [],
|
||||
isFolderPresentInEnv,
|
||||
|
||||
isSelected,
|
||||
onToggleFolderSelect,
|
||||
onClick
|
||||
}: Props) => {
|
||||
return (
|
||||
@@ -23,7 +26,21 @@ export const SecretOverviewFolderRow = ({
|
||||
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
|
||||
<div className="flex items-center space-x-5 border-r border-mineshaft-600 px-5 py-2.5">
|
||||
<div className="text-yellow-700">
|
||||
<FontAwesomeIcon icon={faFolder} />
|
||||
<Checkbox
|
||||
id={`checkbox-${folderName}`}
|
||||
isChecked={isSelected}
|
||||
onCheckedChange={() => {
|
||||
onToggleFolderSelect(folderName);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={twMerge("hidden group-hover:flex", isSelected && "flex")}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
className={twMerge("block group-hover:hidden", isSelected && "hidden")}
|
||||
icon={faFolder}
|
||||
/>
|
||||
</div>
|
||||
<div>{folderName}</div>
|
||||
</div>
|
||||
|
||||
+21
-3
@@ -11,7 +11,7 @@ import {
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Button, TableContainer, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||
|
||||
@@ -23,6 +23,8 @@ type Props = {
|
||||
secretPath: string;
|
||||
environments: { name: string; slug: string }[];
|
||||
expandableColWidth: number;
|
||||
isSelected: boolean;
|
||||
onToggleSecretSelect: (key: string) => void;
|
||||
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
|
||||
onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
|
||||
onSecretUpdate: (env: string, key: string, value: string, secretId?: string) => Promise<void>;
|
||||
@@ -39,7 +41,9 @@ export const SecretOverviewTableRow = ({
|
||||
onSecretCreate,
|
||||
onSecretDelete,
|
||||
isImportedSecretPresentInEnv,
|
||||
expandableColWidth
|
||||
expandableColWidth,
|
||||
onToggleSecretSelect,
|
||||
isSelected
|
||||
}: Props) => {
|
||||
const [isFormExpanded, setIsFormExpanded] = useToggle();
|
||||
const totalCols = environments.length + 1; // secret key row
|
||||
@@ -56,7 +60,21 @@ export const SecretOverviewTableRow = ({
|
||||
<div className="h-full w-full border-r border-mineshaft-600 py-2.5 px-5">
|
||||
<div className="flex items-center space-x-5">
|
||||
<div className="text-blue-300/70">
|
||||
<FontAwesomeIcon icon={isFormExpanded ? faAngleDown : faKey} />
|
||||
<Checkbox
|
||||
id={`checkbox-${secretKey}`}
|
||||
isChecked={isSelected}
|
||||
onCheckedChange={() => {
|
||||
onToggleSecretSelect(secretKey);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={twMerge("hidden group-hover:flex", isSelected && "flex")}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
className={twMerge("block group-hover:hidden", isSelected && "hidden")}
|
||||
icon={isFormExpanded ? faAngleDown : faKey}
|
||||
/>
|
||||
</div>
|
||||
<div title={secretKey}>{secretKey}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import { faMinusSquare, faTrash } 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, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
|
||||
import { DecryptedSecret, TDeleteSecretBatchDTO, TSecretFolder } from "@app/hooks/api/types";
|
||||
|
||||
export enum EntryType {
|
||||
FOLDER = "folder",
|
||||
SECRET = "secret"
|
||||
}
|
||||
|
||||
type Props = {
|
||||
secretPath: string;
|
||||
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
|
||||
getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined;
|
||||
resetSelectedEntries: () => void;
|
||||
selectedEntries: {
|
||||
[EntryType.FOLDER]: Record<string, boolean>;
|
||||
[EntryType.SECRET]: Record<string, boolean>;
|
||||
};
|
||||
};
|
||||
|
||||
export const SelectionPanel = ({
|
||||
getFolderByNameAndEnv,
|
||||
getSecretByKey,
|
||||
secretPath,
|
||||
resetSelectedEntries,
|
||||
selectedEntries
|
||||
}: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
"bulkDeleteEntries"
|
||||
] as const);
|
||||
|
||||
const selectedCount =
|
||||
Object.keys(selectedEntries.folder).length + Object.keys(selectedEntries.secret).length;
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const userAvailableEnvs = currentWorkspace?.environments || [];
|
||||
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
||||
|
||||
const isMultiSelectActive = selectedCount > 0;
|
||||
|
||||
// user should have the ability to delete secrets/folders in at least one of the envs
|
||||
const shouldShowDelete = userAvailableEnvs.some((env) =>
|
||||
permission.can(
|
||||
ProjectPermissionActions.Delete,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath })
|
||||
)
|
||||
);
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
let processedEntries = 0;
|
||||
|
||||
const promises = userAvailableEnvs.map(async (env) => {
|
||||
// additional check: ensure that bulk delete is only executed on envs that user has access to
|
||||
if (
|
||||
permission.cannot(
|
||||
ProjectPermissionActions.Delete,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath })
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.keys(selectedEntries.folder).map(async (folderName) => {
|
||||
const folder = getFolderByNameAndEnv(folderName, env.slug);
|
||||
if (folder) {
|
||||
processedEntries += 1;
|
||||
await deleteFolder({
|
||||
folderId: folder?.id,
|
||||
path: secretPath,
|
||||
environment: env.slug,
|
||||
projectId: workspaceId
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const secretsToDelete = Object.keys(selectedEntries.secret).reduce(
|
||||
(accum: TDeleteSecretBatchDTO["secrets"], secretName) => {
|
||||
const entry = getSecretByKey(env.slug, secretName);
|
||||
if (entry) {
|
||||
return [
|
||||
...accum,
|
||||
{
|
||||
secretName: entry.key,
|
||||
type: "shared" as "shared"
|
||||
}
|
||||
];
|
||||
}
|
||||
return accum;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (secretsToDelete.length > 0) {
|
||||
processedEntries += secretsToDelete.length;
|
||||
await deleteBatchSecretV3({
|
||||
secretPath,
|
||||
workspaceId,
|
||||
environment: env.slug,
|
||||
secrets: secretsToDelete
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const areEntriesDeleted = results.some((result) => result.status === "fulfilled");
|
||||
if (processedEntries === 0) {
|
||||
handlePopUpClose("bulkDeleteEntries");
|
||||
createNotification({
|
||||
type: "info",
|
||||
text: "You don't have access to delete selected items"
|
||||
});
|
||||
} else if (areEntriesDeleted) {
|
||||
handlePopUpClose("bulkDeleteEntries");
|
||||
resetSelectedEntries();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted selected secrets and folders"
|
||||
});
|
||||
} else {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete selected secrets and folders"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={twMerge(
|
||||
"h-0 flex-shrink-0 overflow-hidden transition-all",
|
||||
isMultiSelectActive && "h-16"
|
||||
)}
|
||||
>
|
||||
<div className="mt-3.5 flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 py-2 px-4 text-bunker-300">
|
||||
<Tooltip content="Clear">
|
||||
<IconButton variant="plain" ariaLabel="clear-selection" onClick={resetSelectedEntries}>
|
||||
<FontAwesomeIcon icon={faMinusSquare} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className="ml-4 flex-grow px-2 text-sm">{selectedCount} Selected</div>
|
||||
{shouldShowDelete && (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
className="ml-4"
|
||||
onClick={() => handlePopUpOpen("bulkDeleteEntries")}
|
||||
size="xs"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.bulkDeleteEntries.isOpen}
|
||||
deleteKey="delete"
|
||||
title="Do you want to delete the selected secrets and folders across envs?"
|
||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
||||
onDeleteApproved={handleBulkDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user