improvements: address feedback

This commit is contained in:
Scott Wilson
2024-12-18 21:18:38 -08:00
parent 62968c5e43
commit 2d60f389c2
55 changed files with 1040 additions and 690 deletions

View File

@@ -8,6 +8,7 @@ export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable(TableName.AppConnection, (t) => { await knex.schema.createTable(TableName.AppConnection, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name", 32).notNullable(); t.string("name", 32).notNullable();
t.string("description");
t.string("app").notNullable(); t.string("app").notNullable();
t.string("method").notNullable(); t.string("method").notNullable();
t.binary("encryptedCredentials").notNullable(); t.binary("encryptedCredentials").notNullable();
@@ -16,9 +17,9 @@ export async function up(knex: Knex): Promise<void> {
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
t.timestamps(true, true, true); t.timestamps(true, true, true);
}); });
}
await createOnUpdateTrigger(knex, TableName.AppConnection); await createOnUpdateTrigger(knex, TableName.AppConnection);
}
} }
export async function down(knex: Knex): Promise<void> { export async function down(knex: Knex): Promise<void> {

View File

@@ -12,6 +12,7 @@ import { TImmutableDBKeys } from "./models";
export const AppConnectionsSchema = z.object({ export const AppConnectionsSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
name: z.string(), name: z.string(),
description: z.string().nullable().optional(),
app: z.string(), app: z.string(),
method: z.string(), method: z.string(),
encryptedCredentials: zodBuffer, encryptedCredentials: zodBuffer,

View File

@@ -4,9 +4,10 @@ import {
} from "@app/ee/services/project-template/project-template-types"; } from "@app/ee/services/project-template/project-template-types";
import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types";
import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types";
import { AppConnection, TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/lib/app-connections";
import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { SymmetricEncryption } from "@app/lib/crypto/cipher";
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/services/app-connection/app-connection-types";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types";
@@ -1875,8 +1876,10 @@ interface ApplyProjectTemplateEvent {
interface GetAppConnectionsEvent { interface GetAppConnectionsEvent {
type: EventType.GET_APP_CONNECTIONS; type: EventType.GET_APP_CONNECTIONS;
metadata?: { metadata: {
app: AppConnection; app?: AppConnection;
count: number;
connectionIds: string[];
}; };
} }

View File

@@ -50,7 +50,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
pkiEst: false, pkiEst: false,
enforceMfa: false, enforceMfa: false,
projectTemplates: false, projectTemplates: false,
appConnections: false appConnections: true
}); });
export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => {

View File

@@ -1,5 +1,5 @@
import { AppConnection } from "@app/lib/app-connections"; import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { APP_CONNECTION_NAME_MAP } from "@app/lib/app-connections/maps"; import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
export const GROUPS = { export const GROUPS = {
CREATE: { CREATE: {
@@ -1620,6 +1620,7 @@ export const AppConnections = {
const appName = APP_CONNECTION_NAME_MAP[app]; const appName = APP_CONNECTION_NAME_MAP[app];
return { return {
name: `The name of the ${appName} Connection to create. Must be slug-friendly.`, name: `The name of the ${appName} Connection to create. Must be slug-friendly.`,
description: `An optional description for the ${appName} Connection.`,
credentials: `The credentials used to connect with ${appName}.`, credentials: `The credentials used to connect with ${appName}.`,
method: `The method used to authenticate with ${appName}.` method: `The method used to authenticate with ${appName}.`
}; };
@@ -1629,11 +1630,12 @@ export const AppConnections = {
return { return {
connectionId: `The ID of the ${appName} Connection to be updated.`, connectionId: `The ID of the ${appName} Connection to be updated.`,
name: `The updated name of the ${appName} Connection. Must be slug-friendly.`, name: `The updated name of the ${appName} Connection. Must be slug-friendly.`,
description: `The updated description of the ${appName} Connection.`,
credentials: `The credentials used to connect with ${appName}.`, credentials: `The credentials used to connect with ${appName}.`,
method: `The method used to authenticate with ${appName}.` method: `The method used to authenticate with ${appName}.`
}; };
}, },
DELETE: (app: AppConnection) => ({ DELETE: (app: AppConnection) => ({
connectionId: `The ID of the ${app} connection to be deleted.` connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} connection to be deleted.`
}) })
}; };

View File

@@ -1,26 +0,0 @@
import { TAwsConnection } from "@app/lib/app-connections/aws/aws-connection-types";
import { TGitHubConnection, TGitHubConnectionInput } from "@app/lib/app-connections/github";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "./app-connection-enums";
export type AppConnectionListItem = {
app: AppConnection;
name: string;
methods: string[];
};
export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection);
export type TAppConnectionInput = { id: string } & (TAwsConnection | TGitHubConnectionInput);
export type TCreateAppConnectionDTO = Pick<TAppConnectionInput, "credentials" | "method" | "name" | "app">;
export type TUpdateAppConnectionDTO = Partial<Omit<TCreateAppConnectionDTO, "method" | "app">> & {
connectionId: string;
};
export type TAppConnectionConfig = { orgId: string } & DiscriminativePick<
TAppConnectionInput,
"app" | "method" | "credentials"
>;

View File

@@ -1,69 +0,0 @@
import { z } from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { slugSchema } from "@app/server/lib/schemas";
import { BaseAppConnectionSchema } from "@app/services/app-connection/app-connection-schemas";
import { AppConnection } from "../app-connection-enums";
import { AwsConnectionMethod } from "./aws-connection-enums";
export const AwsConnectionAssumeRoleCredentialsSchema = z.object({
roleArn: z.string().min(1, "Role ARN required")
});
export const AwsConnectionAccessTokenCredentialsSchema = z.object({
accessKeyId: z.string().min(1, "Access Key ID required"),
secretAccessKey: z.string().min(1, "Secret Access Key required")
});
const BaseAwsConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AWS) });
export const AwsConnectionSchema = z.intersection(
BaseAwsConnectionSchema,
z.discriminatedUnion("method", [
z.object({
method: z.literal(AwsConnectionMethod.AssumeRole),
credentials: AwsConnectionAssumeRoleCredentialsSchema
}),
z.object({
method: z.literal(AwsConnectionMethod.AccessKey),
credentials: AwsConnectionAccessTokenCredentialsSchema
})
])
);
export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [
BaseAwsConnectionSchema.extend({
method: z.literal(AwsConnectionMethod.AssumeRole),
credentials: AwsConnectionAssumeRoleCredentialsSchema.omit({ roleArn: true })
}),
BaseAwsConnectionSchema.extend({
method: z.literal(AwsConnectionMethod.AccessKey),
credentials: AwsConnectionAccessTokenCredentialsSchema.omit({ secretAccessKey: true })
})
]);
export const CreateAwsConnectionSchema = z
.discriminatedUnion("method", [
z.object({
method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections.CREATE(AppConnection.AWS).method),
credentials: AwsConnectionAssumeRoleCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AWS).credentials
)
}),
z.object({
method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections.CREATE(AppConnection.AWS).method),
credentials: AwsConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AWS).credentials
)
})
])
.and(z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(AppConnection.AWS).name) }));
export const UpdateAwsConnectionSchema = z.object({
name: slugSchema({ field: "name" }).optional().describe(AppConnections.UPDATE(AppConnection.AWS).name),
credentials: z
.union([AwsConnectionAccessTokenCredentialsSchema, AwsConnectionAssumeRoleCredentialsSchema])
.optional()
.describe(AppConnections.UPDATE(AppConnection.AWS).credentials)
});

View File

@@ -1,9 +0,0 @@
import { z } from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AwsConnectionSchema } from "./aws-connection-schemas";
export type TAwsConnection = z.infer<typeof AwsConnectionSchema>;
export type TAwsConnectionConfig = DiscriminativePick<TAwsConnection, "orgId" | "method" | "app" | "credentials">;

View File

@@ -1,77 +0,0 @@
import { z } from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/lib/app-connections";
import { slugSchema } from "@app/server/lib/schemas";
import { BaseAppConnectionSchema } from "@app/services/app-connection/app-connection-schemas";
import { GitHubConnectionMethod } from "./github-connection-enums";
export const GitHubConnectionOAuthInputCredentialsSchema = z.object({
code: z.string().min(1, "OAuth code required")
});
export const GitHubConnectionAppInputCredentialsSchema = z.object({
code: z.string().min(1, "GitHub App code required"),
installationId: z.string().min(1, "GitHub App Installation ID required")
});
export const GitHubConnectionOAuthOutputCredentialsSchema = z.object({
accessToken: z.string()
});
export const GitHubConnectionAppOutputCredentialsSchema = z.object({
installationId: z.string()
});
export const CreateGitHubConnectionSchema = z
.discriminatedUnion("method", [
z.object({
method: z.literal(GitHubConnectionMethod.App).describe(AppConnections.CREATE(AppConnection.GitHub).method),
credentials: GitHubConnectionAppInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.GitHub).credentials
)
}),
z.object({
method: z.literal(GitHubConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.GitHub).method),
credentials: GitHubConnectionOAuthInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.GitHub).credentials
)
})
])
.and(z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(AppConnection.GitHub).name) }));
export const UpdateGitHubConnectionSchema = z.object({
name: slugSchema({ field: "name" }).optional().describe(AppConnections.UPDATE(AppConnection.GitHub).name),
credentials: z
.union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema])
.optional()
.describe(AppConnections.UPDATE(AppConnection.GitHub).credentials)
});
const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) });
export const GitHubAppConnectionSchema = z.intersection(
BaseGitHubConnectionSchema,
z.discriminatedUnion("method", [
z.object({
method: z.literal(GitHubConnectionMethod.App),
credentials: GitHubConnectionAppOutputCredentialsSchema
}),
z.object({
method: z.literal(GitHubConnectionMethod.OAuth),
credentials: GitHubConnectionOAuthOutputCredentialsSchema
})
])
);
export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [
BaseGitHubConnectionSchema.extend({
method: z.literal(GitHubConnectionMethod.App),
credentials: GitHubConnectionAppOutputCredentialsSchema.omit({ installationId: true })
}),
BaseGitHubConnectionSchema.extend({
method: z.literal(GitHubConnectionMethod.OAuth),
credentials: GitHubConnectionOAuthOutputCredentialsSchema.omit({ accessToken: true })
})
]);

View File

@@ -1,2 +0,0 @@
export * from "./app-connection-enums";
export * from "./app-connection-types";

View File

@@ -1,17 +0,0 @@
import { TAppConnection } from "@app/lib/app-connections/app-connection-types";
import { AppConnection } from "./app-connection-enums";
import { AwsConnectionMethod } from "./aws/aws-connection-enums";
import { GitHubConnectionMethod } from "./github/github-connection-enums";
export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.AWS]: "AWS",
[AppConnection.GitHub]: "GitHub"
};
export const APP_CONNECTION_METHOD_NAME_MAP: Record<TAppConnection["method"], string> = {
[AwsConnectionMethod.AssumeRole]: "Assume Role",
[AwsConnectionMethod.AccessKey]: "Access Key",
[GitHubConnectionMethod.App]: "Github App",
[GitHubConnectionMethod.OAuth]: "OAuth"
};

View File

@@ -14,3 +14,5 @@ export const prefixWithSlash = (str: string) => {
if (str.startsWith("/")) return str; if (str.startsWith("/")) return str;
return `/${str}`; return `/${str}`;
}; };
export const startsWithVowel = (str: string) => /^[aeiou]/i.test(str);

View File

@@ -1,19 +1,23 @@
import { AwsConnectionListItemSchema, SanitizedAwsConnectionSchema } from "src/services/app-connection/aws";
import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "src/services/app-connection/github";
import { z } from "zod"; import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { AppConnection } from "@app/lib/app-connections";
import { SanitizedAwsConnectionSchema } from "@app/lib/app-connections/aws";
import { SanitizedGitHubConnectionSchema } from "@app/lib/app-connections/github";
import { readLimit } from "@app/server/config/rateLimiter"; import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
// can't use discriminated due to multiple schemas for certain apps // can't use discriminated due to multiple schemas for certain apps
export const SanitizedAppConnectionSchema = z.union([ const SanitizedAppConnectionSchema = z.union([
...SanitizedAwsConnectionSchema.options, ...SanitizedAwsConnectionSchema.options,
...SanitizedGitHubConnectionSchema.options ...SanitizedGitHubConnectionSchema.options
]); ]);
const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
AwsConnectionListItemSchema,
GitHubConnectionListItemSchema
]);
export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { export const registerAppConnectionRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
method: "GET", method: "GET",
@@ -25,18 +29,11 @@ export const registerAppConnectionRouter = async (server: FastifyZodProvider) =>
description: "List the available App Connection Options.", description: "List the available App Connection Options.",
response: { response: {
200: z.object({ 200: z.object({
appConnectionOptions: z appConnectionOptions: AppConnectionOptionsSchema.array()
.object({
name: z.string(),
app: z.nativeEnum(AppConnection),
methods: z.string().array()
})
.passthrough()
.array()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: () => { handler: () => {
const appConnectionOptions = server.services.appConnection.listAppConnectionOptions(); const appConnectionOptions = server.services.appConnection.listAppConnectionOptions();
return { appConnectionOptions }; return { appConnectionOptions };
@@ -55,7 +52,7 @@ export const registerAppConnectionRouter = async (server: FastifyZodProvider) =>
200: z.object({ appConnections: SanitizedAppConnectionSchema.array() }) 200: z.object({ appConnections: SanitizedAppConnectionSchema.array() })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const appConnections = await server.services.appConnection.listAppConnectionsByOrg(req.permission); const appConnections = await server.services.appConnection.listAppConnectionsByOrg(req.permission);
@@ -63,7 +60,11 @@ export const registerAppConnectionRouter = async (server: FastifyZodProvider) =>
...req.auditLogInfo, ...req.auditLogInfo,
orgId: req.permission.orgId, orgId: req.permission.orgId,
event: { event: {
type: EventType.GET_APP_CONNECTIONS type: EventType.GET_APP_CONNECTIONS,
metadata: {
count: appConnections.length,
connectionIds: appConnections.map((connection) => connection.id)
}
} }
}); });

View File

@@ -2,10 +2,12 @@ import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { AppConnections } from "@app/lib/api-docs"; import { AppConnections } from "@app/lib/api-docs";
import { AppConnection, TAppConnection, TAppConnectionInput } from "@app/lib/app-connections"; import { startsWithVowel } from "@app/lib/fn";
import { APP_CONNECTION_NAME_MAP } from "@app/lib/app-connections/maps";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
import { TAppConnection, TAppConnectionInput } from "@app/services/app-connection/app-connection-types";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
export const registerAppConnectionEndpoints = <T extends TAppConnection, I extends TAppConnectionInput>({ export const registerAppConnectionEndpoints = <T extends TAppConnection, I extends TAppConnectionInput>({
@@ -17,8 +19,13 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
}: { }: {
app: AppConnection; app: AppConnection;
server: FastifyZodProvider; server: FastifyZodProvider;
createSchema: z.ZodType<{ name: string; method: I["method"]; credentials: I["credentials"] }>; createSchema: z.ZodType<{
updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"] }>; name: string;
method: I["method"];
credentials: I["credentials"];
description?: string | null;
}>;
updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null }>;
responseSchema: z.ZodTypeAny; responseSchema: z.ZodTypeAny;
}) => { }) => {
const appName = APP_CONNECTION_NAME_MAP[app]; const appName = APP_CONNECTION_NAME_MAP[app];
@@ -35,7 +42,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
200: z.object({ appConnections: responseSchema.array() }) 200: z.object({ appConnections: responseSchema.array() })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const appConnections = (await server.services.appConnection.listAppConnectionsByOrg(req.permission, app)) as T[]; const appConnections = (await server.services.appConnection.listAppConnectionsByOrg(req.permission, app)) as T[];
@@ -45,7 +52,9 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
event: { event: {
type: EventType.GET_APP_CONNECTIONS, type: EventType.GET_APP_CONNECTIONS,
metadata: { metadata: {
app app,
count: appConnections.length,
connectionIds: appConnections.map((connection) => connection.id)
} }
} }
}); });
@@ -69,7 +78,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
200: z.object({ appConnection: responseSchema }) 200: z.object({ appConnection: responseSchema })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const { connectionId } = req.params; const { connectionId } = req.params;
@@ -112,7 +121,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
200: z.object({ appConnection: responseSchema }) 200: z.object({ appConnection: responseSchema })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const { connectionName } = req.params; const { connectionName } = req.params;
@@ -144,18 +153,20 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
description: `Create an ${appName} Connection for the current organization.`, description: `Create ${
startsWithVowel(appName) ? "an" : "a"
} ${appName} Connection for the current organization.`,
body: createSchema, body: createSchema,
response: { response: {
200: z.object({ appConnection: responseSchema }) 200: z.object({ appConnection: responseSchema })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const { name, method, credentials } = req.body; const { name, method, credentials, description } = req.body;
const appConnection = (await server.services.appConnection.createAppConnection( const appConnection = (await server.services.appConnection.createAppConnection(
{ name, method, app, credentials }, { name, method, app, credentials, description },
req.permission req.permission
)) as TAppConnection; )) as TAppConnection;
@@ -193,13 +204,13 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
200: z.object({ appConnection: responseSchema }) 200: z.object({ appConnection: responseSchema })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const { name, credentials } = req.body; const { name, credentials, description } = req.body;
const { connectionId } = req.params; const { connectionId } = req.params;
const appConnection = (await server.services.appConnection.updateAppConnection( const appConnection = (await server.services.appConnection.updateAppConnection(
{ name, credentials, connectionId }, { name, credentials, connectionId, description },
req.permission req.permission
)) as T; )) as T;
@@ -210,6 +221,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
type: EventType.UPDATE_APP_CONNECTION, type: EventType.UPDATE_APP_CONNECTION,
metadata: { metadata: {
name, name,
description,
credentialsUpdated: Boolean(credentials), credentialsUpdated: Boolean(credentials),
connectionId connectionId
} }
@@ -235,7 +247,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
200: z.object({ appConnection: responseSchema }) 200: z.object({ appConnection: responseSchema })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const { connectionId } = req.params; const { connectionId } = req.params;

View File

@@ -1,9 +1,10 @@
import { AppConnection } from "@app/lib/app-connections";
import { import {
CreateAwsConnectionSchema, CreateAwsConnectionSchema,
SanitizedAwsConnectionSchema, SanitizedAwsConnectionSchema,
UpdateAwsConnectionSchema UpdateAwsConnectionSchema
} from "@app/lib/app-connections/aws"; } from "src/services/app-connection/aws";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; import { registerAppConnectionEndpoints } from "./app-connection-endpoints";

View File

@@ -1,9 +1,10 @@
import { AppConnection } from "@app/lib/app-connections";
import { import {
CreateGitHubConnectionSchema, CreateGitHubConnectionSchema,
GitHubAppConnectionSchema, SanitizedGitHubConnectionSchema,
UpdateGitHubConnectionSchema UpdateGitHubConnectionSchema
} from "@app/lib/app-connections/github"; } from "src/services/app-connection/github";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
@@ -11,7 +12,7 @@ export const registerGitHubConnectionRouter = async (server: FastifyZodProvider)
registerAppConnectionEndpoints({ registerAppConnectionEndpoints({
app: AppConnection.GitHub, app: AppConnection.GitHub,
server, server,
responseSchema: GitHubAppConnectionSchema, responseSchema: SanitizedGitHubConnectionSchema,
createSchema: CreateGitHubConnectionSchema, createSchema: CreateGitHubConnectionSchema,
updateSchema: UpdateGitHubConnectionSchema updateSchema: UpdateGitHubConnectionSchema
}); });

View File

@@ -1,6 +1,6 @@
import { AppConnection } from "@app/lib/app-connections";
import { registerAwsConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/aws-connection-router"; import { registerAwsConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/aws-connection-router";
import { registerGitHubConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/github-connection-router"; import { registerGitHubConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/github-connection-router";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
export const APP_CONNECTION_REGISTER_MAP: Record<AppConnection, (server: FastifyZodProvider) => Promise<void>> = { export const APP_CONNECTION_REGISTER_MAP: Record<AppConnection, (server: FastifyZodProvider) => Promise<void>> = {
[AppConnection.AWS]: registerAwsConnectionRouter, [AppConnection.AWS]: registerAwsConnectionRouter,

View File

@@ -5,7 +5,7 @@ import { ormify } from "@app/lib/knex";
export type TAppConnectionDALFactory = ReturnType<typeof appConnectionDALFactory>; export type TAppConnectionDALFactory = ReturnType<typeof appConnectionDALFactory>;
export const appConnectionDALFactory = (db: TDbClient) => { export const appConnectionDALFactory = (db: TDbClient) => {
const appConnection = ormify(db, TableName.AppConnection); const appConnectionOrm = ormify(db, TableName.AppConnection);
return { ...appConnection }; return { ...appConnectionOrm };
}; };

View File

@@ -1,10 +1,20 @@
import { AppConnection, AppConnectionListItem, TAppConnection, TAppConnectionConfig } from "@app/lib/app-connections"; import {
import { getAwsAppConnectionListItem, validateAwsConnectionCredentials } from "@app/lib/app-connections/aws"; AwsConnectionMethod,
import { getGitHubConnectionListItem, validateGitHubConnectionCredentials } from "@app/lib/app-connections/github"; getAwsAppConnectionListItem,
validateAwsConnectionCredentials
} from "src/services/app-connection/aws";
import {
getGitHubConnectionListItem,
GitHubConnectionMethod,
validateGitHubConnectionCredentials
} from "src/services/app-connection/github";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service"; import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service";
import { TAppConnection, TAppConnectionConfig } from "@app/services/app-connection/app-connection-types";
import { KmsDataKey } from "@app/services/kms/kms-types"; import { KmsDataKey } from "@app/services/kms/kms-types";
export const listAppConnectionOptions = (): (AppConnectionListItem & Record<string, unknown>)[] => { export const listAppConnectionOptions = () => {
return [getAwsAppConnectionListItem(), getGitHubConnectionListItem()].sort((a, b) => a.name.localeCompare(b.name)); return [getAwsAppConnectionListItem(), getGitHubConnectionListItem()].sort((a, b) => a.name.localeCompare(b.name));
}; };
@@ -65,3 +75,19 @@ export const validateAppConnectionCredentials = async (
throw new Error(`Unhandled App Connection ${app}`); throw new Error(`Unhandled App Connection ${app}`);
} }
}; };
export const getAppConnectionMethodName = (method: TAppConnection["method"]) => {
switch (method) {
case GitHubConnectionMethod.App:
return "GitHub App";
case GitHubConnectionMethod.OAuth:
return "OAuth";
case AwsConnectionMethod.AccessKey:
return "Access Key";
case AwsConnectionMethod.AssumeRole:
return "Assume Role";
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled App Connection Method: ${method}`);
}
};

View File

@@ -0,0 +1,6 @@
import { AppConnection } from "./app-connection-enums";
export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.AWS]: "AWS",
[AppConnection.GitHub]: "GitHub"
};

View File

@@ -1,7 +1,35 @@
import { z } from "zod";
import { AppConnectionsSchema } from "@app/db/schemas/app-connections"; import { AppConnectionsSchema } from "@app/db/schemas/app-connections";
import { AppConnections } from "@app/lib/api-docs";
import { slugSchema } from "@app/server/lib/schemas";
import { AppConnection } from "./app-connection-enums";
export const BaseAppConnectionSchema = AppConnectionsSchema.omit({ export const BaseAppConnectionSchema = AppConnectionsSchema.omit({
encryptedCredentials: true, encryptedCredentials: true,
app: true, app: true,
method: true method: true
}); });
export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) =>
z.object({
name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(app).name),
description: z
.string()
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
.describe(AppConnections.CREATE(app).description)
});
export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) =>
z.object({
name: slugSchema({ field: "name" }).describe(AppConnections.UPDATE(app).name).optional(),
description: z
.string()
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
.describe(AppConnections.UPDATE(app).description)
});

View File

@@ -3,21 +3,26 @@ import { ForbiddenError } from "@casl/ability";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
AppConnection,
TAppConnection,
TAppConnectionConfig,
TCreateAppConnectionDTO,
TUpdateAppConnectionDTO
} from "@app/lib/app-connections";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types"; import { DiscriminativePick, OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { import {
decryptAppConnectionCredentials, decryptAppConnectionCredentials,
encryptAppConnectionCredentials, encryptAppConnectionCredentials,
getAppConnectionMethodName,
listAppConnectionOptions, listAppConnectionOptions,
validateAppConnectionCredentials validateAppConnectionCredentials
} from "@app/services/app-connection/app-connection-fns"; } from "@app/services/app-connection/app-connection-fns";
import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
import {
TAppConnection,
TAppConnectionConfig,
TCreateAppConnectionDTO,
TUpdateAppConnectionDTO,
TValidateAppConnectionCredentials
} from "@app/services/app-connection/app-connection-types";
import { ValidateAwsConnectionCredentialsSchema } from "@app/services/app-connection/aws";
import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-connection/github";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "./app-connection-dal"; import { TAppConnectionDALFactory } from "./app-connection-dal";
@@ -31,6 +36,11 @@ export type TAppConnectionServiceFactoryDep = {
export type TAppConnectionServiceFactory = ReturnType<typeof appConnectionServiceFactory>; export type TAppConnectionServiceFactory = ReturnType<typeof appConnectionServiceFactory>;
const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAppConnectionCredentials> = {
[AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema,
[AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema
};
export const appConnectionServiceFactory = ({ export const appConnectionServiceFactory = ({
appConnectionDAL, appConnectionDAL,
permissionService, permissionService,
@@ -160,40 +170,53 @@ export const appConnectionServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections);
const isConflictingName = Boolean( const appConnection = await appConnectionDAL.transaction(async (tx) => {
await appConnectionDAL.findOne({ const isConflictingName = Boolean(
name: params.name, await appConnectionDAL.findOne(
orgId: actor.orgId {
}) name: params.name,
); orgId: actor.orgId
},
tx
)
);
if (isConflictingName) if (isConflictingName)
throw new BadRequestError({ throw new BadRequestError({
message: `An App Connection with the name "${params.name}" already exists` message: `An App Connection with the name "${params.name}" already exists`
});
const validatedCredentials = await validateAppConnectionCredentials({
app,
credentials,
method,
orgId: actor.orgId
} as TAppConnectionConfig);
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: validatedCredentials,
orgId: actor.orgId,
kmsService
}); });
const validatedCredentials = await validateAppConnectionCredentials({ const connection = await appConnectionDAL.create(
app, {
credentials, orgId: actor.orgId,
method, encryptedCredentials,
orgId: actor.orgId method,
} as TAppConnectionConfig); app,
...params
},
tx
);
const encryptedCredentials = await encryptAppConnectionCredentials({ return {
credentials: validatedCredentials, ...connection,
orgId: actor.orgId, credentials: validatedCredentials
kmsService };
}); });
const appConnection = await appConnectionDAL.create({ return appConnection;
orgId: actor.orgId,
encryptedCredentials,
method,
app,
...params
});
return { ...appConnection, credentials: validatedCredentials };
}; };
const updateAppConnection = async ( const updateAppConnection = async (
@@ -216,44 +239,69 @@ export const appConnectionServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections);
if (params.name && appConnection.name !== params.name) { const updatedAppConnection = await appConnectionDAL.transaction(async (tx) => {
const isConflictingName = Boolean( if (params.name && appConnection.name !== params.name) {
await appConnectionDAL.findOne({ const isConflictingName = Boolean(
name: params.name, await appConnectionDAL.findOne(
orgId: appConnection.orgId {
}) name: params.name,
orgId: appConnection.orgId
},
tx
)
);
if (isConflictingName)
throw new BadRequestError({
message: `An App Connection with the name "${params.name}" already exists`
});
}
let encryptedCredentials: undefined | Buffer;
if (credentials) {
const { app, method } = appConnection as DiscriminativePick<TAppConnectionConfig, "app" | "method">;
if (
!VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[app].safeParse({
method,
credentials
}).success
)
throw new BadRequestError({
message: `Invalid credential format for ${
APP_CONNECTION_NAME_MAP[app]
} Connection with method ${getAppConnectionMethodName(method)}`
});
const validatedCredentials = await validateAppConnectionCredentials({
app,
orgId: actor.orgId,
credentials,
method
} as TAppConnectionConfig);
if (!validatedCredentials)
throw new BadRequestError({ message: "Unable to validate connection - check credentials" });
encryptedCredentials = await encryptAppConnectionCredentials({
credentials: validatedCredentials,
orgId: actor.orgId,
kmsService
});
}
const updatedConnection = await appConnectionDAL.updateById(
connectionId,
{
orgId: actor.orgId,
encryptedCredentials,
...params
},
tx
); );
if (isConflictingName) return updatedConnection;
throw new BadRequestError({
message: `An App Connection with the name "${params.name}" already exists`
});
}
let encryptedCredentials: undefined | Buffer;
if (credentials) {
const validatedCredentials = await validateAppConnectionCredentials({
app: appConnection.app,
credentials,
method: appConnection.method,
orgId: actor.orgId
} as TAppConnectionConfig);
if (!validatedCredentials)
throw new BadRequestError({ message: "Unable to validate connection - check credentials" });
encryptedCredentials = await encryptAppConnectionCredentials({
credentials: validatedCredentials,
orgId: actor.orgId,
kmsService
});
}
const updatedAppConnection = await appConnectionDAL.updateById(connectionId, {
orgId: actor.orgId,
encryptedCredentials,
...params
}); });
return { return {

View File

@@ -0,0 +1,31 @@
import {
TAwsConnection,
TAwsConnectionConfig,
TAwsConnectionInput,
TValidateAwsConnectionCredentials
} from "@app/services/app-connection/aws";
import {
TGitHubConnection,
TGitHubConnectionConfig,
TGitHubConnectionInput,
TValidateGitHubConnectionCredentials
} from "@app/services/app-connection/github";
export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection);
export type TAppConnectionInput = { id: string } & (TAwsConnectionInput | TGitHubConnectionInput);
export type TCreateAppConnectionDTO = Pick<
TAppConnectionInput,
"credentials" | "method" | "name" | "app" | "description"
>;
export type TUpdateAppConnectionDTO = Partial<Omit<TCreateAppConnectionDTO, "method" | "app">> & {
connectionId: string;
};
export type TAppConnectionConfig = TAwsConnectionConfig | TGitHubConnectionConfig;
export type TValidateAppConnectionCredentials =
| TValidateAwsConnectionCredentials
| TValidateGitHubConnectionCredentials;

View File

@@ -2,20 +2,20 @@ import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts";
import AWS from "aws-sdk"; import AWS from "aws-sdk";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { AppConnection } from "@app/lib/app-connections/app-connection-enums";
import { TAwsConnectionConfig } from "@app/lib/app-connections/aws/aws-connection-types";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, InternalServerError } from "@app/lib/errors"; import { BadRequestError, InternalServerError } from "@app/lib/errors";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { AwsConnectionMethod } from "./aws-connection-enums"; import { AwsConnectionMethod } from "./aws-connection-enums";
import { TAwsConnectionConfig } from "./aws-connection-types";
export const getAwsAppConnectionListItem = () => { export const getAwsAppConnectionListItem = () => {
const { INF_APP_CONNECTION_AWS_ACCESS_KEY_ID } = getConfig(); const { INF_APP_CONNECTION_AWS_ACCESS_KEY_ID } = getConfig();
return { return {
name: "AWS", name: "AWS" as const,
app: AppConnection.AWS, app: AppConnection.AWS as const,
methods: Object.values(AwsConnectionMethod), methods: Object.values(AwsConnectionMethod) as [AwsConnectionMethod.AssumeRole, AwsConnectionMethod.AccessKey],
accessKeyId: INF_APP_CONNECTION_AWS_ACCESS_KEY_ID accessKeyId: INF_APP_CONNECTION_AWS_ACCESS_KEY_ID
}; };
}; };

View File

@@ -0,0 +1,82 @@
import { z } from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { AwsConnectionMethod } from "./aws-connection-enums";
export const AwsConnectionAssumeRoleCredentialsSchema = z.object({
roleArn: z.string().trim().min(1, "Role ARN required")
});
export const AwsConnectionAccessTokenCredentialsSchema = z.object({
accessKeyId: z.string().trim().min(1, "Access Key ID required"),
secretAccessKey: z.string().trim().min(1, "Secret Access Key required")
});
const BaseAwsConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AWS) });
export const AwsConnectionSchema = z.intersection(
BaseAwsConnectionSchema,
z.discriminatedUnion("method", [
z.object({
method: z.literal(AwsConnectionMethod.AssumeRole),
credentials: AwsConnectionAssumeRoleCredentialsSchema
}),
z.object({
method: z.literal(AwsConnectionMethod.AccessKey),
credentials: AwsConnectionAccessTokenCredentialsSchema
})
])
);
export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [
BaseAwsConnectionSchema.extend({
method: z.literal(AwsConnectionMethod.AssumeRole),
credentials: AwsConnectionAssumeRoleCredentialsSchema.omit({ roleArn: true })
}),
BaseAwsConnectionSchema.extend({
method: z.literal(AwsConnectionMethod.AccessKey),
credentials: AwsConnectionAccessTokenCredentialsSchema.omit({ secretAccessKey: true })
})
]);
export const ValidateAwsConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections?.CREATE(AppConnection.AWS).method),
credentials: AwsConnectionAssumeRoleCredentialsSchema.describe(AppConnections.CREATE(AppConnection.AWS).credentials)
}),
z.object({
method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections?.CREATE(AppConnection.AWS).method),
credentials: AwsConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AWS).credentials
)
})
]);
export const CreateAwsConnectionSchema = ValidateAwsConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.AWS)
);
export const UpdateAwsConnectionSchema = z
.object({
credentials: z
.union([AwsConnectionAccessTokenCredentialsSchema, AwsConnectionAssumeRoleCredentialsSchema])
.optional()
.describe(AppConnections.UPDATE(AppConnection.AWS).credentials)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AWS));
export const AwsConnectionListItemSchema = z.object({
name: z.literal("AWS"),
app: z.literal(AppConnection.AWS),
// the below is preferable but currently breaks mintlify
// methods: z.tuple([z.literal(AwsConnectionMethod.AssumeRole), z.literal(AwsConnectionMethod.AccessKey)]),
methods: z.nativeEnum(AwsConnectionMethod).array(),
accessKeyId: z.string().optional()
});

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
AwsConnectionSchema,
CreateAwsConnectionSchema,
ValidateAwsConnectionCredentialsSchema
} from "./aws-connection-schemas";
export type TAwsConnection = z.infer<typeof AwsConnectionSchema>;
export type TAwsConnectionInput = z.infer<typeof CreateAwsConnectionSchema> & {
app: AppConnection.AWS;
};
export type TValidateAwsConnectionCredentials = typeof ValidateAwsConnectionCredentialsSchema;
export type TAwsConnectionConfig = DiscriminativePick<TAwsConnectionInput, "method" | "app" | "credentials"> & {
orgId: string;
};

