mirror of
https://github.com/Infisical/infisical.git
synced 2025-08-05 07:30:33 +00:00
Compare commits
36 Commits
sid/ENG-32
...
sid/ENG-33
Author | SHA1 | Date | |
---|---|---|---|
|
b719f2d6ba | ||
|
b413f0f49e | ||
|
058dbc144d | ||
|
8399181e3d | ||
|
3c50291cd3 | ||
|
b7b059bb50 | ||
|
f3a8e30548 | ||
|
b0c93e5c4c | ||
|
4ab0da6b03 | ||
|
9674b71df8 | ||
|
b7d7b555b2 | ||
|
954ca58e15 | ||
|
e4a28ab0f4 | ||
|
4ab8d680c4 | ||
|
a3b0d86996 | ||
|
0080d5f291 | ||
|
a276d27451 | ||
|
cec15d6d51 | ||
|
007e10d409 | ||
|
a8b448be0f | ||
|
bc98c42c79 | ||
|
e6bfb6ce2b | ||
|
1c20e4fef0 | ||
|
b560cdb0f8 | ||
|
0174d36136 | ||
|
968d7420c6 | ||
|
59c0f1ff08 | ||
|
cd84d57025 | ||
|
19cb220107 | ||
|
fce6738562 | ||
|
aab204a68a | ||
|
49afaa4d2d | ||
|
2f9baee210 | ||
|
bd7947c04e | ||
|
7ff8a19518 | ||
|
221de8beb4 |
11
README.md
11
README.md
@@ -149,11 +149,8 @@ Not sure where to get started? You can:
|
||||
|
||||
- Join our <a href="https://infisical.com/slack">Slack</a>, and ask us any questions there.
|
||||
|
||||
## Resources
|
||||
## We are hiring!
|
||||
|
||||
- [Docs](https://infisical.com/docs/documentation/getting-started/introduction) for comprehensive documentation and guides
|
||||
- [Slack](https://infisical.com/slack) for discussion with the community and Infisical team.
|
||||
- [GitHub](https://github.com/Infisical/infisical) for code, issues, and pull requests
|
||||
- [Twitter](https://twitter.com/infisical) for fast news
|
||||
- [YouTube](https://www.youtube.com/@infisical_os) for videos on secret management
|
||||
- [Blog](https://infisical.com/blog) for secret management insights, articles, tutorials, and updates
|
||||
If you're reading this, there is a strong chance you like the products we created.
|
||||
|
||||
You might also make a great addition to our team. We're growing fast and would love for you to [join us](https://infisical.com/careers).
|
||||
|
@@ -0,0 +1,19 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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<void> {
|
||||
if (await knex.schema.hasColumn(TableName.AppConnection, "gatewayId")) {
|
||||
await knex.schema.alterTable(TableName.AppConnection, (t) => {
|
||||
t.dropColumn("gatewayId");
|
||||
});
|
||||
}
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn(TableName.IdentityAwsAuth, "allowedPrincipalArns");
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable(TableName.IdentityAwsAuth, (t) => {
|
||||
t.string("allowedPrincipalArns", 4096).notNullable().alter();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn(TableName.IdentityAwsAuth, "allowedPrincipalArns");
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable(TableName.IdentityAwsAuth, (t) => {
|
||||
t.string("allowedPrincipalArns", 2048).notNullable().alter();
|
||||
});
|
||||
}
|
||||
}
|
@@ -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<typeof AppConnectionsSchema>;
|
||||
|
@@ -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"),
|
||||
|
@@ -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";
|
||||
@@ -107,6 +108,7 @@ export type TSecretRotationV2ServiceFactoryDep = {
|
||||
queueService: Pick<TQueueServiceFactory, "queuePg">;
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">;
|
||||
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
||||
};
|
||||
|
||||
export type TSecretRotationV2ServiceFactory = ReturnType<typeof secretRotationV2ServiceFactory>;
|
||||
@@ -148,7 +150,8 @@ export const secretRotationV2ServiceFactory = ({
|
||||
keyStore,
|
||||
queueService,
|
||||
folderCommitService,
|
||||
appConnectionDAL
|
||||
appConnectionDAL,
|
||||
gatewayService
|
||||
}: TSecretRotationV2ServiceFactoryDep) => {
|
||||
const $queueSendSecretRotationStatusNotification = async (secretRotation: TSecretRotationV2Raw) => {
|
||||
const appCfg = getConfig();
|
||||
@@ -461,7 +464,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
|
||||
@@ -824,7 +828,8 @@ export const secretRotationV2ServiceFactory = ({
|
||||
connection: appConnection
|
||||
} as TSecretRotationV2WithConnection,
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
kmsService,
|
||||
gatewayService
|
||||
);
|
||||
|
||||
const generatedCredentials = await decryptSecretRotationCredentials({
|
||||
@@ -907,7 +912,8 @@ export const secretRotationV2ServiceFactory = ({
|
||||
connection: appConnection
|
||||
} as TSecretRotationV2WithConnection,
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
kmsService,
|
||||
gatewayService
|
||||
);
|
||||
|
||||
const updatedRotation = await rotationFactory.rotateCredentials(
|
||||
|
@@ -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";
|
||||
@@ -239,7 +240,8 @@ export type TRotationFactory<
|
||||
> = (
|
||||
secretRotation: T,
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
issueCredentials: TRotationFactoryIssueCredentials<C, P>;
|
||||
revokeCredentials: TRotationFactoryRevokeCredentials<C>;
|
||||
|
@@ -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 = <T>(
|
||||
operation: (client: Knex) => Promise<T>,
|
||||
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<TSqlCredentialsRotationGeneratedCredentials> = 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);
|
||||
|
@@ -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();
|
||||
|
@@ -1706,7 +1706,9 @@ export const registerRoutes = async (
|
||||
appConnectionDAL,
|
||||
permissionService,
|
||||
kmsService,
|
||||
licenseService
|
||||
licenseService,
|
||||
gatewayService,
|
||||
gatewayDAL
|
||||
});
|
||||
|
||||
const secretSyncService = secretSyncServiceFactory({
|
||||
@@ -1804,7 +1806,8 @@ export const registerRoutes = async (
|
||||
snapshotService,
|
||||
secretQueueService,
|
||||
queueService,
|
||||
appConnectionDAL
|
||||
appConnectionDAL,
|
||||
gatewayService
|
||||
});
|
||||
|
||||
const certificateAuthorityService = certificateAuthorityServiceFactory({
|
||||
|
@@ -25,12 +25,14 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
||||
credentials: I["credentials"];
|
||||
description?: string | null;
|
||||
isPlatformManagedCredentials?: boolean;
|
||||
gatewayId?: string | null;
|
||||
}>;
|
||||
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 = <T extends TAppConnection, I exten
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
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 = <T extends TAppConnection, I exten
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
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;
|
||||
|
||||
|
@@ -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" };
|
||||
}
|
||||
});
|
||||
|
@@ -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";
|
||||
@@ -201,7 +202,8 @@ export const decryptAppConnectionCredentials = async ({
|
||||
};
|
||||
|
||||
export const validateAppConnectionCredentials = async (
|
||||
appConnection: TAppConnectionConfig
|
||||
appConnection: TAppConnectionConfig,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
): Promise<TAppConnection["credentials"]> => {
|
||||
const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TAppConnectionCredentialsValidator> = {
|
||||
[AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
@@ -242,7 +244,7 @@ export const validateAppConnectionCredentials = async (
|
||||
[AppConnection.Supabase]: validateSupabaseConnectionCredentials 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"]) => {
|
||||
|
@@ -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.`)
|
||||
});
|
||||
|
@@ -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";
|
||||
@@ -96,6 +102,8 @@ export type TAppConnectionServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
||||
gatewayDAL: Pick<TGatewayDALFactory, "find">;
|
||||
};
|
||||
|
||||
export type TAppConnectionServiceFactory = ReturnType<typeof appConnectionServiceFactory>;
|
||||
@@ -141,7 +149,9 @@ export const appConnectionServiceFactory = ({
|
||||
appConnectionDAL,
|
||||
permissionService,
|
||||
kmsService,
|
||||
licenseService
|
||||
licenseService,
|
||||
gatewayService,
|
||||
gatewayDAL
|
||||
}: TAppConnectionServiceFactoryDep) => {
|
||||
const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => {
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
@@ -222,7 +232,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(
|
||||
@@ -238,6 +248,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,
|
||||
@@ -245,12 +269,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"]) => {
|
||||
@@ -265,6 +293,7 @@ export const appConnectionServiceFactory = ({
|
||||
encryptedCredentials,
|
||||
method,
|
||||
app,
|
||||
gatewayId,
|
||||
...params
|
||||
});
|
||||
};
|
||||
@@ -277,9 +306,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);
|
||||
@@ -300,7 +331,7 @@ export const appConnectionServiceFactory = ({
|
||||
};
|
||||
|
||||
const updateAppConnection = async (
|
||||
{ connectionId, credentials, ...params }: TUpdateAppConnectionDTO,
|
||||
{ connectionId, credentials, gatewayId, ...params }: TUpdateAppConnectionDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const appConnection = await appConnectionDAL.findById(connectionId);
|
||||
@@ -327,6 +358,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({
|
||||
@@ -351,12 +398,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" });
|
||||
@@ -375,6 +426,7 @@ export const appConnectionServiceFactory = ({
|
||||
return appConnectionDAL.updateById(connectionId, {
|
||||
orgId: actor.orgId,
|
||||
encryptedCredentials,
|
||||
gatewayId,
|
||||
...params
|
||||
});
|
||||
};
|
||||
@@ -391,9 +443,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);
|
||||
|
@@ -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";
|
||||
@@ -282,7 +283,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<Omit<TCreateAppConnectionDTO, "method" | "app">> & {
|
||||
@@ -369,14 +370,17 @@ export type TListAwsConnectionIamUsers = {
|
||||
};
|
||||
|
||||
export type TAppConnectionCredentialsValidator = (
|
||||
appConnection: TAppConnectionConfig
|
||||
appConnection: TAppConnectionConfig,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => Promise<TAppConnection["credentials"]>;
|
||||
|
||||
export type TAppConnectionTransitionCredentialsToPlatform = (
|
||||
appConnection: TAppConnectionConfig,
|
||||
callback: (credentials: TAppConnection["credentials"]) => Promise<TAppConnectionRaw>
|
||||
callback: (credentials: TAppConnection["credentials"]) => Promise<TAppConnectionRaw>,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => Promise<TAppConnectionRaw>;
|
||||
|
||||
export type TAppConnectionBaseConfig = {
|
||||
supportsPlatformManagedCredentials?: boolean;
|
||||
supportsGateways?: boolean;
|
||||
};
|
||||
|
@@ -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<TokenRespData>;
|
||||
let tokenResp: AxiosResponse<GithubTokenRespData>;
|
||||
|
||||
try {
|
||||
tokenResp = await request.get<TokenRespData>("https://github.com/login/oauth/access_token", {
|
||||
tokenResp = await request.get<GithubTokenRespData>("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 {
|
||||
|
@@ -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<TokenRespData>;
|
||||
let tokenResp: AxiosResponse<GithubTokenRespData>;
|
||||
|
||||
try {
|
||||
tokenResp = await request.get<TokenRespData>("https://github.com/login/oauth/access_token", {
|
||||
tokenResp = await request.get<GithubTokenRespData>("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}`
|
||||
});
|
||||
|
@@ -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"),
|
||||
|
@@ -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"),
|
||||
|
@@ -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"),
|
||||
|
@@ -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<TSqlConnection,
|
||||
return client;
|
||||
};
|
||||
|
||||
export const validateSqlConnectionCredentials = async (config: TSqlConnectionConfig) => {
|
||||
const { credentials, app } = config;
|
||||
export const executeWithPotentialGateway = async <T>(
|
||||
config: TSqlConnectionConfig,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
|
||||
operation: (client: Knex) => Promise<T>
|
||||
): Promise<T> => {
|
||||
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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
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<TAppConnectionRaw>
|
||||
callback: (credentials: TSqlConnectionConfig["credentials"]) => Promise<TAppConnectionRaw>,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
@@ -1,6 +1,9 @@
|
||||
import { DiscriminativePick } from "@app/lib/types";
|
||||
import { TSqlConnectionInput } from "@app/services/app-connection/app-connection-types";
|
||||
|
||||
export type TSqlConnectionConfig = DiscriminativePick<TSqlConnectionInput, "method" | "app" | "credentials"> & {
|
||||
export type TSqlConnectionConfig = DiscriminativePick<
|
||||
TSqlConnectionInput,
|
||||
"method" | "app" | "credentials" | "gatewayId"
|
||||
> & {
|
||||
orgId: string;
|
||||
};
|
||||
|
@@ -37,7 +37,7 @@ export const validateAccountIds = z
|
||||
export const validatePrincipalArns = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2048)
|
||||
.max(4096)
|
||||
.default("")
|
||||
// Custom validation for ARN format
|
||||
.refine(
|
||||
|
@@ -174,6 +174,7 @@ export const fnSecretsV2FromImports = async ({
|
||||
skipMultilineEncoding?: boolean | null;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
secretKey: string;
|
||||
}) => Promise<string | undefined>;
|
||||
hasSecretAccess: (environment: string, secretPath: string, secretName: string, secretTagSlugs: string[]) => boolean;
|
||||
}) => {
|
||||
@@ -293,7 +294,8 @@ export const fnSecretsV2FromImports = async ({
|
||||
value: decryptedSecret.secretValue,
|
||||
secretPath: processedImport.secretPath,
|
||||
environment: processedImport.environment,
|
||||
skipMultilineEncoding: decryptedSecret.skipMultilineEncoding
|
||||
skipMultilineEncoding: decryptedSecret.skipMultilineEncoding,
|
||||
secretKey: decryptedSecret.secretKey
|
||||
});
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
processedImport.secrets[index].secretValue = expandedSecretValue || "";
|
||||
|
@@ -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<TRenderSecret, "key">) => {
|
||||
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();
|
||||
}
|
||||
|
@@ -231,7 +231,8 @@ export const secretSyncQueueFactory = ({
|
||||
environment: environment.slug,
|
||||
secretPath: folder.path,
|
||||
skipMultilineEncoding: secret.skipMultilineEncoding,
|
||||
value: secretValue
|
||||
value: secretValue,
|
||||
secretKey
|
||||
});
|
||||
secretMap[secretKey] = { value: expandedSecretValue || "" };
|
||||
|
||||
|
@@ -614,6 +614,7 @@ export const expandSecretReferencesFactory = ({
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
shouldStackTrace?: boolean;
|
||||
secretKey: string;
|
||||
}) => {
|
||||
const stackTrace = { ...dto, key: "root", children: [] } as TSecretReferenceTraceNode;
|
||||
|
||||
@@ -656,7 +657,7 @@ export const expandSecretReferencesFactory = ({
|
||||
const referredValue = await fetchSecret(environment, secretPath, secretKey);
|
||||
if (!canExpandValue(environment, secretPath, secretKey, referredValue.tags))
|
||||
throw new ForbiddenRequestError({
|
||||
message: `You are attempting to reference secret named ${secretKey} from environment ${environment} in path ${secretPath} which you do not have access to read value on.`
|
||||
message: `You do not have permission to read secret '${secretKey}' in environment '${environment}' at path '${secretPath}', which is referenced by secret '${dto.secretKey}' in environment '${dto.environment}' at path '${dto.secretPath}'.`
|
||||
});
|
||||
|
||||
const cacheKey = getCacheUniqueKey(environment, secretPath);
|
||||
@@ -675,7 +676,7 @@ export const expandSecretReferencesFactory = ({
|
||||
const referedValue = await fetchSecret(secretReferenceEnvironment, secretReferencePath, secretReferenceKey);
|
||||
if (!canExpandValue(secretReferenceEnvironment, secretReferencePath, secretReferenceKey, referedValue.tags))
|
||||
throw new ForbiddenRequestError({
|
||||
message: `You are attempting to reference secret named ${secretReferenceKey} from environment ${secretReferenceEnvironment} in path ${secretReferencePath} which you do not have access to read value on.`
|
||||
message: `You do not have permission to read secret '${secretReferenceKey}' in environment '${secretReferenceEnvironment}' at path '${secretReferencePath}', which is referenced by secret '${dto.secretKey}' in environment '${dto.environment}' at path '${dto.secretPath}'.`
|
||||
});
|
||||
|
||||
const cacheKey = getCacheUniqueKey(secretReferenceEnvironment, secretReferencePath);
|
||||
@@ -692,6 +693,7 @@ export const expandSecretReferencesFactory = ({
|
||||
secretPath: referencedSecretPath,
|
||||
environment: referencedSecretEnvironmentSlug,
|
||||
depth: depth + 1,
|
||||
secretKey: referencedSecretKey,
|
||||
trace
|
||||
};
|
||||
|
||||
@@ -726,6 +728,7 @@ export const expandSecretReferencesFactory = ({
|
||||
skipMultilineEncoding?: boolean | null;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
secretKey: string;
|
||||
}) => {
|
||||
if (!inputSecret.value) return inputSecret.value;
|
||||
|
||||
@@ -741,6 +744,7 @@ export const expandSecretReferencesFactory = ({
|
||||
value?: string;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
secretKey: string;
|
||||
}) => {
|
||||
const { stackTrace, expandedValue } = await recursivelyExpandSecret({ ...inputSecret, shouldStackTrace: true });
|
||||
return { stackTrace, expandedValue };
|
||||
|
@@ -1105,7 +1105,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
if (shouldExpandSecretReferences) {
|
||||
const secretsGroupByPath = groupBy(decryptedSecrets, (i) => i.secretPath);
|
||||
await Promise.allSettled(
|
||||
const settledPromises = await Promise.allSettled(
|
||||
Object.keys(secretsGroupByPath).map((groupedPath) =>
|
||||
Promise.allSettled(
|
||||
secretsGroupByPath[groupedPath].map(async (decryptedSecret, index) => {
|
||||
@@ -1113,7 +1113,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
value: decryptedSecret.secretValue,
|
||||
secretPath: groupedPath,
|
||||
environment,
|
||||
skipMultilineEncoding: decryptedSecret.skipMultilineEncoding
|
||||
skipMultilineEncoding: decryptedSecret.skipMultilineEncoding,
|
||||
secretKey: decryptedSecret.secretKey
|
||||
});
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
secretsGroupByPath[groupedPath][index].secretValue = expandedSecretValue || "";
|
||||
@@ -1121,6 +1122,35 @@ export const secretV2BridgeServiceFactory = ({
|
||||
)
|
||||
)
|
||||
);
|
||||
const errors: { path: string; error: string }[] = [];
|
||||
|
||||
settledPromises.forEach((outerResult: PromiseSettledResult<PromiseSettledResult<void>[]>, outerIndex) => {
|
||||
const groupedPath = Object.keys(secretsGroupByPath)[outerIndex];
|
||||
|
||||
if (outerResult.status === "rejected") {
|
||||
errors.push({
|
||||
path: groupedPath,
|
||||
error: `Failed to process secret group: ${outerResult.reason}`
|
||||
});
|
||||
} else {
|
||||
// Check inner promise results
|
||||
outerResult.value.forEach((innerResult: PromiseSettledResult<void>) => {
|
||||
if (innerResult.status === "rejected") {
|
||||
const reason = innerResult.reason as ForbiddenRequestError;
|
||||
errors.push({
|
||||
path: groupedPath,
|
||||
error: reason.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to expand one or more secret references",
|
||||
details: errors.map((err) => err.error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeImports) {
|
||||
@@ -1424,7 +1454,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
environment,
|
||||
secretPath: path,
|
||||
value: secretValue,
|
||||
skipMultilineEncoding: secret.skipMultilineEncoding
|
||||
skipMultilineEncoding: secret.skipMultilineEncoding,
|
||||
secretKey: secret.key
|
||||
});
|
||||
|
||||
secretValue = expandedSecretValue || "";
|
||||
@@ -2722,7 +2753,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
const { expandedValue, stackTrace } = await getExpandedSecretStackTrace({
|
||||
environment,
|
||||
secretPath,
|
||||
value: decryptedSecretValue
|
||||
value: decryptedSecretValue,
|
||||
secretKey: secretName
|
||||
});
|
||||
|
||||
return { tree: stackTrace, value: expandedValue };
|
||||
|
@@ -426,7 +426,8 @@ export const secretQueueFactory = ({
|
||||
environment: dto.environment,
|
||||
secretPath: dto.secretPath,
|
||||
skipMultilineEncoding: secret.skipMultilineEncoding,
|
||||
value: secretValue
|
||||
value: secretValue,
|
||||
secretKey
|
||||
});
|
||||
content[secretKey] = { value: expandedSecretValue || "" };
|
||||
|
||||
|
@@ -113,11 +113,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<TAppConnection, "name" | "credentials" | "description" | "isPlatformManagedCredentials">
|
||||
Pick<
|
||||
TAppConnection,
|
||||
"name" | "credentials" | "description" | "isPlatformManagedCredentials" | "gatewayId"
|
||||
>
|
||||
> & {
|
||||
connectionId: string;
|
||||
app: AppConnection;
|
||||
|
@@ -7,4 +7,5 @@ export type TRootAppConnection = {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isPlatformManagedCredentials?: boolean;
|
||||
gatewayId?: string | null;
|
||||
};
|
||||
|
@@ -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<string, Commit[]>
|
||||
);
|
||||
},
|
||||
getNextPageParam: (lastPage, pages) => (lastPage.hasMore ? pages.length * limit : undefined)
|
||||
});
|
||||
};
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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 = () => {
|
||||
|
@@ -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 && <GenericAppConnectionsFields />}
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
|
@@ -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 { MySqlConnectionMethod, TMySqlConnection } from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
|
||||
@@ -48,6 +55,7 @@ export const MySqlConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
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 && <GenericAppConnectionsFields />}
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
|
@@ -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 { OracleDBConnectionMethod, TOracleDBConnection } from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
|
||||
@@ -48,6 +55,7 @@ export const OracleDBConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
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 && <GenericAppConnectionsFields />}
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
|
@@ -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 { PostgresConnectionMethod, TPostgresConnection } from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
|
||||
@@ -48,6 +55,7 @@ export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
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 && <GenericAppConnectionsFields />}
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
|
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faAngleDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faAngleDown,
|
||||
faChevronLeft,
|
||||
faCodeCommit,
|
||||
faWarning
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
@@ -7,12 +12,14 @@ import { useSearch } from "@tanstack/react-router";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
ContentLoader,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Spinner
|
||||
EmptyState,
|
||||
PageHeader
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import {
|
||||
@@ -108,25 +115,25 @@ export const CommitDetailsTab = ({
|
||||
// If no commit is selected or data is loading, show appropriate message
|
||||
if (!selectedCommitId) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-gray-400">Select a commit to view details</p>
|
||||
</div>
|
||||
<EmptyState className="mt-40" title="Select a commit to view details." icon={faCodeCommit}>
|
||||
<Button className="mt-4" colorSchema="secondary" onClick={() => goBackToHistory()}>
|
||||
Back to Commits
|
||||
</Button>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
return <ContentLoader />;
|
||||
}
|
||||
|
||||
if (!commitDetails) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-gray-400">No details found for this commit</p>
|
||||
</div>
|
||||
<EmptyState className="mt-40" title="No details found for this commit." icon={faCodeCommit}>
|
||||
<Button className="mt-4" colorSchema="secondary" onClick={() => goBackToHistory()}>
|
||||
Back to Commits
|
||||
</Button>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,9 +145,11 @@ export const CommitDetailsTab = ({
|
||||
} catch (error) {
|
||||
console.error("Failed to parse commit details:", error);
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-gray-400">Error parsing commit details</p>
|
||||
</div>
|
||||
<EmptyState className="mt-40" title="Error parsing commit details." icon={faWarning}>
|
||||
<Button className="mt-4" colorSchema="secondary" onClick={() => goBackToHistory()}>
|
||||
Back to Commits
|
||||
</Button>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,13 +232,12 @@ export const CommitDetailsTab = ({
|
||||
// Render an item from the merged list
|
||||
const renderMergedItem = (item: MergedItem): JSX.Element => {
|
||||
return (
|
||||
<div key={item.id} className="mb-2">
|
||||
<SecretVersionDiffView
|
||||
item={item}
|
||||
isCollapsed={collapsedItems[item.id]}
|
||||
onToggleCollapse={(id) => toggleItemCollapsed(id)}
|
||||
/>
|
||||
</div>
|
||||
<SecretVersionDiffView
|
||||
key={item.id}
|
||||
item={item}
|
||||
isCollapsed={collapsedItems[item.id]}
|
||||
onToggleCollapse={(id) => toggleItemCollapsed(id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -240,114 +248,104 @@ export const CommitDetailsTab = ({
|
||||
"Unknown";
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div>
|
||||
<div className="flex justify-between pb-2">
|
||||
<div className="w-5/6">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h1 className="mr-4 truncate text-3xl font-semibold text-white">
|
||||
{parsedCommitDetails.changes?.message || "No message"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-small mb-4 mt-2 flex items-center text-sm">
|
||||
<p>
|
||||
<span> Commited by </span>
|
||||
<b>{actorDisplay}</b>
|
||||
<span> on </span>
|
||||
<b>
|
||||
{formatDisplayDate(
|
||||
parsedCommitDetails.changes?.createdAt || new Date().toISOString()
|
||||
)}
|
||||
</b>
|
||||
{parsedCommitDetails.changes?.isLatest && (
|
||||
<span className="ml-1 italic text-gray-400">(Latest)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-start">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCommitsActions.PerformRollback}
|
||||
a={ProjectPermissionSub.Commits}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={!isAllowed}
|
||||
className={`${!isAllowed ? "cursor-not-allowed" : ""}`}
|
||||
<>
|
||||
<Button
|
||||
variant="link"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
onClick={() => {
|
||||
goBackToHistory();
|
||||
}}
|
||||
>
|
||||
Commit History
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={`${parsedCommitDetails.changes?.message}` || "No message"}
|
||||
description={
|
||||
<>
|
||||
Commited by {actorDisplay} on{" "}
|
||||
{formatDisplayDate(parsedCommitDetails.changes?.createdAt || new Date().toISOString())}
|
||||
{parsedCommitDetails.changes?.isLatest && (
|
||||
<span className="ml-1 text-mineshaft-400">(Latest)</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCommitsActions.PerformRollback}
|
||||
a={ProjectPermissionSub.Commits}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={!isAllowed}
|
||||
className={`${!isAllowed ? "cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<Button
|
||||
rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}
|
||||
variant="solid"
|
||||
className="h-min"
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Restore Options
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-w-sm bg-bunker-500" sideOffset={2}>
|
||||
{!parsedCommitDetails.changes.isLatest && (
|
||||
<DropdownMenuItem
|
||||
className="group cursor-pointer border-b border-mineshaft-600 px-3 py-3 transition-colors hover:bg-mineshaft-700"
|
||||
onClick={() => goToRollbackPreview()}
|
||||
>
|
||||
<IconButton
|
||||
ariaLabel="commit-options"
|
||||
variant="outline_bg"
|
||||
className="h-10 rounded border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<p className="mr-2">Restore Options</p>
|
||||
<FontAwesomeIcon icon={faAngleDown} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={2}
|
||||
className="animate-in fade-in-50 zoom-in-95 min-w-[240px] rounded-md bg-mineshaft-800 p-1 shadow-lg"
|
||||
style={{ marginTop: "0" }}
|
||||
>
|
||||
{!parsedCommitDetails.changes.isLatest && (
|
||||
<DropdownMenuItem
|
||||
className="group cursor-pointer rounded-md px-3 py-3 transition-colors hover:bg-mineshaft-700"
|
||||
onClick={() => goToRollbackPreview()}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">
|
||||
Roll back to this commit
|
||||
</span>
|
||||
<span className="max-w-[180px] whitespace-normal break-words text-xs leading-snug text-gray-400">
|
||||
Return this folder to its exact state at the time of this commit,
|
||||
discarding all other changes made after it
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="group cursor-pointer rounded-md px-3 py-3 transition-colors hover:bg-mineshaft-700"
|
||||
onClick={() => handlePopUpOpen("revertChanges")}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">Revert changes</span>
|
||||
<span className="max-w-[180px] whitespace-normal break-words text-xs leading-snug text-gray-400">
|
||||
Will restore to the previous version of affected resources
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">
|
||||
Roll back to this commit
|
||||
</span>
|
||||
<span className="whitespace-normal break-words text-xs leading-snug text-gray-400">
|
||||
Return this folder to its exact state at the time of this commit,
|
||||
discarding all other changes made after it
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="group cursor-pointer px-3 py-3 transition-colors hover:bg-mineshaft-700"
|
||||
onClick={() => handlePopUpOpen("revertChanges")}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">Revert changes</span>
|
||||
<span className="whitespace-normal break-words text-xs leading-snug text-gray-400">
|
||||
Will restore to the previous version of affected resources
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</PageHeader>
|
||||
<div className="flex w-full flex-col rounded-lg border border-mineshaft-600 bg-mineshaft-900 pt-4">
|
||||
<div className="mx-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Commit Changes</h3>
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
<div className="overflow-hidden">
|
||||
<div className="space-y-2">
|
||||
{sortedChangedItems.length > 0 ? (
|
||||
sortedChangedItems.map((item) => renderMergedItem(item))
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-mineshaft-600 bg-mineshaft-800">
|
||||
<p className="text-gray-400">No changed items found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden px-4">
|
||||
<div className="thin-scrollbar overflow-y-auto py-4">
|
||||
{sortedChangedItems.length > 0 ? (
|
||||
sortedChangedItems.map((item) => renderMergedItem(item))
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No changes found."
|
||||
className="h-full pb-0 pt-28"
|
||||
icon={faCodeCommit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.revertChanges.isOpen}
|
||||
deleteKey="revert"
|
||||
@@ -357,6 +355,6 @@ export const CommitDetailsTab = ({
|
||||
onDeleteApproved={handleRevertChanges}
|
||||
buttonText="Yes, revert changes"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -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 = (
|
||||
<div className="w-4 flex-shrink-0">{prefix}</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
|
||||
<span className={getHighlightClass(isDifferent)}>{valueDisplay}</span>
|
||||
{keyName && <span>{keyDisplay}</span>}
|
||||
<span>{valueDisplay}</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,8 +267,8 @@ const renderJsonWithDiffs = (
|
||||
<div className="w-4 flex-shrink-0">{prefix}</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
|
||||
<span className={getHighlightClass(isDifferent)}>[]</span>
|
||||
{keyName && <span>{keyDisplay}</span>}
|
||||
<span>[]</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,8 +281,8 @@ const renderJsonWithDiffs = (
|
||||
<div className="w-4 flex-shrink-0">{prefix}</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
|
||||
<span className={getHighlightClass(isDifferent)}>{"{}"}</span>
|
||||
{keyName && <span>{keyDisplay}</span>}
|
||||
<span>{"{}"}</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,16 +318,12 @@ const renderJsonWithDiffs = (
|
||||
<div key={reactKey}>
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && (
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>
|
||||
{keyDisplay}
|
||||
</span>
|
||||
)}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>[</span>
|
||||
{keyName && <span>{keyDisplay}</span>}
|
||||
<span>[</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -357,11 +351,11 @@ const renderJsonWithDiffs = (
|
||||
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>]</span>
|
||||
<span>]</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,16 +370,12 @@ const renderJsonWithDiffs = (
|
||||
<div key={reactKey}>
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && (
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>
|
||||
{keyDisplay}
|
||||
</span>
|
||||
)}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>{"{"}</span>
|
||||
{keyName && <span>{keyDisplay}</span>}
|
||||
<span>{"{"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -414,11 +404,11 @@ const renderJsonWithDiffs = (
|
||||
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>{"}"}</span>
|
||||
<span>{"}"}</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
@@ -527,7 +517,7 @@ export const SecretVersionDiffView = ({
|
||||
}
|
||||
|
||||
oldVersionContent = (
|
||||
<div className="font-mono text-sm">
|
||||
<div className="w-fit min-w-[100%] font-mono text-sm">
|
||||
{renderJsonWithDiffs(
|
||||
cleanOldVersion,
|
||||
diffPaths,
|
||||
@@ -543,7 +533,7 @@ export const SecretVersionDiffView = ({
|
||||
</div>
|
||||
);
|
||||
newVersionContent = (
|
||||
<div className="font-mono text-sm">
|
||||
<div className="w-fit min-w-[100%] font-mono text-sm">
|
||||
{renderJsonWithDiffs(
|
||||
cleanNewVersion,
|
||||
diffPaths,
|
||||
@@ -583,19 +573,19 @@ export const SecretVersionDiffView = ({
|
||||
if (item.isDeleted) {
|
||||
textStyle = "line-through text-red-300";
|
||||
changeBadge = (
|
||||
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
|
||||
<span className="ml-2 whitespace-nowrap rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
|
||||
{isSecret ? "Secret" : "Folder"} Deleted
|
||||
</span>
|
||||
);
|
||||
} else if (item.isAdded) {
|
||||
changeBadge = (
|
||||
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
|
||||
<span className="ml-2 whitespace-nowrap rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
|
||||
{isSecret ? "Secret" : "Folder"} Added
|
||||
</span>
|
||||
);
|
||||
} else if (item.isUpdated) {
|
||||
changeBadge = (
|
||||
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
|
||||
<span className="ml-2 whitespace-nowrap rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
|
||||
{isSecret ? "Secret" : "Folder"} Updated
|
||||
</span>
|
||||
);
|
||||
@@ -615,32 +605,34 @@ export const SecretVersionDiffView = ({
|
||||
tabIndex={0}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className={textStyle}>{key}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<p className={twMerge(textStyle, "truncate")}>{key}</p>
|
||||
{changeBadge}
|
||||
</div>
|
||||
<FontAwesomeIcon icon={collapsed ? faChevronDown : faChevronUp} className="text-gray-400" />
|
||||
<FontAwesomeIcon
|
||||
icon={collapsed ? faChevronDown : faChevronUp}
|
||||
className="ml-2 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-mineshaft-600 bg-mineshaft-800">
|
||||
<div className="overflow-hidden border border-b-0 border-mineshaft-600 bg-mineshaft-800 first:rounded-t last:rounded-b last:border-b">
|
||||
{showHeader && renderHeader()}
|
||||
|
||||
{!collapsed && (
|
||||
<div className="border-t border-mineshaft-700 bg-mineshaft-900 px-6 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="border-t border-mineshaft-700 bg-mineshaft-900 p-3 text-mineshaft-100">
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
ref={oldContainerRef}
|
||||
className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
className="thin-scrollbar max-h-96 flex-1 overflow-auto whitespace-pre"
|
||||
>
|
||||
{oldVersionContent}
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 w-[0.05rem] self-stretch bg-mineshaft-600" />
|
||||
<div
|
||||
ref={newContainerRef}
|
||||
className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
className="thin-scrollbar max-h-96 flex-1 overflow-auto whitespace-pre"
|
||||
>
|
||||
{newVersionContent}
|
||||
</div>
|
||||
|
@@ -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."
|
||||
/>
|
||||
<NoticeBannerV2 title="" className="mb-2">
|
||||
<NoticeBannerV2 title="Secret Snapshots Update" className="mb-2">
|
||||
<p className="my-1 text-sm text-mineshaft-300">
|
||||
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
|
||||
|
@@ -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 (
|
||||
<div className="border-b border-zinc-800 last:border-b-0">
|
||||
<div className="px-4 py-4 transition-colors duration-200 hover:bg-zinc-800">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between">
|
||||
<div className="w-5/6 flex-1">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="link"
|
||||
className="truncate text-left text-white hover:underline"
|
||||
isFullWidth
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectCommit(commit.id, "tab-commit-details");
|
||||
}}
|
||||
>
|
||||
{commit.message}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-white-400 mt-2 flex flex-wrap items-center gap-4 text-sm">
|
||||
<span className="flex items-center text-mineshaft-300">
|
||||
{commit.actorMetadata?.email || commit.actorMetadata?.name || commit.actorType}
|
||||
<p className="ml-1 mr-1">committed</p>
|
||||
<time dateTime={commit.createdAt}>{formatTimeAgo(commit.createdAt)}</time>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex w-1/6 items-center justify-end sm:mt-0">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectCommit(commit.id, "tab-commit-details");
|
||||
}}
|
||||
>
|
||||
<code className="text-white-400 px-3 py-1 font-mono text-sm">
|
||||
{commit.id?.substring(0, 11)}
|
||||
</code>
|
||||
</Button>
|
||||
<CopyButton
|
||||
value={commit.id}
|
||||
name={commit.id}
|
||||
size="sm"
|
||||
variant="plain"
|
||||
color="text-mineshaft-400"
|
||||
icon={faCopy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectCommit(commit.id, "tab-commit-details");
|
||||
}}
|
||||
className="w-full border border-b-0 border-mineshaft-600 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md last:border-b"
|
||||
>
|
||||
<div className="flex gap-2 px-4 py-3 transition-colors duration-200 hover:bg-zinc-800">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start">
|
||||
<p className="block w-full truncate text-left text-sm text-mineshaft-100">
|
||||
{commit.message}
|
||||
</p>
|
||||
<p className="text-left text-xs text-mineshaft-300">
|
||||
{commit.actorMetadata?.email || commit.actorMetadata?.name || commit.actorType}{" "}
|
||||
committed <time dateTime={commit.createdAt}>{formatTimeAgo(commit.createdAt)}</time>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<code className="mt-0.5 font-mono text-xs text-mineshaft-400">
|
||||
{commit.id?.substring(0, 11)}
|
||||
</code>
|
||||
<CopyButton
|
||||
value={commit.id}
|
||||
name={commit.id}
|
||||
size="xs"
|
||||
variant="plain"
|
||||
color="text-mineshaft-400"
|
||||
icon={faCopy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -108,24 +78,16 @@ const DateGroup = ({
|
||||
onSelectCommit: (commitId: string, tab: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-8 last:mb-0 last:pb-2">
|
||||
<div className="mb-4 flex items-center">
|
||||
<div className="relative mr-3 flex h-6 w-6 items-center justify-center">
|
||||
<div className="z-10 h-3 w-3 rounded-full border-2 border-mineshaft-600 bg-bunker-800" />
|
||||
<div className="absolute left-0 right-0 top-1/2 h-0.5 -translate-y-1/2 bg-mineshaft-600" />
|
||||
</div>
|
||||
<h2 className="text-sm text-white">Commits on {date}</h2>
|
||||
<div className="mt-4 first:mt-0">
|
||||
<div className="mb-4 ml-[0.15rem] flex items-center">
|
||||
<FontAwesomeIcon icon={faCodeCommit} className="text-mineshaft-400" />
|
||||
<h2 className="ml-4 text-sm text-mineshaft-400">Commits on {date}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute bottom-0 left-3 top-0 w-0.5 bg-mineshaft-600" />
|
||||
<div className="absolute bottom-0 left-3 top-0 w-[0.1rem] bg-mineshaft-500" />
|
||||
<div className="ml-10">
|
||||
{commits.map((commit) => (
|
||||
<div key={commit.id} className="relative mb-3 pb-1">
|
||||
<div className="overflow-hidden rounded-md border border-solid border-mineshaft-600">
|
||||
<CommitItem commit={commit} onSelectCommit={onSelectCommit} />
|
||||
</div>
|
||||
</div>
|
||||
<CommitItem key={commit.id} commit={commit} onSelectCommit={onSelectCommit} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<Commit[]>([]);
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
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<string, Commit[]>
|
||||
);
|
||||
}, [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 (
|
||||
<div className="w-full">
|
||||
<div className="mt-4 w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Commit History</p>
|
||||
<div className="mb-4 flex flex-col sm:flex-row sm:justify-end">
|
||||
<div className="flex w-full flex-wrap items-center gap-2">
|
||||
<div className="relative flex-grow">
|
||||
<Input
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} aria-hidden="true" />}
|
||||
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"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 transform text-gray-400">
|
||||
<FontAwesomeIcon icon={faSearch} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
size="md"
|
||||
className="flex h-10 items-center justify-center gap-2 rounded-md bg-zinc-800 px-4 py-2 text-sm text-white transition-colors duration-200 hover:bg-zinc-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
size="sm"
|
||||
className="flex h-[2.4rem] items-center justify-center gap-2 rounded-md"
|
||||
onClick={handleSort}
|
||||
aria-label={`Sort by date ${sortDirection === "desc" ? "ascending" : "descending"}`}
|
||||
ariaLabel={`Sort by date ${sortDirection === "desc" ? "ascending" : "descending"}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={sortDirection === "desc" ? faArrowDownWideShort : faArrowUpWideShort}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && offset === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spinner size="lg" aria-label="Loading commits" />
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<ContentLoader className="h-80" />
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{Object.keys(groupedCommits).length > 0 ? (
|
||||
<div>
|
||||
{groupedCommits && Object.keys(groupedCommits).length > 0 ? (
|
||||
<>
|
||||
{Object.entries(groupedCommits).map(([date, dateCommits]) => (
|
||||
<DateGroup
|
||||
@@ -282,34 +196,20 @@ export const CommitHistoryTab = ({
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-white-400 flex min-h-40 flex-col items-center justify-center rounded-lg bg-zinc-900 py-8 text-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faSearch}
|
||||
className="text-white-500 mb-3 text-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>No matching commits found. Try a different search term.</p>
|
||||
</div>
|
||||
<EmptyState title="No commits found." icon={faCodeCommit} />
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center pb-2">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
size="md"
|
||||
className="rounded-md bg-zinc-900 px-6 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
onClick={loadMoreCommits}
|
||||
disabled={isFetching}
|
||||
size="sm"
|
||||
className="ml-10 mt-4 w-full"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
isLoading={isFetchingNextPage}
|
||||
aria-label="Load more commits"
|
||||
>
|
||||
{isFetching ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Load more commits"
|
||||
)}
|
||||
Load More Commits
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
Reference in New Issue
Block a user