diff --git a/backend/api/utils/syncing/vercel/main.py b/backend/api/utils/syncing/vercel/main.py index 4c8b602f5..6ab0046f2 100644 --- a/backend/api/utils/syncing/vercel/main.py +++ b/backend/api/utils/syncing/vercel/main.py @@ -1,19 +1,25 @@ import requests -import graphene -from graphene import ObjectType - +import logging +from graphene import ObjectType, List, ID, String from api.utils.syncing.auth import get_credentials +logger = logging.getLogger(__name__) + VERCEL_API_BASE_URL = "https://api.vercel.com" -from graphene import ObjectType, List, ID, String +class VercelEnvironmentType(ObjectType): + id = ID(required=True) + name = String(required=True) + description = String() + slug = String(required=True) + type = String() # "standard" or "custom" class VercelProjectType(ObjectType): id = ID(required=True) name = String(required=True) - environment = List(String) + environments = List(VercelEnvironmentType) class VercelTeamProjectsType(ObjectType): @@ -45,6 +51,63 @@ def test_vercel_creds(credential_id): return False +def delete_env_var(token, project_id, team_id, env_var_id): + """Delete a Vercel environment variable using its ID.""" + url = f"{VERCEL_API_BASE_URL}/v9/projects/{project_id}/env/{env_var_id}" + if team_id is not None: + url += f"?teamId={team_id}" + response = requests.delete(url, headers=get_vercel_headers(token)) + + if response.status_code != 200: + raise Exception(f"Error deleting environment variable: {response.text}") + + +def get_project_custom_environments(token, project_id, team_id=None): + """ + Retrieve custom environments for a specific Vercel project. + + Args: + token (str): Vercel API token + project_id (str): Project ID + team_id (str, optional): Team ID + + Returns: + list: List of custom environment dictionaries + """ + url = f"{VERCEL_API_BASE_URL}/v1/projects/{project_id}/custom-environments" + if team_id: + url += f"?teamId={team_id}" + + try: + response = requests.get(url, headers=get_vercel_headers(token)) + + if response.status_code != 200: + logger.warning( + f"Failed to fetch custom environments for project '{project_id}'" + f"{' (team: ' + team_id + ')' if team_id else ''}: " + f"Status code: {response.status_code}" + ) + return [] + + custom_envs = response.json().get("environments", []) + logger.info( + f"Found {len(custom_envs)} custom environments for project {project_id}" + ) + + return [ + { + "id": env["id"], + "name": env["slug"].capitalize(), + "description": env.get("description", ""), + "slug": env["slug"], + } + for env in custom_envs + ] + except Exception as e: + logger.error(f"Exception fetching custom environments: {str(e)}") + return [] + + def list_vercel_projects(credential_id): """ List all Vercel projects accessible with the provided credentials. @@ -79,24 +142,74 @@ def list_vercel_projects(credential_id): team_projects_url, headers=get_vercel_headers(token) ) if team_projects_response.status_code != 200: - print( + logger.error( f"Failed to list projects for team {team_name}: {team_projects_response.text}" ) continue team_projects = team_projects_response.json().get("projects", []) + + # Get available environments for each project (including custom environments) + projects_with_envs = [] + for project in team_projects: + project_id = project["id"] + custom_envs = get_project_custom_environments( + token, project_id, team_id + ) + + # Standard environments + environments = [ + { + "id": "all", + "name": "All Environments", + "slug": "all", + "type": "all", + }, + { + "id": "dev", + "name": "Development", + "slug": "development", + "type": "standard", + }, + { + "id": "prev", + "name": "Preview", + "slug": "preview", + "type": "standard", + }, + { + "id": "prod", + "name": "Production", + "slug": "production", + "type": "standard", + }, + ] + + # Add custom environments with full details + for env in custom_envs: + environments.append( + { + "id": env["id"], + "name": env["name"], + "description": env.get("description", ""), + "slug": env["slug"], + "type": "custom", + } + ) + + projects_with_envs.append( + { + "id": project["id"], + "name": project["name"], + "environments": environments, + } + ) + result.append( { "id": team_id, "team_name": team_name, - "projects": [ - { - "id": project["id"], - "name": project["name"], - "environment": ["development", "preview", "production"], - } - for project in team_projects - ], + "projects": projects_with_envs, } ) @@ -106,29 +219,62 @@ def list_vercel_projects(credential_id): raise Exception(f"Error listing Vercel projects: {str(e)}") -def get_existing_env_vars(token, project_id, team_id=None, target_environment=None): +def get_existing_env_vars(token, project_id, team_id, target_environment): """ Retrieve environment variables for a specific Vercel project and environment. - + Only returns variables that EXCLUSIVELY target the specified environment. + Args: token (str): Vercel API token project_id (str): Project ID - team_id (str, optional): Team ID - target_environment (str, optional): Specific environment to filter by + team_id (str): Team ID + target_environment (str): Specific environment to filter by (REQUIRED) + + Returns: + dict: Dictionary mapping variable keys to their metadata (id, value, target, comment) """ url = f"{VERCEL_API_BASE_URL}/v9/projects/{project_id}/env" if team_id is not None: url += f"?teamId={team_id}" - response = requests.get(url, headers=get_vercel_headers(token)) + response = requests.get(url, headers=get_vercel_headers(token)) if response.status_code != 200: raise Exception(f"Error retrieving environment variables: {response.text}") envs = response.json().get("envs", []) - - # Filter variables by target environment if specified - if target_environment: - envs = [env for env in envs if target_environment in env["target"]] + filtered_envs = [] + + # Get custom environments to map slugs to IDs for proper filtering + custom_envs = get_project_custom_environments(token, project_id, team_id) + custom_env_map = {env["slug"]: env["id"] for env in custom_envs} + + for env in envs: + env_targets = set(env.get("target", [])) + custom_env_ids = set(env.get("customEnvironmentIds", [])) + + if target_environment in custom_env_map: + # For custom environments, check customEnvironmentIds field + custom_env_id = custom_env_map[target_environment] + + # Variable should ONLY target this custom environment + if ( + len(custom_env_ids) == 1 + and custom_env_id in custom_env_ids + and len(env_targets) == 0 + ): + filtered_envs.append(env) + else: + # For standard environments, only include if it EXCLUSIVELY targets this environment + if ( + len(env_targets) == 1 + and target_environment in env_targets + and len(custom_env_ids) == 0 + ): + filtered_envs.append(env) + + logger.info( + f"Found {len(filtered_envs)} variables exclusively targeting environment: {target_environment}" + ) return { env["key"]: { @@ -137,21 +283,10 @@ def get_existing_env_vars(token, project_id, team_id=None, target_environment=No "target": env["target"], "comment": env.get("comment"), } - for env in envs + for env in filtered_envs } -def delete_env_var(token, project_id, team_id, env_var_id): - """Delete a Vercel environment variable using its ID.""" - url = f"{VERCEL_API_BASE_URL}/v9/projects/{project_id}/env/{env_var_id}" - if team_id is not None: - url += f"?teamId={team_id}" - response = requests.delete(url, headers=get_vercel_headers(token)) - - if response.status_code != 200: - raise Exception(f"Error deleting environment variable: {response.text}") - - def sync_vercel_secrets( secrets, credential_id, @@ -162,13 +297,14 @@ def sync_vercel_secrets( ): """ Sync secrets to a specific Vercel project environment. + For 'all' environments, creates separate variables for each environment to avoid cross-environment deletion issues. Args: secrets (list of tuple): List of (key, value, comment) tuples to sync credential_id (str): The ID of the stored credentials project_id (str): The Vercel project ID team_id (str): The Vercel project team ID - environment (str): Target environment (development/preview/production/all) + environment (str): Target environment (development/preview/production/all/custom-env-slug) secret_type (str): Type of secret (plain/encrypted/sensitive) Returns: @@ -177,71 +313,129 @@ def sync_vercel_secrets( try: token = get_vercel_credentials(credential_id) - # Determine target environments - target_environments = ( - ["production", "preview", "development"] - if environment == "all" - else [environment] - ) + # Get custom environments mapping for proper target identification + custom_envs = get_project_custom_environments(token, project_id, team_id) + custom_env_map = {env["slug"]: env["id"] for env in custom_envs} + + # Validate environment exists before proceeding + if ( + environment not in ["all", "production", "preview", "development"] + and environment not in custom_env_map + ): + available_envs = ["all", "production", "preview", "development"] + list( + custom_env_map.keys() + ) + raise Exception( + f"Environment '{environment}' not found. Available environments: {available_envs}" + ) - all_updates_successful = True + logger.info(f"Syncing {len(secrets)} secrets to environment: {environment}") + + # Determine target environments to sync to + if environment == "all": + target_environments = ["production", "preview", "development"] + list( + custom_env_map.keys() + ) + logger.info( + f"Syncing to all environments individually: {target_environments}" + ) + else: + target_environments = [environment] + + all_success = True messages = [] # Process each target environment separately for target_env in target_environments: + logger.info(f"Processing environment: {target_env}") + # Get existing environment variables for this specific environment existing_env_vars = get_existing_env_vars( token, project_id, team_id, target_environment=target_env ) - # Prepare payload for bulk creation + logger.info( + f"Found {len(existing_env_vars)} existing variables for environment: {target_env}" + ) + + # Prepare payload for this specific environment payload = [] + updated_count = 0 + for key, value, comment in secrets: # Check if the environment variable exists and needs updating if key in existing_env_vars: existing_var = existing_env_vars[key] - if ( - value != existing_var["value"] - or comment != existing_var.get("comment") + if value != existing_var["value"] or comment != existing_var.get( + "comment" ): - # Only delete if we're updating this specific variable + logger.info(f"Updating variable in environment: {target_env}") delete_env_var(token, project_id, team_id, existing_var["id"]) + updated_count += 1 + # Create environment variable with proper targeting for this specific environment env_var = { "key": key, "value": value, "type": secret_type, - "target": [target_env], # Set target to specific environment } + if comment: env_var["comment"] = comment + + # Always create single-environment variables + if target_env in custom_env_map: + # For custom environments, use customEnvironmentIds + env_var["customEnvironmentIds"] = [custom_env_map[target_env]] + else: + # For standard environments, use target array + env_var["target"] = [target_env] + payload.append(env_var) - # Delete environment variables not in the source (only for this environment) + # Handle deletion of variables not in the source for this specific environment + secrets_to_keep = {secret[0] for secret in secrets} + deleted_count = 0 + for key, env_var in existing_env_vars.items(): - if not any(s[0] == key for s in secrets): + if key not in secrets_to_keep: + logger.info( + f"Deleting unused variable from environment: {target_env}" + ) delete_env_var(token, project_id, team_id, env_var["id"]) + deleted_count += 1 - # Bulk create environment variables + # Bulk create environment variables for this environment if payload: + logger.info( + f"Syncing {len(payload)} variables to environment: {target_env}" + ) + url = f"{VERCEL_API_BASE_URL}/v10/projects/{project_id}/env?upsert=true" if team_id is not None: url += f"&teamId={team_id}" + response = requests.post( url, headers=get_vercel_headers(token), json=payload ) - if response.status_code != 201: - all_updates_successful = False - messages.append( - f"Failed to sync secrets for environment {target_env}: {response.text}" + if response.status_code not in [200, 201]: + all_success = False + error_msg = ( + f"Failed to sync secrets to {target_env}: {response.text}" ) + logger.error(error_msg) + messages.append(error_msg) else: - messages.append( - f"Successfully synced secrets to environment: {target_env}" - ) + success_msg = f"Successfully synced to {target_env}: {len(payload)} total, {updated_count} updated, {deleted_count} deleted" + logger.info(success_msg) + messages.append(success_msg) + else: + messages.append(f"No secrets to sync for environment: {target_env}") - return all_updates_successful, {"message": "; ".join(messages)} + return all_success, {"message": "\n".join(messages)} except Exception as e: - return False, {"message": f"Failed to sync secrets: {str(e)}"} + error_msg = f"Failed to sync secrets: {str(e)}" + logger.error(error_msg) + return False, {"message": error_msg} diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index 2e2ef231f..c2cd191b7 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -162,7 +162,7 @@ const documents = { "query GetRailwayProjects($credentialId: ID!) {\n railwayProjects(credentialId: $credentialId) {\n id\n name\n environments {\n id\n name\n }\n services {\n id\n name\n }\n }\n}": types.GetRailwayProjectsDocument, "query GetRenderResources($credentialId: ID!) {\n renderServices(credentialId: $credentialId) {\n id\n name\n type\n }\n renderEnvgroups(credentialId: $credentialId) {\n id\n name\n }\n}": types.GetRenderResourcesDocument, "query TestVaultAuth($credentialId: ID!) {\n testVaultCreds(credentialId: $credentialId)\n}": types.TestVaultAuthDocument, - "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environment\n }\n }\n}": types.GetVercelProjectsDocument, + "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n slug\n type\n }\n }\n }\n}": types.GetVercelProjectsDocument, "query GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\n id\n role {\n id\n name\n description\n permissions\n color\n }\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n lastLogin\n self\n appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n }\n}": types.GetOrganisationMemberDetailDocument, "query GetUserTokens($organisationId: ID!) {\n userTokens(organisationId: $organisationId) {\n id\n name\n wrappedKeyShare\n createdAt\n expiresAt\n }\n}": types.GetUserTokensDocument, }; @@ -780,7 +780,7 @@ export function graphql(source: "query TestVaultAuth($credentialId: ID!) {\n te /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environment\n }\n }\n}"): (typeof documents)["query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environment\n }\n }\n}"]; +export function graphql(source: "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n slug\n type\n }\n }\n }\n}"): (typeof documents)["query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n slug\n type\n }\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 f6de5ce83..65f5a0a90 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -2523,9 +2523,18 @@ export type UserTokenType = { wrappedKeyShare: Scalars['String']['output']; }; +export type VercelEnvironmentType = { + __typename?: 'VercelEnvironmentType'; + description?: Maybe; + id: Scalars['ID']['output']; + name: Scalars['String']['output']; + slug: Scalars['String']['output']; + type?: Maybe; +}; + export type VercelProjectType = { __typename?: 'VercelProjectType'; - environment?: Maybe>>; + environments?: Maybe>>; id: Scalars['ID']['output']; name: Scalars['String']['output']; }; @@ -3800,7 +3809,7 @@ export type GetVercelProjectsQueryVariables = Exact<{ }>; -export type GetVercelProjectsQuery = { __typename?: 'Query', vercelProjects?: Array<{ __typename?: 'VercelTeamProjectsType', id: string, teamName: string, projects?: Array<{ __typename?: 'VercelProjectType', id: string, name: string, environment?: Array | null } | null> | null } | null> | null }; +export type GetVercelProjectsQuery = { __typename?: 'Query', vercelProjects?: Array<{ __typename?: 'VercelTeamProjectsType', id: string, teamName: string, projects?: Array<{ __typename?: 'VercelProjectType', id: string, name: string, environments?: Array<{ __typename?: 'VercelEnvironmentType', id: string, name: string, slug: string, type?: string | null } | null> | null } | null> | null } | null> | null }; export type GetOrganisationMemberDetailQueryVariables = Exact<{ organisationId: Scalars['ID']['input']; @@ -3967,6 +3976,6 @@ export const TestNomadAuthDocument = {"kind":"Document","definitions":[{"kind":" export const GetRailwayProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRailwayProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"railwayProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetRenderResourcesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRenderResources"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"renderServices"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"renderEnvgroups"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const TestVaultAuthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TestVaultAuth"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"testVaultCreds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}]}]}}]} as unknown as DocumentNode; -export const GetVercelProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetVercelProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"vercelProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"teamName"}},{"kind":"Field","name":{"kind":"Name","value":"projects"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environment"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetVercelProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetVercelProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"vercelProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"teamName"}},{"kind":"Field","name":{"kind":"Name","value":"projects"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetOrganisationMemberDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationMemberDetail"},"variableDefinitions":[{"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":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisationMembers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastLogin"}},{"kind":"Field","name":{"kind":"Name","value":"self"}},{"kind":"Field","name":{"kind":"Name","value":"appMemberships"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"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":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"networkPolicies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"allowedIps"}},{"kind":"Field","name":{"kind":"Name","value":"isGlobal"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetUserTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserTokens"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userTokens"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedKeyShare"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index e87d120d1..16dbd9b12 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -864,7 +864,15 @@ type VercelTeamProjectsType { type VercelProjectType { id: ID! name: String! - environment: [String] + environments: [VercelEnvironmentType] +} + +type VercelEnvironmentType { + id: ID! + name: String! + description: String + slug: String! + type: String } type RenderServiceType { diff --git a/frontend/components/syncing/EnvSyncStatus.tsx b/frontend/components/syncing/EnvSyncStatus.tsx index 6bd4bc306..74529a651 100644 --- a/frontend/components/syncing/EnvSyncStatus.tsx +++ b/frontend/components/syncing/EnvSyncStatus.tsx @@ -17,16 +17,16 @@ export const EnvSyncStatus = (props: { const syncStatus = () => { if ( syncs.some( - (sync: EnvironmentSyncType) => sync.status === ApiEnvironmentSyncStatusChoices.Failed + (sync: EnvironmentSyncType) => sync.status === ApiEnvironmentSyncStatusChoices.InProgress ) ) - return ApiEnvironmentSyncStatusChoices.Failed + return ApiEnvironmentSyncStatusChoices.InProgress else if ( syncs.some( - (sync: EnvironmentSyncType) => sync.status === ApiEnvironmentSyncStatusChoices.InProgress + (sync: EnvironmentSyncType) => sync.status === ApiEnvironmentSyncStatusChoices.Failed ) ) - return ApiEnvironmentSyncStatusChoices.InProgress + return ApiEnvironmentSyncStatusChoices.Failed else return ApiEnvironmentSyncStatusChoices.Completed } diff --git a/frontend/components/syncing/ManageSyncDialog.tsx b/frontend/components/syncing/ManageSyncDialog.tsx index c68a56015..88436d6e1 100644 --- a/frontend/components/syncing/ManageSyncDialog.tsx +++ b/frontend/components/syncing/ManageSyncDialog.tsx @@ -82,7 +82,7 @@ export const ManageSyncDialog = (props: { sync: EnvironmentSyncType; button: Rea - {sync.environment.envType} + {sync.environment.name} diff --git a/frontend/components/syncing/Vercel/CreateVercelSync.tsx b/frontend/components/syncing/Vercel/CreateVercelSync.tsx index ee5b3fcdb..45a341329 100644 --- a/frontend/components/syncing/Vercel/CreateVercelSync.tsx +++ b/frontend/components/syncing/Vercel/CreateVercelSync.tsx @@ -9,6 +9,7 @@ import { Button } from '../../common/Button' import { EnvironmentType, ProviderCredentialsType, + VercelEnvironmentType, VercelProjectType, VercelTeamProjectsType, } from '@/apollo/graphql' @@ -46,9 +47,12 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void const [vercelProject, setVercelProject] = useState(null) const [projectQuery, setProjectQuery] = useState('') + const [vercelEnvironment, setVercelEnvironment] = useState(null) + const [envQuery, setEnvQuery] = useState('') + const [phaseEnv, setPhaseEnv] = useState(null) const [path, setPath] = useState('/') - const [environment, setEnvironment] = useState('production') + const [secretType, setSecretType] = useState('encrypted') const [credentialsValid, setCredentialsValid] = useState(false) @@ -67,6 +71,13 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void } }, [appEnvsData]) + // Preselect first available Vercel team + useEffect(() => { + if (vercelTeams.length > 0) { + setVercelTeam(vercelTeams[0]) + } + }, [vercelTeams]) + const handleSubmit = async (e: { preventDefault: () => void }) => { e.preventDefault() @@ -99,7 +110,7 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void projectName: vercelProject.name, teamId: vercelTeam?.id, teamName: vercelTeam?.teamName, - environment, + environment: vercelEnvironment?.slug, secretType, }, refetchQueries: [{ query: GetAppSyncStatus, variables: { appId } }], @@ -125,15 +136,28 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void project!.name?.toLowerCase().includes(projectQuery.toLowerCase()) ) - const environments = ['production', 'preview', 'development', 'all'] + const filteredEnvs: VercelEnvironmentType[] = + envQuery === '' + ? (vercelProject?.environments as VercelEnvironmentType[]) ?? [] + : ((vercelProject?.environments as VercelEnvironmentType[]) ?? []).filter((env) => + env?.name?.toLowerCase().includes(envQuery.toLowerCase()) + ) + const secretTypes = [ { value: 'plain', label: 'Plain Text' }, { value: 'encrypted', label: 'Encrypted' }, { value: 'sensitive', label: 'Sensitive' }, ] + // Environment type badge styling + const envTypeBadgeStyles = { + all: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200', + standard: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + custom: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', + } + return ( -
+
@@ -241,14 +265,14 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void leaveTo="transform scale-95 opacity-0" > -
+
{filteredTeams.map((team) => ( {({ active }) => (
@@ -305,14 +329,14 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void leaveTo="transform scale-95 opacity-0" > -
+
{filteredProjects!.map((project) => ( {({ active }) => (
@@ -334,8 +358,84 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void
)} -
- +
+ + {({ open }) => ( + <> +
+ + + +
+ setEnvQuery(event.target.value)} + displayValue={(env: VercelEnvironmentType) => env?.name!} + required + /> +
+ + + +
+
+
+ + +
+ {filteredEnvs!.map((env) => ( + + {({ active }) => ( +
+
+
+ {env!.name} +
+
+ {env!.id} +
+
+
+ {env!.type} +
+
+ )} +
+ ))} +
+
+
+ + )} +
+ + {/*
{environments.map((env) => ( - + {({ active, checked }) => (
void )} > {checked ? : } - {env} + {env.name}
)}
))}
-
+ */}
diff --git a/frontend/graphql/queries/syncing/vercel/getProject.gql b/frontend/graphql/queries/syncing/vercel/getProject.gql index 9c4c2d5e5..449e3ab46 100644 --- a/frontend/graphql/queries/syncing/vercel/getProject.gql +++ b/frontend/graphql/queries/syncing/vercel/getProject.gql @@ -5,7 +5,12 @@ query GetVercelProjects($credentialId: ID!) { projects { id name - environment + environments { + id + name + slug + type + } } } }