diff --git a/backend/backend/graphene/mutations/environment.py b/backend/backend/graphene/mutations/environment.py index afffb2839..ae56ff495 100644 --- a/backend/backend/graphene/mutations/environment.py +++ b/backend/backend/graphene/mutations/environment.py @@ -63,6 +63,7 @@ class EnvironmentKeyInput(graphene.InputObjectType): class SecretInput(graphene.InputObjectType): + id = graphene.ID(required=False) env_id = graphene.ID(required=False) path = graphene.String(required=False) key = graphene.String(required=True) @@ -688,6 +689,65 @@ def mutate(cls, root, info, secret_data): return CreateSecretMutation(secret=secret) +class BulkCreateSecretMutation(graphene.Mutation): + class Arguments: + secrets_data = graphene.List(SecretInput, required=True) + + secrets = graphene.List(SecretType) + + @classmethod + def mutate(cls, root, info, secrets_data): + created_secrets = [] + + for secret_data in secrets_data: + env = Environment.objects.get(id=secret_data.env_id) + org = env.app.organisation + if not user_is_org_member(info.context.user.userId, org.id): + raise GraphQLError("You don't have permission to perform this action") + + tags = SecretTag.objects.filter(id__in=secret_data.tags) + + path = ( + normalize_path_string(secret_data.path) + if secret_data.path is not None + else "/" + ) + + folder = None + if path != "/": + folder_name = path.split("/")[-1] + folder_path, _, _ = path.rpartition("/" + folder_name) + folder_path = folder_path if folder_path else "/" + folder = SecretFolder.objects.get( + environment_id=env.id, path=folder_path, name=folder_name + ) + + secret_obj_data = { + "environment_id": env.id, + "path": path, + "folder_id": folder.id if folder is not None else None, + "key": secret_data.key, + "key_digest": secret_data.key_digest, + "value": secret_data.value, + "version": 1, + "comment": secret_data.comment, + } + + secret = Secret.objects.create(**secret_obj_data) + secret.tags.set(tags) + created_secrets.append(secret) + + ip_address, user_agent = get_resolver_request_meta(info.context) + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=org, deleted_at=None + ) + log_secret_event( + secret, SecretEvent.CREATE, org_member, None, ip_address, user_agent + ) + + return BulkCreateSecretMutation(secrets=created_secrets) + + class EditSecretMutation(graphene.Mutation): class Arguments: id = graphene.ID(required=True) @@ -740,6 +800,59 @@ def mutate(cls, root, info, id, secret_data): return EditSecretMutation(secret=secret) +class BulkEditSecretMutation(graphene.Mutation): + class Arguments: + secrets_data = graphene.List(SecretInput, required=True) + + secrets = graphene.List(SecretType) + + @classmethod + def mutate(cls, root, info, secrets_data): + updated_secrets = [] + + for secret_data in secrets_data: + secret = Secret.objects.get(id=secret_data.id) + env = secret.environment + org = env.app.organisation + if not user_is_org_member(info.context.user.userId, org.id): + raise GraphQLError("You don't have permission to perform this action") + + tags = SecretTag.objects.filter(id__in=secret_data.tags) + + path = ( + normalize_path_string(secret_data.path) + if secret_data.path is not None + else "/" + ) + + secret_obj_data = { + "path": path, + "key": secret_data.key, + "key_digest": secret_data.key_digest, + "value": secret_data.value, + "version": secret.version + 1, + "comment": secret_data.comment, + } + + for key, value in secret_obj_data.items(): + setattr(secret, key, value) + + secret.updated_at = timezone.now() + secret.tags.set(tags) + secret.save() + updated_secrets.append(secret) + + ip_address, user_agent = get_resolver_request_meta(info.context) + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=org, deleted_at=None + ) + log_secret_event( + secret, SecretEvent.UPDATE, org_member, None, ip_address, user_agent + ) + + return BulkEditSecretMutation(secrets=updated_secrets) + + class DeleteSecretMutation(graphene.Mutation): class Arguments: id = graphene.ID(required=True) @@ -772,6 +885,40 @@ def mutate(cls, root, info, id): return DeleteSecretMutation(secret=secret) +class BulkDeleteSecretMutation(graphene.Mutation): + class Arguments: + ids = graphene.List(graphene.ID, required=True) + + secrets = graphene.List(SecretType) + + @classmethod + def mutate(cls, root, info, ids): + deleted_secrets = [] + + for id in ids: + secret = Secret.objects.get(id=id) + env = secret.environment + org = env.app.organisation + + if not user_is_org_member(info.context.user.userId, org.id): + raise GraphQLError("You don't have permission to perform this action") + + secret.updated_at = timezone.now() + secret.deleted_at = timezone.now() + secret.save() + deleted_secrets.append(secret) + + ip_address, user_agent = get_resolver_request_meta(info.context) + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=org, deleted_at=None + ) + log_secret_event( + secret, SecretEvent.DELETE, org_member, None, ip_address, user_agent + ) + + return BulkDeleteSecretMutation(secrets=deleted_secrets) + + class ReadSecretMutation(graphene.Mutation): class Arguments: ids = graphene.List(graphene.ID) @@ -810,7 +957,7 @@ def mutate(cls, root, info, override_data): secret = Secret.objects.get(id=override_data.secret_id) org = secret.environment.app.organisation org_member = OrganisationMember.objects.get( - organisation=org, user=info.context.user + organisation=org, user=info.context.user, deleted_at=None ) if not user_can_access_environment(info.context.user, secret.environment.id): @@ -837,7 +984,7 @@ def mutate(cls, root, info, secret_id): secret = Secret.objects.get(id=secret_id) org = secret.environment.app.organisation org_member = OrganisationMember.objects.get( - organisation=org, user=info.context.user + organisation=org, user=info.context.user, deleted_at=None ) if not user_can_access_environment(info.context.user, secret.environment.id): diff --git a/backend/backend/schema.py b/backend/backend/schema.py index 98db971ff..848f7a8c7 100644 --- a/backend/backend/schema.py +++ b/backend/backend/schema.py @@ -30,6 +30,9 @@ from .graphene.queries.quotas import resolve_organisation_plan from .graphene.queries.license import resolve_license, resolve_organisation_license from .graphene.mutations.environment import ( + BulkCreateSecretMutation, + BulkDeleteSecretMutation, + BulkEditSecretMutation, CreateEnvironmentKeyMutation, CreateEnvironmentMutation, CreateEnvironmentTokenMutation, @@ -756,6 +759,10 @@ class Mutation(graphene.ObjectType): delete_secret = DeleteSecretMutation.Field() read_secret = ReadSecretMutation.Field() + create_secrets = BulkCreateSecretMutation.Field() + edit_secrets = BulkEditSecretMutation.Field() + delete_secrets = BulkDeleteSecretMutation.Field() + create_override = CreatePersonalSecretMutation.Field() remove_override = DeletePersonalSecretMutation.Field() diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index ac73d5409..8983d37db 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -20,6 +20,7 @@ const documents = { "mutation CreateApplication($id: ID!, $organisationId: ID!, $name: String!, $identityKey: String!, $appToken: String!, $appSeed: String!, $wrappedKeyShare: String!, $appVersion: Int!) {\n createApp(\n id: $id\n organisationId: $organisationId\n name: $name\n identityKey: $identityKey\n appToken: $appToken\n appSeed: $appSeed\n wrappedKeyShare: $wrappedKeyShare\n appVersion: $appVersion\n ) {\n app {\n id\n name\n identityKey\n }\n }\n}": types.CreateApplicationDocument, "mutation CreateOrg($id: ID!, $name: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n createOrganisation(\n id: $id\n name: $name\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n organisation {\n id\n name\n memberId\n }\n }\n}": types.CreateOrgDocument, "mutation DeleteApplication($id: ID!) {\n deleteApp(id: $id) {\n ok\n }\n}": types.DeleteApplicationDocument, + "mutation BulkProcessSecrets($secretsToCreate: [SecretInput!]!, $secretsToUpdate: [SecretInput!]!, $secretsToDelete: [ID!]!) {\n createSecrets(secretsData: $secretsToCreate) {\n secrets {\n id\n key\n value\n createdAt\n }\n }\n editSecrets(secretsData: $secretsToUpdate) {\n secrets {\n id\n updatedAt\n }\n }\n deleteSecrets(ids: $secretsToDelete) {\n secrets {\n id\n }\n }\n}": types.BulkProcessSecretsDocument, "mutation CreateEnv($envInput: EnvironmentInput!, $adminKeys: [EnvironmentKeyInput], $wrappedSeed: String, $wrappedSalt: String) {\n createEnvironment(\n environmentData: $envInput\n adminKeys: $adminKeys\n wrappedSeed: $wrappedSeed\n wrappedSalt: $wrappedSalt\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n}": types.CreateEnvDocument, "mutation CreateEnvKey($envId: ID!, $userId: ID, $wrappedSeed: String!, $wrappedSalt: String!, $identityKey: String!) {\n createEnvironmentKey(\n envId: $envId\n userId: $userId\n wrappedSeed: $wrappedSeed\n wrappedSalt: $wrappedSalt\n identityKey: $identityKey\n ) {\n environmentKey {\n id\n createdAt\n }\n }\n}": types.CreateEnvKeyDocument, "mutation CreateEnvToken($envId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!) {\n createEnvironmentToken(\n envId: $envId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n ) {\n environmentToken {\n id\n createdAt\n }\n }\n}": types.CreateEnvTokenDocument, @@ -146,6 +147,10 @@ export function graphql(source: "mutation CreateOrg($id: ID!, $name: String!, $i * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "mutation DeleteApplication($id: ID!) {\n deleteApp(id: $id) {\n ok\n }\n}"): (typeof documents)["mutation DeleteApplication($id: ID!) {\n deleteApp(id: $id) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation BulkProcessSecrets($secretsToCreate: [SecretInput!]!, $secretsToUpdate: [SecretInput!]!, $secretsToDelete: [ID!]!) {\n createSecrets(secretsData: $secretsToCreate) {\n secrets {\n id\n key\n value\n createdAt\n }\n }\n editSecrets(secretsData: $secretsToUpdate) {\n secrets {\n id\n updatedAt\n }\n }\n deleteSecrets(ids: $secretsToDelete) {\n secrets {\n id\n }\n }\n}"): (typeof documents)["mutation BulkProcessSecrets($secretsToCreate: [SecretInput!]!, $secretsToUpdate: [SecretInput!]!, $secretsToDelete: [ID!]!) {\n createSecrets(secretsData: $secretsToCreate) {\n secrets {\n id\n key\n value\n createdAt\n }\n }\n editSecrets(secretsData: $secretsToUpdate) {\n secrets {\n id\n updatedAt\n }\n }\n deleteSecrets(ids: $secretsToDelete) {\n secrets {\n id\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/apollo/graphql.ts b/frontend/apollo/graphql.ts index 858bb01aa..6f4f73442 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -177,6 +177,21 @@ export type AppType = { wrappedKeyShare: Scalars['String']['output']; }; +export type BulkCreateSecretMutation = { + __typename?: 'BulkCreateSecretMutation'; + secrets?: Maybe>>; +}; + +export type BulkDeleteSecretMutation = { + __typename?: 'BulkDeleteSecretMutation'; + secrets?: Maybe>>; +}; + +export type BulkEditSecretMutation = { + __typename?: 'BulkEditSecretMutation'; + secrets?: Maybe>>; +}; + export type ChartDataPointType = { __typename?: 'ChartDataPointType'; data?: Maybe; @@ -573,6 +588,7 @@ export type Mutation = { createSecret?: Maybe; createSecretFolder?: Maybe; createSecretTag?: Maybe; + createSecrets?: Maybe; createServiceToken?: Maybe; createUserToken?: Maybe; createVaultSync?: Maybe; @@ -584,9 +600,11 @@ export type Mutation = { deleteProviderCredentials?: Maybe; deleteSecret?: Maybe; deleteSecretFolder?: Maybe; + deleteSecrets?: Maybe; deleteServiceToken?: Maybe; deleteUserToken?: Maybe; editSecret?: Maybe; + editSecrets?: Maybe; initEnvSync?: Maybe; inviteOrganisationMember?: Maybe; readSecret?: Maybe; @@ -770,6 +788,11 @@ export type MutationCreateSecretTagArgs = { }; +export type MutationCreateSecretsArgs = { + secretsData: Array>; +}; + + export type MutationCreateServiceTokenArgs = { appId: Scalars['ID']['input']; environmentKeys?: InputMaybe>>; @@ -840,6 +863,11 @@ export type MutationDeleteSecretFolderArgs = { }; +export type MutationDeleteSecretsArgs = { + ids: Array>; +}; + + export type MutationDeleteServiceTokenArgs = { tokenId: Scalars['ID']['input']; }; @@ -856,6 +884,11 @@ export type MutationEditSecretArgs = { }; +export type MutationEditSecretsArgs = { + secretsData: Array>; +}; + + export type MutationInitEnvSyncArgs = { appId?: InputMaybe; envKeys?: InputMaybe>>; @@ -1388,6 +1421,7 @@ export type SecretFolderType = { export type SecretInput = { comment?: InputMaybe; envId?: InputMaybe; + id?: InputMaybe; key: Scalars['String']['input']; keyDigest: Scalars['String']['input']; path?: InputMaybe; @@ -1590,6 +1624,15 @@ export type DeleteApplicationMutationVariables = Exact<{ export type DeleteApplicationMutation = { __typename?: 'Mutation', deleteApp?: { __typename?: 'DeleteAppMutation', ok?: boolean | null } | null }; +export type BulkProcessSecretsMutationVariables = Exact<{ + secretsToCreate: Array | SecretInput; + secretsToUpdate: Array | SecretInput; + secretsToDelete: Array | Scalars['ID']['input']; +}>; + + +export type BulkProcessSecretsMutation = { __typename?: 'Mutation', createSecrets?: { __typename?: 'BulkCreateSecretMutation', secrets?: Array<{ __typename?: 'SecretType', id: string, key: string, value: string, createdAt?: any | null } | null> | null } | null, editSecrets?: { __typename?: 'BulkEditSecretMutation', secrets?: Array<{ __typename?: 'SecretType', id: string, updatedAt: any } | null> | null } | null, deleteSecrets?: { __typename?: 'BulkDeleteSecretMutation', secrets?: Array<{ __typename?: 'SecretType', id: string } | null> | null } | null }; + export type CreateEnvMutationVariables = Exact<{ envInput: EnvironmentInput; adminKeys?: InputMaybe> | InputMaybe>; @@ -2261,6 +2304,7 @@ export const InitStripeProUpgradeCheckoutDocument = {"kind":"Document","definiti export const CreateApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appSeed"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appVersion"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"appToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"appSeed"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appSeed"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}},{"kind":"Argument","name":{"kind":"Name","value":"appVersion"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appVersion"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateOrgDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOrg"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOrganisation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"memberId"}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const BulkProcessSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BulkProcessSecrets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"secretsToCreate"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SecretInput"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"secretsToUpdate"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SecretInput"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"secretsToDelete"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"secretsData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"secretsToCreate"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"secrets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"editSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"secretsData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"secretsToUpdate"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"secrets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"deleteSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"secretsToDelete"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"secrets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateEnvDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateEnv"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envInput"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"adminKeys"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentKeyInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSeed"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSalt"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createEnvironment"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"environmentData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envInput"}}},{"kind":"Argument","name":{"kind":"Name","value":"adminKeys"},"value":{"kind":"Variable","name":{"kind":"Name","value":"adminKeys"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedSeed"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSeed"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedSalt"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSalt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateEnvKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateEnvKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSeed"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSalt"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createEnvironmentKey"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"envId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}},{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedSeed"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSeed"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedSalt"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSalt"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environmentKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateEnvTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateEnvToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createEnvironmentToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"envId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environmentToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index 9a09c692f..6edacd1d5 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -655,6 +655,9 @@ type Mutation { editSecret(id: ID!, secretData: SecretInput): EditSecretMutation deleteSecret(id: ID!): DeleteSecretMutation readSecret(ids: [ID]): ReadSecretMutation + createSecrets(secretsData: [SecretInput]!): BulkCreateSecretMutation + editSecrets(secretsData: [SecretInput]!): BulkEditSecretMutation + deleteSecrets(ids: [ID]!): BulkDeleteSecretMutation createOverride(overrideData: PersonalSecretInput): CreatePersonalSecretMutation removeOverride(secretId: ID): DeletePersonalSecretMutation createLockbox(input: LockboxInput): CreateLockboxMutation @@ -853,6 +856,7 @@ type CreateSecretMutation { } input SecretInput { + id: ID envId: ID path: String key: String! @@ -874,6 +878,18 @@ type ReadSecretMutation { ok: Boolean } +type BulkCreateSecretMutation { + secrets: [SecretType] +} + +type BulkEditSecretMutation { + secrets: [SecretType] +} + +type BulkDeleteSecretMutation { + secrets: [SecretType] +} + type CreatePersonalSecretMutation { override: PersonalSecretType } diff --git a/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx b/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx index 5b39599da..f95516ba2 100644 --- a/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx +++ b/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx @@ -4,9 +4,7 @@ import { EnvironmentType, SecretFolderType, SecretInput, SecretType } from '@/ap import { KeyringContext } from '@/contexts/keyringContext' import { GetSecrets } from '@/graphql/queries/secrets/getSecrets.gql' import { GetFolders } from '@/graphql/queries/secrets/getFolders.gql' -import { CreateNewSecret } from '@/graphql/mutations/environments/createSecret.gql' -import { UpdateSecret } from '@/graphql/mutations/environments/editSecret.gql' -import { DeleteSecretOp } from '@/graphql/mutations/environments/deleteSecret.gql' +import { BulkProcessSecrets } from '@/graphql/mutations/environments/bulkProcessSecrets.gql' import { DeleteFolder } from '@/graphql/mutations/environments/deleteFolder.gql' import { GetAppEnvironments } from '@/graphql/queries/secrets/getAppEnvironments.gql' import { CreateNewSecretFolder } from '@/graphql/mutations/environments/createFolder.gql' @@ -71,8 +69,9 @@ export default function EnvironmentPath({ const highlightedRef = useRef(null) const [envKeys, setEnvKeys] = useState(null) - const [secrets, setSecrets] = useState([]) - const [updatedSecrets, updateSecrets] = useState([]) + const [serverSecrets, setServerSecrets] = useState([]) + const [clientSecrets, setClientSecrets] = useState([]) + const [secretsToDelete, setSecretsToDelete] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [isLoading, setIsloading] = useState(false) const [folderMenuIsOpen, setFolderMenuIsOpen] = useState(false) @@ -87,7 +86,7 @@ export default function EnvironmentPath({ const secretPath = params.path ? `/${params.path.join('/')}` : '/' const logGlobalReveals = async () => { - await readSecrets({ variables: { ids: secrets.map((secret) => secret.id) } }) + await readSecrets({ variables: { ids: serverSecrets.map((secret) => secret.id) } }) } const toggleGlobalReveal = () => { @@ -101,19 +100,20 @@ export default function EnvironmentPath({ useEffect(() => { // 2. Scroll into view when secretToHighlight changes - if (highlightedRef.current && secrets.length > 0) { + if (highlightedRef.current && serverSecrets.length > 0) { highlightedRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest', }) } - }, [secretToHighlight, secrets]) + }, [secretToHighlight, serverSecrets]) const unsavedChanges = - secrets.length !== updatedSecrets.length || - secrets.some((secret, index) => { - const updatedSecret = updatedSecrets[index] + serverSecrets.length !== clientSecrets.length || + secretsToDelete.length > 0 || + serverSecrets.some((secret, index) => { + const updatedSecret = clientSecrets[index] // Compare secret properties (comment, key, tags, value) return ( @@ -143,9 +143,7 @@ export default function EnvironmentPath({ const savingAndFetching = isLoading || loading - const [createSecret] = useMutation(CreateNewSecret) - const [updateSecret] = useMutation(UpdateSecret) - const [deleteSecret] = useMutation(DeleteSecretOp) + const [bulkProcessSecrets] = useMutation(BulkProcessSecrets) const [createFolder] = useMutation(CreateNewSecretFolder) const [deleteFolder] = useMutation(DeleteFolder) @@ -179,23 +177,37 @@ export default function EnvironmentPath({ environment, } as SecretType start - ? updateSecrets([newSecret, ...updatedSecrets]) - : updateSecrets([...updatedSecrets, newSecret]) + ? setClientSecrets([newSecret, ...clientSecrets]) + : setClientSecrets([...clientSecrets, newSecret]) } - const handleUpdateSecret = async (secret: SecretType) => { - const { id, key, value, comment, tags } = secret - - const encryptedKey = await encryptAsymmetric(key, environment.identityKey) - const encryptedValue = await encryptAsymmetric(value, environment.identityKey) - const keyDigest = await digest(key, envKeys!.salt) - const encryptedComment = await encryptAsymmetric(comment, environment.identityKey) - const tagIds = tags.map((tag) => tag.id) - - if (id.split('-')[0] === 'new') { - await createSecret({ - variables: { - newSecret: { + const handleBulkUpdateSecrets = async () => { + const secretsToCreate: SecretInput[] = [] + const secretsToUpdate: SecretInput[] = [] + + 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 isModified = + !isNewSecret && + serverSecret && + (serverSecret.comment !== clientSecret.comment || + serverSecret.key !== clientSecret.key || + !arraysEqual(serverSecret.tags, clientSecret.tags) || + serverSecret.value !== clientSecret.value) + + // Only process if the secret is new or has been modified + if (isNewSecret || isModified) { + const encryptedKey = await encryptAsymmetric(key, environment.identityKey) + const encryptedValue = await encryptAsymmetric(value, environment.identityKey) + const keyDigest = await digest(key, envKeys!.salt) + const encryptedComment = await encryptAsymmetric(comment, environment.identityKey) + const tagIds = tags.map((tag) => tag.id) + + const secretInput: SecretInput = { envId: params.environment, path: secretPath, key: encryptedKey, @@ -203,31 +215,24 @@ export default function EnvironmentPath({ value: encryptedValue, comment: encryptedComment, tags: tagIds, - } as SecretInput, - }, - refetchQueries: [ - { - query: GetSecrets, - variables: { - appId: params.app, - envId: params.environment, - path: secretPath, - }, - }, - ], + } + + if (isNewSecret) { + secretsToCreate.push(secretInput) + } else { + secretsToUpdate.push({ ...secretInput, id }) + } + } }) - } else { - await updateSecret({ + ) + + // Only call the mutation if there are changes + if (secretsToCreate.length > 0 || secretsToUpdate.length > 0 || secretsToDelete.length > 0) { + const { data, errors } = await bulkProcessSecrets({ variables: { - id, - secretData: { - key: encryptedKey, - keyDigest, - value: encryptedValue, - path: secretPath, - comment: encryptedComment, - tags: tagIds, - } as SecretInput, + secretsToCreate, + secretsToUpdate, + secretsToDelete, }, refetchQueries: [ { @@ -240,30 +245,22 @@ export default function EnvironmentPath({ }, ], }) + + if (!errors) setSecretsToDelete([]) } } - const handleDeleteSecret = async (id: string) => { - if (id.split('-')[0] === 'new') - updateSecrets(updatedSecrets.filter((secret) => secret.id !== id)) - else { - await deleteSecret({ - variables: { - id, - }, - refetchQueries: [ - { - query: GetSecrets, - variables: { - appId: params.app, - envId: params.environment, - path: secretPath, - }, - }, - ], - }) + const stageSecretForDelete = async (id: string) => { + if (id.split('-')[0] !== 'new') { + if (secretsToDelete.includes(id)) + setSecretsToDelete(secretsToDelete.filter((secretId) => secretId !== id)) + else { + const secretToDelete = clientSecrets.find((secret) => secret.id === id) + if (secretToDelete) setSecretsToDelete([...secretsToDelete, ...[secretToDelete.id]]) + } + } else { + setClientSecrets(clientSecrets.filter((secret) => secret.id !== id)) } - toast.success('Secret deleted.') } const handleDeleteFolder = async (id: string) => { @@ -386,29 +383,29 @@ export default function EnvironmentPath({ } decryptSecrets().then((decryptedSecrets) => { - setSecrets(decryptedSecrets) - updateSecrets(decryptedSecrets) + setServerSecrets(decryptedSecrets) + setClientSecrets(decryptedSecrets) }) } }, [envKeys, data]) const handleUpdateSecretProperty = (id: string, property: string, value: any) => { - const updatedSecretList = updatedSecrets.map((secret) => { + const updatedSecretList = clientSecrets.map((secret) => { if (secret.id === id) { return { ...secret, [property]: value } } return secret }) - updateSecrets(updatedSecretList) + setClientSecrets(updatedSecretList) } const getUpdatedSecrets = () => { const changedElements = [] - for (let i = 0; i < updatedSecrets.length; i++) { - const updatedSecret = updatedSecrets[i] - const originalSecret = secrets.find((secret) => secret.id === updatedSecret.id) + for (let i = 0; i < clientSecrets.length; i++) { + const updatedSecret = clientSecrets[i] + const originalSecret = serverSecrets.find((secret) => secret.id === updatedSecret.id) // this is a newly created secret that doesn't exist on the server yet if (!originalSecret) { @@ -429,7 +426,7 @@ export default function EnvironmentPath({ const duplicateKeysExist = () => { const keySet = new Set() - for (const secret of updatedSecrets) { + for (const secret of clientSecrets) { if (keySet.has(secret.key)) { return true // Duplicate key found } @@ -454,9 +451,7 @@ export default function EnvironmentPath({ return false } - const updates = changedSecrets.map((secret) => handleUpdateSecret(secret)) - - await Promise.all(updates) + await handleBulkUpdateSecrets() setTimeout(() => setIsloading(false), 500) @@ -464,10 +459,11 @@ export default function EnvironmentPath({ } const handleDiscardChanges = () => { - updateSecrets(secrets) + setClientSecrets(serverSecrets) + setSecretsToDelete([]) } - const secretNames = secrets.map((secret) => { + const secretNames = serverSecrets.map((secret) => { const { id, key } = secret return { id, @@ -485,18 +481,18 @@ export default function EnvironmentPath({ const filteredSecrets = searchQuery === '' - ? updatedSecrets - : updatedSecrets.filter((secret) => { + ? clientSecrets + : clientSecrets.filter((secret) => { const searchRegex = new RegExp(searchQuery, 'i') return searchRegex.test(secret.key) }) - const filteredAndSortedSecrets = sortSecrets(filteredSecrets, sort) + const cannonicalSecret = (id: string) => serverSecrets.find((secret) => secret.id === id) - const cannonicalSecret = (id: string) => secrets.find((secret) => secret.id === id) + const filteredAndSortedSecrets = sortSecrets(filteredSecrets, sort) const downloadEnvFile = () => { - const envContent = secrets + const envContent = serverSecrets .map((secret) => { const comment = secret.comment ? `#${secret.comment}\n` : '' return `${comment}${secret.key}=${secret.value}` @@ -836,6 +832,7 @@ export default function EnvironmentPath({ +
{unsavedChanges && (
- {(updatedSecrets.length > 0 || folders.length > 0) && ( + {(clientSecrets.length > 0 || folders.length > 0) && (
key @@ -926,8 +924,9 @@ export default function EnvironmentPath({ cannonicalSecret={cannonicalSecret(secret.id)} secretNames={secretNames} handlePropertyChange={handleUpdateSecretProperty} - handleDelete={handleDeleteSecret} + handleDelete={stageSecretForDelete} globallyRevealed={globallyRevealed} + stagedForDelete={secretsToDelete.includes(secret.id)} />
))} diff --git a/frontend/components/apps/NewAppDialog.tsx b/frontend/components/apps/NewAppDialog.tsx index c59af63d7..1cf281053 100644 --- a/frontend/components/apps/NewAppDialog.tsx +++ b/frontend/components/apps/NewAppDialog.tsx @@ -5,7 +5,7 @@ import { toast } from 'react-toastify' import { Button } from '../common/Button' import { GetApps } from '@/graphql/queries/getApps.gql' import { CreateApplication } from '@/graphql/mutations/createApp.gql' -import { CreateNewSecret } from '@/graphql/mutations/environments/createSecret.gql' +import { BulkProcessSecrets } from '@/graphql/mutations/environments/bulkProcessSecrets.gql' import { GetOrganisationAdminsAndSelf } from '@/graphql/queries/organisation/getOrganisationAdminsAndSelf.gql' import { InitAppEnvironments } from '@/graphql/mutations/environments/initAppEnvironments.gql' import { GetAppEnvironments } from '@/graphql/queries/secrets/getAppEnvironments.gql' @@ -20,7 +20,6 @@ import { SecretType, } from '@/apollo/graphql' -import { UpgradeRequestForm } from '../forms/UpgradeRequestForm' import { KeyringContext } from '@/contexts/keyringContext' import { MAX_INPUT_STRING_LENGTH } from '@/constants' @@ -57,7 +56,7 @@ export default function NewAppDialog(props: { appCount: number; organisation: Or const [createApp, { error }] = useMutation(CreateApplication) const [initAppEnvironments] = useMutation(InitAppEnvironments) - const [createSecret] = useMutation(CreateNewSecret) + const [bulkProcessSecrets] = useMutation(BulkProcessSecrets) const [getApps] = useLazyQuery(GetApps) const [getAppEnvs] = useLazyQuery(GetAppEnvironments) @@ -102,42 +101,55 @@ export default function NewAppDialog(props: { appCount: number; organisation: Or * * @throws {Error} If the specified environment is invalid or if an error occurs during processing. */ - async function processSecrets(env: EnvironmentType, secrets: Array>) { + async function processSecrets( + envs: Array<{ env: EnvironmentType; secrets: Array> }> + ) { const userKxKeys = { publicKey: await getUserKxPublicKey(keyring!.publicKey), privateKey: await getUserKxPrivateKey(keyring!.privateKey), } - const envSalt = await decryptAsymmetric( - env.wrappedSalt, - userKxKeys.privateKey, - userKxKeys.publicKey - ) + const allSecretsToCreate: SecretInput[] = [] - const promises = secrets.map(async (secret) => { - const { key, value, comment } = secret + await Promise.all( + envs.map(async ({ env, secrets }) => { + const envSalt = await decryptAsymmetric( + env.wrappedSalt, + userKxKeys.privateKey, + userKxKeys.publicKey + ) - const encryptedKey = await encryptAsymmetric(key!, env.identityKey) - const encryptedValue = await encryptAsymmetric(value!, env.identityKey) - const keyDigest = await digest(key!, envSalt) - const encryptedComment = await encryptAsymmetric(comment!, env.identityKey) + const envSecretsPromises = secrets.map(async (secret) => { + const { key, value, comment } = secret - await createSecret({ - variables: { - newSecret: { + const encryptedKey = await encryptAsymmetric(key!, env.identityKey) + const encryptedValue = await encryptAsymmetric(value!, env.identityKey) + const keyDigest = await digest(key!, envSalt) + const encryptedComment = await encryptAsymmetric(comment!, env.identityKey) + + allSecretsToCreate.push({ envId: env.id, key: encryptedKey, keyDigest, value: encryptedValue, path: '/', comment: encryptedComment, - tags: [], - } as SecretInput, - }, + tags: [], // Adjust as necessary if you need to include tags + }) + }) + + await Promise.all(envSecretsPromises) }) - }) + ) - return Promise.all(promises) + // Use the bulkProcessSecrets mutation + await bulkProcessSecrets({ + variables: { + secretsToCreate: allSecretsToCreate, + secretsToUpdate: [], + secretsToDelete: [], + }, + }) } /** @@ -234,24 +246,31 @@ export default function NewAppDialog(props: { appCount: number; organisation: Or const { data: appEnvsData } = await getAppEnvs({ variables: { appId } }) - await processSecrets( - appEnvsData.appEnvironments.find( - (env: EnvironmentType) => env.envType === ApiEnvironmentEnvTypeChoices.Dev - ), - DEV_SECRETS - ) - await processSecrets( - appEnvsData.appEnvironments.find( - (env: EnvironmentType) => env.envType === ApiEnvironmentEnvTypeChoices.Staging - ), - STAG_SECRETS - ) - await processSecrets( - appEnvsData.appEnvironments.find( - (env: EnvironmentType) => env.envType === ApiEnvironmentEnvTypeChoices.Prod - ), - PROD_SECRETS - ) + const envsToProcess = [ + { + env: appEnvsData.appEnvironments.find( + (env: EnvironmentType) => env.envType === ApiEnvironmentEnvTypeChoices.Dev + ), + secrets: DEV_SECRETS, + }, + { + env: appEnvsData.appEnvironments.find( + (env: EnvironmentType) => env.envType === ApiEnvironmentEnvTypeChoices.Staging + ), + secrets: STAG_SECRETS, + }, + { + env: appEnvsData.appEnvironments.find( + (env: EnvironmentType) => env.envType === ApiEnvironmentEnvTypeChoices.Prod + ), + secrets: PROD_SECRETS, + }, + ] + + // Remove any null or undefined environments + const validEnvsToProcess = envsToProcess.filter(({ env }) => env !== undefined) + + await processSecrets(validEnvsToProcess) } /** diff --git a/frontend/components/common/Button.tsx b/frontend/components/common/Button.tsx index a943ebe8d..a49fe0589 100644 --- a/frontend/components/common/Button.tsx +++ b/frontend/components/common/Button.tsx @@ -67,7 +67,18 @@ export function Button(buttonProps: ButtonProps) { /> ) - const spinnerColor = variant === 'danger' ? 'red' : 'emerald' + const spinnerColor = () => { + switch (variant) { + case 'primary': + return 'emerald' + case 'danger': + return 'red' + case 'warning': + return 'amber' + default: + return 'emerald' + } + } return ( diff --git a/frontend/components/environments/secrets/SecretRow.tsx b/frontend/components/environments/secrets/SecretRow.tsx index a338fcc11..63ce008d9 100644 --- a/frontend/components/environments/secrets/SecretRow.tsx +++ b/frontend/components/environments/secrets/SecretRow.tsx @@ -1,6 +1,6 @@ import { EnvironmentType, SecretType } from '@/apollo/graphql' import { useEffect, useRef, useState } from 'react' -import { FaEyeSlash, FaEye } from 'react-icons/fa' +import { FaEyeSlash, FaEye, FaUndo, FaTrashAlt } from 'react-icons/fa' import { Button } from '../../common/Button' import { LogSecretReads } from '@/graphql/mutations/environments/readSecret.gql' @@ -8,7 +8,6 @@ import clsx from 'clsx' import { useMutation } from '@apollo/client' import { areTagsAreSame } from '@/utils/tags' -import { DeleteConfirmDialog } from './DeleteDialog' import { CommentDialog } from './CommentDialog' import { HistoryDialog } from './HistoryDialog' import { OverrideDialog } from './OverrideDialog' @@ -26,6 +25,7 @@ export default function SecretRow(props: { handlePropertyChange: Function handleDelete: Function globallyRevealed: boolean + stagedForDelete?: boolean }) { const { orgId, @@ -35,6 +35,7 @@ export default function SecretRow(props: { handlePropertyChange, handleDelete, globallyRevealed, + stagedForDelete, } = props const isBoolean = ['true', 'false'].includes(secret.value.toLowerCase()) @@ -85,7 +86,7 @@ export default function SecretRow(props: { } const INPUT_BASE_STYLE = - 'w-full text-zinc-800 font-mono custom bg-transparent group-hover:bg-zinc-200 dark:group-hover:bg-zinc-700 dark:text-white transition ease ph-no-capture' + '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 keyIsBlank = secret.key.length === 0 @@ -102,11 +103,25 @@ export default function SecretRow(props: { ) } + const rowBgColor = () => { + if (!cannonicalSecret) return 'bg-emerald-400/20 dark:bg-emerald-400/10' + else if (stagedForDelete) return 'bg-red-400/20 dark:bg-red-400/10' + else if (secretHasBeenModified()) return 'bg-amber-400/20 dark:bg-amber-400/10' + } + + const inputTextColor = () => { + if (!cannonicalSecret) return 'text-emerald-700 dark:text-emerald-200' + else if (stagedForDelete) return 'text-red-700 dark:text-red-400 line-through' + else if (secretHasBeenModified()) return 'text-amber-700 dark:text-amber-300' + else return 'text-zinc-900 dark:text-zinc-100' + } + return ( -
+
@@ -139,8 +154,8 @@ export default function SecretRow(props: {
-
- {isBoolean && ( +
+ {isBoolean && !stagedForDelete && (
)} handlePropertyChange(secret.id, 'value', e.target.value)} /> @@ -183,25 +199,29 @@ export default function SecretRow(props: { )}
-
- -
+ {!stagedForDelete && ( +
+ +
+ )} -
- -
+ {!stagedForDelete && ( +
+ +
+ )} - {cannonicalSecret && ( + {cannonicalSecret && !stagedForDelete && (
- + {/* */} +
) diff --git a/frontend/graphql/mutations/environments/bulkProcessSecrets.gql b/frontend/graphql/mutations/environments/bulkProcessSecrets.gql new file mode 100644 index 000000000..1136f0baa --- /dev/null +++ b/frontend/graphql/mutations/environments/bulkProcessSecrets.gql @@ -0,0 +1,21 @@ +mutation BulkProcessSecrets( + $secretsToCreate: [SecretInput!]! + $secretsToUpdate: [SecretInput!]! + $secretsToDelete: [ID!]! +) { + createSecrets(secretsData: $secretsToCreate) { + secrets { + id + } + } + editSecrets(secretsData: $secretsToUpdate) { + secrets { + id + } + } + deleteSecrets(ids: $secretsToDelete) { + secrets { + id + } + } +}