misc: added import support and a few ui/ux updates

This commit is contained in:
Sheen Capadngan
2025-01-28 16:36:56 +08:00
parent effd88c4bd
commit 0a3e99b334
19 changed files with 179 additions and 107 deletions
@@ -25,7 +25,7 @@ export const getGcpAppConnectionListItem = () => {
}; };
}; };
export const getAuthToken = async (appConnection: TGcpConnectionConfig) => { export const getGcpConnectionAuthToken = async (appConnection: TGcpConnectionConfig) => {
const appCfg = getConfig(); const appCfg = getConfig();
if (!appCfg.INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL) { if (!appCfg.INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL) {
throw new InternalServerError({ throw new InternalServerError({
@@ -78,7 +78,7 @@ export const getAuthToken = async (appConnection: TGcpConnectionConfig) => {
}; };
export const getGcpSecretManagerProjects = async (appConnection: TGcpConnection) => { export const getGcpSecretManagerProjects = async (appConnection: TGcpConnection) => {
const accessToken = await getAuthToken(appConnection); const accessToken = await getGcpConnectionAuthToken(appConnection);
let gcpApps: GCPApp[] = []; let gcpApps: GCPApp[] = [];
@@ -146,19 +146,19 @@ export const getGcpSecretManagerProjects = async (appConnection: TGcpConnection)
}; };
export const validateGcpConnectionCredentials = async (appConnection: TGcpConnectionConfig) => { export const validateGcpConnectionCredentials = async (appConnection: TGcpConnectionConfig) => {
// Check if provided service account email prefix matches organization ID. // Check if provided service account email suffix matches organization ID.
// We do this to mitigate confused deputy attacks in multi-tenant instances // We do this to mitigate confused deputy attacks in multi-tenant instances
const expectedEmailPrefix = appConnection.orgId.split("-").slice(0, 2).join("-"); if (appConnection.credentials.serviceAccountEmail) {
if ( const expectedAccountIdSuffix = appConnection.orgId.split("-").slice(0, 2).join("-");
appConnection.credentials.serviceAccountEmail && const serviceAccountId = appConnection.credentials.serviceAccountEmail.split("@")[0];
!appConnection.credentials.serviceAccountEmail.startsWith(expectedEmailPrefix) if (!serviceAccountId.endsWith(expectedAccountIdSuffix)) {
) { throw new BadRequestError({
throw new BadRequestError({ message: `GCP service account ID (the part of the email before '@') must have a suffix of "${expectedAccountIdSuffix}"`
message: `GCP service account email must have a prefix of "${expectedEmailPrefix}"` });
}); }
} }
await getAuthToken(appConnection); await getGcpConnectionAuthToken(appConnection);
return appConnection.credentials; return appConnection.credentials;
}; };
@@ -6,5 +6,5 @@ export const GCP_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "GCP Secret Manager", name: "GCP Secret Manager",
destination: SecretSync.GCPSecretManager, destination: SecretSync.GCPSecretManager,
connection: AppConnection.GCP, connection: AppConnection.GCP,
canImportSecrets: false canImportSecrets: true
}; };
@@ -0,0 +1,3 @@
export enum GcpSyncScope {
Global = "global"
}
@@ -1,9 +1,11 @@
import { AxiosError } from "axios";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { getAuthToken } from "@app/services/app-connection/gcp"; import { getGcpConnectionAuthToken } from "@app/services/app-connection/gcp";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; import { SecretSyncError } from "../secret-sync-errors";
import { TSecretMap } from "../secret-sync-types"; import { TSecretMap } from "../secret-sync-types";
import { import {
GCPLatestSecretVersionAccess, GCPLatestSecretVersionAccess,
@@ -13,6 +15,8 @@ import {
} from "./gcp-sync-types"; } from "./gcp-sync-types";
const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCredentials) => { const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCredentials) => {
const { destinationConfig } = secretSync;
let gcpSecrets: GCPSecret[] = []; let gcpSecrets: GCPSecret[] = [];
const pageSize = 100; const pageSize = 100;
@@ -48,21 +52,13 @@ const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCreden
pageToken = secretsRes.nextPageToken; pageToken = secretsRes.nextPageToken;
} }
return gcpSecrets; const res: { [key: string]: string } = {};
};
export const GcpSyncFns = { for await (const gcpSecret of gcpSecrets) {
syncSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => { const arr = gcpSecret.name.split("/");
const { destinationConfig, connection } = secretSync; const key = arr[arr.length - 1];
const accessToken = await getAuthToken(connection);
const gcpSecrets = await getGcpSecrets(accessToken, secretSync);
const res: { [key: string]: string } = {};
for await (const gcpSecret of gcpSecrets) {
const arr = gcpSecret.name.split("/");
const key = arr[arr.length - 1];
try {
const { data: secretLatest } = await request.get<GCPLatestSecretVersionAccess>( const { data: secretLatest } = await request.get<GCPLatestSecretVersionAccess>(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}/versions/latest:access`, `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}/versions/latest:access`,
{ {
@@ -74,100 +70,132 @@ export const GcpSyncFns = {
); );
res[key] = Buffer.from(secretLatest.payload.data, "base64").toString("utf-8"); res[key] = Buffer.from(secretLatest.payload.data, "base64").toString("utf-8");
} catch (error) {
// when a secret in GCP has no versions, we treat it as if it's a blank value
if (error instanceof AxiosError && error.response?.status === 404) {
res[key] = "";
} else {
throw new SecretSyncError({
error,
secretKey: key
});
}
} }
}
return res;
};
export const GcpSyncFns = {
syncSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig, connection } = secretSync;
const accessToken = await getGcpConnectionAuthToken(connection);
const gcpSecrets = await getGcpSecrets(accessToken, secretSync);
for await (const key of Object.keys(secretMap)) { for await (const key of Object.keys(secretMap)) {
if (!(key in res)) { try {
// case: create secret if (!(key in gcpSecrets)) {
await request.post( // case: create secret
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets`, await request.post(
{ `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets`,
replication: { {
automatic: {} replication: {
} automatic: {}
}, }
{
params: {
secretId: key
}, },
headers: { {
Authorization: `Bearer ${accessToken}`, params: {
"Accept-Encoding": "application/json" secretId: key
},
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
} }
} );
);
if (!secretMap[key].value) { await request.post(
logger.warn( `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`,
`syncSecretsGcpsecretManager: create secret value in gcp where [key=${key}] and [projectId=${destinationConfig.projectId}]` {
payload: {
data: Buffer.from(secretMap[key].value).toString("base64")
}
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
); );
} }
} catch (error) {
await request.post( throw new SecretSyncError({
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`, error,
{ secretKey: key
payload: { });
data: Buffer.from(secretMap[key].value).toString("base64")
}
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
);
} }
} }
for await (const key of Object.keys(res)) { for await (const key of Object.keys(gcpSecrets)) {
if (!(key in secretMap)) { try {
// case: delete secret if (!(key in secretMap)) {
await request.delete( // case: delete secret
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`, await request.delete(
{ `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`,
headers: { {
Authorization: `Bearer ${accessToken}`, headers: {
"Accept-Encoding": "application/json" Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
} }
);
} else if (secretMap[key].value !== gcpSecrets[key]) {
if (!secretMap[key].value) {
logger.warn(
`syncSecretsGcpsecretManager: update secret value in gcp where [key=${key}] and [projectId=${destinationConfig.projectId}]`
);
} }
);
} else if (secretMap[key].value !== res[key]) { await request.post(
if (!secretMap[key].value) { `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`,
logger.warn( {
`syncSecretsGcpsecretManager: update secret value in gcp where [key=${key}] and [projectId=${destinationConfig.projectId}]` payload: {
data: Buffer.from(secretMap[key].value).toString("base64")
}
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
); );
} }
} catch (error) {
await request.post( throw new SecretSyncError({
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`, error,
{ secretKey: key
payload: { });
data: Buffer.from(secretMap[key].value).toString("base64")
}
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
);
} }
} }
}, },
getSecrets: async (secretSync: TGcpSyncWithCredentials): Promise<TSecretMap> => { getSecrets: async (secretSync: TGcpSyncWithCredentials): Promise<TSecretMap> => {
throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); const { connection } = secretSync;
const accessToken = await getGcpConnectionAuthToken(connection);
const gcpSecrets = await getGcpSecrets(accessToken, secretSync);
return Object.fromEntries(Object.entries(gcpSecrets).map(([key, value]) => [key, { value: value ?? "" }]));
}, },
removeSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => { removeSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig, connection } = secretSync; const { destinationConfig, connection } = secretSync;
const accessToken = await getAuthToken(connection); const accessToken = await getGcpConnectionAuthToken(connection);
const gcpSecrets = await getGcpSecrets(accessToken, secretSync); const gcpSecrets = await getGcpSecrets(accessToken, secretSync);
for await (const entry of gcpSecrets) { for await (const [key] of Object.entries(gcpSecrets)) {
const arr = entry.name.split("/");
const key = arr[arr.length - 1];
if (key in secretMap) { if (key in secretMap) {
await request.delete( await request.delete(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`, `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`,
@@ -9,10 +9,12 @@ import {
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
import { SecretSync } from "../secret-sync-enums"; import { SecretSync } from "../secret-sync-enums";
import { GcpSyncScope } from "./gcp-sync-enums";
const GcpSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; const GcpSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
const GcpSyncDestinationConfigSchema = z.object({ const GcpSyncDestinationConfigSchema = z.object({
scope: z.literal(GcpSyncScope.Global),
projectId: z.string().min(1, "Project ID is required") projectId: z.string().min(1, "Project ID is required")
}); });
@@ -39,5 +41,5 @@ export const GcpSyncListItemSchema = z.object({
name: z.literal("GCP Secret Manager"), name: z.literal("GCP Secret Manager"),
connection: z.literal(AppConnection.GCP), connection: z.literal(AppConnection.GCP),
destination: z.literal(SecretSync.GCPSecretManager), destination: z.literal(SecretSync.GCPSecretManager),
canImportSecrets: z.literal(false) canImportSecrets: z.literal(true)
}); });
@@ -1,3 +1,4 @@
export * from "./gcp-sync-constants"; export * from "./gcp-sync-constants";
export * from "./gcp-sync-enums";
export * from "./gcp-sync-schemas"; export * from "./gcp-sync-schemas";
export * from "./gcp-sync-types"; export * from "./gcp-sync-types";
@@ -126,7 +126,7 @@ export const parseSyncErrorMessage = (err: unknown): string => {
if (err instanceof SecretSyncError) { if (err instanceof SecretSyncError) {
return JSON.stringify({ return JSON.stringify({
secretKey: err.secretKey, secretKey: err.secretKey,
error: err.message ?? parseSyncErrorMessage(err.error) error: err.message || parseSyncErrorMessage(err.error)
}); });
} }
@@ -0,0 +1,4 @@
---
title: "Import Secrets"
openapi: "POST /api/v1/secret-syncs/gcp-secret-manager/{syncId}/import-secrets"
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 685 KiB

After

Width:  |  Height:  |  Size: 602 KiB

+2 -2
View File
@@ -40,11 +40,11 @@ Infisical supports [service account impersonation](https://cloud.google.com/iam/
<Step title="Create Service Account"> <Step title="Create Service Account">
Create a new service account with an ID that follows this requirement: Create a new service account with an ID that follows this requirement:
Your service account ID must start with the first two sections of your Infisical organization ID. Your service account ID must end with the first two sections of your Infisical organization ID.
Example: Example:
- Infisical organization ID: `df92581a-0fe9-42b5-b526-0a1e88ec8085` - Infisical organization ID: `df92581a-0fe9-42b5-b526-0a1e88ec8085`
- Required service account ID prefix: `df92581a-0fe9` - Required service account ID suffix: `df92581a-0fe9`
![Create Service Account](/images/app-connections/gcp/create-service-account.png) ![Create Service Account](/images/app-connections/gcp/create-service-account.png)
</Step> </Step>
@@ -7,7 +7,7 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical."
- Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - Set up and add secrets to [Infisical Cloud](https://app.infisical.com)
- Create a [GCP Connection](/integrations/app-connections/gcp) with the required **Secret Sync** permissions - Create a [GCP Connection](/integrations/app-connections/gcp) with the required **Secret Sync** permissions
- Enable Cloud Manager Resource API and Secret Manager API on your GCP project - Enable **Cloud Resource Manager API** and **Secret Manager API** on your GCP project
![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-resource-manager-api.png) ![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-resource-manager-api.png)
![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-secret-manager-api.png) ![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-secret-manager-api.png)
@@ -40,6 +40,8 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical."
- **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync.
- **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical.
- **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint prior to syncing, prioritizing values present in Infisical if secrets conflict.
- **Import Secrets (Prioritize GCP Secret Manager)**: Imports secrets from the destination endpoint prior to syncing, prioritizing values present in GCP secret manager if secrets conflict.
- **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only.
6. Configure the **Details** of your GCP Secret Manager Sync, then click **Next**. 6. Configure the **Details** of your GCP Secret Manager Sync, then click **Next**.
@@ -67,6 +69,7 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical."
--header 'Content-Type: application/json' \ --header 'Content-Type: application/json' \
--data '{ --data '{
"destinationConfig": { "destinationConfig": {
"scope": "global",
"projectId": "infisical-test-playground" "projectId": "infisical-test-playground"
}, },
"name": "my-gcp-sync", "name": "my-gcp-sync",
+1
View File
@@ -880,6 +880,7 @@
"api-reference/endpoints/secret-syncs/gcp-secret-manager/update", "api-reference/endpoints/secret-syncs/gcp-secret-manager/update",
"api-reference/endpoints/secret-syncs/gcp-secret-manager/delete", "api-reference/endpoints/secret-syncs/gcp-secret-manager/delete",
"api-reference/endpoints/secret-syncs/gcp-secret-manager/sync-secrets", "api-reference/endpoints/secret-syncs/gcp-secret-manager/sync-secrets",
"api-reference/endpoints/secret-syncs/gcp-secret-manager/import-secrets",
"api-reference/endpoints/secret-syncs/gcp-secret-manager/remove-secrets" "api-reference/endpoints/secret-syncs/gcp-secret-manager/remove-secrets"
] ]
} }
@@ -1,11 +1,15 @@
import { useEffect } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form"; import { Controller, useFormContext, useWatch } from "react-hook-form";
import { SingleValue } from "react-select"; import { SingleValue } from "react-select";
import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl } from "@app/components/v2"; import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2";
import { useGcpConnectionListProjects } from "@app/hooks/api/appConnections/gcp/queries"; import { useGcpConnectionListProjects } from "@app/hooks/api/appConnections/gcp/queries";
import { TGitHubConnectionEnvironment } from "@app/hooks/api/appConnections/github"; import { TGitHubConnectionEnvironment } from "@app/hooks/api/appConnections/github";
import { SecretSync } from "@app/hooks/api/secretSyncs"; import { SecretSync } from "@app/hooks/api/secretSyncs";
import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync";
import { TSecretSyncForm } from "../schemas"; import { TSecretSyncForm } from "../schemas";
@@ -20,6 +24,10 @@ export const GcpSyncFields = () => {
enabled: Boolean(connectionId) enabled: Boolean(connectionId)
}); });
useEffect(() => {
setValue("destinationConfig.scope", GcpSyncScope.Global);
}, []);
return ( return (
<> <>
<SecretSyncConnectionField <SecretSyncConnectionField
@@ -31,7 +39,22 @@ export const GcpSyncFields = () => {
name="destinationConfig.projectId" name="destinationConfig.projectId"
control={control} control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Project"> <FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project"
helperText={
<Tooltip
className="max-w-md"
content="Ensure that you've enabled the Secret Manager API and Cloud Resource Manager API on your GCP project. Additionally, make sure that the service account is assigned the appropriate GCP roles."
>
<div>
<span>Don&#39;t see the project you&#39;re looking for?</span>{" "}
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</div>
</Tooltip>
}
>
<FilterableSelect <FilterableSelect
menuPlacement="top" menuPlacement="top"
isLoading={isPending && Boolean(connectionId)} isLoading={isPending && Boolean(connectionId)}
@@ -8,7 +8,7 @@ import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/hel
import { SecretSync } from "@app/hooks/api/secretSyncs"; import { SecretSync } from "@app/hooks/api/secretSyncs";
import { AwsParameterStoreSyncReviewFields } from "./AwsParameterStoreSyncReviewFields"; import { AwsParameterStoreSyncReviewFields } from "./AwsParameterStoreSyncReviewFields";
import { GcpSyncReviewFields } from "./GcpSyncReviewFIelds"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields";
import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields";
export const SecretSyncReviewFields = () => { export const SecretSyncReviewFields = () => {
@@ -1,10 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { SecretSync } from "@app/hooks/api/secretSyncs"; import { SecretSync } from "@app/hooks/api/secretSyncs";
import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync";
export const GcpSyncDestinationSchema = z.object({ export const GcpSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.GCPSecretManager), destination: z.literal(SecretSync.GCPSecretManager),
destinationConfig: z.object({ destinationConfig: z.object({
scope: z.literal(GcpSyncScope.Global),
projectId: z.string().min(1, "Project ID required") projectId: z.string().min(1, "Project ID required")
}) })
}); });
@@ -2,9 +2,14 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSync } from "@app/hooks/api/secretSyncs"; import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export enum GcpSyncScope {
Global = "global"
}
export type TGcpSync = TRootSecretSync & { export type TGcpSync = TRootSecretSync & {
destination: SecretSync.GCPSecretManager; destination: SecretSync.GCPSecretManager;
destinationConfig: { destinationConfig: {
scope: GcpSyncScope.Global;
projectId: string; projectId: string;
}; };
connection: { connection: {
@@ -103,7 +103,7 @@ export const GcpConnectionForm = ({ appConnection, onSubmit }: Props) => {
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
label="Service Account Email" label="Service Account Email"
className="group" className="group"
helperText={`Service account email must be prefixed with "${currentOrg.id.split("-").slice(0, 2).join("-")}".`} helperText={`Service account ID (the part of the email before '@') must be suffixed with "${currentOrg.id.split("-").slice(0, 2).join("-")}".`}
> >
<SecretInput <SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5" containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"