View File

@@ -3,10 +3,10 @@ import { AxiosResponse } from "axios";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors";
import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { AppConnection } from "../app-connection-enums"; import { AppConnection } from "../app-connection-enums";
import { APP_CONNECTION_METHOD_NAME_MAP } from "../maps";
import { GitHubConnectionMethod } from "./github-connection-enums"; import { GitHubConnectionMethod } from "./github-connection-enums";
import { TGitHubConnectionConfig } from "./github-connection-types"; import { TGitHubConnectionConfig } from "./github-connection-types";
@@ -14,9 +14,9 @@ export const getGitHubConnectionListItem = () => {
const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig(); const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig();
return { return {
name: "GitHub", name: "GitHub" as const,
app: AppConnection.GitHub, app: AppConnection.GitHub as const,
methods: Object.values(GitHubConnectionMethod), methods: Object.values(GitHubConnectionMethod) as [GitHubConnectionMethod.App, GitHubConnectionMethod.OAuth],
oauthClientId: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, oauthClientId: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID,
appClientSlug: INF_APP_CONNECTION_GITHUB_APP_SLUG appClientSlug: INF_APP_CONNECTION_GITHUB_APP_SLUG
}; };
@@ -53,7 +53,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
if (!clientId || !clientSecret) { if (!clientId || !clientSecret) {
throw new InternalServerError({ throw new InternalServerError({
message: `GitHub ${APP_CONNECTION_METHOD_NAME_MAP[method]} environment variables have not been configured` message: `GitHub ${getAppConnectionMethodName(method)} environment variables have not been configured`
}); });
} }

View File

@@ -0,0 +1,93 @@
import { z } from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { GitHubConnectionMethod } from "./github-connection-enums";
export const GitHubConnectionOAuthInputCredentialsSchema = z.object({
code: z.string().trim().min(1, "OAuth code required")
});
export const GitHubConnectionAppInputCredentialsSchema = z.object({
code: z.string().trim().min(1, "GitHub App code required"),
installationId: z.string().min(1, "GitHub App Installation ID required")
});
export const GitHubConnectionOAuthOutputCredentialsSchema = z.object({
accessToken: z.string()
});
export const GitHubConnectionAppOutputCredentialsSchema = z.object({
installationId: z.string()
});
export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal(GitHubConnectionMethod.App).describe(AppConnections.CREATE(AppConnection.GitHub).method),
credentials: GitHubConnectionAppInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.GitHub).credentials
)
}),
z.object({
method: z.literal(GitHubConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.GitHub).method),
credentials: GitHubConnectionOAuthInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.GitHub).credentials
)
})
]);
export const CreateGitHubConnectionSchema = ValidateGitHubConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.GitHub)
);
export const UpdateGitHubConnectionSchema = z
.object({
credentials: z
.union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema])
.optional()
.describe(AppConnections.UPDATE(AppConnection.GitHub).credentials)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GitHub));
const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) });
export const GitHubAppConnectionSchema = z.intersection(
BaseGitHubConnectionSchema,
z.discriminatedUnion("method", [
z.object({
method: z.literal(GitHubConnectionMethod.App),
credentials: GitHubConnectionAppOutputCredentialsSchema
}),
z.object({
method: z.literal(GitHubConnectionMethod.OAuth),
credentials: GitHubConnectionOAuthOutputCredentialsSchema
})
])
);
export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [
BaseGitHubConnectionSchema.extend({
method: z.literal(GitHubConnectionMethod.App),
credentials: GitHubConnectionAppOutputCredentialsSchema.omit({ installationId: true })
}),
BaseGitHubConnectionSchema.extend({
method: z.literal(GitHubConnectionMethod.OAuth),
credentials: GitHubConnectionOAuthOutputCredentialsSchema.omit({ accessToken: true })
})
]);
export const GitHubConnectionListItemSchema = z.object({
name: z.literal("GitHub"),
app: z.literal(AppConnection.GitHub),
// the below is preferable but currently breaks mintlify
// methods: z.tuple([z.literal(GitHubConnectionMethod.GitHubApp), z.literal(GitHubConnectionMethod.OAuth)]),
methods: z.nativeEnum(GitHubConnectionMethod).array(),
oauthClientId: z.string().optional(),
appClientSlug: z.string().optional()
});

