diff --git a/backend/src/controllers/v1/secretApprovalRequestsController.ts b/backend/src/controllers/v1/secretApprovalRequestsController.ts index c97c9eed4..c2b053bb9 100644 --- a/backend/src/controllers/v1/secretApprovalRequestsController.ts +++ b/backend/src/controllers/v1/secretApprovalRequestsController.ts @@ -12,6 +12,54 @@ import { Types } from "mongoose"; import { EEAuditLogService } from "../../ee/services"; import { EventType } from "../../ee/models"; +export const getSecretApprovalRequestCount = async (req: Request, res: Response) => { + const { + query: { workspaceId } + } = await validateRequest(reqValidator.getSecretApprovalRequestCount, req); + + const { membership } = await getUserProjectPermissions(req.user._id, workspaceId); + const approvalRequestCount = await SecretApprovalRequest.aggregate([ + { + $match: { + workspace: new Types.ObjectId(workspaceId) + } + }, + { + $lookup: { + from: SecretApprovalPolicy.collection.name, + localField: "policy", + foreignField: "_id", + as: "policy" + } + }, + { $unwind: "$policy" }, + ...(membership.role !== "admin" + ? [ + { + $match: { + $or: [ + { committer: new Types.ObjectId(membership.id) }, + { "policy.approvers": new Types.ObjectId(membership.id) } + ] + } + } + ] + : []), + { + $group: { + _id: "$status", + count: { $sum: 1 } + } + } + ]); + const openRequests = approvalRequestCount.find(({ _id }) => _id === "open"); + const closedRequests = approvalRequestCount.find(({ _id }) => _id === "close"); + + return res.send({ + approvals: { open: openRequests?.count || 0, closed: closedRequests?.count || 0 } + }); +}; + export const getSecretApprovalRequests = async (req: Request, res: Response) => { const { query: { status, committer, workspaceId, environment, limit, offset } @@ -95,7 +143,8 @@ export const getSecretApprovalRequestDetails = async (req: Request, res: Respons } }) .populate("commits.secret", "version") - .populate("commits.newVersion.tags"); + .populate("commits.newVersion.tags") + .lean(); if (!secretApprovalRequest) throw BadRequestError({ message: "Secret approval request not found" }); @@ -114,8 +163,19 @@ export const getSecretApprovalRequestDetails = async (req: Request, res: Respons throw UnauthorizedRequestError({ message: "User has no access" }); } + let secretPath = "/"; + const approvalRootFolders = await Folder.findOne({ + workspace: secretApprovalRequest.workspace, + environment: secretApprovalRequest.environment + }).lean(); + if (approvalRootFolders) { + secretPath = + getFolderWithPathFromId(approvalRootFolders?.nodes, secretApprovalRequest.folderId) + ?.folderPath || "/"; + } + return res.send({ - approval: secretApprovalRequest + approval: { ...secretApprovalRequest, secretPath } }); }; diff --git a/backend/src/routes/v1/secretApprovalRequest.ts b/backend/src/routes/v1/secretApprovalRequest.ts index e53e425ff..15289d8c9 100644 --- a/backend/src/routes/v1/secretApprovalRequest.ts +++ b/backend/src/routes/v1/secretApprovalRequest.ts @@ -12,6 +12,14 @@ router.get( secretApprovalRequestController.getSecretApprovalRequests ); +router.get( + "/count", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.getSecretApprovalRequestCount +); + router.get( "/:id", requireAuth({ diff --git a/backend/src/services/SecretApprovalService.ts b/backend/src/services/SecretApprovalService.ts index a9a42688f..a0f7d271f 100644 --- a/backend/src/services/SecretApprovalService.ts +++ b/backend/src/services/SecretApprovalService.ts @@ -355,7 +355,7 @@ export const performSecretApprovalRequestMerge = async ( const environment = secretApprovalRequest.environment; const folderId = secretApprovalRequest.folderId; const postHogClient = await TelemetryService.getPostHogClient(); - const conflicts: Array<{ id: string; op: CommitType }> = []; + const conflicts: Array<{ secretId: string; op: CommitType }> = []; const secretCreationCommits = secretApprovalRequest.commits.filter( ({ op }) => op === CommitType.CREATE @@ -382,7 +382,7 @@ export const performSecretApprovalRequestMerge = async ( secretCreationCommits .filter(({ newVersion }) => conflictGroupByBlindIndex[newVersion.secretBlindIndex || ""]) .forEach((el) => { - conflicts.push({ op: CommitType.CREATE, id: el.newVersion._id.toString() }); + conflicts.push({ op: CommitType.CREATE, secretId: el.newVersion._id.toString() }); }); // create secret @@ -482,15 +482,20 @@ export const performSecretApprovalRequestMerge = async ( ); secretUpdationCommits .filter( - ({ newVersion }) => - newVersion.secretBlindIndex && conflictGroupByBlindIndex[newVersion.secretBlindIndex] + ({ newVersion, secret }) => + (newVersion.secretBlindIndex && conflictGroupByBlindIndex[newVersion.secretBlindIndex]) || + !secret ) .forEach((el) => { - conflicts.push({ op: CommitType.UPDATE, id: el.newVersion._id.toString() }); + conflicts.push({ op: CommitType.UPDATE, secretId: el.newVersion._id.toString() }); }); - const nonConflictSecrets = secretUpdationCommits.filter(({ newVersion }) => - newVersion?.secretBlindIndex ? !conflictGroupByBlindIndex[newVersion.secretBlindIndex] : true + const nonConflictSecrets = secretUpdationCommits.filter( + ({ newVersion, secret }) => + Boolean(secret) && + (newVersion?.secretBlindIndex + ? !conflictGroupByBlindIndex[newVersion.secretBlindIndex] + : true) ); await Secret.bulkWrite( // id and version are stripped off diff --git a/backend/src/validation/secretApprovalRequest.ts b/backend/src/validation/secretApprovalRequest.ts index 93bc68267..07aff586c 100644 --- a/backend/src/validation/secretApprovalRequest.ts +++ b/backend/src/validation/secretApprovalRequest.ts @@ -12,6 +12,12 @@ export const getSecretApprovalRequests = z.object({ }) }); +export const getSecretApprovalRequestCount = z.object({ + query: z.object({ + workspaceId: z.string().trim() + }) +}); + export const getSecretApprovalRequestDetails = z.object({ params: z.object({ id: z.string().trim() diff --git a/frontend/src/hooks/api/secretApprovalRequest/index.tsx b/frontend/src/hooks/api/secretApprovalRequest/index.tsx index 58391d2ac..a72399901 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/index.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/index.tsx @@ -3,4 +3,8 @@ export { useUpdateSecretApprovalRequestStatus, useUpdateSecretApprovalReviewStatus } from "./mutation"; -export { useGetSecretApprovalRequestDetails, useGetSecretApprovalRequests } from "./queries"; +export { + useGetSecretApprovalRequestCount, + useGetSecretApprovalRequestDetails, + useGetSecretApprovalRequests +} from "./queries"; diff --git a/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx b/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx index bc17810d1..94358ed79 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx @@ -35,8 +35,9 @@ export const useUpdateSecretApprovalRequestStatus = () => { }); return data; }, - onSuccess: (_, { id }) => { + onSuccess: (_, { id, workspaceId }) => { queryClient.invalidateQueries(secretApprovalRequestKeys.detail({ id })); + queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId })); } }); }; @@ -52,6 +53,7 @@ export const usePerformSecretApprovalRequestMerge = () => { onSuccess: (_, { id, workspaceId }) => { queryClient.invalidateQueries(secretApprovalRequestKeys.detail({ id })); queryClient.invalidateQueries(secretApprovalRequestKeys.list({ workspaceId })); + queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId })); } }); }; diff --git a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx index dfe82ee43..db4f6f94c 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx @@ -15,9 +15,11 @@ import { UserWsKeyPair } from "../keys/types"; import { decryptSecrets } from "../secrets/queries"; import { DecryptedSecret } from "../secrets/types"; import { + TGetSecretApprovalRequestCount, TGetSecretApprovalRequestDetails, TGetSecretApprovalRequestList, TSecretApprovalRequest, + TSecretApprovalRequestCount, TSecretApprovalSecChange, TSecretApprovalSecChangeData } from "./types"; @@ -36,7 +38,11 @@ export const secretApprovalRequestKeys = { "secret-approval-requests" ] as const, detail: ({ id }: Omit) => - [{ id }, "secret-approval-request-detail"] as const + [{ id }, "secret-approval-request-detail"] as const, + count: ({ workspaceId }: TGetSecretApprovalRequestCount) => [ + { workspaceId }, + "secret-approval-request-count" + ] }; export const decryptSecretApprovalSecret = ( @@ -187,3 +193,32 @@ export const useGetSecretApprovalRequestDetails = ({ }), enabled: Boolean(id && decryptKey) && (options?.enabled ?? true) }); + +const fetchSecretApprovalRequestCount = async ({ workspaceId }: TGetSecretApprovalRequestCount) => { + const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequestCount }>( + "/api/v1/secret-approval-requests/count", + { params: { workspaceId } } + ); + + return data.approvals; +}; + +export const useGetSecretApprovalRequestCount = ({ + workspaceId, + options = {} +}: TGetSecretApprovalRequestCount & { + options?: Omit< + UseQueryOptions< + TSecretApprovalRequestCount, + unknown, + TSecretApprovalRequestCount, + ReturnType + >, + "queryKey" | "queryFn" + >; +}) => + useQuery({ + queryKey: secretApprovalRequestKeys.count({ workspaceId }), + queryFn: () => fetchSecretApprovalRequestCount({ workspaceId }), + enabled: Boolean(workspaceId) && (options?.enabled ?? true) + }); diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index c6ddfbb7c..1e129a61c 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -17,7 +17,6 @@ export enum CommitType { export type TSecretApprovalSecChangeData = { _id: string; - version: number; secretKeyCiphertext: string; secretKeyIV: string; secretKeyTag: string; @@ -31,6 +30,7 @@ export type TSecretApprovalSecChangeData = { algorithm: "aes-256-gcm"; keyEncoding: "utf8" | "base64"; tags?: WsTag[]; + version: number; }; export type TSecretApprovalSecChange = { @@ -57,10 +57,12 @@ export type TSecretApprovalRequest< workspace: string; environment: string; folderId: string; + secretPath: string; hasMerged: boolean; status: "open" | "close"; policy: TSecretApprovalPolicy; statusChangeBy: string; + conflicts: Array<{ secretId: string; op: CommitType.UPDATE }>; commits: { // if there is no secret means it was creation secret?: { version: number }; @@ -71,6 +73,11 @@ export type TSecretApprovalRequest< }[]; }; +export type TSecretApprovalRequestCount = { + open: number; + closed: number; +}; + export type TGetSecretApprovalRequestList = { workspaceId: string; environment?: string; @@ -80,6 +87,10 @@ export type TGetSecretApprovalRequestList = { offset?: number; }; +export type TGetSecretApprovalRequestCount = { + workspaceId: string; +}; + export type TGetSecretApprovalRequestDetails = { id: string; decryptKey: UserWsKeyPair; @@ -93,6 +104,7 @@ export type TUpdateSecretApprovalReviewStatusDTO = { export type TUpdateSecretApprovalRequestStatusDTO = { status: "open" | "close"; id: string; + workspaceId: string; }; export type TPerformSecretApprovalRequestMerge = { diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index c1bf59c85..18c52c4cb 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -8,6 +8,7 @@ import { } from "@app/components/utilities/cryptography/crypto"; import { apiRequest } from "@app/config/request"; +import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries"; import { secretSnapshotKeys } from "../secretSnapshots/queries"; import { secretKeys } from "./queries"; import { @@ -114,6 +115,7 @@ export const useCreateSecretV3 = ({ queryClient.invalidateQueries( secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) ); + queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId })); }, ...options }); @@ -173,6 +175,7 @@ export const useUpdateSecretV3 = ({ queryClient.invalidateQueries( secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) ); + queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId })); }, ...options }); @@ -209,6 +212,7 @@ export const useDeleteSecretV3 = ({ queryClient.invalidateQueries( secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) ); + queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId })); }, ...options }); @@ -261,6 +265,7 @@ export const useCreateSecretBatch = ({ queryClient.invalidateQueries( secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) ); + queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId })); }, ...options }); @@ -313,6 +318,7 @@ export const useUpdateSecretBatch = ({ queryClient.invalidateQueries( secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) ); + queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId })); }, ...options }); @@ -349,6 +355,7 @@ export const useDeleteSecretBatch = ({ queryClient.invalidateQueries( secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) ); + queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId })); }, ...options }); diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index eeb4c7a61..4b1af23d7 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -66,6 +66,7 @@ import { useAddUserToWs, useCreateWorkspace, useGetOrgTrialUrl, + useGetSecretApprovalRequestCount, useLogoutUser, useUploadWsKey } from "@app/hooks/api"; @@ -114,7 +115,9 @@ export const AppLayout = ({ children }: LayoutProps) => { const { orgs, currentOrg } = useOrganization(); const { user } = useUser(); const { subscription } = useSubscription(); + const workspaceId = currentWorkspace?._id || ""; // const [ isLearningNoteOpen, setIsLearningNoteOpen ] = useState(true); + const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId }); const isAddingProjectsAllowed = subscription?.workspaceLimit ? subscription.workspacesUsed < subscription.workspaceLimit @@ -477,21 +480,24 @@ export const AppLayout = ({ children }: LayoutProps) => { - {process.env.NEXT_PUBLIC_SECRET_APPROVAL === "true" && ( - - - - Secret Approval - - - - )} - {/* + + + + Secret approval + {Boolean(secretApprovalReqCount?.open) && ( + + {secretApprovalReqCount?.open} + + )} + + + + { environment: envFilter, committer: committerFilter }); + const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } = + useGetSecretApprovalRequestCount({ workspaceId }); const { user: presentUser } = useUser(); const { data: members } = useGetWorkspaceUsers(workspaceId); const membersGroupById = members?.reduce>( @@ -103,13 +109,17 @@ export const SecretApprovalRequest = () => { onKeyDown={(evt) => { if (evt.key === "Enter") setStatusFilter("open"); }} - className={statusFilter === "close" ? "text-gray-500 hover:text-gray-400 duration-100" : ""} + className={ + statusFilter === "close" ? "text-gray-500 hover:text-gray-400 duration-100" : "" + } > - Open + {isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
setStatusFilter("close")} @@ -118,7 +128,7 @@ export const SecretApprovalRequest = () => { }} > - Closed + {isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx index 05e7b9afb..73d72b4a5 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -65,7 +65,8 @@ export const SecretApprovalRequestAction = ({ try { await updateSecretStatusChange({ id: approvalRequestId, - status: reqState + status: reqState, + workspaceId }); createNotification({ type: "success", diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx index 4757cceb0..d921f9975 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx @@ -1,4 +1,4 @@ -import { faFilePen } from "@fortawesome/free-solid-svg-icons"; +import { faExclamationTriangle, faInfo } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { @@ -10,6 +10,7 @@ import { Td, Th, THead, + Tooltip, Tr } from "@app/components/v2"; import { CommitType, DecryptedSecret, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types"; @@ -20,6 +21,7 @@ export type Props = { newVersion?: Omit & { tags?: WsTag[] }; presentSecretVersionNumber: number; hasMerged?: Boolean; + conflicts: Array<{ secretId: string; op: CommitType }>; }; const generateItemTitle = (op: CommitType) => { @@ -35,25 +37,44 @@ const generateItemTitle = (op: CommitType) => { ); }; +const generateConflictText = (op: CommitType) => { + if (op === CommitType.CREATE) return
Secret already exist
; + if (op === CommitType.UPDATE) return
Secret not found
; + return null; +}; + export const SecretApprovalRequestChangeItem = ({ op, secretVersion, newVersion, presentSecretVersionNumber, - hasMerged + hasMerged, + conflicts }: Props) => { // meaning request has changed - const isStale = (secretVersion?.version || 1) < presentSecretVersionNumber && !hasMerged; + const isStale = (secretVersion?.version || 1) < presentSecretVersionNumber; + const itemConflict = + hasMerged && conflicts.find((el) => el.op === op && el.secretId === newVersion?._id); + const hasConflict = Boolean(itemConflict); + return (
{generateItemTitle(op)}
- {isStale && ( + {!hasMerged && isStale && (
- + Secret has been changed(stale)
)} + {hasMerged && hasConflict && ( +
+ + + +
{generateConflictText(op)}
+
+ )}
diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index 695531d54..b90e156bf 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -5,6 +5,7 @@ import { faCheckCircle, faCircle, faCodeBranch, + faFolder, faXmarkCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -175,13 +176,19 @@ export const SecretApprovalRequestChanges = ({
{generateCommitText(secretApprovalRequestDetails.commits)}
-
+
{committer?.user?.firstName} {committer?.user?.lastName} ({committer?.user?.email}) wants to change{" "} - {secretApprovalRequestDetails.commits.length} secret values in{" "} - + {secretApprovalRequestDetails.commits.length} secret values in + {secretApprovalRequestDetails.environment} +
+
+ +
+
{secretApprovalRequestDetails.secretPath}
+
{!hasMerged && secretApprovalRequestDetails.status === "open" && ( @@ -213,6 +220,7 @@ export const SecretApprovalRequestChanges = ({ ({ op, secretVersion, secret, newVersion }, index) => (