diff --git a/backend/src/db/migrations/20250717195959_gatewayid-for-app-conn.ts b/backend/src/db/migrations/20250717195959_gatewayid-for-app-conn.ts new file mode 100644 index 000000000..531cdcae8 --- /dev/null +++ b/backend/src/db/migrations/20250717195959_gatewayid-for-app-conn.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.AppConnection, "gatewayId"))) { + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.uuid("gatewayId").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.AppConnection, "gatewayId")) { + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.dropColumn("gatewayId"); + }); + } +} diff --git a/backend/src/db/schemas/app-connections.ts b/backend/src/db/schemas/app-connections.ts index ee4282b73..2218b75ce 100644 --- a/backend/src/db/schemas/app-connections.ts +++ b/backend/src/db/schemas/app-connections.ts @@ -20,7 +20,8 @@ export const AppConnectionsSchema = z.object({ orgId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - isPlatformManagedCredentials: z.boolean().default(false).nullable().optional() + isPlatformManagedCredentials: z.boolean().default(false).nullable().optional(), + gatewayId: z.string().uuid().nullable().optional() }); export type TAppConnections = z.infer; diff --git a/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts index f93abae83..38e0fc828 100644 --- a/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts +++ b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts @@ -45,7 +45,10 @@ export const ValidateOracleDBConnectionCredentialsSchema = z.discriminatedUnion( ]); export const CreateOracleDBConnectionSchema = ValidateOracleDBConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.OracleDB, { supportsPlatformManagedCredentials: true }) + GenericCreateAppConnectionFieldsSchema(AppConnection.OracleDB, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) ); export const UpdateOracleDBConnectionSchema = z @@ -54,7 +57,12 @@ export const UpdateOracleDBConnectionSchema = z AppConnections.UPDATE(AppConnection.OracleDB).credentials ) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.OracleDB, { supportsPlatformManagedCredentials: true })); + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.OracleDB, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) + ); export const OracleDBConnectionListItemSchema = z.object({ name: z.literal("OracleDB"), diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 7e784d9ad..1a8d5b2af 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -8,7 +8,6 @@ import { ForbiddenError } from "@casl/ability"; import { CronJob } from "cron"; import { Knex } from "knex"; -import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { verifyOfflineLicense } from "@app/lib/crypto"; import { NotFoundError } from "@app/lib/errors"; @@ -47,7 +46,6 @@ type TLicenseServiceFactoryDep = { orgDAL: Pick; permissionService: Pick; licenseDAL: TLicenseDALFactory; - keyStore: Pick; identityOrgMembershipDAL: TIdentityOrgDALFactory; projectDAL: TProjectDALFactory; }; @@ -57,14 +55,10 @@ export type TLicenseServiceFactory = ReturnType; const LICENSE_SERVER_CLOUD_LOGIN = "/api/auth/v1/license-server-login"; const LICENSE_SERVER_ON_PREM_LOGIN = "/api/auth/v1/license-login"; -const LICENSE_SERVER_CLOUD_PLAN_TTL = 5 * 60; // 5 mins -const FEATURE_CACHE_KEY = (orgId: string) => `infisical-cloud-plan-${orgId}`; - export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL, - keyStore, identityOrgMembershipDAL, projectDAL }: TLicenseServiceFactoryDep) => { @@ -178,12 +172,6 @@ export const licenseServiceFactory = ({ logger.info(`getPlan: attempting to fetch plan for [orgId=${orgId}] [projectId=${projectId}]`); try { if (instanceType === InstanceType.Cloud) { - const cachedPlan = await keyStore.getItem(FEATURE_CACHE_KEY(orgId)); - if (cachedPlan) { - logger.info(`getPlan: plan fetched from cache [orgId=${orgId}] [projectId=${projectId}]`); - return JSON.parse(cachedPlan) as TFeatureSet; - } - const org = await orgDAL.findOrgById(orgId); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); const { @@ -199,23 +187,12 @@ export const licenseServiceFactory = ({ const identityUsed = await licenseDAL.countOrgUsersAndIdentities(orgId); currentPlan.identitiesUsed = identityUsed; - await keyStore.setItemWithExpiry( - FEATURE_CACHE_KEY(org.id), - LICENSE_SERVER_CLOUD_PLAN_TTL, - JSON.stringify(currentPlan) - ); - return currentPlan; } } catch (error) { logger.error( error, - `getPlan: encountered an error when fetching pan [orgId=${orgId}] [projectId=${projectId}] [error]` - ); - await keyStore.setItemWithExpiry( - FEATURE_CACHE_KEY(orgId), - LICENSE_SERVER_CLOUD_PLAN_TTL, - JSON.stringify(onPremFeatures) + `getPlan: encountered an error when fetching plan [orgId=${orgId}] [projectId=${projectId}] [error]` ); return onPremFeatures; } finally { @@ -226,7 +203,6 @@ export const licenseServiceFactory = ({ const refreshPlan = async (orgId: string) => { if (instanceType === InstanceType.Cloud) { - await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); await getPlan(orgId); } }; @@ -264,7 +240,6 @@ export const licenseServiceFactory = ({ quantityIdentities }); } - await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); } else if (instanceType === InstanceType.EnterpriseOnPrem) { const usedSeats = await licenseDAL.countOfOrgMembers(null, tx); const usedIdentitySeats = await licenseDAL.countOrgUsersAndIdentities(null, tx); @@ -328,7 +303,6 @@ export const licenseServiceFactory = ({ `/api/license-server/v1/customers/${organization.customerId}/session/trial`, { success_url } ); - await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); return { url }; }; @@ -705,10 +679,6 @@ export const licenseServiceFactory = ({ return licenses; }; - const invalidateGetPlan = async (orgId: string) => { - await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); - }; - return { generateOrgCustomerId, removeOrgCustomer, @@ -723,7 +693,6 @@ export const licenseServiceFactory = ({ return onPremFeatures; }, getPlan, - invalidateGetPlan, updateSubscriptionOrgMemberCount, refreshPlan, getOrgPlan, diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index 855bf8036..334a9e9e8 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -4,6 +4,7 @@ import isEqual from "lodash.isequal"; import { SecretType, TableName } from "@app/db/schemas"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { hasSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -108,6 +109,7 @@ export type TSecretRotationV2ServiceFactoryDep = { queueService: Pick; appConnectionDAL: Pick; folderCommitService: Pick; + gatewayService: Pick; }; export type TSecretRotationV2ServiceFactory = ReturnType; @@ -150,7 +152,8 @@ export const secretRotationV2ServiceFactory = ({ keyStore, queueService, folderCommitService, - appConnectionDAL + appConnectionDAL, + gatewayService }: TSecretRotationV2ServiceFactoryDep) => { const $queueSendSecretRotationStatusNotification = async (secretRotation: TSecretRotationV2Raw) => { const appCfg = getConfig(); @@ -463,7 +466,8 @@ export const secretRotationV2ServiceFactory = ({ rotationInterval: payload.rotationInterval } as TSecretRotationV2WithConnection, appConnectionDAL, - kmsService + kmsService, + gatewayService ); // even though we have a db constraint we want to check before any rotation of credentials is attempted @@ -826,7 +830,8 @@ export const secretRotationV2ServiceFactory = ({ connection: appConnection } as TSecretRotationV2WithConnection, appConnectionDAL, - kmsService + kmsService, + gatewayService ); const generatedCredentials = await decryptSecretRotationCredentials({ @@ -909,7 +914,8 @@ export const secretRotationV2ServiceFactory = ({ connection: appConnection } as TSecretRotationV2WithConnection, appConnectionDAL, - kmsService + kmsService, + gatewayService ); const updatedRotation = await rotationFactory.rotateCredentials( diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts index 827c567e6..ab348f172 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts @@ -1,4 +1,5 @@ import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TSqlCredentialsRotationGeneratedCredentials } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types"; import { OrderByDirection } from "@app/lib/types"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; @@ -251,7 +252,8 @@ export type TRotationFactory< > = ( secretRotation: T, appConnectionDAL: Pick, - kmsService: Pick + kmsService: Pick, + gatewayService: Pick ) => { issueCredentials: TRotationFactoryIssueCredentials; revokeCredentials: TRotationFactoryRevokeCredentials; diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts index 12e9b5964..3e6e5d265 100644 --- a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts @@ -1,3 +1,5 @@ +import { Knex } from "knex"; + import { TRotationFactory, TRotationFactoryGetSecretsPayload, @@ -5,7 +7,10 @@ import { TRotationFactoryRevokeCredentials, TRotationFactoryRotateCredentials } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; -import { getSqlConnectionClient, SQL_CONNECTION_ALTER_LOGIN_STATEMENT } from "@app/services/app-connection/shared/sql"; +import { + executeWithPotentialGateway, + SQL_CONNECTION_ALTER_LOGIN_STATEMENT +} from "@app/services/app-connection/shared/sql"; import { generatePassword } from "../utils"; import { @@ -30,7 +35,7 @@ const redactPasswords = (e: unknown, credentials: TSqlCredentialsRotationGenerat export const sqlCredentialsRotationFactory: TRotationFactory< TSqlCredentialsRotationWithConnection, TSqlCredentialsRotationGeneratedCredentials -> = (secretRotation) => { +> = (secretRotation, _appConnectionDAL, _kmsService, gatewayService) => { const { connection, parameters: { username1, username2 }, @@ -38,29 +43,38 @@ export const sqlCredentialsRotationFactory: TRotationFactory< secretsMapping } = secretRotation; - const $validateCredentials = async (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => { - const client = await getSqlConnectionClient({ - ...connection, - credentials: { - ...connection.credentials, - ...credentials - } - }); + const executeOperation = ( + operation: (client: Knex) => Promise, + credentialsOverride?: TSqlCredentialsRotationGeneratedCredentials[number] + ) => { + const finalCredentials = { + ...connection.credentials, + ...credentialsOverride + }; + return executeWithPotentialGateway( + { + ...connection, + credentials: finalCredentials + }, + gatewayService, + (client) => operation(client) + ); + }; + + const $validateCredentials = async (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => { try { - await client.raw("SELECT 1"); + await executeOperation(async (client) => { + await client.raw("SELECT 1"); + }, credentials); } catch (error) { throw new Error(redactPasswords(error, [credentials])); - } finally { - await client.destroy(); } }; const issueCredentials: TRotationFactoryIssueCredentials = async ( callback ) => { - const client = await getSqlConnectionClient(connection); - // For SQL, since we get existing users, we change both their passwords // on issue to invalidate their existing passwords const credentialsSet = [ @@ -69,15 +83,15 @@ export const sqlCredentialsRotationFactory: TRotationFactory< ]; try { - await client.transaction(async (tx) => { - for await (const credentials of credentialsSet) { - await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); - } + await executeOperation(async (client) => { + await client.transaction(async (tx) => { + for await (const credentials of credentialsSet) { + await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + } + }); }); } catch (error) { throw new Error(redactPasswords(error, credentialsSet)); - } finally { - await client.destroy(); } for await (const credentials of credentialsSet) { @@ -91,21 +105,19 @@ export const sqlCredentialsRotationFactory: TRotationFactory< credentialsToRevoke, callback ) => { - const client = await getSqlConnectionClient(connection); - const revokedCredentials = credentialsToRevoke.map(({ username }) => ({ username, password: generatePassword() })); try { - await client.transaction(async (tx) => { - for await (const credentials of revokedCredentials) { - // invalidate previous passwords - await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); - } + await executeOperation(async (client) => { + await client.transaction(async (tx) => { + for await (const credentials of revokedCredentials) { + // invalidate previous passwords + await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + } + }); }); } catch (error) { throw new Error(redactPasswords(error, revokedCredentials)); - } finally { - await client.destroy(); } return callback(); @@ -115,17 +127,15 @@ export const sqlCredentialsRotationFactory: TRotationFactory< _, callback ) => { - const client = await getSqlConnectionClient(connection); - // generate new password for the next active user const credentials = { username: activeIndex === 0 ? username2 : username1, password: generatePassword() }; try { - await client.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + await executeOperation(async (client) => { + await client.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + }); } catch (error) { throw new Error(redactPasswords(error, [credentials])); - } finally { - await client.destroy(); } await $validateCredentials(credentials); diff --git a/backend/src/server/lib/cookie.ts b/backend/src/server/lib/cookie.ts index cc6956056..323bf4be9 100644 --- a/backend/src/server/lib/cookie.ts +++ b/backend/src/server/lib/cookie.ts @@ -3,6 +3,11 @@ import { FastifyReply } from "fastify"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; +/** + * `aod` (Auth Origin Domain) cookie is used to store the origin domain of the application when user was last authenticated. + * This is useful for determining the target domain for authentication redirects, especially in cloud deployments. + * It is set only in cloud mode to ensure that the cookie is shared across subdomains. + */ export function addAuthOriginDomainCookie(res: FastifyReply) { try { const appCfg = getConfig(); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dcaa2b654..b566192f1 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -500,7 +500,6 @@ export const registerRoutes = async ( permissionService, orgDAL, licenseDAL, - keyStore, identityOrgMembershipDAL, projectDAL }); @@ -1706,7 +1705,9 @@ export const registerRoutes = async ( appConnectionDAL, permissionService, kmsService, - licenseService + licenseService, + gatewayService, + gatewayDAL }); const secretSyncService = secretSyncServiceFactory({ @@ -1804,7 +1805,8 @@ export const registerRoutes = async ( snapshotService, secretQueueService, queueService, - appConnectionDAL + appConnectionDAL, + gatewayService }); const certificateAuthorityService = certificateAuthorityServiceFactory({ diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts index 0bc8f7c59..50111b109 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts @@ -25,12 +25,14 @@ export const registerAppConnectionEndpoints = ; updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null; isPlatformManagedCredentials?: boolean; + gatewayId?: string | null; }>; sanitizedResponseSchema: z.ZodTypeAny; }) => { @@ -224,10 +226,10 @@ export const registerAppConnectionEndpoints = { - const { name, method, credentials, description, isPlatformManagedCredentials } = req.body; + const { name, method, credentials, description, isPlatformManagedCredentials, gatewayId } = req.body; const appConnection = (await server.services.appConnection.createAppConnection( - { name, method, app, credentials, description, isPlatformManagedCredentials }, + { name, method, app, credentials, description, isPlatformManagedCredentials, gatewayId }, req.permission )) as T; @@ -270,11 +272,11 @@ export const registerAppConnectionEndpoints = { - const { name, credentials, description, isPlatformManagedCredentials } = req.body; + const { name, credentials, description, isPlatformManagedCredentials, gatewayId } = req.body; const { connectionId } = req.params; const appConnection = (await server.services.appConnection.updateAppConnection( - { name, credentials, connectionId, description, isPlatformManagedCredentials }, + { name, credentials, connectionId, description, isPlatformManagedCredentials, gatewayId }, req.permission )) as T; diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index e7c06ff51..a91548285 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -42,6 +42,14 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { maxAge: 0 }); + void res.cookie("aod", "", { + httpOnly: false, + path: "/", + sameSite: "lax", + secure: appCfg.HTTPS_ENABLED, + maxAge: 0 + }); + return { message: "Successfully logged out" }; } }); diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index bf886d39c..9568761f7 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -5,6 +5,7 @@ import { validateOCIConnectionCredentials } from "@app/ee/services/app-connections/oci"; import { getOracleDBConnectionListItem, OracleDBConnectionMethod } from "@app/ee/services/app-connections/oracledb"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; @@ -203,7 +204,8 @@ export const decryptAppConnectionCredentials = async ({ }; export const validateAppConnectionCredentials = async ( - appConnection: TAppConnectionConfig + appConnection: TAppConnectionConfig, + gatewayService: Pick ): Promise => { const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { [AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator, @@ -245,7 +247,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator }; - return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); + return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService); }; export const getAppConnectionMethodName = (method: TAppConnection["method"]) => { diff --git a/backend/src/services/app-connection/app-connection-schemas.ts b/backend/src/services/app-connection/app-connection-schemas.ts index 0d3968637..d0dcb1a54 100644 --- a/backend/src/services/app-connection/app-connection-schemas.ts +++ b/backend/src/services/app-connection/app-connection-schemas.ts @@ -18,7 +18,7 @@ export const BaseAppConnectionSchema = AppConnectionsSchema.omit({ export const GenericCreateAppConnectionFieldsSchema = ( app: AppConnection, - { supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {} + { supportsPlatformManagedCredentials = false, supportsGateways = false }: TAppConnectionBaseConfig = {} ) => z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(app).name), @@ -30,12 +30,23 @@ export const GenericCreateAppConnectionFieldsSchema = ( .describe(AppConnections.CREATE(app).description), isPlatformManagedCredentials: supportsPlatformManagedCredentials ? z.boolean().optional().default(false).describe(AppConnections.CREATE(app).isPlatformManagedCredentials) - : z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) + : z + .literal(false, { + errorMap: () => ({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` }) + }) + .optional() + .describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`), + gatewayId: supportsGateways + ? z.string().uuid().nullish().describe("The Gateway ID to use for this connection.") + : z + .undefined({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` }) + .or(z.null({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` })) + .describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) }); export const GenericUpdateAppConnectionFieldsSchema = ( app: AppConnection, - { supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {} + { supportsPlatformManagedCredentials = false, supportsGateways = false }: TAppConnectionBaseConfig = {} ) => z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.UPDATE(app).name).optional(), @@ -47,5 +58,16 @@ export const GenericUpdateAppConnectionFieldsSchema = ( .describe(AppConnections.UPDATE(app).description), isPlatformManagedCredentials: supportsPlatformManagedCredentials ? z.boolean().optional().describe(AppConnections.UPDATE(app).isPlatformManagedCredentials) - : z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) + : z + .literal(false, { + errorMap: () => ({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` }) + }) + .optional() + .describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`), + gatewayId: supportsGateways + ? z.string().uuid().nullish().describe("The Gateway ID to use for this connection.") + : z + .undefined({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` }) + .or(z.null({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` })) + .describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) }); diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index d6bcf7b8f..86be7ac8d 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -3,8 +3,14 @@ import { ForbiddenError, subject } from "@casl/ability"; import { ValidateOCIConnectionCredentialsSchema } from "@app/ee/services/app-connections/oci"; import { ociConnectionService } from "@app/ee/services/app-connections/oci/oci-connection-service"; import { ValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb"; +import { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionAppConnectionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + OrgPermissionAppConnectionActions, + OrgPermissionGatewayActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { crypto } from "@app/lib/crypto/cryptography"; import { DatabaseErrorCode } from "@app/lib/error-codes"; @@ -98,6 +104,8 @@ export type TAppConnectionServiceFactoryDep = { permissionService: Pick; kmsService: Pick; licenseService: Pick; + gatewayService: Pick; + gatewayDAL: Pick; }; export type TAppConnectionServiceFactory = ReturnType; @@ -144,7 +152,9 @@ export const appConnectionServiceFactory = ({ appConnectionDAL, permissionService, kmsService, - licenseService + licenseService, + gatewayService, + gatewayDAL }: TAppConnectionServiceFactoryDep) => { const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { const { permission } = await permissionService.getOrgPermission( @@ -225,7 +235,7 @@ export const appConnectionServiceFactory = ({ }; const createAppConnection = async ( - { method, app, credentials, ...params }: TCreateAppConnectionDTO, + { method, app, credentials, gatewayId, ...params }: TCreateAppConnectionDTO, actor: OrgServiceActor ) => { const { permission } = await permissionService.getOrgPermission( @@ -241,6 +251,20 @@ export const appConnectionServiceFactory = ({ OrgPermissionSubjects.AppConnections ); + if (gatewayId) { + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actor.orgId }); + if (!gateway) { + throw new NotFoundError({ + message: `Gateway with ID ${gatewayId} not found for org` + }); + } + } + await enterpriseAppCheck( licenseService, app, @@ -248,12 +272,16 @@ export const appConnectionServiceFactory = ({ "Failed to create app connection due to plan restriction. Upgrade plan to access enterprise app connections." ); - const validatedCredentials = await validateAppConnectionCredentials({ - app, - credentials, - method, - orgId: actor.orgId - } as TAppConnectionConfig); + const validatedCredentials = await validateAppConnectionCredentials( + { + app, + credentials, + method, + orgId: actor.orgId, + gatewayId + } as TAppConnectionConfig, + gatewayService + ); try { const createConnection = async (connectionCredentials: TAppConnection["credentials"]) => { @@ -268,6 +296,7 @@ export const appConnectionServiceFactory = ({ encryptedCredentials, method, app, + gatewayId, ...params }); }; @@ -280,9 +309,11 @@ export const appConnectionServiceFactory = ({ app, orgId: actor.orgId, credentials: validatedCredentials, - method + method, + gatewayId } as TAppConnectionConfig, - (platformCredentials) => createConnection(platformCredentials) + (platformCredentials) => createConnection(platformCredentials), + gatewayService ); } else { connection = await createConnection(validatedCredentials); @@ -303,7 +334,7 @@ export const appConnectionServiceFactory = ({ }; const updateAppConnection = async ( - { connectionId, credentials, ...params }: TUpdateAppConnectionDTO, + { connectionId, credentials, gatewayId, ...params }: TUpdateAppConnectionDTO, actor: OrgServiceActor ) => { const appConnection = await appConnectionDAL.findById(connectionId); @@ -330,6 +361,22 @@ export const appConnectionServiceFactory = ({ OrgPermissionSubjects.AppConnections ); + if (gatewayId !== appConnection.gatewayId) { + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + + if (gatewayId) { + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actor.orgId }); + if (!gateway) { + throw new NotFoundError({ + message: `Gateway with ID ${gatewayId} not found for org` + }); + } + } + } + // prevent updating credentials or management status if platform managed if (appConnection.isPlatformManagedCredentials && (params.isPlatformManagedCredentials === false || credentials)) { throw new BadRequestError({ @@ -354,12 +401,16 @@ export const appConnectionServiceFactory = ({ } Connection with method ${getAppConnectionMethodName(method)}` }); - updatedCredentials = await validateAppConnectionCredentials({ - app, - orgId: actor.orgId, - credentials, - method - } as TAppConnectionConfig); + updatedCredentials = await validateAppConnectionCredentials( + { + app, + orgId: actor.orgId, + credentials, + method, + gatewayId + } as TAppConnectionConfig, + gatewayService + ); if (!updatedCredentials) throw new BadRequestError({ message: "Unable to validate connection - check credentials" }); @@ -378,6 +429,7 @@ export const appConnectionServiceFactory = ({ return appConnectionDAL.updateById(connectionId, { orgId: actor.orgId, encryptedCredentials, + gatewayId, ...params }); }; @@ -394,9 +446,11 @@ export const appConnectionServiceFactory = ({ app, orgId: actor.orgId, credentials: updatedCredentials, - method + method, + gatewayId } as TAppConnectionConfig, - (platformCredentials) => updateConnection(platformCredentials) + (platformCredentials) => updateConnection(platformCredentials), + gatewayService ); } else { updatedConnection = await updateConnection(updatedCredentials); diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index 2ac549f70..6a0c27b7f 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -9,6 +9,7 @@ import { TOracleDBConnectionInput, TValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -290,7 +291,7 @@ export type TSqlConnectionInput = export type TCreateAppConnectionDTO = Pick< TAppConnectionInput, - "credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials" + "credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials" | "gatewayId" >; export type TUpdateAppConnectionDTO = Partial> & { @@ -379,14 +380,17 @@ export type TListAwsConnectionIamUsers = { }; export type TAppConnectionCredentialsValidator = ( - appConnection: TAppConnectionConfig + appConnection: TAppConnectionConfig, + gatewayService: Pick ) => Promise; export type TAppConnectionTransitionCredentialsToPlatform = ( appConnection: TAppConnectionConfig, - callback: (credentials: TAppConnection["credentials"]) => Promise + callback: (credentials: TAppConnection["credentials"]) => Promise, + gatewayService: Pick ) => Promise; export type TAppConnectionBaseConfig = { supportsPlatformManagedCredentials?: boolean; + supportsGateways?: boolean; }; diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts index 84d7a1ce1..fba852a0a 100644 --- a/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts @@ -9,6 +9,7 @@ import { getAppConnectionMethodName } from "@app/services/app-connection/app-con import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { AppConnection } from "../app-connection-enums"; +import { GithubTokenRespData, isGithubErrorResponse } from "../github/github-connection-fns"; import { GitHubRadarConnectionMethod } from "./github-radar-connection-enums"; import { TGitHubRadarConnection, @@ -71,13 +72,6 @@ export const listGitHubRadarRepositories = async (appConnection: TGitHubRadarCon return repositories; }; -type TokenRespData = { - access_token: string; - scope: string; - token_type: string; - error?: string; -}; - export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRadarConnectionConfig) => { const { credentials, method } = config; @@ -93,10 +87,10 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa }); } - let tokenResp: AxiosResponse; + let tokenResp: AxiosResponse; try { - tokenResp = await request.get("https://github.com/login/oauth/access_token", { + tokenResp = await request.get("https://github.com/login/oauth/access_token", { params: { client_id: INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID, client_secret: INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET, @@ -108,19 +102,27 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa "Accept-Encoding": "application/json" } }); + + if (isGithubErrorResponse(tokenResp?.data)) { + throw new BadRequestError({ + message: `Unable to validate credentials: GitHub responded with an error: ${tokenResp.data.error} - ${tokenResp.data.error_description}` + }); + } } catch (e: unknown) { + if (e instanceof BadRequestError) { + throw e; + } + throw new BadRequestError({ message: `Unable to validate connection: verify credentials` }); } - if (tokenResp.status !== 200) { - throw new BadRequestError({ - message: `Unable to validate credentials: GitHub responded with a status code of ${tokenResp.status} (${tokenResp.statusText}). Verify credentials and try again.` - }); - } - if (method === GitHubRadarConnectionMethod.App) { + if (!tokenResp.data.access_token) { + throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` }); + } + const installationsResp = await request.get<{ installations: { id: number; @@ -149,10 +151,6 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa } } - if (!tokenResp.data.access_token) { - throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` }); - } - switch (method) { case GitHubRadarConnectionMethod.App: return { diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index e4281625b..360923e19 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -144,14 +144,14 @@ export const getGitHubEnvironments = async (appConnection: TGitHubConnection, ow } }; -type TokenRespData = { +export type GithubTokenRespData = { access_token?: string; scope: string; token_type: string; error?: string; }; -function isErrorResponse(data: TokenRespData): data is TokenRespData & { +export function isGithubErrorResponse(data: GithubTokenRespData): data is GithubTokenRespData & { error: string; error_description: string; error_uri: string; @@ -191,10 +191,10 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect }); } - let tokenResp: AxiosResponse; + let tokenResp: AxiosResponse; try { - tokenResp = await request.get("https://github.com/login/oauth/access_token", { + tokenResp = await request.get("https://github.com/login/oauth/access_token", { params: { client_id: clientId, client_secret: clientSecret, @@ -207,7 +207,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect } }); - if (isErrorResponse(tokenResp?.data)) { + if (isGithubErrorResponse(tokenResp?.data)) { throw new BadRequestError({ message: `Unable to validate credentials: GitHub responded with an error: ${tokenResp.data.error} - ${tokenResp.data.error_description}` }); diff --git a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts index 994f9a40d..f8d380949 100644 --- a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts +++ b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts @@ -49,7 +49,10 @@ export const ValidateMsSqlConnectionCredentialsSchema = z.discriminatedUnion("me ]); export const CreateMsSqlConnectionSchema = ValidateMsSqlConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true }) + GenericCreateAppConnectionFieldsSchema(AppConnection.MsSql, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) ); export const UpdateMsSqlConnectionSchema = z @@ -58,7 +61,12 @@ export const UpdateMsSqlConnectionSchema = z AppConnections.UPDATE(AppConnection.MsSql).credentials ) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true })); + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.MsSql, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) + ); export const MsSqlConnectionListItemSchema = z.object({ name: z.literal("Microsoft SQL Server"), diff --git a/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts b/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts index 082bac557..51a533395 100644 --- a/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts +++ b/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts @@ -47,7 +47,10 @@ export const ValidateMySqlConnectionCredentialsSchema = z.discriminatedUnion("me ]); export const CreateMySqlConnectionSchema = ValidateMySqlConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.MySql, { supportsPlatformManagedCredentials: true }) + GenericCreateAppConnectionFieldsSchema(AppConnection.MySql, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) ); export const UpdateMySqlConnectionSchema = z @@ -56,7 +59,12 @@ export const UpdateMySqlConnectionSchema = z AppConnections.UPDATE(AppConnection.MySql).credentials ) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.MySql, { supportsPlatformManagedCredentials: true })); + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.MySql, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) + ); export const MySqlConnectionListItemSchema = z.object({ name: z.literal("MySQL"), diff --git a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts index 1ddf1e2da..da74bd669 100644 --- a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts +++ b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts @@ -47,7 +47,10 @@ export const ValidatePostgresConnectionCredentialsSchema = z.discriminatedUnion( ]); export const CreatePostgresConnectionSchema = ValidatePostgresConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true }) + GenericCreateAppConnectionFieldsSchema(AppConnection.Postgres, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) ); export const UpdatePostgresConnectionSchema = z @@ -56,7 +59,12 @@ export const UpdatePostgresConnectionSchema = z AppConnections.UPDATE(AppConnection.Postgres).credentials ) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true })); + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.Postgres, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) + ); export const PostgresConnectionListItemSchema = z.object({ name: z.literal("PostgreSQL"), diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts index 33cc8257d..d9adc91dd 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -1,11 +1,13 @@ import knex, { Knex } from "knex"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TSqlCredentialsRotationGeneratedCredentials, TSqlCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { TAppConnectionRaw, TSqlConnection } from "@app/services/app-connection/app-connection-types"; @@ -98,25 +100,80 @@ export const getSqlConnectionClient = async (appConnection: Pick { - const { credentials, app } = config; +export const executeWithPotentialGateway = async ( + config: TSqlConnectionConfig, + gatewayService: Pick, + operation: (client: Knex) => Promise +): Promise => { + const { credentials, app, gatewayId } = config; - let client: Knex | undefined; + if (gatewayId && gatewayService) { + const [targetHost] = await verifyHostInputValidity(credentials.host, true); + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + return withGatewayProxy( + async (proxyPort) => { + const client = knex({ + client: SQL_CONNECTION_CLIENT_MAP[app], + connection: { + database: credentials.database, + port: proxyPort, + host: "localhost", + user: credentials.username, + password: credentials.password, + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ...getConnectionConfig({ app, credentials }) + } + }); + try { + return await operation(client); + } finally { + await client.destroy(); + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost, + targetPort: credentials.port, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + } + + // Non-gateway path + const client = await getSqlConnectionClient({ app, credentials }); try { - client = await getSqlConnectionClient({ app, credentials }); + return await operation(client); + } finally { + await client.destroy(); + } +}; - await client.raw(`Select 1`); - - return credentials; +export const validateSqlConnectionCredentials = async ( + config: TSqlConnectionConfig, + gatewayService: Pick +) => { + try { + await executeWithPotentialGateway(config, gatewayService, async (client) => { + await client.raw(`Select 1`); + }); + return config.credentials; } catch (error) { throw new BadRequestError({ message: `Unable to validate connection: ${ - (error as Error)?.message?.replaceAll(credentials.password, "********************") ?? "verify credentials" + (error as Error)?.message?.replaceAll(config.credentials.password, "********************") ?? + "verify credentials" }` }); - } finally { - await client?.destroy(); } }; @@ -132,22 +189,23 @@ export const SQL_CONNECTION_ALTER_LOGIN_STATEMENT: Record< export const transferSqlConnectionCredentialsToPlatform = async ( config: TSqlConnectionConfig, - callback: (credentials: TSqlConnectionConfig["credentials"]) => Promise + callback: (credentials: TSqlConnectionConfig["credentials"]) => Promise, + gatewayService: Pick ) => { const { credentials, app } = config; - const client = await getSqlConnectionClient({ app, credentials }); - const newPassword = alphaNumericNanoId(32); try { - return await client.transaction(async (tx) => { - await tx.raw( - ...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[app]({ username: credentials.username, password: newPassword }) - ); - return callback({ - ...credentials, - password: newPassword + return await executeWithPotentialGateway(config, gatewayService, (client) => { + return client.transaction(async (tx) => { + await tx.raw( + ...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[app]({ username: credentials.username, password: newPassword }) + ); + return callback({ + ...credentials, + password: newPassword + }); }); }); } catch (error) { @@ -161,7 +219,5 @@ export const transferSqlConnectionCredentialsToPlatform = async ( (error as Error)?.message?.replaceAll(newPassword, "********************") ?? "Encountered an error transferring credentials to platform" }); - } finally { - await client.destroy(); } }; diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-types.ts b/backend/src/services/app-connection/shared/sql/sql-connection-types.ts index bbfe4086c..104aacfb9 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-types.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-types.ts @@ -1,6 +1,9 @@ import { DiscriminativePick } from "@app/lib/types"; import { TSqlConnectionInput } from "@app/services/app-connection/app-connection-types"; -export type TSqlConnectionConfig = DiscriminativePick & { +export type TSqlConnectionConfig = DiscriminativePick< + TSqlConnectionInput, + "method" | "app" | "credentials" | "gatewayId" +> & { orgId: string; }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index de35c11f4..346352c3e 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -161,8 +161,8 @@ type TProjectServiceFactoryDep = { sshHostGroupDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; - licenseService: Pick; queueService: Pick; + licenseService: Pick; smtpService: Pick; orgDAL: Pick; keyStore: Pick; @@ -489,10 +489,6 @@ export const projectServiceFactory = ({ ); } - // no need to invalidate if there was no limit - if (plan.workspaceLimit) { - await licenseService.invalidateGetPlan(organization.id); - } return { ...project, environments: envs, diff --git a/backend/src/services/secret-sync/render/render-sync-fns.ts b/backend/src/services/secret-sync/render/render-sync-fns.ts index 8a9039e2e..9140136a0 100644 --- a/backend/src/services/secret-sync/render/render-sync-fns.ts +++ b/backend/src/services/secret-sync/render/render-sync-fns.ts @@ -1,4 +1,6 @@ /* eslint-disable no-await-in-loop */ +import { isAxiosError } from "axios"; + import { request } from "@app/lib/config/request"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; @@ -71,7 +73,7 @@ const putEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secr ); }; -const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secret: TRenderSecret) => { +const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secret: Pick) => { const { destinationConfig, connection: { @@ -79,15 +81,24 @@ const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, s } } = secretSync; - await request.delete( - `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars/${secret.key}`, - { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" + try { + await request.delete( + `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars/${secret.key}`, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } } + ); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + // If the secret does not exist, we can ignore this error + return; } - ); + + throw error; + } }; const sleep = async () => @@ -99,6 +110,11 @@ export const RenderSyncFns = { syncSecrets: async (secretSync: TRenderSyncWithCredentials, secretMap: TSecretMap) => { const renderSecrets = await getRenderEnvironmentSecrets(secretSync); for await (const key of Object.keys(secretMap)) { + // If value is empty skip it as render does not allow empty variables + if (secretMap[key].value === "") { + // eslint-disable-next-line no-continue + continue; + } await putEnvironmentSecret(secretSync, secretMap, key); await sleep(); } diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 734aca8d9..5085feab3 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -119,11 +119,20 @@ export type TAvailableAppConnectionsResponse = { appConnections: TAvailableAppCo export type TCreateAppConnectionDTO = Pick< TAppConnection, - "name" | "credentials" | "method" | "app" | "description" | "isPlatformManagedCredentials" + | "name" + | "credentials" + | "method" + | "app" + | "description" + | "isPlatformManagedCredentials" + | "gatewayId" >; export type TUpdateAppConnectionDTO = Partial< - Pick + Pick< + TAppConnection, + "name" | "credentials" | "description" | "isPlatformManagedCredentials" | "gatewayId" + > > & { connectionId: string; app: AppConnection; diff --git a/frontend/src/hooks/api/appConnections/types/root-connection.ts b/frontend/src/hooks/api/appConnections/types/root-connection.ts index 52571d067..5b8f5cd02 100644 --- a/frontend/src/hooks/api/appConnections/types/root-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/root-connection.ts @@ -7,4 +7,5 @@ export type TRootAppConnection = { createdAt: string; updatedAt: string; isPlatformManagedCredentials?: boolean; + gatewayId?: string | null; }; diff --git a/frontend/src/hooks/api/folderCommits/queries.tsx b/frontend/src/hooks/api/folderCommits/queries.tsx index 8d0883c5b..1bca7ce9a 100644 --- a/frontend/src/hooks/api/folderCommits/queries.tsx +++ b/frontend/src/hooks/api/folderCommits/queries.tsx @@ -1,8 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; import { apiRequest } from "@app/config/request"; -import { CommitHistoryItem, CommitWithChanges, RollbackPreview } from "./types"; +import { Commit, CommitHistoryItem, CommitWithChanges, RollbackPreview } from "./types"; export const commitKeys = { count: ({ @@ -242,7 +243,6 @@ export const useGetFolderCommitHistory = ({ workspaceId, environment, directory, - offset = 0, limit = 20, search, sort = "desc" @@ -250,22 +250,33 @@ export const useGetFolderCommitHistory = ({ workspaceId: string; environment: string; directory: string; - offset?: number; limit?: number; search?: string; sort?: "asc" | "desc"; }) => { - return useQuery({ - queryKey: [ - commitKeys.history({ workspaceId, environment, directory }), - offset, - limit, - search, - sort - ], - queryFn: () => - fetchFolderCommitHistory(workspaceId, environment, directory, offset, limit, search, sort), - enabled: Boolean(workspaceId && environment) + return useInfiniteQuery({ + initialPageParam: 0, + queryKey: [commitKeys.history({ workspaceId, environment, directory }), limit, search, sort], + queryFn: ({ pageParam }) => + fetchFolderCommitHistory(workspaceId, environment, directory, pageParam, limit, search, sort), + enabled: Boolean(workspaceId && environment), + select: (data) => { + return (data?.pages ?? []) + ?.map((page) => page.commits) + .flat() + .reduce( + (acc, commit) => { + const date = format(new Date(commit.createdAt), "MMM d, yyyy"); + if (!acc[date]) { + acc[date] = []; + } + acc[date].push(commit); + return acc; + }, + {} as Record + ); + }, + getNextPageParam: (lastPage, pages) => (lastPage.hasMore ? pages.length * limit : undefined) }); }; diff --git a/frontend/src/hooks/api/folderCommits/types.ts b/frontend/src/hooks/api/folderCommits/types.ts index 878e3224d..a5f4e6df6 100644 --- a/frontend/src/hooks/api/folderCommits/types.ts +++ b/frontend/src/hooks/api/folderCommits/types.ts @@ -62,3 +62,16 @@ export type RollbackPreview = { folderPath: string; changes: RollbackChange[]; }; + +interface CommitActorMetadata { + email?: string; + name?: string; +} + +export interface Commit { + id: string; + message: string; + createdAt: string; + actorType: string; + actorMetadata?: CommitActorMetadata; +} diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx index 70128025d..e7d9cf80c 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx @@ -6,7 +6,11 @@ import { slugSchema } from "@app/lib/schemas"; export const genericAppConnectionFieldsSchema = z.object({ name: slugSchema({ min: 1, max: 64, field: "Name" }), - description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish() + description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish(), + gatewayId: z + .string() + .nullish() + .transform((v) => (v === "" ? null : v)) }); export const GenericAppConnectionsFields = () => { diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MsSqlConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MsSqlConnectionForm.tsx index f7d48744d..0735a863c 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MsSqlConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MsSqlConnectionForm.tsx @@ -1,10 +1,17 @@ import { useState } from "react"; import { Controller, FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { z } from "zod"; -import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, FormControl, ModalClose, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { + OrgGatewayPermissionActions, + OrgPermissionSubjects +} from "@app/context/OrgPermissionContext/types"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { gatewaysQueryKeys } from "@app/hooks/api"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { MsSqlConnectionMethod, @@ -51,6 +58,7 @@ export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => { defaultValues: appConnection ?? { app: AppConnection.MsSql, method: MsSqlConnectionMethod.UsernameAndPassword, + gatewayId: null, credentials: { host: "", port: 1433, @@ -71,6 +79,7 @@ export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => { } = form; const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false; + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const confirmSubmit = async (formData: FormData) => { if (formData.isPlatformManagedCredentials) { @@ -90,6 +99,55 @@ export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
{ defaultValues: appConnection ?? { app: AppConnection.MySql, method: MySqlConnectionMethod.UsernameAndPassword, + gatewayId: null, credentials: { host: "", port: 3306, @@ -68,6 +76,7 @@ export const MySqlConnectionForm = ({ appConnection, onSubmit }: Props) => { } = form; const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false; + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const confirmSubmit = async (formData: FormData) => { if (formData.isPlatformManagedCredentials) { @@ -87,6 +96,55 @@ export const MySqlConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
{ defaultValues: appConnection ?? { app: AppConnection.OracleDB, method: OracleDBConnectionMethod.UsernameAndPassword, + gatewayId: null, credentials: { host: "", port: 1521, @@ -68,6 +76,7 @@ export const OracleDBConnectionForm = ({ appConnection, onSubmit }: Props) => { } = form; const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false; + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const confirmSubmit = async (formData: FormData) => { if (formData.isPlatformManagedCredentials) { @@ -87,6 +96,55 @@ export const OracleDBConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
{ defaultValues: appConnection ?? { app: AppConnection.Postgres, method: PostgresConnectionMethod.UsernameAndPassword, + gatewayId: null, credentials: { host: "", port: 5432, @@ -68,6 +76,7 @@ export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => { } = form; const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false; + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const confirmSubmit = async (formData: FormData) => { if (formData.isPlatformManagedCredentials) { @@ -87,6 +96,55 @@ export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
-

Select a commit to view details

- + + + ); } if (isLoading) { - return ( -
- -
- ); + return ; } if (!commitDetails) { return ( -
-

No details found for this commit

-
+ + + ); } @@ -138,9 +145,11 @@ export const CommitDetailsTab = ({ } catch (error) { console.error("Failed to parse commit details:", error); return ( -
-

Error parsing commit details

-
+ + + ); } @@ -223,13 +232,12 @@ export const CommitDetailsTab = ({ // Render an item from the merged list const renderMergedItem = (item: MergedItem): JSX.Element => { return ( -
- toggleItemCollapsed(id)} - /> -
+ toggleItemCollapsed(id)} + /> ); }; @@ -240,114 +248,104 @@ export const CommitDetailsTab = ({ "Unknown"; return ( -
-
-
-
-
-
-

- {parsedCommitDetails.changes?.message || "No message"} -

-
-
-
-

- Commited by - {actorDisplay} - on - - {formatDisplayDate( - parsedCommitDetails.changes?.createdAt || new Date().toISOString() - )} - - {parsedCommitDetails.changes?.isLatest && ( - (Latest) - )} -

-
-
-
- - {(isAllowed) => ( - - + + + Commited by {actorDisplay} on{" "} + {formatDisplayDate(parsedCommitDetails.changes?.createdAt || new Date().toISOString())} + {parsedCommitDetails.changes?.isLatest && ( + (Latest) + )} + + } + > + + {(isAllowed) => ( + + + + + + {!parsedCommitDetails.changes.isLatest && ( + goToRollbackPreview()} > - -

Restore Options

- -
-
- - {!parsedCommitDetails.changes.isLatest && ( - goToRollbackPreview()} - > -
-
- - Roll back to this commit - - - Return this folder to its exact state at the time of this commit, - discarding all other changes made after it - -
-
-
- )} - - handlePopUpOpen("revertChanges")} - > -
-
- Revert changes - - Will restore to the previous version of affected resources - -
+
+
+ + Roll back to this commit + + + Return this folder to its exact state at the time of this commit, + discarding all other changes made after it +
- - - - )} - -
+
+
+ )} + handlePopUpOpen("revertChanges")} + > +
+
+ Revert changes + + Will restore to the previous version of affected resources + +
+
+
+
+
+ )} +
+ +
+
+

Commit Changes

- -
-
-
- {sortedChangedItems.length > 0 ? ( - sortedChangedItems.map((item) => renderMergedItem(item)) - ) : ( -
-

No changed items found

-
- )} -
+
+
+ {sortedChangedItems.length > 0 ? ( + sortedChangedItems.map((item) => renderMergedItem(item)) + ) : ( + + )}
- -
+ ); }; diff --git a/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx b/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx index 7744d3626..a1d058ea3 100644 --- a/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx +++ b/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from "react"; import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; export interface Version { id?: string; @@ -225,15 +226,12 @@ const renderJsonWithDiffs = ( const getLineClass = (different: boolean) => { if (!different) return "flex"; - return isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300"; + return isOldVersion + ? "flex bg-red-500/50 rounded-sm text-red-300" + : "flex bg-green-500/50 rounded-sm text-green-300"; }; - const getHighlightClass = (different: boolean) => { - if (!different) return ""; - return isOldVersion ? "bg-red-900 rounded px-1" : "bg-green-900 rounded px-1"; - }; - - const prefix = isDifferent ? (isOldVersion ? "-" : "+") : " "; + const prefix = isDifferent ? (isOldVersion ? " -" : " +") : " "; const keyDisplay = keyName ? `"${keyName}": ` : ""; const comma = !isLastItem ? "," : ""; @@ -255,8 +253,8 @@ const renderJsonWithDiffs = (
{prefix}
{indent} - {keyName && {keyDisplay}} - {valueDisplay} + {keyName && {keyDisplay}} + {valueDisplay} {comma}
@@ -269,8 +267,8 @@ const renderJsonWithDiffs = (
{prefix}
{indent} - {keyName && {keyDisplay}} - [] + {keyName && {keyDisplay}} + [] {comma}
@@ -283,8 +281,8 @@ const renderJsonWithDiffs = (
{prefix}
{indent} - {keyName && {keyDisplay}} - {"{}"} + {keyName && {keyDisplay}} + {"{}"} {comma}
@@ -320,16 +318,12 @@ const renderJsonWithDiffs = (
- {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} + {isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
{indent} - {keyName && ( - - {keyDisplay} - - )} - [ + {keyName && {keyDisplay}} + [
@@ -357,11 +351,11 @@ const renderJsonWithDiffs = (
- {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} + {isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
{indent} - ] + ] {comma}
@@ -376,16 +370,12 @@ const renderJsonWithDiffs = (
- {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} + {isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
{indent} - {keyName && ( - - {keyDisplay} - - )} - {"{"} + {keyName && {keyDisplay}} + {"{"}
@@ -414,11 +404,11 @@ const renderJsonWithDiffs = (
- {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} + {isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
{indent} - {"}"} + {"}"} {comma}
@@ -527,7 +517,7 @@ export const SecretVersionDiffView = ({ } oldVersionContent = ( -
+
{renderJsonWithDiffs( cleanOldVersion, diffPaths, @@ -543,7 +533,7 @@ export const SecretVersionDiffView = ({
); newVersionContent = ( -
+
{renderJsonWithDiffs( cleanNewVersion, diffPaths, @@ -583,19 +573,19 @@ export const SecretVersionDiffView = ({ if (item.isDeleted) { textStyle = "line-through text-red-300"; changeBadge = ( - + {isSecret ? "Secret" : "Folder"} Deleted ); } else if (item.isAdded) { changeBadge = ( - + {isSecret ? "Secret" : "Folder"} Added ); } else if (item.isUpdated) { changeBadge = ( - + {isSecret ? "Secret" : "Folder"} Updated ); @@ -615,32 +605,34 @@ export const SecretVersionDiffView = ({ tabIndex={0} aria-expanded={!collapsed} > -
- {key} +
+

{key}

{changeBadge}
- +
); }; return ( -
+
{showHeader && renderHeader()} - {!collapsed && ( -
-
+
+
{oldVersionContent}
- +
{newVersionContent}
diff --git a/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx b/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx index 406a1b112..591c03d4d 100644 --- a/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx +++ b/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx @@ -52,7 +52,7 @@ export const CommitsPage = () => { title="Commits" description="Track, inspect, and restore your secrets and folders with confidence. View the complete history of changes made to your environment, examine specific modifications at each commit point, and preview the exact impact before rolling back to previous states." /> - +

Secret Snapshots have been officially renamed to Commits. Going forward, all secret changes will be tracked as Commits. If you made changes before this update, you can diff --git a/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx b/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx index 8f529ba86..58191f389 100644 --- a/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx +++ b/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx @@ -1,29 +1,17 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { faArrowDownWideShort, faArrowUpWideShort, + faCodeCommit, faCopy, faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { format, formatDistanceToNow } from "date-fns"; +import { formatDistanceToNow } from "date-fns"; -import { Button, Input, Spinner } from "@app/components/v2"; +import { Button, ContentLoader, EmptyState, IconButton, Input } from "@app/components/v2"; import { CopyButton } from "@app/components/v2/CopyButton"; -import { useGetFolderCommitHistory } from "@app/hooks/api/folderCommits"; - -interface CommitActorMetadata { - email?: string; - name?: string; -} - -interface Commit { - id: string; - message: string; - createdAt: string; - actorType: string; - actorMetadata?: CommitActorMetadata; -} +import { Commit, useGetFolderCommitHistory } from "@app/hooks/api/folderCommits"; const formatTimeAgo = (timestamp: string): string => { return formatDistanceToNow(new Date(timestamp), { addSuffix: true }); @@ -40,58 +28,40 @@ const CommitItem = ({ onSelectCommit: (commitId: string, tab: string) => void; }) => { return ( -

-
-
-
-
- -
-

- - {commit.actorMetadata?.email || commit.actorMetadata?.name || commit.actorType} -

committed

- - -

-
-
-
- - -
-
+
+ ); }; @@ -108,24 +78,16 @@ const DateGroup = ({ onSelectCommit: (commitId: string, tab: string) => void; }) => { return ( -
-
-
-
-
-
-

Commits on {date}

+
+
+ +

Commits on {date}

-
-
+
{commits.map((commit) => ( -
-
- -
-
+ ))}
@@ -147,10 +109,8 @@ export const CommitHistoryTab = ({ const [searchTerm, setSearchTerm] = useState(""); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); - const [offset, setOffset] = useState(0); - const [allCommits, setAllCommits] = useState([]); const debounceTimeoutRef = useRef(); - const limit = 5; + const limit = 10; // Debounce search term useEffect(() => { @@ -170,55 +130,20 @@ export const CommitHistoryTab = ({ }, [searchTerm]); const { - data: response, + data: groupedCommits, isLoading, - isFetching + fetchNextPage, + isFetchingNextPage, + hasNextPage } = useGetFolderCommitHistory({ workspaceId: projectId, environment, directory: secretPath, - offset, limit, search: debouncedSearchTerm, sort: sortDirection }); - const commits = response?.commits || []; - const hasMore = response?.hasMore || false; - - // Reset accumulated commits when search or sort changes - useEffect(() => { - setAllCommits([]); - setOffset(0); - }, [debouncedSearchTerm, sortDirection]); - - // Accumulate commits instead of replacing them - useEffect(() => { - if (commits.length > 0) { - if (offset === 0) { - // First load or after search/sort change - replace all commits - setAllCommits(commits); - } else { - // Subsequent loads - append new commits - setAllCommits((prev) => [...prev, ...commits]); - } - } - }, [commits, offset]); - - const groupedCommits = useMemo(() => { - return allCommits.reduce( - (acc, commit) => { - const date = format(new Date(commit.createdAt), "MMM d, yyyy"); - if (!acc[date]) { - acc[date] = []; - } - acc[date].push(commit); - return acc; - }, - {} as Record - ); - }, [allCommits]); - const handleSort = useCallback(() => { setSortDirection((prev) => (prev === "desc" ? "asc" : "desc")); }, []); @@ -227,50 +152,39 @@ export const CommitHistoryTab = ({ setSearchTerm(value); }, []); - const loadMoreCommits = useCallback(() => { - if (hasMore && !isFetching) { - setOffset((prev) => prev + limit); - } - }, [hasMore, isFetching, limit]); - return ( -
+
+

Commit History

} placeholder="Search commits..." - className="h-10 w-full rounded-md border-transparent bg-zinc-800 pl-9 pr-3 text-sm text-white placeholder-gray-400 focus:border-gray-600 focus:ring-primary-500/20" onChange={(e) => handleSearch(e.target.value)} value={searchTerm} aria-label="Search commits" /> -
-
- +
- - {isLoading && offset === 0 ? ( -
- -
+ {isLoading ? ( + ) : ( -
- {Object.keys(groupedCommits).length > 0 ? ( +
+ {groupedCommits && Object.keys(groupedCommits).length > 0 ? ( <> {Object.entries(groupedCommits).map(([date, dateCommits]) => ( ) : ( -
-
+ )} - - {hasMore && ( + {hasNextPage && (
)}