View File

@@ -3,12 +3,18 @@ import { z } from "zod";
import { DiscriminativePick } from "@app/lib/types"; import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums"; import { AppConnection } from "../app-connection-enums";
import { CreateGitHubConnectionSchema, GitHubAppConnectionSchema } from "./github-connection-schemas"; import {
CreateGitHubConnectionSchema,
export type TGitHubConnectionConfig = DiscriminativePick<TGitHubConnectionInput, "method" | "app" | "credentials">; GitHubAppConnectionSchema,
ValidateGitHubConnectionCredentialsSchema
} from "./github-connection-schemas";
export type TGitHubConnection = z.infer<typeof GitHubAppConnectionSchema>; export type TGitHubConnection = z.infer<typeof GitHubAppConnectionSchema>;
export type TGitHubConnectionInput = z.infer<typeof CreateGitHubConnectionSchema> & { export type TGitHubConnectionInput = z.infer<typeof CreateGitHubConnectionSchema> & {
app: AppConnection.GitHub; app: AppConnection.GitHub;
}; };
export type TValidateGitHubConnectionCredentials = typeof ValidateGitHubConnectionCredentialsSchema;
export type TGitHubConnectionConfig = DiscriminativePick<TGitHubConnectionInput, "method" | "app" | "credentials">;

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

