From b6b9fb6ef5f9492736e30e02d909d340f1ad4a47 Mon Sep 17 00:00:00 2001 From: x032205 Date: Thu, 7 Aug 2025 20:20:48 -0400 Subject: [PATCH 01/27] feat(app-connection): Make GitHub paginated requests concurrent --- .../github/github-connection-fns.ts | 119 ++++++++++++++---- 1 file changed, 93 insertions(+), 26 deletions(-) diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index e4bd2e10d..b76987fc1 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -142,16 +142,27 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => return token; }; +const parseGitHubLinkHeader = (linkHeader: string | undefined): Record => { + if (!linkHeader) return {}; + + const links: Record = {}; + const segments = linkHeader.split(","); + const re = new RE2(/<([^>]+)>;\s*rel="([^"]+)"/); + + for (const segment of segments) { + const match = re.exec(segment.trim()); + if (match) { + const url = match[1]; + const rel = match[2]; + links[rel] = url; + } + } + return links; +}; + function extractNextPageUrl(linkHeader: string | undefined): string | null { - if (!linkHeader) return null; - - const links = linkHeader.split(","); - const nextLink = links.find((link) => link.includes('rel="next"')); - - if (!nextLink) return null; - - const match = new RE2(/<([^>]+)>/).exec(nextLink); - return match ? match[1] : null; + const links = parseGitHubLinkHeader(linkHeader); + return links.next || null; } export const makePaginatedGitHubRequest = async ( @@ -164,27 +175,83 @@ export const makePaginatedGitHubRequest = async ( const token = method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection); - let url: string | null = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; + + const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; + const initialUrlObj = new URL(baseUrl); + initialUrlObj.searchParams.set("per_page", "100"); + let results: T[] = []; - let i = 0; + const maxIterations = 1000; - while (url && i < 1000) { - // eslint-disable-next-line no-await-in-loop - const response: AxiosResponse = await requestWithGitHubGateway(appConnection, gatewayService, { - url, - method: "GET", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28" - } - }); + // Make initial request to get link header + const firstResponse: AxiosResponse = await requestWithGitHubGateway(appConnection, gatewayService, { + url: initialUrlObj.toString(), + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); - const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); - results = results.concat(items); + const firstPageItems = dataMapper ? dataMapper(firstResponse.data) : (firstResponse.data as unknown as T[]); + results = results.concat(firstPageItems); - url = extractNextPageUrl(response.headers.link as string | undefined); - i += 1; + const linkHeader = parseGitHubLinkHeader(firstResponse.headers.link as string | undefined); + const lastPageUrl = linkHeader.last; + + // If there's a last page URL, get it's page number and concurrently fetch every page starting from 2 to last + if (lastPageUrl) { + const lastPageParam = new URL(lastPageUrl).searchParams.get("page"); + const totalPages = lastPageParam ? parseInt(lastPageParam, 10) : 1; + + const pageRequests: Promise>[] = []; + + for (let pageNum = 2; pageNum <= totalPages && pageNum - 1 < maxIterations; pageNum += 1) { + const pageUrlObj = new URL(initialUrlObj.toString()); + pageUrlObj.searchParams.set("page", pageNum.toString()); + + pageRequests.push( + requestWithGitHubGateway(appConnection, gatewayService, { + url: pageUrlObj.toString(), + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }) + ); + } + const responses = await Promise.all(pageRequests); + + for (const response of responses) { + const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); + results = results.concat(items); + } + } else { + // Fallback in case last link isn't present + let url: string | null = extractNextPageUrl(firstResponse.headers.link as string | undefined); + let i = 1; + + while (url && i < maxIterations) { + // eslint-disable-next-line no-await-in-loop + const response: AxiosResponse = await requestWithGitHubGateway(appConnection, gatewayService, { + url, + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); + + const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); + results = results.concat(items); + + url = extractNextPageUrl(response.headers.link as string | undefined); + i += 1; + } } return results; From 352ef050c3fb014cf636e7947947d94607264417 Mon Sep 17 00:00:00 2001 From: x032205 <120525481+x032205@users.noreply.github.com> Date: Thu, 7 Aug 2025 20:26:12 -0400 Subject: [PATCH 02/27] Update backend/src/services/app-connection/github/github-connection-fns.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../src/services/app-connection/github/github-connection-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index b76987fc1..a71036d82 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -200,7 +200,7 @@ export const makePaginatedGitHubRequest = async ( const linkHeader = parseGitHubLinkHeader(firstResponse.headers.link as string | undefined); const lastPageUrl = linkHeader.last; - // If there's a last page URL, get it's page number and concurrently fetch every page starting from 2 to last + // If there's a last page URL, get its page number and concurrently fetch every page starting from 2 to last if (lastPageUrl) { const lastPageParam = new URL(lastPageUrl).searchParams.get("page"); const totalPages = lastPageParam ? parseInt(lastPageParam, 10) : 1; From 458dcd31c10b59c9681acab26e8a31df4eceadc4 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 11 Aug 2025 14:09:04 -0700 Subject: [PATCH 03/27] feat(secret-import): CSV support (with a base for other matrix-based formats) --- frontend/public/locales/en/translations.json | 4 +- .../src/components/utilities/parseSecrets.ts | 58 +++++ .../SecretDropzone/SecretDropzone.tsx | 226 +++++++++++++++++- .../SecretListView/SecretDetailSidebar.tsx | 13 +- 4 files changed, 284 insertions(+), 17 deletions(-) diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index 167e1c7b5..fdfefcf93 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -53,8 +53,8 @@ "project-id": "Project ID", "save-changes": "Save Changes", "saved": "Saved", - "drop-zone": "Drag and drop a .env, .json, or .yml file here.", - "drop-zone-keys": "Drag and drop a .env, .json, or .yml file here to add more secrets.", + "drop-zone": "Drag and drop a .env, .json, .csv, or .yml file here.", + "drop-zone-keys": "Drag and drop a .env, .json, .csv, or .yml file here to add more secrets.", "role": "Role", "role_admin": "admin", "display-name": "Display Name", diff --git a/frontend/src/components/utilities/parseSecrets.ts b/frontend/src/components/utilities/parseSecrets.ts index 3e5a57edf..e33f25ace 100644 --- a/frontend/src/components/utilities/parseSecrets.ts +++ b/frontend/src/components/utilities/parseSecrets.ts @@ -165,3 +165,61 @@ export function parseYaml(src: ArrayBuffer | string) { return result; } + +function detectSeparator(csvContent: string): string { + const firstLine = csvContent.split("\n")[0]; + const separators = [",", ";", "\t", "|"]; + + const counts = separators.map((sep) => ({ + separator: sep, + count: (firstLine.match(new RegExp(`\\${sep}`, "g")) || []).length + })); + + const detected = counts.reduce((max, curr) => (curr.count > max.count ? curr : max)); + + return detected.count > 0 ? detected.separator : ","; +} + +export function parseCsvToMatrix(src: ArrayBuffer | string): string[][] { + let csvContent: string; + if (typeof src === "string") { + csvContent = src; + } else { + csvContent = new TextDecoder("utf-8").decode(src); + } + + const separator = detectSeparator(csvContent); + const lines = csvContent.replace(/\r\n?/g, "\n").split("\n"); + const matrix: string[][] = []; + + lines.forEach((line) => { + if (line.trim() !== "") { + const cells: string[] = []; + let currentCell = ""; + let inQuote = false; + + for (let i = 0; i < line.length; i += 1) { + const char = line[i]; + const nextChar = line[i + 1]; + + if (char === '"') { + if (inQuote && nextChar === '"') { + currentCell += '"'; + i += 1; + } else { + inQuote = !inQuote; + } + } else if (char === separator && !inQuote) { + cells.push(currentCell.trim()); + currentCell = ""; + } else { + currentCell += char; + } + } + cells.push(currentCell.trim()); + matrix.push(cells); + } + }); + + return matrix; +} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx index 92df89cae..126306e3d 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -1,7 +1,14 @@ -import { ChangeEvent, DragEvent } from "react"; +import { ChangeEvent, Dispatch, DragEvent, SetStateAction, useState } from "react"; import { useTranslation } from "react-i18next"; import { subject } from "@casl/ability"; -import { faPlus, faUpload } from "@fortawesome/free-solid-svg-icons"; +import { + faArrowRight, + faAsterisk, + faComment, + faKey, + faPlus, + faUpload +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQueryClient } from "@tanstack/react-query"; import { twMerge } from "tailwind-merge"; @@ -9,8 +16,22 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; // TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality -import { parseDotEnv, parseJson, parseYaml } from "@app/components/utilities/parseSecrets"; -import { Button, Lottie, Modal, ModalContent } from "@app/components/v2"; +import { + parseCsvToMatrix, + parseDotEnv, + parseJson, + parseYaml +} from "@app/components/utilities/parseSecrets"; +import { + Badge, + Button, + FormLabel, + Lottie, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api"; @@ -38,6 +59,84 @@ type Props = { isProtectedBranch?: boolean; }; +type SecretMatrixMap = { + key: number; + value: number | null; + comment: number | null; +}; + +const popupKeys = ["importSecEnv", "confirmUpload", "pasteSecEnv", "importMatrixMap"] as const; + +const MatrixImportModalTableRow = ({ + importSecretMatrixMap, + setImportSecretMatrixMap, + headers, + mapKey +}: { + importSecretMatrixMap: SecretMatrixMap; + setImportSecretMatrixMap: Dispatch>; + headers: string[]; + mapKey: keyof SecretMatrixMap; +}) => { + return ( + + + + + +
+ +
+ + +
+ + {mapKey === "key" && ( + <> + + Secret Key + + )} + {mapKey === "value" && ( + <> + + Secret Value + + )} + {mapKey === "comment" && ( + <> + + Comment + + )} + +
+ + + ); +}; + export const SecretDropzone = ({ isSmaller, environments = [], @@ -50,11 +149,14 @@ export const SecretDropzone = ({ const [isDragActive, setDragActive] = useToggle(); const [isLoading, setIsLoading] = useToggle(); - const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "importSecEnv", - "confirmUpload", - "pasteSecEnv" - ] as const); + // Maps matrix columns to parts of a secret + const [importSecretMatrixMap, setImportSecretMatrixMap] = useState({ + key: 0, + value: null, + comment: null + }); + + const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp(popupKeys); const queryClient = useQueryClient(); const { openPopUp } = usePopUpAction(); @@ -136,7 +238,7 @@ export const SecretDropzone = ({ }); return; } - // const fileType = file.name.split('.')[1]; + setIsLoading.on(); reader.onload = (event) => { if (!event?.target?.result) return; @@ -154,7 +256,22 @@ export const SecretDropzone = ({ case "application/yaml": env = parseYaml(src); break; - + case "text/csv": { + const fullMatrix = parseCsvToMatrix(src); + if (!fullMatrix.length) { + createNotification({ + type: "error", + text: "Failed to find secrets in CSV file. File might be empty." + }); + setIsLoading.off(); + return; + } + const headers = fullMatrix[0]; + const matrix = fullMatrix.slice(1); + handlePopUpOpen("importMatrixMap", { headers, matrix }); + setIsLoading.off(); + return; + } default: env = parseDotEnv(src); break; @@ -171,6 +288,22 @@ export const SecretDropzone = ({ } }; + const finishMappedMatrixImport = (matrix: string[][]) => { + const env: TParsedEnv = {}; + matrix.forEach((row) => { + const key = row[importSecretMatrixMap.key]; + if (key) { + env[key] = { + value: importSecretMatrixMap.value ? row[importSecretMatrixMap.value] : "", + comments: importSecretMatrixMap.comment ? [row[importSecretMatrixMap.comment]] : [] + }; + } + }); + handlePopUpClose("importMatrixMap"); + setImportSecretMatrixMap({ key: 0, value: null, comment: null }); + handleParsedEnv(env); + }; + const handleDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -293,7 +426,7 @@ export const SecretDropzone = ({ disabled={!isAllowed} type="file" className="absolute h-full w-full cursor-pointer opacity-0" - accept=".txt,.env,.yml,.yaml,.json" + accept=".txt,.env,.yml,.yaml,.json,.csv" onChange={handleFileUpload} /> )} @@ -407,6 +540,75 @@ export const SecretDropzone = ({ )} + + {/* Matrix Import Modal */} + handlePopUpToggle("importMatrixMap", open)} + > + +
+ + + + + + + + + {/* Key */} + + + {/* Value */} + + + {/* Comment */} + + +
+ + + + +
+
+ +
+ +
+
+
); }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index d3845fcbc..953176ff5 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -27,7 +27,10 @@ import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { hasSecretReference, SecretReferenceTree } from "@app/components/secrets/SecretReferenceDetails"; +import { + hasSecretReference, + SecretReferenceTree +} from "@app/components/secrets/SecretReferenceDetails"; import { Button, Drawer, @@ -49,8 +52,12 @@ import { Tooltip } from "@app/components/v2"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission, useWorkspace } from "@app/context"; - +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { getProjectBaseURL } from "@app/helpers/project"; import { usePopUp } from "@app/hooks"; From e4d90eb055db06822d8242e4f22d087a621ff922 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 12 Aug 2025 12:12:31 -0700 Subject: [PATCH 04/27] Fix empty file infinite load --- .../components/SecretDropzone/SecretDropzone.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx index 126306e3d..d7cb87da7 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -241,7 +241,14 @@ export const SecretDropzone = ({ setIsLoading.on(); reader.onload = (event) => { - if (!event?.target?.result) return; + if (!event?.target?.result) { + createNotification({ + type: "error", + text: "Invalid file contents." + }); + setIsLoading.off(); + return; + } let env: TParsedEnv; From 97b2c534a7378c15f28fbc2b4c807dfd2ccce750 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 13 Aug 2025 02:27:12 +0530 Subject: [PATCH 05/27] feat: added api document for project router get id from slug --- backend/src/server/routes/v2/project-router.ts | 2 ++ .../endpoints/workspaces/get-workspace-by-slug.mdx | 4 ++++ docs/docs.json | 1 + 3 files changed, 7 insertions(+) create mode 100644 docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index b71a744fd..37d1efee6 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -283,6 +283,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.Projects], params: z.object({ slug: slugSchema({ max: 36 }).describe("The slug of the project to get.") }), diff --git a/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx b/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx new file mode 100644 index 000000000..944f61402 --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Project Details By Slug" +openapi: "GET /api/v2/workspace/{slug}" +--- diff --git a/docs/docs.json b/docs/docs.json index 02cfd236a..922e80d32 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -995,6 +995,7 @@ { "group": "Projects", "pages": [ + "api-reference/endpoints/workspaces/get-workspace-by-slug", "api-reference/endpoints/workspaces/create-workspace", "api-reference/endpoints/workspaces/delete-workspace", "api-reference/endpoints/workspaces/get-workspace", From 3cec1b4021b1030a566a5b7719f7753655283053 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 13 Aug 2025 02:34:03 +0530 Subject: [PATCH 06/27] feat: reptile review feedback --- backend/src/server/routes/v2/project-router.ts | 6 ++++++ .../endpoints/workspaces/get-workspace-by-slug.mdx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 37d1efee6..8b9091364 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -285,6 +285,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Projects], + description: "Get project details by slug", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ slug: slugSchema({ max: 36 }).describe("The slug of the project to get.") }), diff --git a/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx b/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx index 944f61402..2b9896f8a 100644 --- a/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx +++ b/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx @@ -1,4 +1,4 @@ --- -title: "Get Project Details By Slug" +title: "Get Project By Slug" openapi: "GET /api/v2/workspace/{slug}" --- From 1b40f5d47584a68285488e47367d49dffce607de Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 12 Aug 2025 14:08:17 -0700 Subject: [PATCH 07/27] improvement: adjust commit modal wording and icons and autofocus commit message --- .../SecretDashboardPage.tsx | 4 ++-- .../components/CommitForm/CommitForm.tsx | 23 +++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 3d9e58b3c..9241c2c6f 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -328,12 +328,12 @@ const Page = () => { createNotification({ text: isProtectedBranch ? "Requested changes have been sent for review" - : "Changes committed successfully", + : "Changes saved successfully", type: "success" }); } catch (error) { createNotification({ - text: "Failed to commit changes", + text: "Failed to save changes", type: "error" }); console.error(error); diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx index c2e3ab12b..0d4983247 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx @@ -1,6 +1,14 @@ /* eslint-disable jsx-a11y/label-has-associated-control */ import React, { useCallback, useState } from "react"; -import { faCodeCommit, faEye, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; +import { + faClipboardCheck, + faClipboardList, + faCodeCommit, + faEye, + faFolder, + faKey, + faSave +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2"; @@ -328,12 +336,12 @@ export const CommitForm: React.FC = ({ @@ -345,14 +353,14 @@ export const CommitForm: React.FC = ({ - - Commit Changes + + Review Changes {totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""} } - subTitle={"Write a commit message and review the changes you're about to commit."} + subTitle="Write a commit message and review the changes you're about to save." className="max-h-[90vh] max-w-[95%] md:max-w-7xl" >
@@ -413,6 +421,7 @@ export const CommitForm: React.FC = ({ onChange={(e) => setCommitMessage(e.target.value)} placeholder="Describe your changes..." className="w-full" + autoFocus required />
@@ -435,7 +444,7 @@ export const CommitForm: React.FC = ({ colorSchema="primary" variant="outline_bg" > - {isCommitting ? "Committing..." : "Commit Changes"} + {isCommitting ? "Save..." : "Save Changes"} From d5390fcafc9e00bf9cf28981c1b935e9123670db Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 12 Aug 2025 14:10:24 -0700 Subject: [PATCH 08/27] fix: correct saving tense --- .../SecretDashboardPage/components/CommitForm/CommitForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx index 0d4983247..f1a7c0d52 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx @@ -444,7 +444,7 @@ export const CommitForm: React.FC = ({ colorSchema="primary" variant="outline_bg" > - {isCommitting ? "Save..." : "Save Changes"} + {isCommitting ? "Saving..." : "Save Changes"} From 7c28ee844ed1ea8c0a4b032e0d4c0c33e94a6d76 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 12 Aug 2025 14:30:46 -0700 Subject: [PATCH 09/27] Type fix --- .../SecretDashboardPage/components/CommitForm/CommitForm.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx index f1a7c0d52..9bf9e3a15 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx @@ -2,9 +2,7 @@ import React, { useCallback, useState } from "react"; import { faClipboardCheck, - faClipboardList, faCodeCommit, - faEye, faFolder, faKey, faSave From 86d430f9112de846eb1dc85784d08293f75c7c63 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 12 Aug 2025 15:39:38 -0700 Subject: [PATCH 10/27] Fix form issue --- .../ApprovalPolicyList/components/AccessPolicyModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index d55556470..0d4d6d650 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -92,7 +92,7 @@ const formSchema = z .array() .default([]) .optional(), - maxTimePeriod: z.string().trim().optional() + maxTimePeriod: z.string().trim().nullish() }) .superRefine((data, ctx) => { if (data.policyType === PolicyType.ChangePolicy) { From 799721782a8964b093ca1ef93b6c9ce68894e504 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 12 Aug 2025 15:42:21 -0700 Subject: [PATCH 11/27] Fix type check --- .../ApprovalPolicyList/components/AccessPolicyModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index 0d4d6d650..30f77d2e1 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -454,7 +454,7 @@ const Form = ({ errorText={error?.message} className="flex-shrink" > - + )} /> From 0c562150f588ca46bb39f5ec8e3196ca6675c49a Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 12 Aug 2025 16:02:43 -0700 Subject: [PATCH 12/27] Add memo to availableConnections to fix infinite re-render issue --- .../components/ExternalCaModal.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index faba9ef37..e0bc547e0 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { SingleValue } from "react-select"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -128,10 +128,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { enabled: caType === CaType.ACME }); - const availableConnections: TAvailableAppConnection[] = [ - ...(availableRoute53Connections || []), - ...(availableCloudflareConnections || []) - ]; + const availableConnections: TAvailableAppConnection[] = useMemo( + () => [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])], + [availableRoute53Connections, availableCloudflareConnections] + ); const isPending = isRoute53Pending || isCloudflarePending; From c7ae7be493abf231785ca3b3599020361cfabead Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 12 Aug 2025 20:04:26 -0700 Subject: [PATCH 13/27] Update security.mdx --- docs/internals/security.mdx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx index 85138be9c..c2ca3e141 100644 --- a/docs/internals/security.mdx +++ b/docs/internals/security.mdx @@ -92,12 +92,11 @@ Infisical Cloud utilizes several strategies to ensure high availability, leverag ## Cross-Region Replication for Disaster Recovery (Infisical Cloud) -To handle regional failures, Infisical Cloud keeps standby regions updated and ready to take over when needed. +To handle regional failures, Infisical Cloud keeps backups both within AWS and across cloud providers in GCP updated and ready to take over when needed. - ElastiCache (Redis): Data is replicated across regions using AWS Global Datastore, keeping cached data consistent and available even if a primary region goes down. -- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple locations, allowing for failover in case of a regional outage. +- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple AWS locations, with additional replication to GCP for multi-cloud disaster recovery, allowing for failover in case of a regional outage or cloud provider issues. -With standby regions and automated failovers in place, Infisical Cloud faces minimal service disruptions even during large-scale outages. ## Penetration testing From e0d19d7b653838341a329e678220fc474d1a8821 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 12 Aug 2025 21:51:22 -0700 Subject: [PATCH 14/27] feat(docs): Suggest higher throughput quotas for AWS parameter store sync --- docs/integrations/secret-syncs/aws-parameter-store.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/integrations/secret-syncs/aws-parameter-store.mdx b/docs/integrations/secret-syncs/aws-parameter-store.mdx index 0a2d52ce1..9a4e5f342 100644 --- a/docs/integrations/secret-syncs/aws-parameter-store.mdx +++ b/docs/integrations/secret-syncs/aws-parameter-store.mdx @@ -9,6 +9,10 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical." - Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Sync** permissions - Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply. + + For workflows involving large amounts of secrets or frequent syncs, we recommend increasing your [AWS Parameter Store throughput quota](https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-throughput.html) to avoid rate limiting. + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. From 0d3cb2d41a4096ecd9e59e963ee09f72d2d72e0a Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 12 Aug 2025 23:25:31 -0700 Subject: [PATCH 15/27] feature: allow editing of access request by admin reviewers --- ...20250813020335_access-request-edit-cols.ts | 38 ++++ .../db/schemas/access-approval-requests.ts | 4 +- .../v1/access-approval-request-router.ts | 44 +++++ .../access-approval-request-service.ts | 145 ++++++++++++++- .../access-approval-request-types.ts | 23 +++ .../src/lib/workflow-integrations/types.ts | 3 + .../AccessApprovalRequestUpdatedTemplate.tsx | 95 ++++++++++ backend/src/services/smtp/emails/index.ts | 1 + backend/src/services/smtp/smtp-service.ts | 3 + .../src/components/features/TtlFormLabel.tsx | 11 +- .../src/hooks/api/accessApproval/mutation.tsx | 23 ++- .../src/hooks/api/accessApproval/types.ts | 9 + .../AccessApprovalRequest.tsx | 10 ++ .../components/EditAccessRequestModal.tsx | 169 ++++++++++++++++++ .../components/ReviewAccessModal.tsx | 48 ++++- 15 files changed, 610 insertions(+), 16 deletions(-) create mode 100644 backend/src/db/migrations/20250813020335_access-request-edit-cols.ts create mode 100644 backend/src/services/smtp/emails/AccessApprovalRequestUpdatedTemplate.tsx create mode 100644 frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal.tsx diff --git a/backend/src/db/migrations/20250813020335_access-request-edit-cols.ts b/backend/src/db/migrations/20250813020335_access-request-edit-cols.ts new file mode 100644 index 000000000..687ee5570 --- /dev/null +++ b/backend/src/db/migrations/20250813020335_access-request-edit-cols.ts @@ -0,0 +1,38 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote"); + const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId"); + + if (!hasEditNoteCol || !hasEditedByUserId) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + if (!hasEditedByUserId) { + t.uuid("editedByUserId").nullable(); + t.foreign("editedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + } + + if (!hasEditNoteCol) { + t.string("editNote").nullable(); + } + }); + } +} + +export async function down(knex: Knex): Promise { + const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote"); + const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId"); + + if (hasEditNoteCol || hasEditedByUserId) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + if (hasEditedByUserId) { + t.dropColumn("editedByUserId"); + } + + if (hasEditNoteCol) { + t.dropColumn("editNote"); + } + }); + } +} diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts index 6a6f09148..14997a974 100644 --- a/backend/src/db/schemas/access-approval-requests.ts +++ b/backend/src/db/schemas/access-approval-requests.ts @@ -20,7 +20,9 @@ export const AccessApprovalRequestsSchema = z.object({ requestedByUserId: z.string().uuid(), note: z.string().nullable().optional(), privilegeDeletedAt: z.date().nullable().optional(), - status: z.string().default("pending") + status: z.string().default("pending"), + editedByUserId: z.string().uuid().nullable().optional(), + editNote: z.string().nullable().optional() }); export type TAccessApprovalRequests = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index c9e7b0d5c..217ae1f13 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; +import { ms } from "@app/lib/ms"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -190,4 +191,47 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv return { review }; } }); + + server.route({ + url: "/:requestId", + method: "PATCH", + schema: { + params: z.object({ + requestId: z.string().trim() + }), + body: z.object({ + temporaryRange: z.string().transform((val, ctx) => { + const parsedMs = ms(val); + + if (typeof parsedMs !== "number" || parsedMs <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')." + }); + return z.NEVER; + } + return val; + }), + editNote: z.string().max(255) + }), + response: { + 200: z.object({ + approval: AccessApprovalRequestsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { request } = await server.services.accessApprovalRequest.updateAccessApprovalRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + temporaryRange: req.body.temporaryRange, + editNote: req.body.editNote, + requestId: req.params.requestId + }); + return { approval: request }; + } + }); }; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 58f5c57db..607373be4 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -54,7 +54,7 @@ type TSecretApprovalRequestServiceFactoryDep = { accessApprovalPolicyDAL: Pick; accessApprovalRequestReviewerDAL: Pick< TAccessApprovalRequestReviewerDALFactory, - "create" | "find" | "findOne" | "transaction" + "create" | "find" | "findOne" | "transaction" | "delete" >; groupDAL: Pick; projectMembershipDAL: Pick; @@ -301,6 +301,148 @@ export const accessApprovalRequestServiceFactory = ({ return { request: approval }; }; + const updateAccessApprovalRequest: TAccessApprovalRequestServiceFactory["updateAccessApprovalRequest"] = async ({ + temporaryRange, + actorId, + actor, + actorOrgId, + actorAuthMethod, + editNote, + requestId + }) => { + const cfg = getConfig(); + + const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId); + if (!accessApprovalRequest) { + throw new NotFoundError({ message: `Access request with ID '${requestId}' not found` }); + } + + const { policy, requestedByUser } = accessApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this access request has been deleted." + }); + } + + const { membership, hasRole } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: accessApprovalRequest.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + if (!membership) { + throw new ForbiddenRequestError({ message: "You are not a member of this project" }); + } + + const isApprover = policy.approvers.find((approver) => approver.userId === actorId); + + if (!hasRole(ProjectMembershipRole.Admin) && !isApprover) { + throw new ForbiddenRequestError({ message: "You are not authorized to modify this request" }); + } + + const project = await projectDAL.findById(accessApprovalRequest.projectId); + + if (!project) { + throw new NotFoundError({ message: "The project associated with this access request was not found." }); + } + + if (accessApprovalRequest.status !== ApprovalStatus.PENDING) { + throw new BadRequestError({ message: "The request has been closed" }); + } + + const editedByUser = await userDAL.findById(actorId); + + if (!editedByUser) throw new ForbiddenRequestError({ message: "User not found" }); + + if (accessApprovalRequest.isTemporary && accessApprovalRequest.temporaryRange) { + if (ms(temporaryRange) > ms(accessApprovalRequest.temporaryRange)) { + throw new BadRequestError({ message: "Updated access duration must be less than current access duration" }); + } + } + + const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({ + permissions: accessApprovalRequest.permissions + }); + + const approval = await accessApprovalRequestDAL.transaction(async (tx) => { + const approvalRequest = await accessApprovalRequestDAL.updateById( + requestId, + { + temporaryRange, + isTemporary: true, + editNote, + editedByUserId: actorId + }, + tx + ); + + // reset review progress + await accessApprovalRequestReviewerDAL.delete({ + requestId + }); + + const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; + const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`; + const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`; + + await triggerWorkflowIntegrationNotification({ + input: { + notification: { + type: TriggerFeature.ACCESS_REQUEST, + payload: { + projectName: project.name, + requesterFullName, + isTemporary: true, + requesterEmail: requestedByUser.email as string, + secretPath, + environment: envSlug, + permissions: accessTypes, + approvalUrl, + editNote, + editorEmail: editedByUser.email as string, + editorFullName + } + }, + projectId: project.id + }, + dependencies: { + projectDAL, + projectSlackConfigDAL, + kmsService, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL + } + }); + + await smtpService.sendMail({ + recipients: policy.approvers.filter((approver) => approver.email).map((approver) => approver.email!), + subjectLine: "Access Approval Request Updated", + substitutions: { + projectName: project.name, + requesterFullName, + requesterEmail: requestedByUser.email, + isTemporary: true, + expiresIn: msFn(ms(temporaryRange || ""), { long: true }), + secretPath, + environment: envSlug, + permissions: accessTypes, + approvalUrl, + editNote, + editorFullName, + editorEmail: editedByUser.email + }, + template: SmtpTemplates.AccessApprovalRequestUpdated + }); + + return approvalRequest; + }); + + return { request: approval }; + }; + const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({ projectSlug, authorUserId, @@ -650,6 +792,7 @@ export const accessApprovalRequestServiceFactory = ({ return { createAccessApprovalRequest, + updateAccessApprovalRequest, listApprovalRequests, reviewAccessRequest, getCount diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index 88a46192b..ed027835a 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -30,6 +30,12 @@ export type TCreateAccessApprovalRequestDTO = { note?: string; } & Omit; +export type TUpdateAccessApprovalRequestDTO = { + requestId: string; + temporaryRange: string; + editNote: string; +} & Omit; + export type TListApprovalRequestsDTO = { projectSlug: string; authorUserId?: string; @@ -54,6 +60,23 @@ export interface TAccessApprovalRequestServiceFactory { privilegeDeletedAt?: Date | null | undefined; }; }>; + updateAccessApprovalRequest: (arg: TUpdateAccessApprovalRequestDTO) => Promise<{ + request: { + status: string; + id: string; + createdAt: Date; + updatedAt: Date; + policyId: string; + isTemporary: boolean; + requestedByUserId: string; + privilegeId?: string | null | undefined; + requestedBy?: string | null | undefined; + temporaryRange?: string | null | undefined; + permissions?: unknown; + note?: string | null | undefined; + privilegeDeletedAt?: Date | null | undefined; + }; + }>; listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{ requests: { policy: { diff --git a/backend/src/lib/workflow-integrations/types.ts b/backend/src/lib/workflow-integrations/types.ts index c18ecb496..4cb783cd2 100644 --- a/backend/src/lib/workflow-integrations/types.ts +++ b/backend/src/lib/workflow-integrations/types.ts @@ -33,6 +33,9 @@ export type TNotification = permissions: string[]; approvalUrl: string; note?: string; + editNote?: string; + editorFullName?: string; + editorEmail?: string; }; }; diff --git a/backend/src/services/smtp/emails/AccessApprovalRequestUpdatedTemplate.tsx b/backend/src/services/smtp/emails/AccessApprovalRequestUpdatedTemplate.tsx new file mode 100644 index 000000000..2ade57dac --- /dev/null +++ b/backend/src/services/smtp/emails/AccessApprovalRequestUpdatedTemplate.tsx @@ -0,0 +1,95 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseButton } from "./BaseButton"; +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; + +interface AccessApprovalRequestUpdatedTemplateProps + extends Omit { + projectName: string; + requesterFullName: string; + requesterEmail: string; + isTemporary: boolean; + secretPath: string; + environment: string; + expiresIn: string; + permissions: string[]; + editNote: string; + editorFullName: string; + editorEmail: string; + approvalUrl: string; +} + +export const AccessApprovalRequestUpdatedTemplate = ({ + projectName, + siteUrl, + requesterFullName, + requesterEmail, + isTemporary, + secretPath, + environment, + expiresIn, + permissions, + editNote, + editorEmail, + editorFullName, + approvalUrl +}: AccessApprovalRequestUpdatedTemplateProps) => { + return ( + + + An access approval request was updated and is pending your review for the project {projectName} + +
+ + {editorFullName} ({editorEmail}) has + updated the access request submitted by {requesterFullName} ( + {requesterEmail}) for {secretPath} in + the {environment} environment. + + + {isTemporary && ( + + This access will expire {expiresIn} after approval. + + )} + + The following permissions are requested: + + {permissions.map((permission) => ( + + - {permission} + + ))} + + Editor Note: "{editNote}" + +
+
+ Review Request +
+
+ ); +}; + +export default AccessApprovalRequestUpdatedTemplate; + +AccessApprovalRequestUpdatedTemplate.PreviewProps = { + requesterFullName: "Abigail Williams", + requesterEmail: "abigail@infisical.com", + isTemporary: true, + secretPath: "/api/secrets", + environment: "Production", + siteUrl: "https://infisical.com", + projectName: "Example Project", + expiresIn: "1 day", + permissions: ["Read Secret", "Delete Project", "Create Dynamic Secret"], + editNote: "Too permissive, they only need 3 days", + editorEmail: "john@infisical.com", + editorFullName: "John Smith" +} as AccessApprovalRequestUpdatedTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 840a98cad..209cd672b 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -1,4 +1,5 @@ export * from "./AccessApprovalRequestTemplate"; +export * from "./AccessApprovalRequestUpdatedTemplate"; export * from "./EmailMfaTemplate"; export * from "./EmailVerificationTemplate"; export * from "./ExternalImportFailedTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index ac56f0ee4..500d0d89c 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -8,6 +8,7 @@ import { logger } from "@app/lib/logger"; import { AccessApprovalRequestTemplate, + AccessApprovalRequestUpdatedTemplate, EmailMfaTemplate, EmailVerificationTemplate, ExternalImportFailedTemplate, @@ -54,6 +55,7 @@ export enum SmtpTemplates { EmailMfa = "emailMfa", UnlockAccount = "unlockAccount", AccessApprovalRequest = "accessApprovalRequest", + AccessApprovalRequestUpdated = "accessApprovalRequestUpdated", AccessSecretRequestBypassed = "accessSecretRequestBypassed", SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview", // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? @@ -96,6 +98,7 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, [SmtpTemplates.EmailMfa]: EmailMfaTemplate, [SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate, + [SmtpTemplates.AccessApprovalRequestUpdated]: AccessApprovalRequestUpdatedTemplate, [SmtpTemplates.EmailVerification]: EmailVerificationTemplate, [SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate, [SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate, diff --git a/frontend/src/components/features/TtlFormLabel.tsx b/frontend/src/components/features/TtlFormLabel.tsx index fdb9aea5e..83f058c7a 100644 --- a/frontend/src/components/features/TtlFormLabel.tsx +++ b/frontend/src/components/features/TtlFormLabel.tsx @@ -1,4 +1,4 @@ -import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { faArrowUpRightFromSquare, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FormLabel, Tooltip } from "../v2"; @@ -10,15 +10,18 @@ export const TtlFormLabel = ({ label }: { label: string }) => ( label={label} icon={ + Examples: 30m, 1h, 3d, etc.{" "} - More + See More Examples{" "} + } @@ -26,7 +29,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => ( } diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 69637243f..539f7dbad 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -6,10 +6,12 @@ import { apiRequest } from "@app/config/request"; import { accessApprovalKeys } from "./queries"; import { TAccessApproval, + TAccessApprovalRequest, TCreateAccessPolicyDTO, TCreateAccessRequestDTO, TDeleteSecretPolicyDTO, - TUpdateAccessPolicyDTO + TUpdateAccessPolicyDTO, + TUpdateAccessRequestDTO } from "./types"; export const useCreateAccessApprovalPolicy = () => { @@ -134,6 +136,25 @@ export const useCreateAccessRequest = () => { }); }; +export const useUpdateAccessRequest = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ requestId, ...payload }) => { + const { data } = await apiRequest.patch<{ approval: TAccessApprovalRequest }>( + `/api/v1/access-approvals/requests/${requestId}`, + payload + ); + + return data.approval; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug) + }); + } + }); +}; + export const useReviewAccessRequest = () => { const queryClient = useQueryClient(); return useMutation< diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index fc14352f8..fa0b47450 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -103,6 +103,8 @@ export type TAccessApprovalRequest = { }[]; note?: string; + editNote?: string; + editedByUserId: string; }; export type TAccessApproval = { @@ -146,6 +148,13 @@ export type TCreateAccessRequestDTO = { note?: string; } & Omit; +export type TUpdateAccessRequestDTO = { + requestId: string; + editNote: string; + temporaryRange: string; + projectSlug: string; +}; + export type TGetAccessApprovalRequestsDTO = { projectSlug: string; policyId?: string; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index a8cb199d4..c55ff8cb2 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -592,6 +592,16 @@ export const AccessApprovalRequest = ({ setSelectedRequest(null); refetchRequests(); }} + onUpdate={(request) => { + // scott: this isn't ideal but our current use of state makes this complicated... + // we shouldn't be using state like this... + handleSelectRequest({ + ...selectedRequest, + isTemporary: true, + temporaryRange: request.temporaryRange, + reviewers: [] + }); + }} canBypass={generateRequestDetails(selectedRequest).canBypass} /> )} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal.tsx new file mode 100644 index 000000000..3cdf8a063 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal.tsx @@ -0,0 +1,169 @@ +import { Controller, useForm } from "react-hook-form"; +import { faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalClose, + ModalContent, + TextArea +} from "@app/components/v2"; +import { useUpdateAccessRequest } from "@app/hooks/api/accessApproval/mutation"; +import { accessApprovalKeys } from "@app/hooks/api/accessApproval/queries"; +import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; + +type ContentProps = { + accessRequest: TAccessApprovalRequest; + onComplete: (request: TAccessApprovalRequest) => void; + projectSlug: string; +}; + +const EditSchema = z.object({ + temporaryRange: z.string().nonempty("Required"), + editNote: z.string().nonempty("Required") +}); + +type FormData = z.infer; + +const Content = ({ accessRequest, onComplete, projectSlug }: ContentProps) => { + const update = useUpdateAccessRequest(); + const queryClient = useQueryClient(); + const { + handleSubmit, + control, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(EditSchema), + defaultValues: { + temporaryRange: accessRequest.temporaryRange ?? "1h", + editNote: "" + } + }); + + const onSubmit = async (form: FormData) => { + try { + const request = await update.mutateAsync({ + requestId: accessRequest.id, + projectSlug, + ...form + }); + await queryClient.refetchQueries({ + queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug) + }); + + createNotification({ + type: "success", + text: "Access request updated successfully." + }); + onComplete(request); + } catch (e) { + console.error(e); + createNotification({ + type: "error", + text: "Failed to update access request" + }); + } + }; + + return ( +
+
+ + Updating this access request will restart the review process and require all approvers to + re-approve it. +
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + helperText={`Must be less than current access duration: ${accessRequest.isTemporary ? accessRequest.temporaryRange : "Permanent"}`} + > + + + )} + /> + ( + +