diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index 200ceefb2..97e1f3d3c 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -22,14 +22,14 @@ export const ModalContent = forwardRef( ) => ( diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index f3a7ab4ca..05de5a8d5 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -7,6 +7,7 @@ export * from "./integrations"; export * from "./keys"; export * from "./organization"; export * from "./roles"; +export * from "./secretApproval"; export * from "./secretFolders"; export * from "./secretImports"; export * from "./secrets"; diff --git a/frontend/src/hooks/api/secretApproval/index.tsx b/frontend/src/hooks/api/secretApproval/index.tsx new file mode 100644 index 000000000..1d4353d9e --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/index.tsx @@ -0,0 +1,6 @@ +export { + useCreateSecretApprovalPolicy, + useDeleteSecretApprovalPolicy, + useUpdateSecretApprovalPolicy +} from "./mutation"; +export { useGetSecretApprovalPolicies } from "./queries"; diff --git a/frontend/src/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx new file mode 100644 index 000000000..f171636a0 --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/mutation.tsx @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { secretApprovalKeys } from "./queries"; +import { TCreateSecretPolicyDTO, TDeleteSecretPolicyDTO, TUpdateSecretPolicyDTO } from "./types"; + +export const useCreateSecretApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateSecretPolicyDTO>({ + mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath }) => { + const { data } = await apiRequest.post("/api/v1/secret-approvals", { + environment, + workspaceId, + approvals, + approvers, + secretPath + }); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretApprovalKeys.getApprovalPolicies(workspaceId)); + } + }); +}; + +export const useUpdateSecretApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSecretPolicyDTO>({ + mutationFn: async ({ id, approvers, approvals, secretPath }) => { + const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { + approvals, + approvers, + secretPath + }); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretApprovalKeys.getApprovalPolicies(workspaceId)); + } + }); +}; + +export const useDeleteSecretApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSecretPolicyDTO>({ + mutationFn: async ({ id }) => { + const { data } = await apiRequest.delete(`/api/v1/secret-approvals/${id}`); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretApprovalKeys.getApprovalPolicies(workspaceId)); + } + }); +}; diff --git a/frontend/src/hooks/api/secretApproval/queries.tsx b/frontend/src/hooks/api/secretApproval/queries.tsx new file mode 100644 index 000000000..6c176a27a --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/queries.tsx @@ -0,0 +1,36 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSecretApprovalPolicy } from "./types"; + +export const secretApprovalKeys = { + getApprovalPolicies: (workspaceId: string) => + [{ workspaceId }, "secret-approval-policies"] as const +}; + +const fetchApprovalPolicies = async (workspaceId: string) => { + const { data } = await apiRequest.get<{ approvals: TSecretApprovalPolicy[] }>( + "/api/v1/secret-approvals", + { params: { workspaceId } } + ); + return data.approvals; +}; + +export const useGetSecretApprovalPolicies = ({ + workspaceId, + options = {} +}: { workspaceId: string } & { + options?: UseQueryOptions< + TSecretApprovalPolicy[], + unknown, + TSecretApprovalPolicy[], + ReturnType + >; +}) => + useQuery({ + queryKey: secretApprovalKeys.getApprovalPolicies(workspaceId), + queryFn: () => fetchApprovalPolicies(workspaceId), + ...options, + enabled: Boolean(workspaceId) && (options?.enabled ?? true) + }); diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts new file mode 100644 index 000000000..b77fc1a91 --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -0,0 +1,31 @@ +export type TSecretApprovalPolicy = { + _id: string; + workspace: string; + environment: string; + secretPath?: string; + approvers: string[]; + approvals: number; +}; + +export type TCreateSecretPolicyDTO = { + workspaceId: string; + environment: string; + secretPath?: string; + approvers?: string[]; + approvals?: number; +}; + +export type TUpdateSecretPolicyDTO = { + id: string; + approvers?: string[]; + secretPath?: string; + approvals?: number; + // for invalidating list + workspaceId: string; +}; + +export type TDeleteSecretPolicyDTO = { + id: string; + // for invalidating list + workspaceId: string; +}; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index 098078090..c91fb551d 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -4,6 +4,7 @@ export type { IntegrationAuth } from "./integrationAuth/types"; export type { TCloudIntegration, TIntegration } from "./integrations/types"; export type { UserWsKeyPair } from "./keys/types"; export type { Organization } from "./organization/types"; +export type { TSecretApprovalPolicy } from "./secretApproval/types"; export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types"; export type { SubscriptionPlan } from "./subscriptions/types"; export type { WsTag } from "./tags/types"; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index d8c522110..8dd5f12ab 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -475,18 +475,20 @@ export const AppLayout = ({ children }: LayoutProps) => { - - - - Secret Change Management - - - + {process.env.NEXT_PUBLIC_SECRET_APPROVAL === "true" && ( + + + + Admin Panel + + + + )} { const { t } = useTranslation(); @@ -16,7 +16,7 @@ const SecretApproval = () => {
- +
); diff --git a/frontend/src/views/SecretApproval/SecretApprovalListPage/SecretApprovalListPage.tsx b/frontend/src/views/SecretApproval/SecretApprovalListPage/SecretApprovalListPage.tsx deleted file mode 100644 index 3264382fb..000000000 --- a/frontend/src/views/SecretApproval/SecretApprovalListPage/SecretApprovalListPage.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { faCheck, faCodeBranch, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { Button, Table, TableContainer, TBody, Td, Th, THead, Tr } from "@app/components/v2"; - -export const SecretApprovalListPage = () => { - return ( -
-
-

Admin Panels

-
-
-
-
- - 27 Open -
-
- - 27 Closed -
-
-
-
-
- 2 secrets added and 1 deleted -
- - Opened 2 hours ago by akhilmhdh - Review required - -
-
-
-
-
- Request for secret change -
-
- - - - - - - - - - - - - - - - - - -
SecretValueCommentTags
TWILIO_SECRET_TOKENvalueSome values-
-
-
-
- - -
-
-
- ); -}; diff --git a/frontend/src/views/SecretApproval/SecretApprovalListPage/index.tsx b/frontend/src/views/SecretApproval/SecretApprovalListPage/index.tsx deleted file mode 100644 index 71d0f7cf3..000000000 --- a/frontend/src/views/SecretApproval/SecretApprovalListPage/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { SecretApprovalListPage } from "./SecretApprovalListPage"; diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx new file mode 100644 index 000000000..35243e75f --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -0,0 +1,31 @@ +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; + +import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; + +enum TabSection { + ApprovalRequests = "approval-requests", + Rules = "approval-rules" +} + +export const SecretApprovalPage = () => { + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + + return ( +
+
+

Admin Panels

+
+ + + Secret PRs + Policies + + + + + +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx new file mode 100644 index 000000000..dcc27e49f --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx @@ -0,0 +1,128 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { usePopUp } from "@app/hooks"; +import { + useDeleteSecretApprovalPolicy, + useGetSecretApprovalPolicies, + useGetWorkspaceUsers +} from "@app/hooks/api"; +import { TSecretApprovalPolicy } from "@app/hooks/api/types"; + +import { SecretApprovalPolicyRow } from "./components/SecretApprovalPolicyRow"; +import { SecretPolicyForm } from "./components/SecretPolicyForm"; + +type Props = { + workspaceId: string; +}; + +export const SecretApprovalPolicyList = ({ workspaceId }: Props) => { + const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([ + "secretPolicyForm", + "deletePolicy" + ] as const); + const { createNotification } = useNotificationContext(); + + const { data: members } = useGetWorkspaceUsers(workspaceId); + const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({ + workspaceId + }); + + const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); + + const handleDeletePolicy = async () => { + const { _id: id } = popUp.deletePolicy.data as TSecretApprovalPolicy; + try { + await deleteSecretApprovalPolicy({ + workspaceId, + id + }); + createNotification({ + type: "success", + text: "Successfully deleted policy" + }); + handlePopUpClose("deletePolicy"); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete policy" + }); + } + }; + + return ( +
+
+
+ Approval Policies +
+ Implement policies to prevent unauthorized secret changes. +
+
+
+ +
+
+ + + + + + + + + + + + {isPoliciesLoading && ( + + )} + {policies?.map((policy) => ( + handlePopUpOpen("secretPolicyForm", policy)} + onDelete={() => handlePopUpOpen("deletePolicy", policy)} + /> + ))} + +
EnvironmentSecret PathEligible ApproversApproval Required +
+
+ handlePopUpToggle("secretPolicyForm", isOpen)} + members={members} + editValues={popUp.secretPolicyForm.data as TSecretApprovalPolicy} + /> + handlePopUpToggle("deletePolicy", isOpen)} + onDeleteApproved={handleDeletePolicy} + /> +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx new file mode 100644 index 000000000..c39ba2b69 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + IconButton, + Input, + Td, + Tooltip, + Tr +} from "@app/components/v2"; +import { useUpdateSecretApprovalPolicy } from "@app/hooks/api"; +import { TSecretApprovalPolicy } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + policy: TSecretApprovalPolicy; + members?: TWorkspaceUser[]; + workspaceId: string; + onEdit: () => void; + onDelete: () => void; +}; + +export const SecretApprovalPolicyRow = ({ + policy, + members = [], + workspaceId, + onEdit, + onDelete +}: Props) => { + const [selectedApprovers, setSelectedApprovers] = useState([]); + const { mutate: updateSecretApprovalPolicy, isLoading } = useUpdateSecretApprovalPolicy(); + + return ( + + {policy.environment} + {policy.secretPath || "*"} + + { + if (!isOpen) { + updateSecretApprovalPolicy( + { + workspaceId, + id: policy._id, + approvers: selectedApprovers + }, + { + onSettled: () => { + setSelectedApprovers([]); + } + } + ); + } else { + setSelectedApprovers(policy.approvers); + } + }} + > + + + + + Select members that must approve changes + {members?.map(({ _id, user }) => { + const isChecked = selectedApprovers.includes(_id); + return ( + { + evt.preventDefault(); + setSelectedApprovers((state) => + isChecked ? state.filter((el) => el !== _id) : [...state, _id] + ); + }} + key={`create-policy-members-${_id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + {policy.approvals} + +
+ + + + + + + + + + +
+ + + ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx new file mode 100644 index 000000000..78d1e3fbc --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx @@ -0,0 +1,239 @@ +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 { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; +import { TSecretApprovalPolicy } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + workspaceId: string; + editValues?: TSecretApprovalPolicy; +}; + +const formSchema = z.object({ + environment: z.string(), + secretPath: z.string().optional(), + approvals: z.number().min(1), + approvers: z.string().array().optional() +}); + +type TFormSchema = z.infer; + +export const SecretPolicyForm = ({ + isOpen, + onToggle, + members = [], + workspaceId, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues + }); + const { currentWorkspace } = useWorkspace(); + const { createNotification } = useNotificationContext(); + + const environments = currentWorkspace?.environments || []; + useEffect(() => { + if (!isOpen) reset({}); + }, [isOpen]); + + const isEditMode = Boolean(editValues); + + const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy(); + const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy(); + + const handleCreatePolicy = async (data: TFormSchema) => { + try { + await createSecretApprovalPolicy({ + ...data, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully created policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create policy" + }); + } + }; + + const handleUpdatePolicy = async (data: TFormSchema) => { + if (!editValues?._id) return; + try { + await updateSecretApprovalPolicy({ + id: editValues?._id, + ...data, + secretPath: data.secretPath ?? "-", + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "failed to update policy" + }); + } + }; + + const handleFormSubmit = async (data: TFormSchema) => { + if (isEditMode) { + await handleUpdatePolicy(data); + } else { + await handleCreatePolicy(data); + } + }; + + return ( + + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + + + + Select members that must approve changes + {members.map(({ _id, user }) => { + const isChecked = value?.includes(_id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el) => el !== _id) + : [...(value || []), _id] + ); + }} + key={`create-policy-members-${_id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx new file mode 100644 index 000000000..f204264b4 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx @@ -0,0 +1 @@ +export { SecretApprovalPolicyList } from "./SecretApprovalPolicyList"; diff --git a/frontend/src/views/SecretApprovalPage/index.tsx b/frontend/src/views/SecretApprovalPage/index.tsx new file mode 100644 index 000000000..e45406427 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/index.tsx @@ -0,0 +1 @@ +export { SecretApprovalPage } from "./SecretApprovalPage";