View File

@@ -68,37 +68,69 @@ Infisical supports two methods for connecting to AWS.
<Tabs> <Tabs>
<Tab title="Secrets Sync"> <Tab title="Secrets Sync">
Add the **SecretsManagerReadWrite** policy to your IAM Role. <AccordionGroup>
<Accordion title="AWS Secrets Manager">
Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager:
![IAM Role Permissions](/images/integrations/aws/integration-aws-iam-assume-permission.png) ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/secrets-manager-permissions.png)
Alternatively, use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: ```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowSecretsManagerAccess",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:CreateSecret",
"secretsmanager:UpdateSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"kms:ListKeys",
"kms:ListAliases",
"kms:Encrypt",
"kms:Decrypt"
],
"Resource": "*"
}
]
}
```
</Accordion>
<Accordion title="AWS Paramter Store">
Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store:
```json ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/parameter-store-permissions.png)
{
"Version": "2012-10-17", ```json
"Statement": [ {
{ "Version": "2012-10-17",
"Sid": "AllowSSMAccess", "Statement": [
"Effect": "Allow", {
"Action": [ "Sid": "AllowSSMAccess",
"ssm:PutParameter", "Effect": "Allow",
"ssm:DeleteParameter", "Action": [
"ssm:GetParameters", "ssm:PutParameter",
"ssm:GetParametersByPath", "ssm:DeleteParameter",
"ssm:DescribeParameters", "ssm:GetParameters",
"ssm:DeleteParameters", "ssm:GetParametersByPath",
"ssm:AddTagsToResource", // if you need to add tags to secrets "ssm:DescribeParameters",
"kms:ListKeys", // if you need to specify the KMS key "ssm:DeleteParameters",
"kms:ListAliases", // if you need to specify the KMS key "ssm:AddTagsToResource", // if you need to add tags to secrets
"kms:Encrypt", // if you need to specify the KMS key "kms:ListKeys", // if you need to specify the KMS key
"kms:Decrypt" // if you need to specify the KMS key "kms:ListAliases", // if you need to specify the KMS key
], "kms:Encrypt", // if you need to specify the KMS key
"Resource": "*" "kms:Decrypt" // if you need to specify the KMS key
} ],
] "Resource": "*"
} }
``` ]
}
```
</Accordion>
</AccordionGroup>
</Tab> </Tab>
</Tabs> </Tabs>
</Step> </Step>
@@ -186,36 +218,69 @@ Infisical supports two methods for connecting to AWS.
<Tabs> <Tabs>
<Tab title="Secrets Sync"> <Tab title="Secrets Sync">
Add the **SecretsManagerReadWrite** policy to your IAM Role. <AccordionGroup>
<Accordion title="AWS Secrets Manager">
Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager:
![IAM Role Permissions](/images/integrations/aws/integration-aws-iam-assume-permission.png) ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/secrets-manager-permissions.png)
Alternatively, use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store:
```json ```json
{ {
"Version": "2012-10-17", "Version": "2012-10-17",
"Statement": [ "Statement": [
{ {
"Sid": "AllowSSMAccess", "Sid": "AllowSecretsManagerAccess",
"Effect": "Allow", "Effect": "Allow",
"Action": [ "Action": [
"ssm:PutParameter", "secretsmanager:GetSecretValue",
"ssm:DeleteParameter", "secretsmanager:CreateSecret",
"ssm:GetParameters", "secretsmanager:UpdateSecret",
"ssm:GetParametersByPath", "secretsmanager:DescribeSecret",
"ssm:DescribeParameters", "secretsmanager:TagResource",
"ssm:DeleteParameters", "secretsmanager:UntagResource",
"ssm:AddTagsToResource", // if you need to add tags to secrets "kms:ListKeys",
"kms:ListKeys", // if you need to specify the KMS key "kms:ListAliases",
"kms:ListAliases", // if you need to specify the KMS key "kms:Encrypt",
"kms:Encrypt", // if you need to specify the KMS key "kms:Decrypt"
"kms:Decrypt" // if you need to specify the KMS key ],
], "Resource": "*"
"Resource": "*" }
} ]
] }
} ```
``` </Accordion>
<Accordion title="AWS Paramter Store">
Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store:
![IAM Role Secrets Manager Permissions](/images/app-connections/aws/parameter-store-permissions.png)
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowSSMAccess",
"Effect": "Allow",
"Action": [
"ssm:PutParameter",
"ssm:DeleteParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath",
"ssm:DescribeParameters",
"ssm:DeleteParameters",
"ssm:AddTagsToResource", // if you need to add tags to secrets
"kms:ListKeys", // if you need to specify the KMS key
"kms:ListAliases", // if you need to specify the KMS key
"kms:Encrypt", // if you need to specify the KMS key
"kms:Decrypt" // if you need to specify the KMS key
],
"Resource": "*"
}
]
}
```
</Accordion>
</AccordionGroup>
</Tab> </Tab>
</Tabs> </Tabs>
</Step> </Step>

View File

@@ -104,6 +104,42 @@ Infisical supports two methods for connecting to GitHub.
- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - Set up and add envars to [Infisical Cloud](https://app.infisical.com)
<Accordion title="Self-Hosted Instance">
Using the GitHub integration on a self-hosted instance of Infisical requires configuring an OAuth application in GitHub
and registering your instance with it.
<Steps>
<Step title="Create an OAuth application in GitHub">
Navigate to your user Settings > Developer settings > OAuth Apps to create a new GitHub OAuth application.
![integrations github config](../../images/integrations/github/integrations-github-config-settings.png)
![integrations github config](../../images/integrations/github/integrations-github-config-dev-settings.png)
![integrations github config](../../images/integrations/github/integrations-github-config-new-app.png)
Create the OAuth application. As part of the form, set the **Homepage URL** to your self-hosted domain `https://your-domain.com`
and the **Authorization callback URL** to `https://your-domain.com/app-connections/github/oauth/callback`.
![integrations github config](../../images/integrations/github/integrations-github-config-new-app-form.png)
<Note>
If you have a GitHub organization, you can create an OAuth application under it
in your organization Settings > Developer settings > OAuth Apps > New Org OAuth App.
</Note>
</Step>
<Step title="Add your OAuth application credentials to Infisical">
Obtain the **Client ID** and generate a new **Client Secret** for your GitHub OAuth application.
![integrations github config](../../images/integrations/github/integrations-github-config-credentials.png)
Back in your Infisical instance, add two new environment variables for the credentials of your GitHub OAuth application:
- `INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID`: The **Client ID** of your GitHub OAuth application.
- `INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET`: The **Client Secret** of your GitHub OAuth application.
Once added, restart your Infisical instance and use the GitHub integration.
</Step>
</Steps>
</Accordion>
## Setup GitHub Connection in Infisical ## Setup GitHub Connection in Infisical
<Steps> <Steps>

