diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index c1d270ee1..6f9c3ce6f 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -342,11 +342,12 @@ export const samlConfigServiceFactory = ({ orgId, inviteEmail: email, role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Accepted + status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later }, tx ); - } else if (orgMembership.status === OrgMembershipStatus.Invited) { + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { await orgDAL.updateMembershipById( orgMembership.id, { diff --git a/backend/src/server/routes/v1/project-env-router.ts b/backend/src/server/routes/v1/project-env-router.ts index ef51f865c..341b8a184 100644 --- a/backend/src/server/routes/v1/project-env-router.ts +++ b/backend/src/server/routes/v1/project-env-router.ts @@ -1,3 +1,4 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectEnvironmentsSchema } from "@app/db/schemas"; @@ -26,7 +27,13 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { }), body: z.object({ name: z.string().trim().describe(ENVIRONMENTS.CREATE.name), - slug: z.string().trim().describe(ENVIRONMENTS.CREATE.slug) + slug: z + .string() + .trim() + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(ENVIRONMENTS.CREATE.slug) }), response: { 200: z.object({ @@ -84,7 +91,14 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { id: z.string().trim().describe(ENVIRONMENTS.UPDATE.id) }), body: z.object({ - slug: z.string().trim().optional().describe(ENVIRONMENTS.UPDATE.slug), + slug: z + .string() + .trim() + .optional() + .refine((v) => !v || slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(ENVIRONMENTS.UPDATE.slug), name: z.string().trim().optional().describe(ENVIRONMENTS.UPDATE.name), position: z.number().optional().describe(ENVIRONMENTS.UPDATE.position) }), diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index bad986da7..13c8b97cb 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -4,6 +4,7 @@ import { OrgMembershipStatus } from "@app/db/schemas"; import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { isDisposableEmail } from "@app/lib/validator"; @@ -139,9 +140,11 @@ export const authSignupServiceFactory = ({ throw new Error("Failed to complete account for complete user"); } - let organizationId; + let organizationId: string | null = null; + let authMethod: AuthMethod | null = null; if (providerAuthToken) { - const { orgId } = validateProviderAuthToken(providerAuthToken, user.username); + const { orgId, authMethod: userAuthMethod } = validateProviderAuthToken(providerAuthToken, user.username); + authMethod = userAuthMethod; organizationId = orgId; } else { validateSignUpAuthorization(authorization, user.id); @@ -165,6 +168,26 @@ export const authSignupServiceFactory = ({ }, tx ); + // If it's SAML Auth and the organization ID is present, we should check if the user has a pending invite for this org, and accept it + if (isAuthMethodSaml(authMethod) && organizationId) { + const [pendingOrgMembership] = await orgDAL.findMembership({ + inviteEmail: email, + userId: user.id, + status: OrgMembershipStatus.Invited, + orgId: organizationId + }); + + if (pendingOrgMembership) { + await orgDAL.updateMembershipById( + pendingOrgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + } + return { info: us, key: userEncKey }; }); diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index 00b33a13c..fffc22a99 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -1,33 +1,66 @@ -import { SecretType, TSecretImports } from "@app/db/schemas"; +import { SecretType, TSecretImports, TSecrets } from "@app/db/schemas"; import { groupBy } from "@app/lib/fn"; import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretImportDALFactory } from "./secret-import-dal"; +type TSecretImportSecrets = { + secretPath: string; + environment: string; + environmentInfo: { + id: string; + slug: string; + name: string; + }; + folderId: string | undefined; + importFolderId: string; + secrets: (TSecrets & { workspace: string; environment: string; _id: string })[]; +}; + +const LEVEL_BREAK = 10; +const getImportUniqKey = (envSlug: string, path: string) => `${envSlug}=${path}`; export const fnSecretsFromImports = async ({ - allowedImports, + allowedImports: possibleCyclicImports, folderDAL, - secretDAL + secretDAL, + secretImportDAL, + depth = 0, + cyclicDetector = new Set() }: { allowedImports: (Omit & { importEnv: { id: string; slug: string; name: string }; })[]; folderDAL: Pick; secretDAL: Pick; + secretImportDAL: Pick; + depth?: number; + cyclicDetector?: Set; }) => { - const importedFolders = await folderDAL.findByManySecretPath( - allowedImports.map(({ importEnv, importPath }) => ({ - envId: importEnv.id, - secretPath: importPath - })) + // avoid going more than a depth + if (depth >= LEVEL_BREAK) return []; + + const allowedImports = possibleCyclicImports.filter( + ({ importPath, importEnv }) => !cyclicDetector.has(getImportUniqKey(importEnv.slug, importPath)) ); - const folderIds = importedFolders.map((el) => el?.id).filter(Boolean) as string[]; - if (!folderIds.length) { + + const importedFolders = ( + await folderDAL.findByManySecretPath( + allowedImports.map(({ importEnv, importPath }) => ({ + envId: importEnv.id, + secretPath: importPath + })) + ) + ).filter(Boolean); // remove undefined ones + if (!importedFolders.length) { return []; } + + const importedFolderIds = importedFolders.map((el) => el?.id) as string[]; + const importedFolderGroupBySourceImport = groupBy(importedFolders, (i) => `${i?.envId}-${i?.path}`); const importedSecrets = await secretDAL.find( { - $in: { folderId: folderIds }, + $in: { folderId: importedFolderIds }, type: SecretType.Shared }, { @@ -35,18 +68,50 @@ export const fnSecretsFromImports = async ({ } ); - const importedSecsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId); - return allowedImports.map(({ importPath, importEnv }, i) => ({ - secretPath: importPath, - environment: importEnv.slug, - environmentInfo: importEnv, - folderId: importedFolders?.[i]?.id, - // this will ensure for cases when secrets are empty. Could be due to missing folder for a path or when emtpy secrets inside a given path - secrets: (importedSecsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []).map((item) => ({ - ...item, + const importedSecretsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId); + + allowedImports.forEach(({ importPath, importEnv }) => { + cyclicDetector.add(getImportUniqKey(importEnv.slug, importPath)); + }); + // now we need to check recursively deeper imports made inside other imports + // we go level wise meaning we take all imports of a tree level and then go deeper ones level by level + const deeperImports = await secretImportDAL.findByFolderIds(importedFolderIds); + let secretsFromDeeperImports: TSecretImportSecrets[] = []; + if (deeperImports.length) { + secretsFromDeeperImports = await fnSecretsFromImports({ + allowedImports: deeperImports, + secretImportDAL, + folderDAL, + secretDAL, + depth: depth + 1, + cyclicDetector + }); + } + const secretsFromdeeperImportGroupedByFolderId = groupBy(secretsFromDeeperImports, (i) => i.importFolderId); + + const secrets = allowedImports.map(({ importPath, importEnv, id, folderId }, i) => { + const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`][0]; + const folderDeeperImportSecrets = + secretsFromdeeperImportGroupedByFolderId?.[sourceImportFolder?.id || ""]?.[0]?.secrets || []; + + return { + secretPath: importPath, environment: importEnv.slug, - workspace: "", // This field should not be used, it's only here to keep the older Python SDK versions backwards compatible with the new Postgres backend. - _id: item.id // The old Python SDK depends on the _id field being returned. We return this to keep the older Python SDK versions backwards compatible with the new Postgres backend. - })) - })); + environmentInfo: importEnv, + folderId: importedFolders?.[i]?.id, + id, + importFolderId: folderId, + // this will ensure for cases when secrets are empty. Could be due to missing folder for a path or when emtpy secrets inside a given path + secrets: (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []) + .map((item) => ({ + ...item, + environment: importEnv.slug, + workspace: "", // This field should not be used, it's only here to keep the older Python SDK versions backwards compatible with the new Postgres backend. + _id: item.id // The old Python SDK depends on the _id field being returned. We return this to keep the older Python SDK versions backwards compatible with the new Postgres backend. + })) + .concat(folderDeeperImportSecrets) + }; + }); + + return secrets; }; diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 2d59284d4..43676ba04 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -290,7 +290,7 @@ export const secretImportServiceFactory = ({ }) ) ); - return fnSecretsFromImports({ allowedImports, folderDAL, secretDAL }); + return fnSecretsFromImports({ allowedImports, folderDAL, secretDAL, secretImportDAL }); }; return { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 584395c54..5c7b9bef9 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -525,7 +525,8 @@ export const secretServiceFactory = ({ const importedSecrets = await fnSecretsFromImports({ allowedImports, secretDAL, - folderDAL + folderDAL, + secretImportDAL }); return { @@ -630,7 +631,8 @@ export const secretServiceFactory = ({ const importedSecrets = await fnSecretsFromImports({ allowedImports, secretDAL, - folderDAL + folderDAL, + secretImportDAL }); for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fbebc0884..e7c587f13 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,7 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@reduxjs/toolkit": "^1.8.3", + "@sindresorhus/slugify": "^2.2.1", "@stripe/react-stripe-js": "^1.16.3", "@stripe/stripe-js": "^1.46.0", "@tanstack/react-query": "^4.23.0", @@ -5776,6 +5777,57 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@sindresorhus/slugify": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", + "integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==", + "dependencies": { + "@sindresorhus/transliterate": "^1.0.0", + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/slugify/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz", + "integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==", + "dependencies": { + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@storybook/addon-actions": { "version": "7.6.8", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-7.6.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0dacef6b3..e01ef945e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@reduxjs/toolkit": "^1.8.3", + "@sindresorhus/slugify": "^2.2.1", "@stripe/react-stripe-js": "^1.16.3", "@stripe/stripe-js": "^1.46.0", "@tanstack/react-query": "^4.23.0", diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx new file mode 100644 index 000000000..2added878 --- /dev/null +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -0,0 +1,375 @@ +import { TextareaHTMLAttributes, useEffect, useRef, useState } from "react"; +import { faCircle, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import * as Popover from "@radix-ui/react-popover"; +import { twMerge } from "tailwind-merge"; + +import { useWorkspace } from "@app/context"; +import { useDebounce } from "@app/hooks"; +import { useGetFoldersByEnv, useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api"; + +import { SecretInput } from "../SecretInput"; + +const REGEX_UNCLOSED_SECRET_REFERENCE = /\${(?![^{}]*\})/g; +const REGEX_OPEN_SECRET_REFERENCE = /\${/g; + +export enum ReferenceType { + ENVIRONMENT = "environment", + FOLDER = "folder", + SECRET = "secret" +} + +type Props = TextareaHTMLAttributes & { + value?: string | null; + isImport?: boolean; + isVisible?: boolean; + isReadOnly?: boolean; + isDisabled?: boolean; + secretPath?: string; + environment?: string; + containerClassName?: string; +}; + +type ReferenceItem = { + name: string; + type: ReferenceType; + slug?: string; +}; + +export const InfisicalSecretInput = ({ + value: propValue, + isVisible, + containerClassName, + onBlur, + isDisabled, + isImport, + isReadOnly, + secretPath: propSecretPath, + environment: propEnvironment, + onChange, + ...props +}: Props) => { + const [inputValue, setInputValue] = useState(propValue ?? ""); + const [isSuggestionsOpen, setIsSuggestionsOpen] = useState(false); + const [currentCursorPosition, setCurrentCursorPosition] = useState(0); + const [currentReference, setCurrentReference] = useState(""); + const [secretPath, setSecretPath] = useState(propSecretPath || "/"); + const [environment, setEnvironment] = useState(propEnvironment); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { data: decryptFileKey } = useGetUserWsKey(workspaceId); + const { data: secrets } = useGetProjectSecrets({ + decryptFileKey: decryptFileKey!, + environment: environment || currentWorkspace?.environments?.[0].slug!, + secretPath, + workspaceId + }); + const { folderNames: folders } = useGetFoldersByEnv({ + path: secretPath, + environments: [environment || currentWorkspace?.environments?.[0].slug!], + projectId: workspaceId + }); + + const debouncedCurrentReference = useDebounce(currentReference, 100); + + const [listReference, setListReference] = useState([]); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const inputRef = useRef(null); + + useEffect(() => { + setInputValue(propValue ?? ""); + }, [propValue]); + + useEffect(() => { + let currentEnvironment = propEnvironment; + let currentSecretPath = propSecretPath || "/"; + + if (!currentReference) { + setSecretPath(currentSecretPath); + setEnvironment(currentEnvironment); + return; + } + + const isNested = currentReference.includes("."); + + if (isNested) { + const [envSlug, ...folderPaths] = currentReference.split("."); + const isValidEnvSlug = currentWorkspace?.environments.find((e) => e.slug === envSlug); + currentEnvironment = isValidEnvSlug ? envSlug : undefined; + + // should be based on the last valid section (with .) + folderPaths.pop(); + currentSecretPath = `/${folderPaths?.join("/")}`; + } + + setSecretPath(currentSecretPath); + setEnvironment(currentEnvironment); + }, [debouncedCurrentReference]); + + useEffect(() => { + const currentListReference: ReferenceItem[] = []; + const isNested = currentReference?.includes("."); + + if (!currentReference) { + setListReference(currentListReference); + return; + } + + if (!environment) { + currentWorkspace?.environments.forEach((env) => { + currentListReference.unshift({ + name: env.slug, + type: ReferenceType.ENVIRONMENT + }); + }); + } else if (isNested) { + folders?.forEach((folder) => { + currentListReference.unshift({ name: folder, type: ReferenceType.FOLDER }); + }); + } else if (environment) { + currentWorkspace?.environments.forEach((env) => { + currentListReference.unshift({ + name: env.slug, + type: ReferenceType.ENVIRONMENT + }); + }); + } + + secrets?.forEach((secret) => { + currentListReference.unshift({ name: secret.key, type: ReferenceType.SECRET }); + }); + + // Get fragment inside currentReference + const searchFragment = isNested ? currentReference.split(".").pop() || "" : currentReference; + const filteredListRef = currentListReference + .filter((suggestionEntry) => + suggestionEntry.name.toUpperCase().startsWith(searchFragment.toUpperCase()) + ) + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + + setListReference(filteredListRef); + }, [secrets, environment, debouncedCurrentReference]); + + const getIndexOfUnclosedRefToTheLeft = (pos: number) => { + // take substring up to pos in order to consider edits for closed references + const unclosedReferenceIndexMatches = [ + ...inputValue.substring(0, pos).matchAll(REGEX_UNCLOSED_SECRET_REFERENCE) + ].map((match) => match.index); + + // find unclosed reference index less than the current cursor position + let indexIter = -1; + unclosedReferenceIndexMatches.forEach((index) => { + if (index !== undefined && index > indexIter && index < pos) { + indexIter = index; + } + }); + + return indexIter; + }; + + const getIndexOfUnclosedRefToTheRight = (pos: number) => { + const unclosedReferenceIndexMatches = [...inputValue.matchAll(REGEX_OPEN_SECRET_REFERENCE)].map( + (match) => match.index + ); + + // find the next unclosed reference index to the right of the current cursor position + // this is so that we know the limitation for slicing references + let indexIter = Infinity; + unclosedReferenceIndexMatches.forEach((index) => { + if (index !== undefined && index > pos && index < indexIter) { + indexIter = index; + } + }); + + return indexIter; + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + // open suggestions if current position is to the right of an unclosed secret reference + const indexIter = getIndexOfUnclosedRefToTheLeft(currentCursorPosition); + if (indexIter === -1) { + return; + } + + setIsSuggestionsOpen(true); + + if (e.key !== "Enter") { + // current reference is then going to be based on the text from the closest ${ to the right + // until the current cursor position + const openReferenceValue = inputValue.slice(indexIter + 2, currentCursorPosition); + setCurrentReference(openReferenceValue); + } + }; + + const handleSuggestionSelect = (selectedIndex?: number) => { + const selectedSuggestion = listReference[selectedIndex ?? highlightedIndex]; + + if (!selectedSuggestion) { + return; + } + + const leftIndexIter = getIndexOfUnclosedRefToTheLeft(currentCursorPosition); + const rightIndexLimit = getIndexOfUnclosedRefToTheRight(currentCursorPosition); + + if (leftIndexIter === -1) { + return; + } + + let newValue = ""; + const currentOpenRef = inputValue.slice(leftIndexIter + 2, currentCursorPosition); + if (currentOpenRef.includes(".")) { + // append suggestion after last DOT (.) + const lastDotIndex = currentReference.lastIndexOf("."); + const existingPath = currentReference.slice(0, lastDotIndex); + const refEndAfterAppending = Math.min( + leftIndexIter + + 3 + + existingPath.length + + selectedSuggestion.name.length + + Number(selectedSuggestion.type !== ReferenceType.SECRET), + rightIndexLimit - 1 + ); + + newValue = `${inputValue.slice(0, leftIndexIter + 2)}${existingPath}.${ + selectedSuggestion.name + }${selectedSuggestion.type !== ReferenceType.SECRET ? "." : "}"}${inputValue.slice( + refEndAfterAppending + )}`; + const openReferenceValue = newValue.slice(leftIndexIter + 2, refEndAfterAppending); + setCurrentReference(openReferenceValue); + + // add 1 in order to prevent referenceOpen from being triggered by handleKeyUp + setCurrentCursorPosition(refEndAfterAppending + 1); + } else { + // append selectedSuggestion at position after unclosed ${ + const refEndAfterAppending = Math.min( + selectedSuggestion.name.length + + leftIndexIter + + 2 + + Number(selectedSuggestion.type !== ReferenceType.SECRET), + rightIndexLimit - 1 + ); + + newValue = `${inputValue.slice(0, leftIndexIter + 2)}${selectedSuggestion.name}${ + selectedSuggestion.type !== ReferenceType.SECRET ? "." : "}" + }${inputValue.slice(refEndAfterAppending)}`; + + const openReferenceValue = newValue.slice(leftIndexIter + 2, refEndAfterAppending); + setCurrentReference(openReferenceValue); + setCurrentCursorPosition(refEndAfterAppending); + } + + onChange?.({ target: { value: newValue } } as any); + setInputValue(newValue); + setHighlightedIndex(-1); + setIsSuggestionsOpen(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const mod = (n: number, m: number) => ((n % m) + m) % m; + if (e.key === "ArrowDown") { + setHighlightedIndex((prevIndex) => mod(prevIndex + 1, listReference.length)); + } else if (e.key === "ArrowUp") { + setHighlightedIndex((prevIndex) => mod(prevIndex - 1, listReference.length)); + } else if (e.key === "Enter" && highlightedIndex >= 0) { + handleSuggestionSelect(); + } + if (["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) { + e.preventDefault(); + } + }; + + const setIsOpen = (isOpen: boolean) => { + setHighlightedIndex(-1); + + if (isSuggestionsOpen) { + setIsSuggestionsOpen(isOpen); + } + }; + + const handleSecretChange = (e: any) => { + // propagate event to react-hook-form onChange + if (onChange) { + onChange(e); + } + + setCurrentCursorPosition(inputRef.current?.selectionStart || 0); + setInputValue(e.target.value); + }; + + return ( + 0 && currentReference.length > 0} + onOpenChange={setIsOpen} + > + + + + e.preventDefault()} + className={twMerge( + "relative top-2 z-[100] overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 font-inter text-bunker-100 shadow-md" + )} + style={{ + width: "var(--radix-popover-trigger-width)", + maxHeight: "var(--radix-select-content-available-height)" + }} + > +
+ {listReference.map((item, i) => { + let entryIcon; + if (item.type === ReferenceType.SECRET) { + entryIcon = faKey; + } else if (item.type === ReferenceType.ENVIRONMENT) { + entryIcon = faCircle; + } else { + entryIcon = faFolder; + } + + return ( +
{ + e.preventDefault(); + setHighlightedIndex(i); + handleSuggestionSelect(i); + }} + style={{ pointerEvents: "auto" }} + className="flex items-center justify-between border-mineshaft-600 text-left" + key={`secret-reference-secret-${i + 1}`} + > +
+
+
+ +
+
{item.name}
+
+
+
+ ); + })} +
+
+
+ ); +}; + +InfisicalSecretInput.displayName = "InfisicalSecretInput"; diff --git a/frontend/src/components/v2/InfisicalSecretInput/index.tsx b/frontend/src/components/v2/InfisicalSecretInput/index.tsx new file mode 100644 index 000000000..bf5be9452 --- /dev/null +++ b/frontend/src/components/v2/InfisicalSecretInput/index.tsx @@ -0,0 +1 @@ +export { InfisicalSecretInput } from "./InfisicalSecretInput"; diff --git a/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx index 23f8089d1..4efd6bdc3 100644 --- a/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -3,7 +3,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input, Modal, ModalContent, SecretInput } from "@app/components/v2"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { useCreateSecretV3 } from "@app/hooks/api"; import { UserWsKeyPair } from "@app/hooks/api/types"; @@ -44,8 +45,6 @@ export const CreateSecretForm = ({ const { isOpen } = usePopUpState(PopUpNames.CreateSecretForm); const { closePopUp, togglePopUp } = usePopUpAction(); - - const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const handleFormSubmit = async ({ key, value }: TFormSchema) => { @@ -103,8 +102,10 @@ export const CreateSecretForm = ({ isError={Boolean(errors?.value)} errorText={errors?.value?.message} > - diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx index 8f23a6ae7..28e2fed15 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx @@ -27,12 +27,12 @@ import { FormControl, IconButton, Input, - SecretInput, Switch, Tag, TextArea, Tooltip } from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; import { useToggle } from "@app/hooks"; import { useGetSecretVersion } from "@app/hooks/api"; @@ -71,7 +71,6 @@ export const SecretDetailSidebar = ({ environment, secretPath }: Props) => { - const { register, control, @@ -204,8 +203,10 @@ export const SecretDetailSidebar = ({ control={control} render={({ field }) => ( - ( - diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx index 7996d932c..455b7bbe6 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx @@ -14,7 +14,6 @@ import { Popover, PopoverContent, PopoverTrigger, - SecretInput, Spinner, TextArea, Tooltip @@ -49,6 +48,7 @@ import { memo, useEffect } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { twMerge } from "tailwind-merge"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { CreateReminderForm } from "./CreateReminderForm"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; @@ -263,10 +263,12 @@ export const SecretItem = memo( key="value-overriden" control={control} render={({ field }) => ( - @@ -278,10 +280,12 @@ export const SecretItem = memo( key="secret-value" control={control} render={({ field }) => ( - diff --git a/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx index 249727655..a571ca5d3 100644 --- a/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx @@ -120,7 +120,9 @@ export const SecretItem = ({ mode, preSecret, postSecret }: Props) => { Value {isModified && ( - + )} diff --git a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index f7605aa93..85d3125d5 100644 --- a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -13,9 +13,9 @@ import { Input, Modal, ModalContent, - SecretInput, Tooltip } from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { useWorkspace } from "@app/context"; import { useCreateFolder, useCreateSecretV3, useUpdateSecretV3 } from "@app/hooks/api"; import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types"; @@ -64,8 +64,6 @@ export const CreateSecretForm = ({ const workspaceId = currentWorkspace?.id || ""; const environments = currentWorkspace?.environments || []; - - const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: createFolder } = useCreateFolder(); @@ -163,7 +161,7 @@ export const CreateSecretForm = ({ isError={Boolean(errors?.value)} errorText={errors?.value?.message} > - diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index e06a6d500..71529c95c 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -6,7 +6,8 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { IconButton, SecretInput, Tooltip } from "@app/components/v2"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useToggle } from "@app/hooks"; @@ -49,7 +50,6 @@ export const SecretEditRow = ({ } }); const [isDeleting, setIsDeleting] = useToggle(); - const handleFormReset = () => { reset(); @@ -97,10 +97,13 @@ export const SecretEditRow = ({ control={control} name="value" render={({ field }) => ( - )} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx index 658faea0e..68bbeb840 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx @@ -1,5 +1,6 @@ import { Controller, useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; +import slugify from "@sindresorhus/slugify"; import * as yup from "yup"; import { createNotification } from "@app/components/notifications"; @@ -16,7 +17,14 @@ type Props = { const schema = yup.object({ environmentName: yup.string().label("Environment Name").required(), - environmentSlug: yup.string().label("Environment Slug").required() + environmentSlug: yup + .string() + .label("Environment Slug") + .test({ + test: (slug) => slugify(slug as string) === slug, + message: "Slug must be a valid slug" + }) + .required() }); export type FormData = yup.InferType; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx index 0acd9fbe1..c6b2152cc 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx @@ -1,5 +1,6 @@ import { Controller, useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; +import slugify from "@sindresorhus/slugify"; import * as yup from "yup"; import { createNotification } from "@app/components/notifications"; @@ -16,13 +17,19 @@ type Props = { const schema = yup.object({ name: yup.string().label("Environment Name").required(), - slug: yup.string().label("Environment Slug").required() + slug: yup + .string() + .label("Environment Slug") + .test({ + test: (slug) => slugify(slug as string) === slug, + message: "Slug must be a valid slug" + }) + .required() }); export type FormData = yup.InferType; export const UpdateEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { - const { currentWorkspace } = useWorkspace(); const { mutateAsync, isLoading } = useUpdateWsEnvironment(); const { control, handleSubmit, reset } = useForm({ @@ -108,7 +115,11 @@ export const UpdateEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpTog Update -