diff --git a/frontend/app/[team]/apps/[app]/_components/AppSecretRow.tsx b/frontend/app/[team]/apps/[app]/_components/AppSecretRow.tsx new file mode 100644 index 000000000..b492037ad --- /dev/null +++ b/frontend/app/[team]/apps/[app]/_components/AppSecretRow.tsx @@ -0,0 +1,499 @@ +import { EnvironmentType, SecretType } from '@/apollo/graphql' +import { userHasPermission } from '@/utils/access/permissions' +import { Disclosure, Switch, Transition } from '@headlessui/react' +import clsx from 'clsx' +import { + FaChevronRight, + FaCircle, + FaCheckCircle, + FaTimesCircle, + FaCopy, + FaExternalLinkAlt, + FaRegEye, + FaRegEyeSlash, + FaTrash, + FaTrashAlt, + FaUndo, + FaChevronDown, + FaPlus, + FaExclamationCircle, +} from 'react-icons/fa' +import { AppSecret } from '../types' +import { organisationContext } from '@/contexts/organisationContext' +import { useContext, useEffect, useState } from 'react' +import { Button } from '@/components/common/Button' +import { copyToClipBoard } from '@/utils/clipboard' +import { useMutation } from '@apollo/client' +import Link from 'next/link' +import { toast } from 'react-toastify' +import { LogSecretReads } from '@/graphql/mutations/environments/readSecret.gql' +import { usePathname } from 'next/navigation' +import { arraysEqual } from '@/utils/crypto' +import { toggleBooleanKeepingCase } from '@/utils/secrets' + +const INPUT_BASE_STYLE = + 'w-full font-mono custom bg-transparent group-hover:bg-zinc-400/20 dark:group-hover:bg-zinc-400/10 transition ease ph-no-capture' + +const EnvSecret = ({ + appSecretId, + clientEnvSecret, + serverEnvSecret, + sameAsProd, + stagedForDelete, + updateEnvValue, + addEnvValue, + deleteEnvValue, +}: { + clientEnvSecret: { + env: Partial + secret: SecretType | null + } + serverEnvSecret?: { + env: Partial + secret: SecretType | null + } + appSecretId: string + sameAsProd: boolean + stagedForDelete?: boolean + updateEnvValue: (id: string, envId: string, value: string | undefined) => void + addEnvValue: (appSecretId: string, environment: EnvironmentType) => void + deleteEnvValue: (appSecretId: string, environment: EnvironmentType) => void +}) => { + const pathname = usePathname() + const { activeOrganisation: organisation } = useContext(organisationContext) + const [readSecret] = useMutation(LogSecretReads) + + const valueIsNew = clientEnvSecret.secret?.id.includes('new') + + const [showValue, setShowValue] = useState(valueIsNew || false) + + const isBoolean = clientEnvSecret?.secret + ? ['true', 'false'].includes(clientEnvSecret.secret.value.toLowerCase()) + : false + const booleanValue = clientEnvSecret.secret?.value.toLowerCase() === 'true' + + // Permisssions + const userCanUpdateSecrets = + userHasPermission(organisation?.role?.permissions, 'Secrets', 'update', true) || + !serverEnvSecret + const userCanDeleteSecrets = + userHasPermission(organisation?.role?.permissions, 'Secrets', 'delete', true) || + !serverEnvSecret + + const handleRevealSecret = async () => { + setShowValue(true) + if (serverEnvSecret?.secret?.id) + await readSecret({ variables: { ids: [serverEnvSecret.secret!.id] } }) + } + + const handleToggleBoolean = () => { + const toggledValue = toggleBooleanKeepingCase(clientEnvSecret.secret!.value) + updateEnvValue(appSecretId, clientEnvSecret.env.id!, toggledValue) + } + + // Reveal boolean values on mount for boolean secrets + useEffect(() => { + if (isBoolean) setShowValue(true) + }, [isBoolean]) + + const handleHideSecret = () => setShowValue(false) + + const toggleShowValue = () => { + showValue ? handleHideSecret() : handleRevealSecret() + } + + const handleCopy = async (val: string) => { + copyToClipBoard(val) + toast.info('Copied', { autoClose: 2000 }) + await readSecret({ variables: { ids: [clientEnvSecret.secret!.id] } }) + } + + const handleDeleteValue = () => + deleteEnvValue(appSecretId, clientEnvSecret!.env as EnvironmentType) + + const handleAddValue = () => addEnvValue(appSecretId, clientEnvSecret.env as EnvironmentType) + + const valueIsModified = () => { + if (serverEnvSecret) { + if (serverEnvSecret.secret?.value !== clientEnvSecret.secret?.value) return true + } + + return false + } + + const inputTextColor = () => { + if (valueIsNew) return 'text-emerald-700 dark:text-emerald-200' + else if (stagedForDelete) return 'text-red-700 dark:text-red-400 line-through' + else if (valueIsModified()) return 'text-amber-700 dark:text-amber-300' + else return 'text-zinc-900 dark:text-zinc-100' + } + + const bgColor = () => { + if (stagedForDelete) return 'bg-red-400/20 dark:bg-red-400/10' + else if (valueIsNew) return 'bg-emerald-400/40' + else if (valueIsModified()) return 'bg-amber-400/20 dark:bg-amber-400/10' + else return '' + } + + return ( +
+
+ +
{clientEnvSecret.env.name}
+ + {sameAsProd && ( + + )} + +
+ + {clientEnvSecret.secret === null ? ( +
+ missing + {' '} +
+ ) : ( +
+
+
+ {isBoolean && !stagedForDelete && ( +
+ + Toggle + + +
+ )} + + updateEnvValue( + appSecretId, + clientEnvSecret.env.id!, + e.target.value.replace(/ /g, '_') + ) + } + /> +
+ {clientEnvSecret.secret !== null && ( +
+ + + +
+ )} +
+
+ )} +
+ ) +} + +export const AppSecretRow = ({ + index, + clientAppSecret, + serverAppSecret, + stagedForDelete, + secretsStagedForDelete, + updateKey, + updateValue, + addEnvValue, + deleteEnvValue, + deleteKey, +}: { + index: number + clientAppSecret: AppSecret + serverAppSecret?: AppSecret + stagedForDelete?: boolean + secretsStagedForDelete: string[] + updateKey: (id: string, v: string) => void + updateValue: (id: string, envId: string, v: string | undefined) => void + addEnvValue: (appSecretId: string, environment: EnvironmentType) => void + deleteEnvValue: (appSecretId: string, environment: EnvironmentType) => void + deleteKey: (id: string) => void +}) => { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const newEnvValueAdded = clientAppSecret.envs.some((env) => env?.secret?.id.includes('new')) + const secretIsNew = !serverAppSecret + + const [key, setKey] = useState(clientAppSecret.key) + const [isOpen, setIsOpen] = useState(false) + + const toggleAccordion = () => setIsOpen(!isOpen) + + const handleUpdateKey = (k: string) => { + const sanitizedK = k.replace(/ /g, '_').toUpperCase() + setKey(sanitizedK) + updateKey(clientAppSecret.id, sanitizedK) + } + + // Permisssions + const userCanUpdateSecrets = + userHasPermission(organisation?.role?.permissions, 'Secrets', 'update', true) || secretIsNew + const userCanDeleteSecrets = + userHasPermission(organisation?.role?.permissions, 'Secrets', 'delete', true) || secretIsNew + + const prodSecret = clientAppSecret.envs.find( + (env) => env.env.envType?.toLowerCase() === 'prod' + )?.secret + + const secretIsSameAsProd = (env: { env: Partial; secret: SecretType | null }) => + prodSecret !== null && + env.secret?.value === prodSecret?.value && + env.env.envType?.toLowerCase() !== 'prod' + + const keyIsBlank = clientAppSecret.key === '' + const keyIsDuplicate = false // TODO implement + + const tooltipText = (env: { env: Partial; secret: SecretType | null }) => { + if (env.secret === null) return `This secret is missing in ${env.env.name}` + else if (env.secret.value.length === 0) return `This secret is blank in ${env.env.name}` + else if (secretIsSameAsProd(env)) return `This secret is the same as Production.` + else return 'This secret is present' + } + + const envValuesAreStagedForDelete = () => { + const envSecretIds = clientAppSecret.envs.map((env) => env.secret?.id) + return envSecretIds.some((id) => (id ? secretsStagedForDelete.includes(id) : false)) + } + + const secretIsModified = () => { + if (serverAppSecret) { + const serverEnvVales = serverAppSecret.envs.map((env) => env.secret?.value) + const clientEnvVales = clientAppSecret.envs.map((env) => env.secret?.value) + if ( + serverAppSecret.key !== clientAppSecret.key || + !arraysEqual(serverEnvVales, clientEnvVales) || + envValuesAreStagedForDelete() || + newEnvValueAdded + ) { + return true + } + } + + return false + } + + const rowBgColorOpen = () => { + if (stagedForDelete) return 'bg-red-400/20 dark:bg-red-400/10' + else if (secretIsNew) return 'bg-emerald-400/40' + else if (secretIsModified()) return 'bg-amber-400/20 dark:bg-amber-400/10' + else return 'bg-zinc-100 dark:bg-zinc-800' + } + + const rowBgColorClosed = () => { + if (stagedForDelete) return 'bg-red-400/20 dark:bg-red-400/10' + if (secretIsNew) return 'bg-emerald-400/20 dark:bg-emerald-400/ hover:bg-emerald-400/40' + else if (secretIsModified()) return 'bg-amber-400/20 dark:bg-amber-400/10' + else return 'bg-zinc-100 dark:bg-zinc-800' + } + + const rowInputColor = () => { + if (stagedForDelete) return 'text-red-700 dark:text-red-400 line-through' + else if (secretIsNew) return 'text-emerald-700 dark:text-emerald-200' + else if (secretIsModified()) return 'text-amber-700 dark:text-amber-300' + else return 'text-zinc-900 dark:text-zinc-100' + } + + const rowBorderColor = () => { + if (stagedForDelete) return '!border-l-red-700 !dark:border-l-red-400' + else if (secretIsNew) return '!border-l-emerald-700 !dark:border-l-emerald-200' + else if (secretIsModified()) return '!border-l-amber-700 !dark:border-l-amber-300' + else return '!border-neutral-500/20' + } + + const serverEnvSecret = (id: string) => serverAppSecret?.envs.find((env) => env.env.id === id) + + return ( + + {({ open }) => ( + <> + + + +
+ handleUpdateKey(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={(e) => e.stopPropagation()} + /> +
+ {userCanDeleteSecrets && ( + + )} +
+
+ + {clientAppSecret.envs.map((env) => ( + +
+ {env.secret !== null ? ( + env.secret.value.length === 0 ? ( + + ) : ( + + ) + ) : ( + + )} +
+ + ))} + + + {isOpen && ( + + +
+ {clientAppSecret.envs.map((envSecret) => ( + + ))} +
+
+ + )} +
+ + )} +
+ ) +} diff --git a/frontend/app/[team]/apps/[app]/_components/SecretInfoLegend.tsx b/frontend/app/[team]/apps/[app]/_components/SecretInfoLegend.tsx new file mode 100644 index 000000000..4f32bee85 --- /dev/null +++ b/frontend/app/[team]/apps/[app]/_components/SecretInfoLegend.tsx @@ -0,0 +1,18 @@ +import { FaCheckCircle, FaCircle, FaTimesCircle } from 'react-icons/fa' + +export const SecretInfoLegend = () => ( +
+
+ Secret is present +
+
+ Secret is the same as Production +
+
+ Secret is blank +
+
+ Secret is missing +
+
+) diff --git a/frontend/app/[team]/apps/[app]/page.tsx b/frontend/app/[team]/apps/[app]/page.tsx index e9bc10032..e3a4b4890 100644 --- a/frontend/app/[team]/apps/[app]/page.tsx +++ b/frontend/app/[team]/apps/[app]/page.tsx @@ -3,13 +3,15 @@ import { GetAppEnvironments } from '@/graphql/queries/secrets/getAppEnvironments.gql' import { GetEnvSecretsKV } from '@/graphql/queries/secrets/getSecretKVs.gql' import { InitAppEnvironments } from '@/graphql/mutations/environments/initAppEnvironments.gql' -import { LogSecretReads } from '@/graphql/mutations/environments/readSecret.gql' +import { BulkProcessSecrets } from '@/graphql/mutations/environments/bulkProcessSecrets.gql' +import { GetAppSyncStatus } from '@/graphql/queries/syncing/getAppSyncStatus.gql' import { useLazyQuery, useMutation, useQuery } from '@apollo/client' import { useContext, useEffect, useState } from 'react' import { ApiOrganisationPlanChoices, EnvironmentType, SecretFolderType, + SecretInput, SecretType, } from '@/apollo/graphql' import _sodium from 'libsodium-wrappers-sumo' @@ -18,17 +20,16 @@ import { KeyringContext } from '@/contexts/keyringContext' import { FaArrowRight, FaBan, + FaBoxOpen, FaCheckCircle, FaChevronRight, - FaCircle, - FaCopy, + FaCloudUploadAlt, FaExchangeAlt, - FaExternalLinkAlt, FaFolder, - FaRegEye, - FaRegEyeSlash, + FaPlus, FaSearch, FaTimesCircle, + FaUndo, } from 'react-icons/fa' import Link from 'next/link' import { usePathname } from 'next/navigation' @@ -36,43 +37,29 @@ import { organisationContext } from '@/contexts/organisationContext' import { Button } from '@/components/common/Button' import clsx from 'clsx' import { Disclosure, Transition } from '@headlessui/react' -import { copyToClipBoard } from '@/utils/clipboard' -import { toast } from 'react-toastify' import { userHasPermission } from '@/utils/access/permissions' import Spinner from '@/components/common/Spinner' import { Card } from '@/components/common/Card' import { BsListColumnsReverse } from 'react-icons/bs' -import { unwrapEnvSecretsForUser, decryptEnvSecretKVs } from '@/utils/crypto' +import { + unwrapEnvSecretsForUser, + decryptEnvSecretKVs, + digest, + encryptAsymmetric, + decryptAsymmetric, + getUserKxPrivateKey, + getUserKxPublicKey, + arraysEqual, +} from '@/utils/crypto' import { ManageEnvironmentDialog } from '@/components/environments/ManageEnvironmentDialog' import { CreateEnvironmentDialog } from '@/components/environments/CreateEnvironmentDialog' import { SwapEnvOrder } from '@/graphql/mutations/environments/swapEnvironmentOrder.gql' import { EmptyState } from '@/components/common/EmptyState' - -type EnvSecrets = { - env: EnvironmentType - secrets: SecretType[] -} - -type EnvFolders = { - env: EnvironmentType - folders: SecretFolderType[] -} - -type AppSecret = { - key: string - envs: Array<{ - env: Partial - secret: SecretType | null - }> -} - -type AppFolder = { - name: string - envs: Array<{ - env: Partial - folder: SecretFolderType | null - }> -} +import { AppSecretRow } from './_components/AppSecretRow' +import { AppSecret, AppFolder, EnvSecrets, EnvFolders } from './types' +import { toast } from 'react-toastify' +import { EnvSyncStatus } from '@/components/syncing/EnvSyncStatus' +import { SecretInfoLegend } from './_components/SecretInfoLegend' const Environments = (props: { environments: EnvironmentType[]; appId: string }) => { const { activeOrganisation: organisation } = useContext(organisationContext) @@ -184,6 +171,7 @@ const Environments = (props: { environments: EnvironmentType[]; appId: string }) export default function Secrets({ params }: { params: { team: string; app: string } }) { const { activeOrganisation: organisation } = useContext(organisationContext) + // Permissions const userCanReadEnvironments = userHasPermission( organisation?.role?.permissions, 'Environments', @@ -196,7 +184,12 @@ export default function Secrets({ params }: { params: { team: string; app: strin 'read', true ) - const userCanReadMembers = userHasPermission(organisation?.role?.permissions, 'Members', 'read') + const userCanReadSyncs = userHasPermission( + organisation?.role?.permissions, + 'Integrations', + 'read', + true + ) const { data } = useQuery(GetAppEnvironments, { variables: { @@ -210,19 +203,50 @@ export default function Secrets({ params }: { params: { team: string; app: strin const [getEnvSecrets] = useLazyQuery(GetEnvSecretsKV) - const [appSecrets, setAppSecrets] = useState([]) + const [serverAppSecrets, setServerAppSecrets] = useState([]) + const [clientAppSecrets, setClientAppSecrets] = useState([]) + const [secretsToDelete, setSecretsToDelete] = useState([]) + const [appSecretsToDelete, setAppSecretsToDelete] = useState([]) + const [appFolders, setAppFolders] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [initAppEnvironments] = useMutation(InitAppEnvironments) + const [bulkProcessSecrets, { loading: bulkUpdatePending }] = useMutation(BulkProcessSecrets) const [loading, setLoading] = useState(false) + const savingAndFetching = bulkUpdatePending || loading + const { keyring } = useContext(KeyringContext) + const normalizeValues = (values: (string | undefined)[]) => values.map((value) => value ?? null) // Replace undefined with null for consistent comparison + + const unsavedChanges = + // Check if any secrets are staged for delete + secretsToDelete.length > 0 || + // Check if any new secret keys are added + !arraysEqual( + clientAppSecrets.map((appSecret) => appSecret.key), + serverAppSecrets.map((appSecret) => appSecret.key) + ) || + // Check if values are modified for existing secrets + serverAppSecrets.some((appSecret) => { + const clientSecret = clientAppSecrets.find( + (clientAppSecret) => clientAppSecret.id === appSecret.id + ) + + if (!clientSecret) return true // Secret is missing in client (potential deletion) + + return !arraysEqual( + normalizeValues(appSecret.envs.map((env) => env.secret?.value)), + normalizeValues(clientSecret.envs.map((env) => env.secret?.value)) + ) + }) + const filteredSecrets = searchQuery === '' - ? appSecrets - : appSecrets.filter((secret) => { + ? clientAppSecrets + : clientAppSecrets.filter((secret) => { const searchRegex = new RegExp(searchQuery, 'i') return searchRegex.test(secret.key) }) @@ -235,162 +259,470 @@ export default function Secrets({ params }: { params: { team: string; app: strin return searchRegex.test(folder.name) }) - useEffect(() => { - const fetchAndDecryptAppEnvs = async (appEnvironments: EnvironmentType[]) => { - setLoading(true) - const envSecrets = [] as EnvSecrets[] - const envFolders = [] as EnvFolders[] - - for (const env of appEnvironments) { - const { data } = await getEnvSecrets({ - variables: { - envId: env.id, - }, - }) + const { data: syncsData } = useQuery(GetAppSyncStatus, { + variables: { + appId: params.app, + }, + skip: !userCanReadSyncs, + pollInterval: unsavedChanges ? 0 : 5000, + }) + + /** + * Fetches encrypted secrets and folders for the given application environments, + * decrypts them using the user's keyring, and processes the data into a unified + * format for managing secrets and folders in the application state. + * + * @param {EnvironmentType[]} appEnvironments - Array of application environments to fetch and decrypt secrets for. + * @returns {Promise} Resolves once the secrets and folders are processed and state is updated. + */ + const fetchAndDecryptAppEnvs = async (appEnvironments: EnvironmentType[]): Promise => { + setLoading(true) + const envSecrets = [] as EnvSecrets[] + const envFolders = [] as EnvFolders[] + + for (const env of appEnvironments) { + const { data } = await getEnvSecrets({ + variables: { + envId: env.id, + }, + fetchPolicy: 'cache-and-network', + }) - const { wrappedSeed, wrappedSalt } = data.environmentKeys[0] + const { wrappedSeed, wrappedSalt } = data.environmentKeys[0] - const { publicKey, privateKey } = await unwrapEnvSecretsForUser( - wrappedSeed, - wrappedSalt, - keyring! - ) + const { publicKey, privateKey } = await unwrapEnvSecretsForUser( + wrappedSeed, + wrappedSalt, + keyring! + ) - const decryptedSecrets = await decryptEnvSecretKVs(data.secrets, { - publicKey, - privateKey, - }) + const decryptedSecrets = await decryptEnvSecretKVs(data.secrets, { + publicKey, + privateKey, + }) + + envSecrets.push({ env, secrets: decryptedSecrets }) + envFolders.push({ env, folders: data.folders }) + } + + // Create a list of unique secret keys + const secretKeys = Array.from( + new Set(envSecrets.flatMap((envCard) => envCard.secrets.map((secret) => secret.key))) + ) + + // Create a list of unique folder names + const folderNames = Array.from( + new Set(envFolders.flatMap((envCard) => envCard.folders.map((folder) => folder.name))) + ) + + // Transform envCards into an array of AppSecret objects + const appSecrets = secretKeys.map((key) => { + const envs = envSecrets.map((envCard) => ({ + env: envCard.env, + secret: envCard.secrets.find((secret) => secret.key === key) || null, + })) + const id = envs.map((env) => env.secret?.id).join('|') + return { id, key, envs } + }) - envSecrets.push({ env, secrets: decryptedSecrets }) - envFolders.push({ env, folders: data.folders }) + // Transform envFolders into an array of AppFolder objects + const appFolders = folderNames.map((name) => { + const envs = envFolders.map((envCard) => ({ + env: envCard.env, + folder: envCard.folders.find((folder) => folder.name === name) || null, + })) + return { name, envs } + }) + + setServerAppSecrets(appSecrets) + setClientAppSecrets(appSecrets) + setAppFolders(appFolders) + setLoading(false) + } + + const serverSecret = (id: string) => serverAppSecrets.find((secret) => secret.id === id) + + const duplicateKeysExist = () => { + const keySet = new Set() + + for (const secret of clientAppSecrets) { + if (keySet.has(secret.key)) { + return true // Duplicate key found } + keySet.add(secret.key) + } - // Create a list of unique secret keys - const secretKeys = Array.from( - new Set(envSecrets.flatMap((envCard) => envCard.secrets.map((secret) => secret.key))) - ) + return false // No duplicate keys found + } - // Create a list of unique folder names - const folderNames = Array.from( - new Set(envFolders.flatMap((envCard) => envCard.folders.map((folder) => folder.name))) - ) + const blankKeysExist = () => { + const secretKeysToSync = clientAppSecrets + .filter((secret) => !appSecretsToDelete.includes(secret.id)) + .map((secret) => secret.key) + return secretKeysToSync.includes('') + } + + /** + * Handles bulk updating of secrets by comparing client-side secrets with server-side secrets + * and determining which secrets need to be created, updated, or deleted. Secrets are encrypted + * using the user's keyring before being sent to the server for processing. + * + * @returns {Promise} Resolves once the secrets are processed and the application state is updated. + */ + const handleBulkUpdateSecrets = async () => { + const userKxKeys = { + publicKey: await getUserKxPublicKey(keyring!.publicKey), + privateKey: await getUserKxPrivateKey(keyring!.privateKey), + } + + const secretsToCreate: SecretInput[] = [] + const secretsToUpdate: SecretInput[] = [] - // Transform envCards into an array of AppSecret objects - const appSecrets = secretKeys.map((key) => { - const envs = envSecrets.map((envCard) => ({ - env: envCard.env, - secret: envCard.secrets.find((secret) => secret.key === key) || null, + const clientSecrets: SecretType[] = clientAppSecrets.flatMap((appSecret) => + appSecret.envs + .filter((env) => env.secret !== null) // Ensure we exclude null secrets + .map((env) => ({ + ...env.secret!, + key: appSecret.key, + environment: env.env as EnvironmentType, })) - return { key, envs } - }) + ) - // Transform envFolders into an array of AppFolder objects - const appFolders = folderNames.map((name) => { - const envs = envFolders.map((envCard) => ({ - env: envCard.env, - folder: envCard.folders.find((folder) => folder.name === name) || null, + const serverSecrets: SecretType[] = serverAppSecrets.flatMap((appSecret) => + appSecret.envs + .filter((env) => env.secret !== null) // Ensure we exclude null secrets + .map((env) => ({ + ...env.secret!, + key: appSecret.key, + environment: env.env as EnvironmentType, })) - return { name, envs } + ) + + await Promise.all( + clientSecrets.map(async (clientSecret, index) => { + const { id, key, value, comment, tags } = clientSecret + + const isNewSecret = id.split('-')[0] === 'new' + const serverSecret = serverSecrets.find((secret) => secret.id === id) + + const salt = await decryptAsymmetric( + clientSecret.environment.wrappedSalt, + userKxKeys.privateKey!, + userKxKeys.publicKey! + ) + + const isModified = + !isNewSecret && + serverSecret && + (serverSecret.key !== clientSecret.key || serverSecret.value !== clientSecret.value) + + // Only process if the secret is new or has been modified + if (isNewSecret || isModified) { + const encryptedKey = await encryptAsymmetric(key, clientSecret.environment.identityKey) + const encryptedValue = await encryptAsymmetric( + value, + clientSecret.environment.identityKey + ) + const keyDigest = await digest(key, salt) + const encryptedComment = await encryptAsymmetric( + comment || '', + clientSecret.environment.identityKey + ) + let tagIds: string[] = [] + + if (tags) tagIds = tags.map((tag) => tag.id) + + const secretInput: SecretInput = { + envId: clientSecret.environment.id, + path: '/', + key: encryptedKey, + keyDigest, + value: encryptedValue, + comment: encryptedComment, + tags: tagIds, + } + + if (isNewSecret) { + secretsToCreate.push(secretInput) + } else { + secretsToUpdate.push({ ...secretInput, id }) + } + } }) + ) - setAppSecrets(appSecrets) - setAppFolders(appFolders) + // Only call the mutation if there are changes + if (secretsToCreate.length > 0 || secretsToUpdate.length > 0 || secretsToDelete.length > 0) { + const { errors } = await bulkProcessSecrets({ + variables: { + secretsToCreate, + secretsToUpdate, + secretsToDelete, + }, + refetchQueries: [ + { + query: GetAppSyncStatus, + variables: { + appId: params.app, + }, + }, + ], + }) + + if (!errors) { + setSecretsToDelete([]) + setAppSecretsToDelete([]) + } + + await fetchAndDecryptAppEnvs(data?.appEnvironments) setLoading(false) } + } - if (keyring !== null && data?.appEnvironments && userCanReadSecrets) - fetchAndDecryptAppEnvs(data?.appEnvironments) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data?.appEnvironments, keyring]) - - + // Wraps handleBulkUpdateSecrets with some basic validation checks, loading state updates and toasts + const handleSaveChanges = async () => { + setLoading(true) + if (blankKeysExist()) { + toast.error('Secret keys cannot be empty!') + setLoading(false) + return false + } - const EnvSecret = (props: { - envSecret: { - env: Partial - secret: SecretType | null + if (duplicateKeysExist()) { + toast.error('Secret keys cannot be repeated!') + setLoading(false) + return false } - sameAsProd: boolean - }) => { - const { envSecret, sameAsProd } = props - const [readSecret] = useMutation(LogSecretReads) + await handleBulkUpdateSecrets() - const [showValue, setShowValue] = useState(false) + setLoading(false) - const handleRevealSecret = async () => { - setShowValue(true) - await readSecret({ variables: { ids: [envSecret.secret!.id] } }) - } + toast.success('Changes successfully deployed.') + } - const handleHideSecret = () => setShowValue(false) + const handleUpdateSecretKey = (id: string, key: string) => { + setClientAppSecrets((prevSecrets) => + prevSecrets.map((secret) => (secret.id === id ? { ...secret, key } : secret)) + ) + } - const toggleShowValue = () => { - showValue ? handleHideSecret() : handleRevealSecret() + const handleUpdateSecretValue = (id: string, envId: string, value: string | undefined) => { + const clonedSecrets = structuredClone(clientAppSecrets) + + const secretToUpdate = clonedSecrets.find((secret) => secret.id === id) + + if (!secretToUpdate) return + + secretToUpdate.envs = secretToUpdate.envs.filter((env) => { + const appSecretEnvValue = env + + if (appSecretEnvValue.env.id === envId) { + if (value === null || value === undefined) return null + + appSecretEnvValue!.secret!.value = value + } + return appSecretEnvValue + }) + + setClientAppSecrets(clonedSecrets) + } + + const handleAddNewClientSecret = () => { + const envs: EnvironmentType[] = data?.appEnvironments + + setClientAppSecrets([ + { + id: crypto.randomUUID(), + key: '', + envs: envs.map((environment) => { + return { + env: environment, + secret: { + id: `new-${crypto.randomUUID()}`, + updatedAt: null, + version: 1, + key: '', + value: '', + tags: [], + comment: '', + path: '/', + environment, + }, + } + }), + }, + ...clientAppSecrets, + ]) + } + + const handleAddNewEnvValue = (appSecretId: string, environment: EnvironmentType) => { + setClientAppSecrets((prevSecrets) => + prevSecrets.map((appSecret) => { + if (appSecret.id === appSecretId) { + const newSecret = { + id: `new-${crypto.randomUUID()}`, + updatedAt: null, + version: 1, + key: '', + value: '', + tags: [], + comment: '', + path: '/', + environment, + } + + const updatedEnvs = appSecret.envs.map((env) => { + if (env.env.id === environment.id) { + return { + ...env, + secret: newSecret, + } + } + return env + }) + + return { + ...appSecret, + envs: updatedEnvs, + } + } + return appSecret + }) + ) + } + + /** + * Handles the delete action for a specific environment's value, for a given appSecret key + * + * + * @param {string} appSecretId + * @param {EnvironmentType} environment + */ + const stageEnvValueForDelete = (appSecretId: string, environment: EnvironmentType) => { + //Find the app secret and env value in local state + const appSecret = clientAppSecrets.find((appSecret) => appSecret.id === appSecretId) + const secretToDelete = appSecret?.envs.find((env) => env.env.id === environment.id) + + if (secretToDelete) { + // Try and find the correspding values on the server + const serverAppSecret = serverAppSecrets.find((appSecret) => appSecret.id === appSecretId) + const envValueOnServer = serverAppSecret?.envs.find((env) => env.env.id === environment.id) + + // Check if the value is null or undefined, which means that the value is not on server, and has been created client-side but not yet saved. + if ( + envValueOnServer?.secret?.value === null || + envValueOnServer?.secret?.value === undefined + ) { + // Update the local state to for this appSecret by setting the env value to null + setClientAppSecrets((prevSecrets) => + prevSecrets.map((prevSecret) => { + if (prevSecret.id === appSecretId) { + const { id, key, envs } = prevSecret + + const updatedEnvs = envs.filter((env) => { + if (env.env.id === environment.id) { + env!.secret = null + } + + return env + }) + + const hasNonNullValue = updatedEnvs.some((env) => env?.secret !== null) + + if (!hasNonNullValue) { + handleStageClientSecretForDelete(appSecretId) + } + + return { + id, + key, + envs: envs.filter((env) => { + if (env.env.id === environment.id) { + env!.secret = null + } + + return env + }), + } + } + + return prevSecret + }) + ) + } + // The value exists on the server, and must be qeued for a server delete + else { + // if already staged for delete, remove it from the list + if (secretsToDelete.includes(secretToDelete.secret!.id)) { + setSecretsToDelete((prevSecretsToDelete) => + prevSecretsToDelete.filter((secretId) => secretId !== secretToDelete.secret!.id) + ) + } else { + setSecretsToDelete([...secretsToDelete, secretToDelete.secret!.id]) + } + } } + } + + /** + * Handles the delete action for an appSecret. If the secret exists on the server, it is queued for delete, else it delete instantly from local state. + * + * @param {string} id + * @returns {void} + */ + const handleStageClientSecretForDelete = (id: string) => { + const clonedSecrets = structuredClone(clientAppSecrets) + + const secretToDelete = clonedSecrets.find((appSecret) => appSecret?.id === id) - const handleCopy = async (val: string) => { - copyToClipBoard(val) - toast.info('Copied', { autoClose: 2000 }) - await readSecret({ variables: { ids: [envSecret.secret!.id] } }) + if (!secretToDelete) return + + // get all non-null secret ids for this app secret + const secretIds = secretToDelete.envs + .map((env) => env.secret?.id) + .filter((id): id is string => id !== undefined) + + // secrets that exist on the server, and need to be deleted server-side + const existingSecrets = secretIds.filter((secretId) => !secretId.includes('new')) + + // filter out secrets that only exist client-side and can be deleted in memory + secretToDelete.envs = secretToDelete.envs.filter((env) => !env.secret?.id.includes('new')) + + // if this app secret no longer has any env-values client-side, remove it from memory entirely + if (secretToDelete.envs.length === 0) { + const index = clonedSecrets.indexOf(secretToDelete) + clonedSecrets.splice(index, 1) } - return ( -
-
- -
{envSecret.env.envType}
- - -
+ // update states + // if server-side secrets are already marked for delete, remove them from delete list + if (existingSecrets.some((existingSecretId) => secretsToDelete.includes(existingSecretId))) { + setSecretsToDelete(secretsToDelete.filter((secretId) => !existingSecrets.includes(secretId))) + setAppSecretsToDelete( + appSecretsToDelete.filter((appSecretId) => appSecretId !== secretToDelete.id) + ) + } else { + setSecretsToDelete([...existingSecrets, ...secretIds]) + setAppSecretsToDelete([...appSecretsToDelete, secretToDelete.id]) + } - {envSecret.secret === null ? ( - missing - ) : envSecret.secret.value.length === 0 ? ( - blank - ) : ( -
- - {showValue ? ( -
{envSecret.secret.value}
- ) : ( - {'*'.repeat(envSecret.secret.value.length)} - )} -
+ setClientAppSecrets(clonedSecrets) + } - {envSecret.secret !== null && ( -
- - -
- )} -
- )} -
- ) + const handleDiscardChanges = () => { + setClientAppSecrets(serverAppSecrets) + setSecretsToDelete([]) + setAppSecretsToDelete([]) } + useEffect(() => { + if (keyring !== null && data?.appEnvironments && userCanReadSecrets) + fetchAndDecryptAppEnvs(data?.appEnvironments) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data?.appEnvironments, keyring]) + const EnvFolder = (props: { envFolder: { env: Partial @@ -425,119 +757,9 @@ export default function Secrets({ params }: { params: { team: string; app: strin ) } - const AppSecretRow = (props: { appSecret: AppSecret }) => { - const { appSecret } = props - - const prodSecret = appSecret.envs.find( - (env) => env.env.envType?.toLowerCase() === 'prod' - )?.secret - - const secretIsSameAsProd = (env: { - env: Partial - secret: SecretType | null - }) => - prodSecret !== null && - env.secret?.value === prodSecret?.value && - env.env.envType?.toLowerCase() !== 'prod' - - const tooltipText = (env: { env: Partial; secret: SecretType | null }) => { - if (env.secret === null) return `This secret is missing in ${env.env.envType}` - else if (env.secret.value.length === 0) return `This secret is blank in ${env.env.envType}` - else if (secretIsSameAsProd(env)) return `This secret is the same as PROD.` - else return 'This secret is present' - } - - return ( - - {({ open }) => ( - <> - - - {appSecret.key} - - - {appSecret.envs.map((env) => ( - -
- {env.secret !== null ? ( - env.secret.value.length === 0 ? ( - - ) : ( - - ) - ) : ( - - )} -
- - ))} -
- - - -
- {appSecret.envs.map((envSecret) => ( - - ))} -
-
- -
- - )} -
- ) - } - const AppFolderRow = (props: { appFolder: AppFolder }) => { const { appFolder } = props - // Assuming folder presence doesn't vary by environment in the same way as secrets, - // adjust logic according to your requirements for folders const tooltipText = (env: { env: Partial folder: SecretFolderType | null @@ -622,97 +844,123 @@ export default function Secrets({ params }: { params: { team: string; app: strin return (
- {keyring !== null && - -
-
-
-

Environments

- {userCanReadEnvironments ? ( -

- You have access to {data?.appEnvironments.length} Environments in this App. -

- ) : ( - - -
- } - > - <> - - )} -
+ {keyring !== null && ( +
+
+
+

Environments

+ {userCanReadEnvironments ? ( +

+ You have access to {data?.appEnvironments.length} Environments in this App. +

+ ) : ( + + +
+ } + > + <> + + )}
+
- {data?.appEnvironments && ( - - )} + {data?.appEnvironments && ( + + )} -
+
-
-
-

Secrets

-

- An overview of Secrets across all Environments in this App. Expand a row in the - table below to compare values across Environments. -

-
+
+
+

Secrets

+

+ An overview of Secrets across all Environments in this App. Expand a row in the + table below to compare and manage values across all Environments. +

+
-
-
-
- -
- setSearchQuery(e.target.value)} - /> - setSearchQuery('')} - /> +
+
+
+
+ setSearchQuery(e.target.value)} + /> + setSearchQuery('')} + /> +
-
-
- Secret is present -
-
- Secret is the same as - Production -
-
- Secret is blank -
-
- Secret is missing -
+
+
+ {unsavedChanges && ( + + )} + + {syncsData?.syncs && userCanReadSyncs && ( +
+ +
+ )} + +
+
+ +
+ +
- {appSecrets.length > 0 || appFolders.length > 0 ? ( - - - -
+ {clientAppSecrets.length > 0 || appFolders.length > 0 ? ( + <> + + + + {data?.appEnvironments.map((env: EnvironmentType) => ( - + {filteredFolders.map((appFolder) => ( ))} {filteredSecrets.map((appSecret, index) => ( - + ))}
key
- ) : loading ? ( -
- -
- ) : userCanReadEnvironments && userCanReadSecrets ? ( -
-
No Secrets
-
- There are no secrets in this app yet. Click on an environment below to start - adding secrets. -
-
- {data?.appEnvironments.map((env: EnvironmentType) => ( - - - - ))} -
-
- ) : ( + + + ) : loading ? ( +
+ +
+ ) : userCanReadEnvironments && userCanReadSecrets ? ( +
- +
} > - <> +
+ +
- )} - - } + + ) : ( + + + + } + > + <> + + )} + + )} ) } diff --git a/frontend/app/[team]/apps/[app]/types.ts b/frontend/app/[team]/apps/[app]/types.ts new file mode 100644 index 000000000..1c148eb50 --- /dev/null +++ b/frontend/app/[team]/apps/[app]/types.ts @@ -0,0 +1,28 @@ +import { EnvironmentType, SecretType, SecretFolderType } from "@/apollo/graphql" + +export type EnvSecrets = { + env: EnvironmentType + secrets: SecretType[] +} + +export type EnvFolders = { + env: EnvironmentType + folders: SecretFolderType[] +} + +export type AppSecret = { + id: string + key: string + envs: Array<{ + env: Partial + secret: SecretType | null + }> +} + +export type AppFolder = { + name: string + envs: Array<{ + env: Partial + folder: SecretFolderType | null + }> +} \ No newline at end of file diff --git a/frontend/graphql/queries/secrets/getSecretKVs.gql b/frontend/graphql/queries/secrets/getSecretKVs.gql index b8a4d2c69..ff7fab40d 100644 --- a/frontend/graphql/queries/secrets/getSecretKVs.gql +++ b/frontend/graphql/queries/secrets/getSecretKVs.gql @@ -7,6 +7,7 @@ query GetEnvSecretsKV($envId: ID!) { id key value + comment path } environmentKeys(environmentId: $envId) { diff --git a/frontend/utils/crypto/environments.ts b/frontend/utils/crypto/environments.ts index e80fc1c5e..414758099 100644 --- a/frontend/utils/crypto/environments.ts +++ b/frontend/utils/crypto/environments.ts @@ -340,6 +340,12 @@ export const decryptEnvSecretKVs = async ( envKeys?.publicKey ) + decryptedSecret.comment = await decryptAsymmetric( + secret.comment, + envKeys?.privateKey, + envKeys?.publicKey + ) + return decryptedSecret }) )