View File

@@ -17,17 +17,25 @@ that can be used across Infisical projects. Example use cases include syncing se
```mermaid ```mermaid
%%{init: {'flowchart': {'curve': 'linear'} } }%% %%{init: {'flowchart': {'curve': 'linear'} } }%%
graph TD graph TD
A[AWS Connection] A[AWS]
A --> B[Project 1 Secret Sync] B[AWS Connection]
A --> C[Project 2 Secret Sync] C[Project 1 Secret Sync]
A --> D[Project 3 Generate Dynamic Secret] D[Project 2 Secret Sync]
E[Project 3 Generate Dynamic Secret]
B --> A
C --> B
D --> B
E --> B
classDef default fill:#ffffff,stroke:#666,stroke-width:2px,rx:10px,color:black classDef default fill:#ffffff,stroke:#666,stroke-width:2px,rx:10px,color:black
classDef aws fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:15px classDef aws fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:15px
classDef project fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px classDef project fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px
classDef connection fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px
class A aws class A aws
class B,C,D project class B connection
class C,D,E project
``` ```
</div> </div>

View File

@@ -3,6 +3,7 @@ import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Modal, ModalContent, ModalTrigger, Select, SelectItem } from "@app/components/v2"; import { Modal, ModalContent, ModalTrigger, Select, SelectItem } from "@app/components/v2";
import { isInfisicalCloud } from "@app/helpers/platform";
enum Region { enum Region {
US = "us", US = "us",
@@ -79,10 +80,7 @@ export const RegionSelect = () => {
}; };
const shouldDisplay = const shouldDisplay =
window.location.origin.includes("https://app.infisical.com") || isInfisicalCloud() || window.location.origin.includes("http://localhost:8080");
window.location.origin.includes("https://us.infisical.com") ||
window.location.origin.includes("https://eu.infisical.com") ||
window.location.origin.includes("http://localhost:8080");
// only display region select for cloud // only display region select for cloud
if (!shouldDisplay) return null; if (!shouldDisplay) return null;

View File

@@ -1,22 +1,29 @@
import { faGithub, IconDefinition } from "@fortawesome/free-brands-svg-icons"; import { faGithub } from "@fortawesome/free-brands-svg-icons";
import { faKey, faPassport, faUser } from "@fortawesome/free-solid-svg-icons"; import { faKey, faPassport, faUser } from "@fortawesome/free-solid-svg-icons";
import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TAppConnection } from "@app/hooks/api/appConnections/types"; import {
import { AwsConnectionMethod } from "@app/hooks/api/appConnections/types/aws-connection"; AwsConnectionMethod,
import { GitHubConnectionMethod } from "@app/hooks/api/appConnections/types/github-connection"; GitHubConnectionMethod,
TAppConnection
} from "@app/hooks/api/appConnections/types";
export const APP_CONNECTION_MAP: Record<AppConnection, { name: string; image: string }> = { export const APP_CONNECTION_MAP: Record<AppConnection, { name: string; image: string }> = {
[AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" },
[AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" } [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" }
}; };
export const APP_CONNECTION_METHOD_MAP: Record< export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => {
TAppConnection["method"], switch (method) {
{ name: string; icon: IconDefinition } case GitHubConnectionMethod.App:
> = { return { name: "GitHub App", icon: faGithub };
[AwsConnectionMethod.AssumeRole]: { name: "Assume Role", icon: faUser }, case GitHubConnectionMethod.OAuth:
[AwsConnectionMethod.AccessKey]: { name: "Access Key", icon: faKey }, return { name: "OAuth", icon: faPassport };
[GitHubConnectionMethod.App]: { name: "GitHub App", icon: faGithub }, case AwsConnectionMethod.AccessKey:
[GitHubConnectionMethod.OAuth]: { name: "OAuth", icon: faPassport } return { name: "Access Key", icon: faKey };
case AwsConnectionMethod.AssumeRole:
return { name: "Assume Role", icon: faUser };
default:
throw new Error(`Unhandled App Connection Method: ${method}`);
}
}; };

View File

@@ -0,0 +1,4 @@
export const isInfisicalCloud = () =>
window.location.origin.includes("https://app.infisical.com") ||
window.location.origin.includes("https://us.infisical.com") ||
window.location.origin.includes("https://eu.infisical.com");

View File

@@ -15,10 +15,12 @@ export type TAppConnectionResponse = { appConnection: TAppConnection };
export type TCreateAppConnectionDTO = Pick< export type TCreateAppConnectionDTO = Pick<
TAppConnection, TAppConnection,
"name" | "credentials" | "method" | "app" "name" | "credentials" | "method" | "app" | "description"
>; >;
export type TUpdateAppConnectionDTO = Partial<Pick<TAppConnection, "name" | "credentials">> & { export type TUpdateAppConnectionDTO = Partial<
Pick<TAppConnection, "name" | "credentials" | "description">
> & {
connectionId: string; connectionId: string;
app: AppConnection; app: AppConnection;
}; };

View File

@@ -1,6 +1,7 @@
export type TRootAppConnection = { export type TRootAppConnection = {
id: string; id: string;
name: string; name: string;
description?: string | null;
version: number; version: number;
orgId: string; orgId: string;
createdAt: string; createdAt: string;

View File

@@ -13,7 +13,7 @@ import {
} from "@app/hooks/api/appConnections"; } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AppConnection } from "@app/hooks/api/appConnections/enums";
type FormData = Pick<TGitHubConnection, "name" | "method"> & { type FormData = Pick<TGitHubConnection, "name" | "method" | "description"> & {
returnUrl?: string; returnUrl?: string;
connectionId?: string; connectionId?: string;
}; };
@@ -58,7 +58,7 @@ export default function GitHubOAuthCallbackPage() {
localStorage.removeItem("githubConnectionFormData"); localStorage.removeItem("githubConnectionFormData");
localStorage.removeItem("latestCSRFToken"); localStorage.removeItem("latestCSRFToken");
const { connectionId, name, returnUrl } = formData; const { connectionId, name, description, returnUrl } = formData;
let appConnection: TAppConnection; let appConnection: TAppConnection;
@@ -85,6 +85,7 @@ export default function GitHubOAuthCallbackPage() {
appConnection = await createAppConnection.mutateAsync({ appConnection = await createAppConnection.mutateAsync({
app: AppConnection.GitHub, app: AppConnection.GitHub,
name, name,
description,
...(installationId ...(installationId
? { ? {
method: GitHubConnectionMethod.App, method: GitHubConnectionMethod.App,

View File

@@ -65,7 +65,7 @@ export const AppConnectionsTab = withPermission(
</div> </div>
<OrgPermissionCan <OrgPermissionCan
I={OrgPermissionActions.Create} I={OrgPermissionActions.Create}
a={OrgPermissionSubjects.ProjectTemplates} a={OrgPermissionSubjects.AppConnections}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button

View File

@@ -1,4 +1,4 @@
import { Controller, useForm } from "react-hook-form"; import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -11,18 +11,21 @@ import {
Select, Select,
SelectItem SelectItem
} from "@app/components/v2"; } from "@app/components/v2";
import { APP_CONNECTION_MAP, APP_CONNECTION_METHOD_MAP } from "@app/helpers/appConnections"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { AwsConnectionMethod, TAwsConnection } from "@app/hooks/api/appConnections"; import { AwsConnectionMethod, TAwsConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { slugSchema } from "@app/lib/schemas";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = { type Props = {
appConnection?: TAwsConnection; appConnection?: TAwsConnection;
onSubmit: (formData: FormData) => void; onSubmit: (formData: FormData) => void;
}; };
const rootSchema = z.object({ const rootSchema = genericAppConnectionFieldsSchema.extend({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
app: z.literal(AppConnection.AWS) app: z.literal(AppConnection.AWS)
}); });
@@ -30,14 +33,14 @@ const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({ rootSchema.extend({
method: z.literal(AwsConnectionMethod.AssumeRole), method: z.literal(AwsConnectionMethod.AssumeRole),
credentials: z.object({ credentials: z.object({
roleArn: z.string().min(1, "Role ARN required") roleArn: z.string().trim().min(1, "Role ARN required")
}) })
}), }),
rootSchema.extend({ rootSchema.extend({
method: z.literal(AwsConnectionMethod.AccessKey), method: z.literal(AwsConnectionMethod.AccessKey),
credentials: z.object({ credentials: z.object({
accessKeyId: z.string().min(1, "Access Key ID required"), accessKeyId: z.string().trim().min(1, "Access Key ID required"),
secretAccessKey: z.string().min(1, "Secret Access Key required") secretAccessKey: z.string().trim().min(1, "Secret Access Key required")
}) })
}) })
]); ]);
@@ -47,13 +50,7 @@ type FormData = z.infer<typeof formSchema>;
export const AwsConnectionForm = ({ appConnection, onSubmit }: Props) => { export const AwsConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection); const isUpdate = Boolean(appConnection);
const { const form = useForm<FormData>({
handleSubmit,
register,
control,
watch,
formState: { isSubmitting, errors, isDirty }
} = useForm<FormData>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: appConnection ?? { defaultValues: appConnection ?? {
app: AppConnection.AWS, app: AppConnection.AWS,
@@ -61,105 +58,61 @@ export const AwsConnectionForm = ({ appConnection, onSubmit }: Props) => {
} }
}); });
const {
handleSubmit,
control,
watch,
formState: { isSubmitting, isDirty }
} = form;
const selectedMethod = watch("method"); const selectedMethod = watch("method");
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <FormProvider {...form}>
{!isUpdate && ( <form onSubmit={handleSubmit(onSubmit)}>
<FormControl {!isUpdate && <GenericAppConnectionsFields />}
helperText="Name must be slug-friendly"
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Name"
>
<Input
autoFocus
placeholder={`my-${AppConnection.AWS}-connection`}
{...register("name")}
/>
</FormControl>
)}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.AWS].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(AwsConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{APP_CONNECTION_METHOD_MAP[method].name}{" "}
{method === AwsConnectionMethod.AssumeRole ? " (Recommended)" : ""}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
{selectedMethod === AwsConnectionMethod.AssumeRole ? (
<Controller <Controller
name="credentials.roleArn" name="method"
control={control} control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.AWS].name
}. This field cannot be changed after creation.`}
errorText={error?.message} errorText={error?.message}
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
label="Role ARN" label="Method"
className="group"
> >
<SecretInput <Select
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5" isDisabled={isUpdate}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onValueChange={(val) => onChange(val)}
/> className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(AwsConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
{method === AwsConnectionMethod.AssumeRole ? " (Recommended)" : ""}
</SelectItem>
);
})}
</Select>
</FormControl> </FormControl>
)} )}
/> />
) : ( {selectedMethod === AwsConnectionMethod.AssumeRole ? (
<>
<Controller <Controller
name="credentials.accessKeyId" name="credentials.roleArn"
control={control} control={control}
shouldUnregister shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
errorText={error?.message} errorText={error?.message}
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
label="Access Key ID" label="Role ARN"
>
<Input
placeholder={"*".repeat(20)}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
name="credentials.secretAccessKey"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Secret Access Key"
className="group" className="group"
> >
<SecretInput <SecretInput
@@ -170,25 +123,65 @@ export const AwsConnectionForm = ({ appConnection, onSubmit }: Props) => {
</FormControl> </FormControl>
)} )}
/> />
</> ) : (
)} <>
<div className="mt-8 flex items-center"> <Controller
<Button name="credentials.accessKeyId"
className="mr-4" control={control}
size="sm" shouldUnregister
type="submit" render={({ field: { value, onChange }, fieldState: { error } }) => (
colorSchema="secondary" <FormControl
isLoading={isSubmitting} errorText={error?.message}
isDisabled={isSubmitting || !isDirty} isError={Boolean(error?.message)}
> label="Access Key ID"
{isUpdate ? "Update Credentials" : "Connect to AWS"} >
</Button> <Input
<ModalClose asChild> placeholder={"*".repeat(20)}
<Button colorSchema="secondary" variant="plain"> value={value}
Cancel onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
name="credentials.secretAccessKey"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Secret Access Key"
className="group"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
</>
)}
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to AWS"}
</Button> </Button>
</ModalClose> <ModalClose asChild>
</div> <Button colorSchema="secondary" variant="plain">
</form> Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
); );
}; };

View File

@@ -0,0 +1,42 @@
import { useFormContext } from "react-hook-form";
import { z } from "zod";
import { FormControl, Input, TextArea } from "@app/components/v2";
import { slugSchema } from "@app/lib/schemas";
export const genericAppConnectionFieldsSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish()
});
export const GenericAppConnectionsFields = () => {
const {
register,
formState: { errors }
} = useFormContext<{ name: string; description?: string | null }>();
return (
<>
<FormControl
helperText="Name must be slug-friendly"
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Name"
>
<Input autoFocus placeholder="my-app-connection" {...register("name")} />
</FormControl>
<FormControl
errorText={errors.description?.message}
isError={Boolean(errors.description?.message)}
label="Description"
isOptional
>
<TextArea
className="h-20 !resize-none"
placeholder="Connection description..."
{...register("description")}
/>
</FormControl>
</>
);
};

View File

@@ -1,38 +1,34 @@
import crypto from "crypto"; import crypto from "crypto";
import { useState } from "react"; import { useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2"; import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
import { APP_CONNECTION_MAP, APP_CONNECTION_METHOD_MAP } from "@app/helpers/appConnections"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { isInfisicalCloud } from "@app/helpers/platform";
import { import {
GitHubConnectionMethod, GitHubConnectionMethod,
TGitHubConnection, TGitHubConnection,
useGetAppConnectionOption useGetAppConnectionOption
} from "@app/hooks/api/appConnections"; } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { slugSchema } from "@app/lib/schemas";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = { type Props = {
appConnection?: TGitHubConnection; appConnection?: TGitHubConnection;
}; };
const rootSchema = z.object({ const formSchema = genericAppConnectionFieldsSchema.extend({
name: slugSchema({ min: 1, max: 32, field: "Name" }), app: z.literal(AppConnection.GitHub),
app: z.literal(AppConnection.GitHub) method: z.nativeEnum(GitHubConnectionMethod)
}); });
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(GitHubConnectionMethod.App)
}),
rootSchema.extend({
method: z.literal(GitHubConnectionMethod.OAuth)
})
]);
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
export const GitHubConnectionForm = ({ appConnection }: Props) => { export const GitHubConnectionForm = ({ appConnection }: Props) => {
@@ -44,13 +40,7 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => {
isLoading isLoading
} = useGetAppConnectionOption(AppConnection.GitHub); } = useGetAppConnectionOption(AppConnection.GitHub);
const { const form = useForm<FormData>({
handleSubmit,
register,
control,
watch,
formState: { isSubmitting, errors, isDirty }
} = useForm<FormData>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: appConnection ?? { defaultValues: appConnection ?? {
app: AppConnection.GitHub, app: AppConnection.GitHub,
@@ -58,6 +48,13 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => {
} }
}); });
const {
handleSubmit,
control,
watch,
formState: { isSubmitting, isDirty }
} = form;
const selectedMethod = watch("method"); const selectedMethod = watch("method");
const onSubmit = (formData: FormData) => { const onSubmit = (formData: FormData) => {
@@ -98,75 +95,70 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => {
throw new Error(`Unhandled GitHub Connection method: ${selectedMethod}`); throw new Error(`Unhandled GitHub Connection method: ${selectedMethod}`);
} }
const methodDetails = getAppConnectionMethodDetails(selectedMethod);
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <FormProvider {...form}>
{!isUpdate && ( <form onSubmit={handleSubmit(onSubmit)}>
<FormControl {!isUpdate && <GenericAppConnectionsFields />}
helperText="Name must be slug-friendly" <Controller
errorText={errors.name?.message} name="method"
isError={Boolean(errors.name?.message)} control={control}
label="Name" render={({ field: { value, onChange }, fieldState: { error } }) => (
> <FormControl
<Input tooltipText={`The method you would like to use to connect with ${
autoFocus APP_CONNECTION_MAP[AppConnection.GitHub].name
placeholder={`my-${AppConnection.GitHub}-connection`} }. This field cannot be changed after creation.`}
{...register("name")} errorText={
/> !isLoading && isMissingConfig
</FormControl> ? `Environment variables have not been configured. ${
)} isInfisicalCloud()
<Controller ? "Please contact Infisical."
name="method" : `See Docs to configure GitHub ${methodDetails.name} Connections.`
control={control} }`
render={({ field: { value, onChange }, fieldState: { error } }) => ( : error?.message
<FormControl }
tooltipText={`The method you would like to use to connect with ${ isError={Boolean(error?.message) || isMissingConfig}
APP_CONNECTION_MAP[AppConnection.GitHub].name label="Method"
}. This field cannot be changed after creation.`}
errorText={
!isLoading && isMissingConfig
? `Environment variables have not been configured. See Docs to configure GitHub ${APP_CONNECTION_METHOD_MAP[selectedMethod].name} Connections.`
: error?.message
}
isError={Boolean(error?.message) || isMissingConfig}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
> >
{Object.values(GitHubConnectionMethod).map((method) => { <Select
return ( isDisabled={isUpdate}
<SelectItem value={method} key={method}> value={value}
{APP_CONNECTION_METHOD_MAP[method].name}{" "} onValueChange={(val) => onChange(val)}
{method === GitHubConnectionMethod.App ? " (Recommended)" : ""} className="w-full border border-mineshaft-500"
</SelectItem> position="popper"
); dropdownContainerClassName="max-w-none"
})} >
</Select> {Object.values(GitHubConnectionMethod).map((method) => {
</FormControl> return (
)} <SelectItem value={method} key={method}>
/> {methodDetails.name}{" "}
<div className="mt-8 flex items-center"> {method === GitHubConnectionMethod.App ? " (Recommended)" : ""}
<Button </SelectItem>
className="mr-4" );
size="sm" })}
type="submit" </Select>
colorSchema="secondary" </FormControl>
isLoading={isSubmitting || isRedirecting} )}
isDisabled={isSubmitting || (!isUpdate && !isDirty) || isMissingConfig || isRedirecting} />
> <div className="mt-8 flex items-center">
{isUpdate ? "Reconnect to GitHub" : "Connect to GitHub"} <Button
</Button> className="mr-4"
<ModalClose asChild> size="sm"
<Button colorSchema="secondary" variant="plain"> type="submit"
Cancel colorSchema="secondary"
isLoading={isSubmitting || isRedirecting}
isDisabled={isSubmitting || (!isUpdate && !isDirty) || isMissingConfig || isRedirecting}
>
{isUpdate ? "Reconnect to GitHub" : "Connect to GitHub"}
</Button> </Button>
</ModalClose> <ModalClose asChild>
</div> <Button colorSchema="secondary" variant="plain">
</form> Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
); );
}; };

View File

@@ -1 +1,2 @@
export * from "./AppConnectionForm"; export * from "./AppConnectionForm";
export * from "./GenericAppConnectionFields";

View File

@@ -24,10 +24,7 @@ export const AppConnectionHeader = ({ app, isConnected, onBack }: Props) => {
<div> <div>
<div className="flex items-center text-mineshaft-300"> <div className="flex items-center text-mineshaft-300">
{appDetails.name} {appDetails.name}
<Link <Link href={`https://infisical.com/docs/integrations/app-connections/${app}`} passHref>
href={`https://infisical.com/docs/documentation/platform/app-connections/${app}`}
passHref
>
<a target="_blank" className="ml-1 mb-1" rel="noopener noreferrer"> <a target="_blank" className="ml-1 mb-1" rel="noopener noreferrer">
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100"> <div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1 mb-[0.03rem] text-[12px]" /> <FontAwesomeIcon icon={faBookOpen} className="mr-1 mb-[0.03rem] text-[12px]" />

View File

@@ -45,8 +45,34 @@ export const AppConnectionsSelect = ({ onSelect }: Props) => {
))} ))}
<Tooltip <Tooltip
side="bottom" side="bottom"
className="text-center" className="max-w-sm py-4"
content="Infisical is busy adding support for more connections. Check back soon if you don't see the one you're looking for." content={
<>
<p className="mb-2">Infisical is constantly adding support for more connections.</p>
<p>
{`If you don't see the third-party
app you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
> >
<div className="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4"> <div className="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4">
<FontAwesomeIcon className="mt-auto text-xl" icon={faWrench} /> <FontAwesomeIcon className="mt-auto text-xl" icon={faWrench} />

View File

@@ -5,6 +5,7 @@ import {
faCopy, faCopy,
faEdit, faEdit,
faEllipsisV, faEllipsisV,
faInfoCircle,
faTrash faTrash
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -23,7 +24,7 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { APP_CONNECTION_MAP, APP_CONNECTION_METHOD_MAP } from "@app/helpers/appConnections"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { TAppConnection } from "@app/hooks/api/appConnections"; import { TAppConnection } from "@app/hooks/api/appConnections";
@@ -31,16 +32,16 @@ type Props = {
appConnection: TAppConnection; appConnection: TAppConnection;
onDelete: (appConnection: TAppConnection) => void; onDelete: (appConnection: TAppConnection) => void;
onEditCredentials: (appConnection: TAppConnection) => void; onEditCredentials: (appConnection: TAppConnection) => void;
onEditName: (appConnection: TAppConnection) => void; onEditDetails: (appConnection: TAppConnection) => void;
}; };
export const AppConnectionRow = ({ export const AppConnectionRow = ({
appConnection, appConnection,
onDelete, onDelete,
onEditCredentials, onEditCredentials,
onEditName onEditDetails
}: Props) => { }: Props) => {
const { id, name, method, app } = appConnection; const { id, name, method, app, description } = appConnection;
const [isIdCopied, setIsIdCopied] = useToggle(false); const [isIdCopied, setIsIdCopied] = useToggle(false);
@@ -59,6 +60,8 @@ export const AppConnectionRow = ({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [isIdCopied]); }, [isIdCopied]);
const methodDetails = getAppConnectionMethodDetails(method);
return ( return (
<Tr <Tr
className={twMerge("group h-12 transition-colors duration-100 hover:bg-mineshaft-700")} className={twMerge("group h-12 transition-colors duration-100 hover:bg-mineshaft-700")}
@@ -75,16 +78,23 @@ export const AppConnectionRow = ({
</div> </div>
</Td> </Td>
<Td className="!min-w-[8rem] max-w-0"> <Td className="!min-w-[8rem] max-w-0">
<p className="truncate">{name}</p> <div className="flex w-full items-center">
<p className="truncate">{name}</p>
{description && (
<Tooltip content={description}>
<FontAwesomeIcon icon={faInfoCircle} className="ml-1 text-mineshaft-400" />
</Tooltip>
)}
</div>
</Td> </Td>
<Td className="!min-w-[8rem] max-w-0"> <Td className="!min-w-[8rem] max-w-0">
<p className="truncate"> <p className="truncate">
<FontAwesomeIcon <FontAwesomeIcon
size="sm" size="sm"
className="mr-1.5 text-mineshaft-300/75" className="mr-1.5 text-mineshaft-300/75"
icon={APP_CONNECTION_METHOD_MAP[method].icon} icon={methodDetails.icon}
/> />
{APP_CONNECTION_METHOD_MAP[method].name} {methodDetails.name}
</p> </p>
</Td> </Td>
@@ -116,9 +126,9 @@ export const AppConnectionRow = ({
<DropdownMenuItem <DropdownMenuItem
isDisabled={!isAllowed} isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />} icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => onEditName(appConnection)} onClick={() => onEditDetails(appConnection)}
> >
Edit Name Edit Details
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</OrgPermissionCan> </OrgPermissionCan>

View File

@@ -30,7 +30,7 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useSubscription } from "@app/context"; import { useSubscription } from "@app/context";
import { APP_CONNECTION_MAP, APP_CONNECTION_METHOD_MAP } from "@app/helpers/appConnections"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { TAppConnection, useListAppConnections } from "@app/hooks/api/appConnections"; import { TAppConnection, useListAppConnections } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AppConnection } from "@app/hooks/api/appConnections/enums";
@@ -39,7 +39,7 @@ import { OrderByDirection } from "@app/hooks/api/generic/types";
import { AppConnectionRow } from "./AppConnectionRow"; import { AppConnectionRow } from "./AppConnectionRow";
import { DeleteAppConnectionModal } from "./DeleteAppConnectionModal"; import { DeleteAppConnectionModal } from "./DeleteAppConnectionModal";
import { EditAppConnectionCredentialsModal } from "./EditAppConnectionCredentialsModal"; import { EditAppConnectionCredentialsModal } from "./EditAppConnectionCredentialsModal";
import { EditAppConnectionNameModal } from "./EditAppConnectionNameModal"; import { EditAppConnectionDetailsModal } from "./EditAppConnectionDetailsModal";
enum AppConnectionsOrderBy { enum AppConnectionsOrderBy {
App = "app", App = "app",
@@ -61,7 +61,7 @@ export const AppConnectionsTable = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"deleteConnection", "deleteConnection",
"editCredentials", "editCredentials",
"editName" "editDetails"
] as const); ] as const);
const [filters, setFilters] = useState<AppConnectionFilters>({ const [filters, setFilters] = useState<AppConnectionFilters>({
@@ -95,7 +95,7 @@ export const AppConnectionsTable = () => {
return ( return (
APP_CONNECTION_MAP[app].name.toLowerCase().includes(searchValue) || APP_CONNECTION_MAP[app].name.toLowerCase().includes(searchValue) ||
APP_CONNECTION_METHOD_MAP[method].name.toLowerCase().includes(searchValue) || getAppConnectionMethodDetails(method).name.toLowerCase().includes(searchValue) ||
name.toLowerCase().includes(searchValue) name.toLowerCase().includes(searchValue)
); );
}) })
@@ -109,9 +109,11 @@ export const AppConnectionsTable = () => {
.toLowerCase() .toLowerCase()
.localeCompare(connectionTwo.name.toLowerCase()); .localeCompare(connectionTwo.name.toLowerCase());
case AppConnectionsOrderBy.Method: case AppConnectionsOrderBy.Method:
return APP_CONNECTION_METHOD_MAP[connectionOne.method].name return getAppConnectionMethodDetails(connectionOne.method)
.toLowerCase() .name.toLowerCase()
.localeCompare(APP_CONNECTION_METHOD_MAP[connectionTwo.method].name.toLowerCase()); .localeCompare(
getAppConnectionMethodDetails(connectionTwo.method).name.toLowerCase()
);
case AppConnectionsOrderBy.App: case AppConnectionsOrderBy.App:
default: default:
return APP_CONNECTION_MAP[connectionOne.app].name return APP_CONNECTION_MAP[connectionOne.app].name
@@ -152,8 +154,8 @@ export const AppConnectionsTable = () => {
const handleEditCredentials = (appConnection: TAppConnection) => const handleEditCredentials = (appConnection: TAppConnection) =>
handlePopUpOpen("editCredentials", appConnection); handlePopUpOpen("editCredentials", appConnection);
const handleEditName = (appConnection: TAppConnection) => const handleEditDetails = (appConnection: TAppConnection) =>
handlePopUpOpen("editName", appConnection); handlePopUpOpen("editDetails", appConnection);
return ( return (
<div> <div>
@@ -274,7 +276,7 @@ export const AppConnectionsTable = () => {
key={connection.id} key={connection.id}
onDelete={handleDelete} onDelete={handleDelete}
onEditCredentials={handleEditCredentials} onEditCredentials={handleEditCredentials}
onEditName={handleEditName} onEditDetails={handleEditDetails}
/> />
))} ))}
</TBody> </TBody>
@@ -309,10 +311,10 @@ export const AppConnectionsTable = () => {
onOpenChange={(isOpen) => handlePopUpToggle("editCredentials", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("editCredentials", isOpen)}
appConnection={popUp.editCredentials.data} appConnection={popUp.editCredentials.data}
/> />
<EditAppConnectionNameModal <EditAppConnectionDetailsModal
isOpen={popUp.editName.isOpen} isOpen={popUp.editDetails.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("editName", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("editDetails", isOpen)}
appConnection={popUp.editName.data} appConnection={popUp.editDetails.data}
/> />
</div> </div>
); );

View File

@@ -1,23 +1,23 @@
import { useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalClose, ModalContent } from "@app/components/v2"; import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { TAppConnection, useUpdateAppConnection } from "@app/hooks/api/appConnections"; import { TAppConnection, useUpdateAppConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { slugSchema } from "@app/lib/schemas";
import { DiscriminativePick } from "@app/lib/types"; import { DiscriminativePick } from "@app/lib/types";
import { genericAppConnectionFieldsSchema, GenericAppConnectionsFields } from "./AppConnectionForm";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
appConnection?: TAppConnection; appConnection?: TAppConnection;
}; };
const formSchema = z.object({ const formSchema = genericAppConnectionFieldsSchema.extend({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
app: z.nativeEnum(AppConnection) app: z.nativeEnum(AppConnection)
}); });
@@ -29,14 +29,19 @@ const Content = ({ appConnection, onComplete }: ContentProps) => {
const updateAppConnection = useUpdateAppConnection(); const updateAppConnection = useUpdateAppConnection();
const { name: appName } = APP_CONNECTION_MAP[appConnection.app]; const { name: appName } = APP_CONNECTION_MAP[appConnection.app];
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: appConnection.name,
app: appConnection.app,
description: appConnection.description
}
});
const { const {
handleSubmit, handleSubmit,
register, formState: { isSubmitting, isDirty }
formState: { isSubmitting, errors, isDirty } } = form;
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: { name: appConnection.name, app: appConnection.app }
});
const onSubmit = async (formData: DiscriminativePick<TAppConnection, "name" | "app">) => { const onSubmit = async (formData: DiscriminativePick<TAppConnection, "name" | "app">) => {
try { try {
@@ -60,38 +65,32 @@ const Content = ({ appConnection, onComplete }: ContentProps) => {
}; };
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <FormProvider {...form}>
<FormControl <form onSubmit={handleSubmit(onSubmit)}>
helperText="Name must be slug-friendly" <GenericAppConnectionsFields />
errorText={errors.name?.message} <div className="mt-8 flex items-center">
isError={Boolean(errors.name?.message)} <Button
label="Name" className="mr-4"
> size="sm"
<Input autoFocus placeholder={`my-${AppConnection.AWS}-connection`} {...register("name")} /> type="submit"
</FormControl> colorSchema="secondary"
isLoading={isSubmitting}
<div className="mt-8 flex items-center"> isDisabled={isSubmitting || !isDirty}
<Button >
className="mr-4" Update Details
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Update Name
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button> </Button>
</ModalClose> <ModalClose asChild>
</div> <Button colorSchema="secondary" variant="plain">
</form> Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
); );
}; };
export const EditAppConnectionNameModal = ({ isOpen, onOpenChange, appConnection }: Props) => { export const EditAppConnectionDetailsModal = ({ isOpen, onOpenChange, appConnection }: Props) => {
if (!appConnection) return null; if (!appConnection) return null;
return ( return (