diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8f608c40c..e3147d650 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -162,6 +162,24 @@ scoop: description: "The official Infisical CLI" license: MIT +winget: + - name: infisical + publisher: infisical + license: MIT + homepage: https://infisical.com + short_description: "The official Infisical CLI" + repository: + owner: infisical + name: winget-pkgs + branch: "infisical-{{.Version}}" + pull_request: + enabled: true + draft: false + base: + owner: microsoft + name: winget-pkgs + branch: master + aurs: - name: infisical-bin homepage: "https://infisical.com" diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index c9f2837df..84cced88f 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -594,6 +594,7 @@ export const scimServiceFactory = ({ }, tx ); + await orgMembershipDAL.updateById( membership.id, { diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index e24cd923e..4a8e74532 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -262,13 +262,14 @@ export const secretApprovalRequestServiceFactory = ({ id: el.id, version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, - isRotatedSecret: el.secret.isRotatedSecret, - // eslint-disable-next-line no-nested-ternary - secretValue: el.secret.isRotatedSecret - ? undefined - : el.encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() - : "", + isRotatedSecret: el.secret?.isRotatedSecret ?? false, + secretValue: + // eslint-disable-next-line no-nested-ternary + el.secret && el.secret.isRotatedSecret + ? undefined + : el.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() + : "", secretComment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "", @@ -615,7 +616,7 @@ export const secretApprovalRequestServiceFactory = ({ tx, inputSecrets: secretUpdationCommits.map((el) => { const encryptedValue = - !el.secret.isRotatedSecret && typeof el.encryptedValue !== "undefined" + !el.secret?.isRotatedSecret && typeof el.encryptedValue !== "undefined" ? { encryptedValue: el.encryptedValue as Buffer, references: el.encryptedValue diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 3124cdb00..55fb37203 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -66,6 +66,17 @@ export const IDENTITIES = { }, LIST: { orgId: "The ID of the organization to list identities." + }, + SEARCH: { + search: { + desc: "The filters to apply to the search.", + name: "The name of the identity to filter by.", + role: "The organizational role of the identity to filter by." + }, + offset: "The offset to start from. If you enter 10, it will start from the 10th identity.", + limit: "The number of identities to return.", + orderBy: "The column to order identities by.", + orderDirection: "The direction to order identities in." } } as const; @@ -1697,6 +1708,9 @@ export const AppConnections = { }, TERRAFORM_CLOUD: { apiToken: "The API token to use to connect with Terraform Cloud." + }, + VERCEL: { + apiToken: "The API token used to authenticate with Vercel." } } }; @@ -1821,6 +1835,13 @@ export const SecretSyncs = { workspaceId: "The ID of the Terraform Cloud workspace to sync secrets to.", scope: "The Terraform Cloud scope that secrets should be synced to.", category: "The Terraform Cloud category that secrets should be synced to." + }, + VERCEL: { + app: "The ID of the Vercel app to sync secrets to.", + appName: "The name of the Vercel app to sync secrets to.", + env: "The ID of the Vercel environment to sync secrets to.", + branch: "The branch to sync preview secrets to.", + teamId: "The ID of the Vercel team to sync secrets to." } } }; diff --git a/backend/src/lib/search-resource/db.ts b/backend/src/lib/search-resource/db.ts new file mode 100644 index 000000000..fc450d9f9 --- /dev/null +++ b/backend/src/lib/search-resource/db.ts @@ -0,0 +1,141 @@ +import { Knex } from "knex"; + +import { SearchResourceOperators, TSearchResourceOperator } from "./search"; + +const buildKnexQuery = ( + query: Knex.QueryBuilder, + // when it's multiple table field means it's field1 or field2 + fields: string | string[], + operator: SearchResourceOperators, + value: unknown +) => { + switch (operator) { + case SearchResourceOperators.$eq: { + if (typeof value !== "string" && typeof value !== "number") + throw new Error("Invalid value type for $eq operator"); + + if (typeof fields === "string") { + return void query.where(fields, "=", value); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.where(el, "=", value); + } + return void qb.orWhere(el, "=", value); + }); + }); + } + + case SearchResourceOperators.$neq: { + if (typeof value !== "string" && typeof value !== "number") + throw new Error("Invalid value type for $neq operator"); + + if (typeof fields === "string") { + return void query.where(fields, "<>", value); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.where(el, "<>", value); + } + return void qb.orWhere(el, "<>", value); + }); + }); + } + case SearchResourceOperators.$in: { + if (!Array.isArray(value)) throw new Error("Invalid value type for $in operator"); + + if (typeof fields === "string") { + return void query.whereIn(fields, value); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.whereIn(el, value); + } + return void qb.orWhereIn(el, value); + }); + }); + } + case SearchResourceOperators.$contains: { + if (typeof value !== "string") throw new Error("Invalid value type for $contains operator"); + + if (typeof fields === "string") { + return void query.whereILike(fields, `%${value}%`); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.whereILike(el, `%${value}%`); + } + return void qb.orWhereILike(el, `%${value}%`); + }); + }); + } + default: + throw new Error(`Unsupported operator: ${String(operator)}`); + } +}; + +export const buildKnexFilterForSearchResource = ( + rootQuery: Knex.QueryBuilder, + searchFilter: T & { $or?: T[] }, + getAttributeField: (attr: K) => string | string[] | null +) => { + const { $or: orFilters = [] } = searchFilter; + (Object.keys(searchFilter) as K[]).forEach((key) => { + // akhilmhdh: yes, we could have split in top. This is done to satisfy ts type error + if (key === "$or") return; + + const dbField = getAttributeField(key); + if (!dbField) throw new Error(`DB field not found for ${String(key)}`); + + const dbValue = searchFilter[key]; + if (typeof dbValue === "string" || typeof dbValue === "number") { + buildKnexQuery(rootQuery, dbField, SearchResourceOperators.$eq, dbValue); + return; + } + + Object.keys(dbValue as Record).forEach((el) => { + buildKnexQuery( + rootQuery, + dbField, + el as SearchResourceOperators, + (dbValue as Record)[el as SearchResourceOperators] + ); + }); + }); + + if (orFilters.length) { + void rootQuery.andWhere((andQb) => { + return orFilters.forEach((orFilter) => { + return void andQb.orWhere((qb) => { + (Object.keys(orFilter) as K[]).forEach((key) => { + const dbField = getAttributeField(key); + if (!dbField) throw new Error(`DB field not found for ${String(key)}`); + + const dbValue = orFilter[key]; + if (typeof dbValue === "string" || typeof dbValue === "number") { + buildKnexQuery(qb, dbField, SearchResourceOperators.$eq, dbValue); + return; + } + + Object.keys(dbValue as Record).forEach((el) => { + buildKnexQuery( + qb, + dbField, + el as SearchResourceOperators, + (dbValue as Record)[el as SearchResourceOperators] + ); + }); + }); + }); + }); + }); + } +}; diff --git a/backend/src/lib/search-resource/search.ts b/backend/src/lib/search-resource/search.ts new file mode 100644 index 000000000..6431bf953 --- /dev/null +++ b/backend/src/lib/search-resource/search.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export enum SearchResourceOperators { + $eq = "$eq", + $neq = "$neq", + $in = "$in", + $contains = "$contains" +} + +export const SearchResourceOperatorSchema = z.union([ + z.string(), + z.number(), + z + .object({ + [SearchResourceOperators.$eq]: z.string().optional(), + [SearchResourceOperators.$neq]: z.string().optional(), + [SearchResourceOperators.$in]: z.string().array().optional(), + [SearchResourceOperators.$contains]: z.string().array().optional() + }) + .partial() +]); + +export type TSearchResourceOperator = z.infer; + +export type TSearchResource = { + [k: string]: z.ZodOptional< + z.ZodUnion< + [ + z.ZodEffects, + z.ZodObject<{ + [SearchResourceOperators.$eq]?: z.ZodOptional>; + [SearchResourceOperators.$neq]?: z.ZodOptional>; + [SearchResourceOperators.$in]?: z.ZodOptional>>; + [SearchResourceOperators.$contains]?: z.ZodOptional>; + }> + ] + > + >; +}; + +export const buildSearchZodSchema = (schema: z.ZodObject) => { + return schema.extend({ $or: schema.array().max(5).optional() }).optional(); +}; diff --git a/backend/src/lib/validator/validate-string.ts b/backend/src/lib/validator/validate-string.ts index bc279fdbf..d2d033693 100644 --- a/backend/src/lib/validator/validate-string.ts +++ b/backend/src/lib/validator/validate-string.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export enum CharacterType { Alphabets = "alphabets", Numbers = "numbers", @@ -101,3 +103,10 @@ export const characterValidator = (allowedCharacters: CharacterType[]) => { return regex.test(input); }; }; + +export const zodValidateCharacters = (allowedCharacters: CharacterType[]) => { + const validator = characterValidator(allowedCharacters); + return (schema: z.ZodString, fieldName: string) => { + return schema.refine(validator, { message: `${fieldName} can only contain ${allowedCharacters.join(",")}` }); + }; +}; diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 26f556508..3f5c477ef 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -113,7 +113,7 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key await server.register(fastifyErrHandler); // Rate limiters and security headers - if (appCfg.isProductionMode) { + if (appCfg.isProductionMode && appCfg.isCloud) { await server.register(ratelimiter, globalRateLimiterCfg()); } diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index d09a2c40b..9f93eaea0 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -45,4 +45,6 @@ export const BaseSecretNameSchema = z.string().trim().min(1); export const SecretNameSchema = BaseSecretNameSchema.refine( (el) => !el.includes(" "), "Secret name cannot contain spaces." -).refine((el) => !el.includes(":"), "Secret name cannot contain colon."); +) + .refine((el) => !el.includes(":"), "Secret name cannot contain colon.") + .refine((el) => !el.includes("/"), "Secret name cannot contain forward slash."); diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index a6f7d3986..7e6b3b830 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -31,6 +31,7 @@ import { SanitizedTerraformCloudConnectionSchema, TerraformCloudConnectionListItemSchema } from "@app/services/app-connection/terraform-cloud"; +import { SanitizedVercelConnectionSchema, VercelConnectionListItemSchema } from "@app/services/app-connection/vercel"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps @@ -43,6 +44,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedDatabricksConnectionSchema.options, ...SanitizedHumanitecConnectionSchema.options, ...SanitizedTerraformCloudConnectionSchema.options, + ...SanitizedVercelConnectionSchema.options, ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options ]); @@ -56,6 +58,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ DatabricksConnectionListItemSchema, HumanitecConnectionListItemSchema, TerraformCloudConnectionListItemSchema, + VercelConnectionListItemSchema, PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema ]); diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index f5d354a74..5f97eb3fd 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -10,6 +10,7 @@ import { registerHumanitecConnectionRouter } from "./humanitec-connection-router import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; +import { registerVercelConnectionRouter } from "./vercel-connection-router"; export * from "./app-connection-router"; @@ -23,6 +24,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Vercel, + server, + sanitizedResponseSchema: SanitizedVercelConnectionSchema, + createSchema: CreateVercelConnectionSchema, + updateSchema: UpdateVercelConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + apps: z + .object({ + id: z.string(), + name: z.string(), + envs: z + .object({ + id: z.string(), + slug: z.string(), + type: z.string(), + target: z.array(z.string()).optional(), + description: z.string().optional(), + createdAt: z.number().optional(), + updatedAt: z.number().optional() + }) + .array() + .optional(), + previewBranches: z.array(z.string()).optional() + }) + .array() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const projects: VercelOrgWithApps[] = await server.services.appConnection.vercel.listProjects( + connectionId, + req.permission + ); + + return projects; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 344da3383..107a4b9ef 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -3,15 +3,26 @@ import { z } from "zod"; import { IdentitiesSchema, IdentityOrgMembershipsSchema, OrgMembershipRole, OrgRolesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { IDENTITIES } from "@app/lib/api-docs"; +import { buildSearchZodSchema, SearchResourceOperators } from "@app/lib/search-resource/search"; +import { OrderByDirection } from "@app/lib/types"; +import { CharacterType, zodValidateCharacters } from "@app/lib/validator/validate-string"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { OrgIdentityOrderBy } from "@app/services/identity/identity-types"; import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { SanitizedProjectSchema } from "../sanitizedSchemas"; +const searchResourceZodValidate = zodValidateCharacters([ + CharacterType.AlphaNumeric, + CharacterType.Spaces, + CharacterType.Underscore, + CharacterType.Hyphen +]); + export const registerIdentityRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -245,7 +256,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { method: "GET", url: "/", config: { - rateLimit: writeLimit + rateLimit: readLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { @@ -289,6 +300,103 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/search", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Search identities", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + orderBy: z + .nativeEnum(OrgIdentityOrderBy) + .default(OrgIdentityOrderBy.Name) + .describe(IDENTITIES.SEARCH.orderBy) + .optional(), + orderDirection: z + .nativeEnum(OrderByDirection) + .default(OrderByDirection.ASC) + .describe(IDENTITIES.SEARCH.orderDirection) + .optional(), + limit: z.number().max(100).default(50).describe(IDENTITIES.SEARCH.limit), + offset: z.number().default(0).describe(IDENTITIES.SEARCH.offset), + search: buildSearchZodSchema( + z + .object({ + name: z + .union([ + searchResourceZodValidate(z.string().max(255), "Name"), + z + .object({ + [SearchResourceOperators.$eq]: searchResourceZodValidate(z.string().max(255), "Name $eq"), + [SearchResourceOperators.$contains]: searchResourceZodValidate( + z.string().max(255), + "Name $contains" + ), + [SearchResourceOperators.$in]: searchResourceZodValidate(z.string().max(255), "Name $in").array() + }) + .partial() + ]) + .describe(IDENTITIES.SEARCH.search.name), + role: z + .union([ + searchResourceZodValidate(z.string().max(255), "Role"), + z + .object({ + [SearchResourceOperators.$eq]: searchResourceZodValidate(z.string().max(255), "Role $eq"), + [SearchResourceOperators.$in]: searchResourceZodValidate(z.string().max(255), "Role $in").array() + }) + .partial() + ]) + .describe(IDENTITIES.SEARCH.search.role) + }) + .describe(IDENTITIES.SEARCH.search.desc) + .partial() + ) + }), + response: { + 200: z.object({ + identities: IdentityOrgMembershipsSchema.extend({ + customRole: OrgRolesSchema.pick({ + id: true, + name: true, + slug: true, + permissions: true, + description: true + }).optional(), + identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + authMethods: z.array(z.string()) + }) + }).array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { identityMemberships, totalCount } = await server.services.identity.searchOrgIdentities({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + searchFilter: req.body.search, + orgId: req.permission.orgId, + limit: req.body.limit, + offset: req.body.offset, + orderBy: req.body.orderBy, + orderDirection: req.body.orderDirection + }); + + return { identities: identityMemberships, totalCount }; + } + }); + server.route({ method: "GET", url: "/:identityId/identity-memberships", diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 622aff324..e783895bb 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -9,6 +9,7 @@ import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; +import { registerVercelSyncRouter } from "./vercel-sync-router"; export * from "./secret-sync-router"; @@ -21,5 +22,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts new file mode 100644 index 000000000..e6e2f40c6 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts @@ -0,0 +1,13 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { CreateVercelSyncSchema, UpdateVercelSyncSchema, VercelSyncSchema } from "@app/services/secret-sync/vercel"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerVercelSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Vercel, + server, + responseSchema: VercelSyncSchema, + createSchema: CreateVercelSyncSchema, + updateSchema: UpdateVercelSyncSchema + }); diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v2/identity-project-router.ts index 18244068c..4205d9326 100644 --- a/backend/src/server/routes/v2/identity-project-router.ts +++ b/backend/src/server/routes/v2/identity-project-router.ts @@ -351,4 +351,56 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) return { identityMembership }; } }); + + server.route({ + method: "GET", + url: "/identity-memberships/:identityMembershipId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + params: z.object({ + identityMembershipId: z.string().trim() + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + authMethods: z.array(z.string()) + }), + project: SanitizedProjectSchema.pick({ name: true, id: true }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.identityProject.getProjectIdentityByMembershipId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityMembershipId: req.params.identityMembershipId + }); + return { identityMembership }; + } + }); }; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 6fcc4bd56..77ffad400 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -7,6 +7,7 @@ export enum AppConnection { AzureAppConfiguration = "azure-app-configuration", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", + Vercel = "vercel", Postgres = "postgres", MsSql = "mssql" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 8e97af7d2..6a44ea37d 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -46,6 +46,8 @@ import { TerraformCloudConnectionMethod, validateTerraformCloudConnectionCredentials } from "./terraform-cloud"; +import { VercelConnectionMethod } from "./vercel"; +import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns"; export const listAppConnectionOptions = () => { return [ @@ -57,6 +59,7 @@ export const listAppConnectionOptions = () => { getDatabricksConnectionListItem(), getHumanitecConnectionListItem(), getTerraformCloudConnectionListItem(), + getVercelConnectionListItem(), getPostgresConnectionListItem(), getMsSqlConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); @@ -115,7 +118,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record return "Service Principal"; case HumanitecConnectionMethod.ApiToken: case TerraformCloudConnectionMethod.ApiToken: + case VercelConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -184,5 +189,6 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, - [AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported + [AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported, + [AppConnection.Vercel]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index f507901f4..41bdf1283 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -9,6 +9,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", [AppConnection.TerraformCloud]: "Terraform Cloud", + [AppConnection.Vercel]: "Vercel", [AppConnection.Postgres]: "PostgreSQL", [AppConnection.MsSql]: "Microsoft SQL Server" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 93adcf5d8..2ef5755fe 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -43,6 +43,8 @@ import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; import { ValidateTerraformCloudConnectionCredentialsSchema } from "./terraform-cloud"; import { terraformCloudConnectionService } from "./terraform-cloud/terraform-cloud-connection-service"; +import { ValidateVercelConnectionCredentialsSchema } from "./vercel"; +import { vercelConnectionService } from "./vercel/vercel-connection-service"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -61,6 +63,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + return { + name: "Vercel" as const, + app: AppConnection.Vercel as const, + methods: Object.values(VercelConnectionMethod) as [VercelConnectionMethod.ApiToken] + }; +}; + +export const validateVercelConnectionCredentials = async (config: TVercelConnectionConfig) => { + const { credentials: inputCredentials } = config; + + let response: AxiosResponse | null = null; + + try { + response = await request.get(`${IntegrationUrls.VERCEL_API_URL}/v9/projects`, { + headers: { + Authorization: `Bearer ${inputCredentials.apiToken}` + } + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection - verify credentials" + }); + } + + if (!response?.data) { + throw new InternalServerError({ + message: "Failed to get organizations: Response was empty" + }); + } + + return inputCredentials; +}; + +interface ApiResponse { + pagination?: { + count: number; + next: number; + }; + data: T[]; + [key: string]: unknown; +} + +async function fetchAllPages( + apiUrl: string, + apiToken: string, + initialParams: Record = {}, + dataPath?: string +): Promise { + const allItems: T[] = []; + let hasMoreItems = true; + let params: Record = { ...initialParams, limit: 100 }; + + while (hasMoreItems) { + try { + const response = await request.get>(apiUrl, { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + }); + + if (!response?.data) { + throw new InternalServerError({ + message: `Failed to fetch data from ${apiUrl}: Response was empty or malformed` + }); + } + + let itemsData: T[]; + + if (dataPath && dataPath in response.data) { + itemsData = response.data[dataPath] as T[]; + } else { + itemsData = response.data.data; + } + + if (!Array.isArray(itemsData)) { + throw new InternalServerError({ + message: `Failed to fetch data from ${apiUrl}: Expected array but got ${typeof itemsData}` + }); + } + + allItems.push(...itemsData); + + if (response.data.pagination?.next) { + params = { ...params, since: response.data.pagination.next }; + } else { + hasMoreItems = false; + } + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch data from ${apiUrl}: ${error.message || "Unknown error"}` + }); + } + throw error; + } + } + + return allItems; +} + +async function fetchOrgProjects(orgId: string, apiToken: string): Promise { + return fetchAllPages( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects`, + apiToken, + { teamId: orgId }, + "projects" + ); +} + +async function fetchProjectEnvironments( + projectId: string, + teamId: string, + apiToken: string +): Promise { + try { + return await fetchAllPages( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${projectId}/custom-environments?teamId=${teamId}`, + apiToken, + {}, + "environments" + ); + } catch (error) { + return []; + } +} + +async function fetchPreviewBranches(projectId: string, apiToken: string): Promise { + try { + const { data } = await request.get( + `${IntegrationUrls.VERCEL_API_URL}/v1/integrations/git-branches`, + { + params: { + projectId + }, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + return data.filter((b) => b.ref !== "main").map((b) => b.ref); + } catch (error) { + return []; + } +} + +type VercelTeam = { + id: string; + name: string; + slug: string; +}; + +type VercelUserResponse = { + user: { + id: string; + name: string; + username: string; + }; +}; + +export const listProjects = async (appConnection: TVercelConnection): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const orgs = await fetchAllPages(`${IntegrationUrls.VERCEL_API_URL}/v2/teams`, apiToken, {}, "teams"); + + const personalAccountResponse = await request.get(`${IntegrationUrls.VERCEL_API_URL}/v2/user`, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + }); + + if (personalAccountResponse?.data?.user) { + const { user } = personalAccountResponse.data; + orgs.push({ + id: user.id, + name: user.name || "Personal Account", + slug: user.username || "personal" + }); + } + + const orgsWithApps: VercelOrgWithApps[] = []; + + const orgPromises = orgs.map(async (org) => { + try { + const projects = await fetchOrgProjects(org.id, apiToken); + + const enhancedProjectsPromises = projects.map(async (project) => { + try { + const [environments, previewBranches] = await Promise.all([ + fetchProjectEnvironments(project.name, org.id, apiToken), + fetchPreviewBranches(project.id, apiToken) + ]); + + return { + name: project.name, + id: project.id, + envs: environments, + previewBranches + }; + } catch (error) { + return { + name: project.name, + id: project.id, + envs: [], + previewBranches: [] + }; + } + }); + + const enhancedProjects = await Promise.all(enhancedProjectsPromises); + + return { + ...org, + apps: enhancedProjects + }; + } catch (error) { + return null; + } + }); + + const results = await Promise.all(orgPromises); + + results.forEach((result) => { + if (result !== null) { + orgsWithApps.push(result); + } + }); + + return orgsWithApps; +}; + +export const getProjectEnvironmentVariables = (project: VercelApp): Record => { + const envVars: Record = {}; + + if (!project.envs) return envVars; + + project.envs.forEach((env) => { + if (env.slug && env.type !== "gitBranch") { + const { id, slug } = env; + envVars[id] = slug; + } + }); + + return envVars; +}; diff --git a/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts b/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts new file mode 100644 index 000000000..60baa4f5c --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts @@ -0,0 +1,58 @@ +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 { VercelConnectionMethod } from "./vercel-connection-enums"; + +export const VercelConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required").describe(AppConnections.CREDENTIALS.VERCEL.apiToken) +}); + +const BaseVercelConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Vercel) +}); + +export const VercelConnectionSchema = BaseVercelConnectionSchema.extend({ + method: z.literal(VercelConnectionMethod.ApiToken), + credentials: VercelConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedVercelConnectionSchema = z.discriminatedUnion("method", [ + BaseVercelConnectionSchema.extend({ + method: z.literal(VercelConnectionMethod.ApiToken), + credentials: VercelConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateVercelConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(VercelConnectionMethod.ApiToken).describe(AppConnections.CREATE(AppConnection.Vercel).method), + credentials: VercelConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Vercel).credentials + ) + }) +]); + +export const CreateVercelConnectionSchema = ValidateVercelConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Vercel) +); + +export const UpdateVercelConnectionSchema = z + .object({ + credentials: VercelConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Vercel).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Vercel)); + +export const VercelConnectionListItemSchema = z.object({ + name: z.literal("Vercel"), + app: z.literal(AppConnection.Vercel), + methods: z.nativeEnum(VercelConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/vercel/vercel-connection-service.ts b/backend/src/services/app-connection/vercel/vercel-connection-service.ts new file mode 100644 index 000000000..68e5215e9 --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-service.ts @@ -0,0 +1,29 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listProjects as getVercelProjects } from "./vercel-connection-fns"; +import { TVercelConnection } from "./vercel-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const vercelConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Vercel, connectionId, actor); + try { + const projects = await getVercelProjects(appConnection); + return projects; + } catch (error) { + logger.error(error, "Failed to establish connection with Vercel"); + return []; + } + }; + + return { + listProjects + }; +}; diff --git a/backend/src/services/app-connection/vercel/vercel-connection-types.ts b/backend/src/services/app-connection/vercel/vercel-connection-types.ts new file mode 100644 index 000000000..4ab69d1df --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-types.ts @@ -0,0 +1,73 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateVercelConnectionSchema, + ValidateVercelConnectionCredentialsSchema, + VercelConnectionSchema +} from "./vercel-connection-schemas"; + +export type TVercelConnection = z.infer; + +export type TVercelConnectionInput = z.infer & { + app: AppConnection.Vercel; +}; + +export type TValidateVercelConnectionCredentialsSchema = typeof ValidateVercelConnectionCredentialsSchema; + +export type TVercelConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type VercelTeam = { + id: string; + name: string; + slug: string; +}; + +export type VercelEnvironment = { + id: string; + slug: string; + type: string; + target?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; +}; + +export type VercelAppMeta = { + githubCommitRef?: string; + githubCommitSha?: string; + githubCommitMessage?: string; + githubCommitAuthorName?: string; +}; + +export type VercelDeployment = { + id: string; + name: string; + url: string; + created: number; + meta?: VercelAppMeta; + target?: "production" | "preview" | "development"; +}; + +export type VercelApp = { + name: string; + id: string; + envs?: VercelEnvironment[]; + previewBranches?: string[]; +}; + +export type VercelOrgWithApps = VercelTeam & { + apps: VercelApp[]; +}; + +export type VercelUserResponse = { + user: { + id: string; + name: string; + username: string; + }; +}; diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index ba63f7afd..14df0cd4a 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -21,6 +21,7 @@ import { TCreateProjectIdentityDTO, TDeleteProjectIdentityDTO, TGetProjectIdentityByIdentityIdDTO, + TGetProjectIdentityByMembershipIdDTO, TListProjectIdentityDTO, TUpdateProjectIdentityDTO } from "./identity-project-types"; @@ -370,11 +371,48 @@ export const identityProjectServiceFactory = ({ return identityMembership; }; + const getProjectIdentityByMembershipId = async ({ + identityMembershipId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetProjectIdentityByMembershipIdDTO) => { + const membership = await identityProjectDAL.findOne({ id: identityMembershipId }); + + if (!membership) { + throw new NotFoundError({ + message: `Project membership with ID '${identityMembershipId}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: membership.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: membership.identityId }) + ); + + const [identityMembership] = await identityProjectDAL.findByProjectId(membership.projectId, { + identityId: membership.identityId + }); + + return identityMembership; + }; + return { createProjectIdentity, updateProjectIdentity, deleteProjectIdentity, listProjectIdentities, - getProjectIdentityByIdentityId + getProjectIdentityByIdentityId, + getProjectIdentityByMembershipId }; }; diff --git a/backend/src/services/identity-project/identity-project-types.ts b/backend/src/services/identity-project/identity-project-types.ts index 607fd4823..bc85ca398 100644 --- a/backend/src/services/identity-project/identity-project-types.ts +++ b/backend/src/services/identity-project/identity-project-types.ts @@ -52,6 +52,10 @@ export type TGetProjectIdentityByIdentityIdDTO = { identityId: string; } & TProjectPermission; +export type TGetProjectIdentityByMembershipIdDTO = { + identityMembershipId: string; +} & Omit; + export enum ProjectIdentityOrderBy { Name = "name" } diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 92a6795d0..dbae59bbe 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -14,10 +14,15 @@ import { TIdentityUniversalAuths, TOrgRoles } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { buildKnexFilterForSearchResource } from "@app/lib/search-resource/db"; import { OrderByDirection } from "@app/lib/types"; -import { OrgIdentityOrderBy, TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types"; +import { + OrgIdentityOrderBy, + TListOrgIdentitiesByOrgIdDTO, + TSearchOrgIdentitiesByOrgIdDAL +} from "@app/services/identity/identity-types"; import { buildAuthMethods } from "./identity-fns"; @@ -195,7 +200,6 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.identityId", `${TableName.IdentityJwtAuth}.identityId` ) - .select( db.ref("id").withSchema("paginatedIdentity"), db.ref("role").withSchema("paginatedIdentity"), @@ -309,6 +313,214 @@ export const identityOrgDALFactory = (db: TDbClient) => { } }; + const searchIdentities = async ( + { + limit, + offset = 0, + orderBy = OrgIdentityOrderBy.Name, + orderDirection = OrderByDirection.ASC, + searchFilter, + orgId + }: TSearchOrgIdentitiesByOrgIdDAL, + tx?: Knex + ) => { + try { + const searchQuery = (tx || db.replicaNode())(TableName.IdentityOrgMembership) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityOrgMembership}.identityId`) + .where(`${TableName.IdentityOrgMembership}.orgId`, orgId) + .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .orderBy(`${TableName.Identity}.${orderBy}`, orderDirection) + .select(`${TableName.IdentityOrgMembership}.id`) + .select<{ id: string; total_count: string }>( + db.raw( + `count(${TableName.IdentityOrgMembership}."identityId") OVER(PARTITION BY ${TableName.IdentityOrgMembership}."orgId") as total_count` + ) + ) + .as("searchedIdentities"); + + if (searchFilter) { + buildKnexFilterForSearchResource(searchQuery, searchFilter, (attr) => { + switch (attr) { + case "role": + return [`${TableName.OrgRoles}.slug`, `${TableName.IdentityOrgMembership}.role`]; + case "name": + return `${TableName.Identity}.name`; + default: + throw new BadRequestError({ message: `Invalid ${String(attr)} provided` }); + } + }); + } + + if (limit) { + void searchQuery.offset(offset).limit(limit); + } + + type TSubquery = Awaited; + const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership) + .where(`${TableName.IdentityOrgMembership}.orgId`, orgId) + .join(searchQuery, `${TableName.IdentityOrgMembership}.id`, "searchedIdentities.id") + .join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) + .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.IdentityOrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .leftJoin( + TableName.IdentityUniversalAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityUniversalAuth}.identityId` + ) + .leftJoin( + TableName.IdentityGcpAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityGcpAuth}.identityId` + ) + .leftJoin( + TableName.IdentityAwsAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityAwsAuth}.identityId` + ) + .leftJoin( + TableName.IdentityKubernetesAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityKubernetesAuth}.identityId` + ) + .leftJoin( + TableName.IdentityOidcAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityOidcAuth}.identityId` + ) + .leftJoin( + TableName.IdentityAzureAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityAzureAuth}.identityId` + ) + .leftJoin( + TableName.IdentityTokenAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityTokenAuth}.identityId` + ) + .leftJoin( + TableName.IdentityJwtAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityJwtAuth}.identityId` + ) + .select( + db.ref("id").withSchema(TableName.IdentityOrgMembership), + db.ref("total_count").withSchema("searchedIdentities"), + db.ref("role").withSchema(TableName.IdentityOrgMembership), + db.ref("roleId").withSchema(TableName.IdentityOrgMembership), + db.ref("orgId").withSchema(TableName.IdentityOrgMembership), + db.ref("createdAt").withSchema(TableName.IdentityOrgMembership), + db.ref("updatedAt").withSchema(TableName.IdentityOrgMembership), + db.ref("identityId").withSchema(TableName.IdentityOrgMembership).as("identityId"), + db.ref("name").withSchema(TableName.Identity).as("identityName"), + + db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), + db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), + db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), + db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth) + ) + // cr stands for custom role + .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) + .select(db.ref("name").as("crName").withSchema(TableName.OrgRoles)) + .select(db.ref("slug").as("crSlug").withSchema(TableName.OrgRoles)) + .select(db.ref("description").as("crDescription").withSchema(TableName.OrgRoles)) + .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) + .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) + .select( + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") + ); + + if (orderBy === OrgIdentityOrderBy.Name) { + void query.orderBy("identityName", orderDirection); + } + + const docs = await query; + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: ({ + crId, + crDescription, + crSlug, + crPermission, + crName, + identityId, + identityName, + role, + roleId, + total_count, + id, + uaId, + awsId, + gcpId, + jwtId, + kubernetesId, + oidcId, + azureId, + tokenId, + createdAt, + updatedAt + }) => ({ + role, + roleId, + identityId, + id, + total_count: total_count as string, + orgId, + createdAt, + updatedAt, + customRole: roleId + ? { + id: crId, + name: crName, + slug: crSlug, + permissions: crPermission, + description: crDescription + } + : undefined, + identity: { + id: identityId, + name: identityName, + authMethods: buildAuthMethods({ + uaId, + awsId, + gcpId, + kubernetesId, + oidcId, + azureId, + tokenId, + jwtId + }) + } + }), + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return { docs: formattedDocs, totalCount: Number(formattedDocs?.[0]?.total_count ?? 0) }; + } catch (error) { + throw new DatabaseError({ error, name: "FindByOrgId" }); + } + }; + const countAllOrgIdentities = async ( { search, ...filter }: Partial & Pick, tx?: Knex @@ -331,5 +543,5 @@ export const identityOrgDALFactory = (db: TDbClient) => { } }; - return { ...identityOrgOrm, find, findOne, countAllOrgIdentities }; + return { ...identityOrgOrm, find, findOne, countAllOrgIdentities, searchIdentities }; }; diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index f9185ba9d..6f72b3c6e 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -21,6 +21,7 @@ import { TGetIdentityByIdDTO, TListOrgIdentitiesByOrgIdDTO, TListProjectIdentitiesByIdentityIdDTO, + TSearchOrgIdentitiesByOrgIdDTO, TUpdateIdentityDTO } from "./identity-types"; @@ -288,6 +289,33 @@ export const identityServiceFactory = ({ return { identityMemberships, totalCount }; }; + const searchOrgIdentities = async ({ + orgId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + limit, + offset, + orderBy, + orderDirection, + searchFilter = {} + }: TSearchOrgIdentitiesByOrgIdDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + + const { totalCount, docs } = await identityOrgMembershipDAL.searchIdentities({ + orgId, + limit, + offset, + orderBy, + orderDirection, + searchFilter + }); + + return { identityMemberships: docs, totalCount }; + }; + const listProjectIdentitiesByIdentityId = async ({ identityId, actor, @@ -317,6 +345,7 @@ export const identityServiceFactory = ({ deleteIdentity, listOrgIdentities, getIdentityById, + searchOrgIdentities, listProjectIdentitiesByIdentityId }; }; diff --git a/backend/src/services/identity/identity-types.ts b/backend/src/services/identity/identity-types.ts index 0eca6b7ee..363d42a88 100644 --- a/backend/src/services/identity/identity-types.ts +++ b/backend/src/services/identity/identity-types.ts @@ -1,4 +1,5 @@ import { IPType } from "@app/lib/ip"; +import { TSearchResourceOperator } from "@app/lib/search-resource/search"; import { OrderByDirection, TOrgPermission } from "@app/lib/types"; export type TCreateIdentityDTO = { @@ -46,3 +47,17 @@ export enum OrgIdentityOrderBy { Name = "name" // Role = "role" } + +export type TSearchOrgIdentitiesByOrgIdDAL = { + limit?: number; + offset?: number; + orderBy?: OrgIdentityOrderBy; + orderDirection?: OrderByDirection; + orgId: string; + searchFilter?: Partial<{ + name: Omit; + role: Omit; + }>; +}; + +export type TSearchOrgIdentitiesByOrgIdDTO = TSearchOrgIdentitiesByOrgIdDAL & TOrgPermission; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index bdfe84950..bbcf9341b 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -7,7 +7,8 @@ export enum SecretSync { AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", Humanitec = "humanitec", - TerraformCloud = "terraform-cloud" + TerraformCloud = "terraform-cloud", + Vercel = "vercel" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index af40aa39a..ca527c503 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -27,6 +27,7 @@ import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; +import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, @@ -37,7 +38,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, [SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION, [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION, - [SecretSync.TerraformCloud]: TERRAFORM_CLOUD_SYNC_LIST_OPTION + [SecretSync.TerraformCloud]: TERRAFORM_CLOUD_SYNC_LIST_OPTION, + [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -125,6 +127,8 @@ export const SecretSyncFns = { return HumanitecSyncFns.syncSecrets(secretSync, secretMap); case SecretSync.TerraformCloud: return TerraformCloudSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.Vercel: + return VercelSyncFns.syncSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -172,6 +176,9 @@ export const SecretSyncFns = { case SecretSync.TerraformCloud: secretMap = await TerraformCloudSyncFns.getSecrets(secretSync); break; + case SecretSync.Vercel: + secretMap = await VercelSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -216,6 +223,8 @@ export const SecretSyncFns = { return HumanitecSyncFns.removeSecrets(secretSync, secretMap); case SecretSync.TerraformCloud: return TerraformCloudSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.Vercel: + return VercelSyncFns.removeSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 84faa90a9..00fa89c9f 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -10,7 +10,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AzureAppConfiguration]: "Azure App Configuration", [SecretSync.Databricks]: "Databricks", [SecretSync.Humanitec]: "Humanitec", - [SecretSync.TerraformCloud]: "Terraform Cloud" + [SecretSync.TerraformCloud]: "Terraform Cloud", + [SecretSync.Vercel]: "Vercel" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -22,5 +23,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, [SecretSync.Humanitec]: AppConnection.Humanitec, - [SecretSync.TerraformCloud]: AppConnection.TerraformCloud + [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, + [SecretSync.Vercel]: AppConnection.Vercel }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 56ea8e06e..73f1258dd 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -55,6 +55,7 @@ import { TTerraformCloudSyncListItem, TTerraformCloudSyncWithCredentials } from "./terraform-cloud"; +import { TVercelSync, TVercelSyncInput, TVercelSyncListItem, TVercelSyncWithCredentials } from "./vercel"; export type TSecretSync = | TAwsParameterStoreSync @@ -65,7 +66,8 @@ export type TSecretSync = | TAzureAppConfigurationSync | TDatabricksSync | THumanitecSync - | TTerraformCloudSync; + | TTerraformCloudSync + | TVercelSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -76,7 +78,8 @@ export type TSecretSyncWithCredentials = | TAzureAppConfigurationSyncWithCredentials | TDatabricksSyncWithCredentials | THumanitecSyncWithCredentials - | TTerraformCloudSyncWithCredentials; + | TTerraformCloudSyncWithCredentials + | TVercelSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -87,7 +90,8 @@ export type TSecretSyncInput = | TAzureAppConfigurationSyncInput | TDatabricksSyncInput | THumanitecSyncInput - | TTerraformCloudSyncInput; + | TTerraformCloudSyncInput + | TVercelSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -98,7 +102,8 @@ export type TSecretSyncListItem = | TAzureAppConfigurationSyncListItem | TDatabricksSyncListItem | THumanitecSyncListItem - | TTerraformCloudSyncListItem; + | TTerraformCloudSyncListItem + | TVercelSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/vercel/index.ts b/backend/src/services/secret-sync/vercel/index.ts new file mode 100644 index 000000000..b8379b5d9 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/index.ts @@ -0,0 +1,5 @@ +export * from "./vercel-sync-constants"; +export * from "./vercel-sync-enums"; +export * from "./vercel-sync-fns"; +export * from "./vercel-sync-schemas"; +export * from "./vercel-sync-types"; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts b/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts new file mode 100644 index 000000000..60b3eb00a --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const VERCEL_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Vercel", + destination: SecretSync.Vercel, + connection: AppConnection.Vercel, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts b/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts new file mode 100644 index 000000000..36c46985b --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts @@ -0,0 +1,12 @@ +export enum VercelSyncScope { + Application = "application", + Environment = "environment" +} + +export const VercelEnvironmentType = { + Development: "development", + Preview: "preview", + Production: "production" +} as const; + +export type VercelEnvironment = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts new file mode 100644 index 000000000..713971283 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts @@ -0,0 +1,313 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; +import { DefaultVercelEnvType, TVercelSyncWithCredentials, VercelApiSecret } from "./vercel-sync-types"; + +function isVercelDefaultEnvType(value: string): value is DefaultVercelEnvType { + return Object.values(VercelEnvironmentType).map(String).includes(value); +} + +const MAX_RETRIES = 5; + +const sleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 60000); + }); + +const getVercelSecretsWithRetries = async ( + secretSync: TVercelSyncWithCredentials, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + const params: { [key: string]: string } = { + decrypt: "true", + ...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {}) + }; + try { + const { data } = await request.get<{ envs: VercelApiSecret[] }>( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`, + { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + return data.envs; + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await getVercelSecretsWithRetries(secretSync, attempt + 1); + } + throw error; + } +}; + +const getDecryptedVercelSecret = async ( + secretSync: TVercelSyncWithCredentials, + secret: VercelApiSecret, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + const params: { [key: string]: string } = { + decrypt: "true", + ...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {}) + }; + + try { + const { data: decryptedSecret } = await request.get( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${secret.id}?teamId=${destinationConfig.teamId}`, + { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + return decryptedSecret as VercelApiSecret; + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await getDecryptedVercelSecret(secretSync, secret, attempt + 1); + } + throw error; + } +}; + +const getVercelSecrets = async (secretSync: TVercelSyncWithCredentials): Promise => { + const { destinationConfig } = secretSync; + + const secrets = await getVercelSecretsWithRetries(secretSync); + + const filteredSecrets = secrets.filter((secret) => { + if (!isVercelDefaultEnvType(destinationConfig.env)) { + if (secret.customEnvironmentIds?.includes(destinationConfig.env)) { + return true; + } + return false; + } + if (secret.target.includes(destinationConfig.env)) { + // If it's preview environment with a branch specified + if ( + destinationConfig.env === VercelEnvironmentType.Preview && + destinationConfig.branch && + secret.gitBranch && + secret.gitBranch !== destinationConfig.branch + ) { + return false; + } + return true; + } + return false; + }); + + // For secrets of type "encrypted", we need to get their decrypted value + const secretsWithValues = await Promise.all( + filteredSecrets.map(async (secret) => { + if (secret.type === "encrypted") { + const decryptedSecret = await getDecryptedVercelSecret(secretSync, secret); + return decryptedSecret; + } + return secret; + }) + ); + + return secretsWithValues; +}; + +const deleteSecret = async ( + secretSync: TVercelSyncWithCredentials, + vercelSecret: VercelApiSecret, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + await request.delete( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await deleteSecret(secretSync, vercelSecret, attempt + 1); + } + throw new SecretSyncError({ + error, + secretKey: vercelSecret.key + }); + } +}; + +const createSecret = async ( + secretSync: TVercelSyncWithCredentials, + secretMap: TSecretMap, + key: string, + attempt = 0 +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + await request.post( + `${IntegrationUrls.VERCEL_API_URL}/v10/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`, + { + key, + value: secretMap[key].value, + type: "encrypted", + target: isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [], + customEnvironmentIds: !isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [], + ...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch + ? { gitBranch: destinationConfig.branch } + : {}) + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await createSecret(secretSync, secretMap, key, attempt + 1); + } + throw new SecretSyncError({ + error, + secretKey: key + }); + } +}; + +const updateSecret = async ( + secretSync: TVercelSyncWithCredentials, + secretMap: TSecretMap, + vercelSecret: VercelApiSecret, + attempt = 0 +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let target = [...vercelSecret.target]; + if (isVercelDefaultEnvType(destinationConfig.env) && !vercelSecret.target.includes(destinationConfig.env)) { + target = [...target, destinationConfig.env]; + } + let customEnvironmentIds = [...(vercelSecret.customEnvironmentIds || [])]; + if ( + !isVercelDefaultEnvType(destinationConfig.env) && + !vercelSecret.customEnvironmentIds?.includes(destinationConfig.env) + ) { + customEnvironmentIds = [...customEnvironmentIds, destinationConfig.env]; + } + + await request.patch( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`, + { + ...(vercelSecret.type !== "sensitive" && { key: vercelSecret.key }), + value: secretMap[vercelSecret.key].value, + type: vercelSecret.type, + target, + customEnvironmentIds, + ...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch + ? { gitBranch: destinationConfig.branch } + : {}) + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await updateSecret(secretSync, secretMap, vercelSecret, attempt + 1); + } + throw new SecretSyncError({ + error, + secretKey: vercelSecret.key + }); + } +}; + +export const VercelSyncFns = { + syncSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => { + const vercelSecrets = await getVercelSecrets(secretSync); + const vercelSecretsMap = new Map(vercelSecrets.map((s) => [s.key, s])); + + // Create or update secrets + for await (const key of Object.keys(secretMap)) { + const existingSecret = vercelSecretsMap.get(key); + + if (!existingSecret) { + await createSecret(secretSync, secretMap, key); + } else if (existingSecret.value !== secretMap[key].value) { + await updateSecret(secretSync, secretMap, existingSecret); + } + } + + // Delete secrets if disableSecretDeletion is not set + if (secretSync.syncOptions.disableSecretDeletion) return; + + for await (const vercelSecret of vercelSecrets) { + if (!secretMap[vercelSecret.key]) { + await deleteSecret(secretSync, vercelSecret); + } + } + }, + + getSecrets: async (secretSync: TVercelSyncWithCredentials): Promise => { + const vercelSecrets = await getVercelSecrets(secretSync); + return Object.fromEntries(vercelSecrets.map((s) => [s.key, { value: s.value ?? "" }])); + }, + + removeSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => { + const vercelSecrets = await getVercelSecrets(secretSync); + + for await (const vercelSecret of vercelSecrets) { + if (vercelSecret.key in secretMap) { + await deleteSecret(secretSync, vercelSecret); + } + } + } +}; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts b/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts new file mode 100644 index 000000000..84d7a6da4 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; + +const VercelSyncDestinationConfigSchema = z.object({ + app: z.string().min(1, "App ID is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.app), + appName: z.string().min(1, "App Name is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.appName), + env: z.nativeEnum(VercelEnvironmentType).or(z.string()).describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.env), + branch: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.branch), + teamId: z.string().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.teamId) +}); + +const VercelSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const VercelSyncSchema = BaseSecretSyncSchema(SecretSync.Vercel, VercelSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Vercel), + destinationConfig: VercelSyncDestinationConfigSchema +}); + +export const CreateVercelSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Vercel, + VercelSyncOptionsConfig +).extend({ + destinationConfig: VercelSyncDestinationConfigSchema +}); + +export const UpdateVercelSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Vercel, + VercelSyncOptionsConfig +).extend({ + destinationConfig: VercelSyncDestinationConfigSchema.optional() +}); + +export const VercelSyncListItemSchema = z.object({ + name: z.literal("Vercel"), + connection: z.literal(AppConnection.Vercel), + destination: z.literal(SecretSync.Vercel), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-types.ts b/backend/src/services/secret-sync/vercel/vercel-sync-types.ts new file mode 100644 index 000000000..d6d2b6433 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-types.ts @@ -0,0 +1,40 @@ +import z from "zod"; + +import { TVercelConnection } from "@app/services/app-connection/vercel"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; +import { CreateVercelSyncSchema, VercelSyncListItemSchema, VercelSyncSchema } from "./vercel-sync-schemas"; + +export type TVercelSyncListItem = z.infer; + +export type TVercelSync = z.infer; + +export type TVercelSyncInput = z.infer; + +export type TVercelSyncWithCredentials = TVercelSync & { + connection: TVercelConnection; +}; + +export type VercelSecret = { + description: string; + is_secret: boolean; + key: string; + source: "app" | "env"; + value: string; +}; + +export interface VercelApiSecret { + id: string; + key: string; + value: string; + type: string; + target: string[]; + customEnvironmentIds?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; + configurationId?: string; + system?: boolean; +} + +export type DefaultVercelEnvType = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; diff --git a/docs/api-reference/endpoints/app-connections/vercel/available.mdx b/docs/api-reference/endpoints/app-connections/vercel/available.mdx new file mode 100644 index 000000000..16859bded --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/vercel/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/create.mdx b/docs/api-reference/endpoints/app-connections/vercel/create.mdx new file mode 100644 index 000000000..63998ac32 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/vercel" +--- + + + Check out the configuration docs for [Vercel Connections](/integrations/app-connections/vercel) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/vercel/delete.mdx b/docs/api-reference/endpoints/app-connections/vercel/delete.mdx new file mode 100644 index 000000000..4e5b12eff --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/vercel/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx new file mode 100644 index 000000000..fdeb715a8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/vercel/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx new file mode 100644 index 000000000..258ed67c7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/vercel/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/list.mdx b/docs/api-reference/endpoints/app-connections/vercel/list.mdx new file mode 100644 index 000000000..5412d35bb --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/vercel" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/update.mdx b/docs/api-reference/endpoints/app-connections/vercel/update.mdx new file mode 100644 index 000000000..d0e2f4ae2 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/vercel/{connectionId}" +--- + + + Check out the configuration docs for [Vercel Connections](/integrations/app-connections/vercel) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/identities/search.mdx b/docs/api-reference/endpoints/identities/search.mdx new file mode 100644 index 000000000..93906a33b --- /dev/null +++ b/docs/api-reference/endpoints/identities/search.mdx @@ -0,0 +1,4 @@ +--- +title: "Search" +openapi: "POST /api/v1/identities/search" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/create.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/create.mdx new file mode 100644 index 000000000..e14d6dddd --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/vercel" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx new file mode 100644 index 000000000..746e7ffe5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx new file mode 100644 index 000000000..9a4efd1e6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx new file mode 100644 index 000000000..3f71a6b3b --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/vercel/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx new file mode 100644 index 000000000..807eb2850 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx new file mode 100644 index 000000000..905470d0d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/vercel" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx new file mode 100644 index 000000000..49c76ef99 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx new file mode 100644 index 000000000..2b3bc8324 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx new file mode 100644 index 000000000..75be8dd89 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index a20f52171..822a8b24a 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -4,6 +4,34 @@ title: "Changelog" The changelog below reflects new product developments and updates on a monthly basis. +## March 2025 + +- Released [Infisical Gateway](https://infisical.com/docs/documentation/platform/gateways/overview) for secure access to private resources without needing direct inbound connections to private networks. +- Enhanced [Terraform](https://infisical.com/docs/integrations/frameworks/terraform#terraform) capabilities with token authentication, ability to import existing Infisical secrets as resources, and support for project templates. +- Self-hosted improvements: Usage and billing visibility for enabled features, ability to delete users, and support for multiple super admins. +- UI and UX updates: Improved secret import interface on the overview page, password reset without backup PDF. +- CLI enhancements: Various improvements including multiline secret support and ability to pass headers. +- Kubernetes operator updates: Auto-reloading for DaemonSets and StatefulSets (previously only Deployments), added support for ConfigMaps. +- Implemented powerful [Access Control](https://infisical.com/docs/documentation/platform/access-controls/overview#access-controls) updates including \"**Grant Privileges**\" feature for designating specific users for policy management, **Access Tree** visualization for simulating permissions, and ability to restrict scope of secret sharing within organizations. +- Released new **Secret Requests** feature under Secret Share, added support for reminders with webhook triggers and implementing password policies for dynamic secrets. +- Enhanced secret version history to show who made changes. +- New integrations and syncs: **Crossplane** provider, **Humanitec** secret sync, **Airflow** system integration +- Performed significant performance optimizations including a 50% reduction in database usage and optimized client secret handling for universal auth. +- Enhanced security features with ability to add custom instance banners (useful for regulated industries), short-lived tokens for Kubernetes auth, and OIDC claim passing from machine identity login to permissions. +- [Golang SDK](https://infisical.com/docs/sdks/languages/go#infisical-go-sdk): New API added for enhanced functionality +- Added capability to programmatically configure an Infisical instance from start to finish without UI interaction. + +## February 2025 + +- Released [KMIP integration](https://infisical.com/docs/documentation/platform/kms/kmip) with PKI structure, auth model integration with machine identities, complete set of client operations, and client certificate authentication flow. +- Added new [AWS App Connection](https://infisical.com/docs/integrations/app-connections/aws) and [Secret Sync](https://infisical.com/docs/integrations/secret-syncs/aws-secrets-manager) functionality for enhanced AWS integration. +- Released new [Azure Key Vault App Connection](https://infisical.com/docs/integrations/app-connections/azure-key-vault) and [Secret Sync](https://infisical.com/docs/integrations/secret-syncs/azure-key-vault), plus Terraform provider support. +- Introduced more comprehensive logging with detailed records for secret sharing and metadata in audit logs. +- Introduced new [permission types](https://infisical.com/docs/internals/permissions/project-permissions#subject-secrets): \"View Value\" vs \"Describe Value\" for more granular access control over secrets. +- Updated encryption logic with unified approach for all platform data, ensuring consistency across the system. +- Added support for [OIDC group mapping](https://infisical.com/docs/documentation/platform/sso/general-oidc) to automatically map groups to Infisical for role-based access control. +- Added [Terraform Cloud support for OIDC](https://infisical.com/docs/documentation/platform/identities/oidc-auth/terraform-cloud#terraform-cloud). + ## January 2025 - Released new integration architecture with decoupled authentication, replacing native integrations with [App Connections](https://infisical.com/docs/integrations/app-connections/overview) and [Secret Syncs](https://infisical.com/docs/integrations/secret-syncs/overview). Initial support for AWS Parameter Store, GitHub, and GCP Secret Manager with improved API and Terraform integration capabilities. @@ -15,7 +43,6 @@ The changelog below reflects new product developments and updates on a monthly b - Implemented secret Access Visibility allowing users to view all entities with access to specific secrets in the secret side panel. - Added secret filtering by metadata and SSH assigned certificates (Version 1). - ## December 2024 - Added [GCP KMS](https://infisical.com/docs/documentation/platform/kms/overview) integration support. - Added support for [K8s CSI integration](https://infisical.com/docs/integrations/platforms/kubernetes-csi) and ability to point K8s operator to specific secret versions. diff --git a/docs/images/app-connections/vercel/vercel-app-connection-created.png b/docs/images/app-connections/vercel/vercel-app-connection-created.png new file mode 100644 index 000000000..8fb371440 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-created.png differ diff --git a/docs/images/app-connections/vercel/vercel-app-connection-modal.png b/docs/images/app-connections/vercel/vercel-app-connection-modal.png new file mode 100644 index 000000000..6b789713d Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-modal.png differ diff --git a/docs/images/app-connections/vercel/vercel-app-connection-option.png b/docs/images/app-connections/vercel/vercel-app-connection-option.png new file mode 100644 index 000000000..b4308a1a2 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-option.png differ diff --git a/docs/images/app-connections/vercel/vercel-copy-token.png b/docs/images/app-connections/vercel/vercel-copy-token.png new file mode 100644 index 000000000..d6491c02e Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-copy-token.png differ diff --git a/docs/images/app-connections/vercel/vercel-create-token.png b/docs/images/app-connections/vercel/vercel-create-token.png new file mode 100644 index 000000000..c507f0d14 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-create-token.png differ diff --git a/docs/images/app-connections/vercel/vercel-main-page.png b/docs/images/app-connections/vercel/vercel-main-page.png new file mode 100644 index 000000000..e1a28248e Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-main-page.png differ diff --git a/docs/images/app-connections/vercel/vercel-settings-page.png b/docs/images/app-connections/vercel/vercel-settings-page.png new file mode 100644 index 000000000..86e67d6e1 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-settings-page.png differ diff --git a/docs/images/app-connections/vercel/vercel-token-created.png b/docs/images/app-connections/vercel/vercel-token-created.png new file mode 100644 index 000000000..5e57a4bd8 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-token-created.png differ diff --git a/docs/images/secret-syncs/vercel/select-vercel-option.png b/docs/images/secret-syncs/vercel/select-vercel-option.png new file mode 100644 index 000000000..b63d33cc7 Binary files /dev/null and b/docs/images/secret-syncs/vercel/select-vercel-option.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-created.png b/docs/images/secret-syncs/vercel/vercel-created.png new file mode 100644 index 000000000..fe955b00f Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-created.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-destination.png b/docs/images/secret-syncs/vercel/vercel-destination.png new file mode 100644 index 000000000..4d0f73d35 Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-destination.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-details.png b/docs/images/secret-syncs/vercel/vercel-details.png new file mode 100644 index 000000000..4421c426c Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-details.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-options.png b/docs/images/secret-syncs/vercel/vercel-options.png new file mode 100644 index 000000000..1d38e7ae6 Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-options.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-review.png b/docs/images/secret-syncs/vercel/vercel-review.png new file mode 100644 index 000000000..7a921f4ec Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-review.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-source.png b/docs/images/secret-syncs/vercel/vercel-source.png new file mode 100644 index 000000000..efb753aad Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-source.png differ diff --git a/docs/integrations/app-connections/vercel.mdx b/docs/integrations/app-connections/vercel.mdx new file mode 100644 index 000000000..8ef4a5647 --- /dev/null +++ b/docs/integrations/app-connections/vercel.mdx @@ -0,0 +1,97 @@ +--- +title: "Vercel Connection" +description: "Learn how to configure a Vercel Connection for Infisical." +--- + +Infisical supports connecting to Vercel using an API Token to securely sync your secrets to Vercel. + +## Setup Vercel Connection in Infisical + + + + Navigate to the Vercel **Account Settings** page by clicking on your profile icon in the top-right corner. + ![Vercel API Tokens Tab](/images/app-connections/vercel/vercel-main-page.png) + + + Select the **API Tokens** tab from the left sidebar navigation menu. + ![Vercel API Tokens Tab](/images/app-connections/vercel/vercel-settings-page.png) + + + Click the **Create** button and provide a name for your token (e.g., "Infisical Integration"). + Choose appropriate scope permissions based on your requirements. + + If you configure an expiry date for your API token, you will need to manually rotate to a new token prior to expiration to avoid integration downtime. Consider setting a calendar reminder for this task. + + ![Vercel Create API Token](/images/app-connections/vercel/vercel-create-token.png) + + + After creation, a modal with the API token will be displayed. Copy this token immediately and store it securely, as you won't be able to view it again after closing this dialog. + ![Vercel Copy API Token](/images/app-connections/vercel/vercel-copy-token.png) + + + You should now see your newly created token in the list of API tokens on the Vercel dashboard. + ![Vercel Connection Created](/images/app-connections/vercel/vercel-token-created.png) + + + + + 1. Navigate to App Connections + + In your Infisical dashboard, go to **Organization Settings** and select the **App Connections** tab. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + 2. Add Connection + + Click the **+ Add Connection** button and select the **Vercel Connection** option from the available integrations. + ![Select Vercel Connection](/images/app-connections/vercel/vercel-app-connection-option.png) + 3. Fill the Vercel Connection Modal + + Complete the Vercel Connection form by entering: + - A descriptive name for the connection + - The API Token you generated in steps 3-4 + - An optional description for future reference + ![Vercel Connection Modal](/images/app-connections/vercel/vercel-app-connection-modal.png) + 4. Connection Created + + After clicking Create, your **Vercel Connection** is established and ready to use with your Infisical projects. + ![Vercel Connection Created](/images/app-connections/vercel/vercel-app-connection-created.png) + + + To create a Vercel Connection, make an API request to the [Create Vercel + Connection](/api-reference/endpoints/app-connections/vercel/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/vercel \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-vercel-connection", + "method": "api-token", + "credentials": { + "apiToken": "...", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-vercel-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-04-01T05:31:56Z", + "updatedAt": "2025-04-01T05:31:56Z", + "app": "vercel", + "method": "api-token", + "credentials": {} + } + } + ``` + + + + \ No newline at end of file diff --git a/docs/integrations/secret-syncs/vercel.mdx b/docs/integrations/secret-syncs/vercel.mdx new file mode 100644 index 000000000..593874dee --- /dev/null +++ b/docs/integrations/secret-syncs/vercel.mdx @@ -0,0 +1,148 @@ +--- +title: "Vercel Sync" +description: "Learn how to configure a Vercel Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [Vercel Connection](/integrations/app-connections/vercel) + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **Vercel** option. + ![Select Vercel](/images/secret-syncs/vercel/select-vercel-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/vercel/vercel-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/vercel/vercel-destination.png) + + - **Vercel Connection**: The Vercel Connection to authenticate with. + - **Vercel App**: The application to deploy secrets to. + - **Vercel App Environment**: The environment to deploy secrets to. + - **Vercel Preview Branch (Optional)**: Specify a branch for preview deployments if needed. + + After configuring these parameters, click the **Next** button to continue to the Sync Options step. + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/vercel/vercel-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Vercel when keys conflict. + - **Import Secrets (Prioritize Vercel)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Vercel over Infisical when keys conflict. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your Vercel Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/vercel/vercel-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Vercel Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/vercel/vercel-review.png) + + 8. If enabled, your Vercel Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/vercel/vercel-created.png) + + + + To create an **Vercel Sync**, make an API request to the [Create Vercel Sync](/api-reference/endpoints/secret-syncs/vercel/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/vercel \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-vercel-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "app": "prj_bz7zgHvQETPvJWc5tmIr0tGRH9kE", + "env": "preview", + "branch": "test", + "appName": "nextjs-boilerplate", + "teamId": "team_0d444b5088888dd257" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-vercel-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "vercel", + "name": "my-vercel-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "vercel", + "destinationConfig": { + "app": "prj_bz7zgHvQETPvJWc5tmIr0tGRH9kE", + "env": "preview", + "branch": "test", + "appName": "nextjs-boilerplate", + "teamId": "team_0d444b5088888dd257" + } + } + } + ``` + + diff --git a/docs/mint.json b/docs/mint.json index da6f3f616..be3f03661 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -422,6 +422,7 @@ "integrations/app-connections/github", "integrations/app-connections/humanitec", "integrations/app-connections/terraform-cloud", + "integrations/app-connections/vercel", "integrations/app-connections/mssql", "integrations/app-connections/postgres" ] @@ -443,7 +444,8 @@ "integrations/secret-syncs/gcp-secret-manager", "integrations/secret-syncs/github", "integrations/secret-syncs/humanitec", - "integrations/secret-syncs/terraform-cloud" + "integrations/secret-syncs/terraform-cloud", + "integrations/secret-syncs/vercel" ] } ] @@ -584,7 +586,8 @@ "api-reference/endpoints/identities/update", "api-reference/endpoints/identities/delete", "api-reference/endpoints/identities/get-by-id", - "api-reference/endpoints/identities/list" + "api-reference/endpoints/identities/list", + "api-reference/endpoints/identities/search" ] }, { @@ -973,6 +976,18 @@ "api-reference/endpoints/app-connections/terraform-cloud/delete" ] }, + { + "group": "Vercel", + "pages": [ + "api-reference/endpoints/app-connections/vercel/list", + "api-reference/endpoints/app-connections/vercel/available", + "api-reference/endpoints/app-connections/vercel/get-by-id", + "api-reference/endpoints/app-connections/vercel/get-by-name", + "api-reference/endpoints/app-connections/vercel/create", + "api-reference/endpoints/app-connections/vercel/update", + "api-reference/endpoints/app-connections/vercel/delete" + ] + }, { "group": "Microsoft SQL Server", "pages": [ @@ -1125,6 +1140,20 @@ "api-reference/endpoints/secret-syncs/terraform-cloud/sync-secrets", "api-reference/endpoints/secret-syncs/terraform-cloud/remove-secrets" ] + }, + { + "group": "Vercel", + "pages": [ + "api-reference/endpoints/secret-syncs/vercel/list", + "api-reference/endpoints/secret-syncs/vercel/get-by-id", + "api-reference/endpoints/secret-syncs/vercel/get-by-name", + "api-reference/endpoints/secret-syncs/vercel/create", + "api-reference/endpoints/secret-syncs/vercel/update", + "api-reference/endpoints/secret-syncs/vercel/delete", + "api-reference/endpoints/secret-syncs/vercel/sync-secrets", + "api-reference/endpoints/secret-syncs/vercel/remove-secrets", + "api-reference/endpoints/secret-syncs/vercel/import-secrets" + ] } ] }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1fa72b81a..abf46cadb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@hcaptcha/react-hcaptcha": "^1.11.0", "@headlessui/react": "^1.7.19", "@hookform/resolvers": "^3.9.1", + "@lexical/react": "^0.29.0", "@lottiefiles/dotlottie-react": "^0.12.0", "@octokit/rest": "^21.0.2", "@peculiar/x509": "^1.12.3", @@ -66,6 +67,7 @@ "jspdf": "^2.5.2", "jsrp": "^0.2.4", "jwt-decode": "^4.0.0", + "lexical": "^0.29.0", "ms": "^2.1.3", "nprogress": "^0.2.0", "picomatch": "^4.0.2", @@ -1570,6 +1572,260 @@ } } }, + "node_modules/@lexical/clipboard": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.29.0.tgz", + "integrity": "sha512-llxZosYCwH13p2GfPfhAinukdvAZYxWuwf5md107X80hsE8TQJj25unjqTwRKQ+w/wD+hpmBMziU8+K/WTitWQ==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.29.0", + "@lexical/list": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/code": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.29.0.tgz", + "integrity": "sha512-yKGzoKpyIO39Xf7OKLPpoCE5V8mTDCM3l3CDHZR3X1gM/VZQzf4jAiO3b06y9YkQ2fM8kqwchYu87wGvs8/iIQ==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0", + "prismjs": "^1.30.0" + } + }, + "node_modules/@lexical/devtools-core": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.29.0.tgz", + "integrity": "sha512-uUq0m9ql/7mthp7Ho1vnG7Id6imQ5kD5mxUhX2lmgHretS+yAHGsGsGiPIVHdPWeVmUb2n4IVDJ+cJbUsUjQJw==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.29.0", + "@lexical/link": "0.29.0", + "@lexical/mark": "0.29.0", + "@lexical/table": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.29.0.tgz", + "integrity": "sha512-Zaky2jd/Pp1blAZqPeGNdyhxnVL4lwVjbWPxhfS1gbW4Q5CBQ3aD3B0T4ljiKfmRNJm004LJ9q7KjhlRbREvZA==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.29.0.tgz", + "integrity": "sha512-fa7s0Yi2RKz/GvgT5XU9fborx6VPU3VtvvEPaIXgyd6zXZRiOhD9rGypwB3oj4fMK1ndx2dX0m7SwhMJo48D8w==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/history": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.29.0.tgz", + "integrity": "sha512-OrCwZycp/yaq63mw511NutkwAB+W6WSchG1xTxlLh6nbc8jnbvKhCf4CGbnrvlhD7hTuzxJ8FI9/2M/2zv/mNQ==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/html": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.29.0.tgz", + "integrity": "sha512-+jV6ijppOpxpUGeXkGssXJbsAmFALfeLrgbM0xuZbxZ7RgYZ+5Atn00WjSno7+JV5EOuRkYmCNtS1tiHtXMY1g==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/link": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.29.0.tgz", + "integrity": "sha512-wGbKRF0x/6ZQHuCfr8m8qD1J0R1kFmWINBG2A1hUXPDf7UY5qm/nS2oKNDGpjiDMGwkVZ7n7WfzeBGO+KRe/Lg==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/list": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.29.0.tgz", + "integrity": "sha512-sWiof+i2ff8rL7KxJ3dxHLwyJfX423e1EVLmAdQEOPhyZJiNbeLTSNhNGsZ8FjFoBwvTTEDwuQZm3iT3hliKOg==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/mark": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.29.0.tgz", + "integrity": "sha512-UB3x6pyUdpZHRqF4tiajLnC1+Umvt7x8Rkkdi29aNNvzIWniVwGkBOlmvFus7x+4dOV1D1fydwiP4m38nGgLDw==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.29.0.tgz", + "integrity": "sha512-4Od8WoDoviv9DxJZVgrIORTIAzyoGOpztbGbIBXguGmwvy7NnHQDh9fZYIYRrdI1Awp1VVGdJ3ku/7KTgSOoRw==", + "license": "MIT", + "dependencies": { + "@lexical/code": "0.29.0", + "@lexical/link": "0.29.0", + "@lexical/list": "0.29.0", + "@lexical/rich-text": "0.29.0", + "@lexical/text": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/offset": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.29.0.tgz", + "integrity": "sha512-VyD2Ff3rBJpo++Fxvi3MNYmDELa+9nA0EgXqGRNb3MvRehRjHbaDbymtLMMHIwvbkF5lnra+ubStcTRQmoQxXw==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.29.0.tgz", + "integrity": "sha512-IzH3M652Ej2gB2sK65N3yTgyiQAa3I3tqKbSnBRiXu/+isxHoCy/qRr9/kL63uy7zhGvgV+EYsoffQCawIFt8Q==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.29.0.tgz", + "integrity": "sha512-F5C3meDb2HmO0NmKJBVRkjmX9PNln6O1jXU/APJuSFBdvfcIWSY58ncHR4zy2M5LF1Q5PQMWyIay9p+SqOtY5A==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/react": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.29.0.tgz", + "integrity": "sha512-YMlnljW/jxmwSzsRv5UPatfOoMZXqxFmRIEltTUIQfrOFdqn+ssUtCpjE6xRD1oxD6KpSIekakzLs+y/8+7CuQ==", + "license": "MIT", + "dependencies": { + "@lexical/devtools-core": "0.29.0", + "@lexical/dragon": "0.29.0", + "@lexical/hashtag": "0.29.0", + "@lexical/history": "0.29.0", + "@lexical/link": "0.29.0", + "@lexical/list": "0.29.0", + "@lexical/mark": "0.29.0", + "@lexical/markdown": "0.29.0", + "@lexical/overflow": "0.29.0", + "@lexical/plain-text": "0.29.0", + "@lexical/rich-text": "0.29.0", + "@lexical/table": "0.29.0", + "@lexical/text": "0.29.0", + "@lexical/utils": "0.29.0", + "@lexical/yjs": "0.29.0", + "lexical": "0.29.0", + "react-error-boundary": "^3.1.4" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.29.0.tgz", + "integrity": "sha512-fSKgXGxJUOWo7dwSTUYFVBNNk4pPN8norsZfdmKM1kGDS1/GKuVzlzHLKZ7rQb8RLD5a43p4ifEL+28P+q0Qqg==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/selection": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.29.0.tgz", + "integrity": "sha512-lX9CRrXgKte65cozTHFXwUJ2fvZD92OEtos+YU+U40GJjf3NdheGeKDxDfOpF4AXrYRSszY7E0CzmIvuEs0p4A==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/table": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.29.0.tgz", + "integrity": "sha512-Jdj32kBDeJh/0dGaZB14JggnEIS956/cN7grnLr7cmhhVzDicvLMBENSXQVEJAQVcSIU4G9EvxC7GJZ9VgqDnA==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/text": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.29.0.tgz", + "integrity": "sha512-QnNGr6ickTLk76o3PdxJjPwt//dpuh8idVfR73WdCIoAwkhiEPUxxTZERoMsudXj6O/lJ+/HhI61wVjLckYr3A==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/utils": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.29.0.tgz", + "integrity": "sha512-y2hhWQDjcXdplsAaQMuZx6ht9u1I4BV5NynA+WKoQ3h8vKxzeDnpCxVOK/zxU1R5dhM/nilnFu7uhvrSeEn+TQ==", + "license": "MIT", + "dependencies": { + "@lexical/list": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/table": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.29.0.tgz", + "integrity": "sha512-6IXWWlGkVJEzWP/+LcuKYJ9jmcFp8k7TT/jmz4V5gBD9Ut3swOGsIA/sQCtB9y7jad10csaDVmFdFzGNWKVH9A==", + "license": "MIT", + "dependencies": { + "@lexical/offset": "0.29.0", + "@lexical/selection": "0.29.0", + "lexical": "0.29.0" + }, + "peerDependencies": { + "yjs": ">=13.5.22" + } + }, "node_modules/@lottiefiles/dotlottie-react": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.12.0.tgz", @@ -8871,6 +9127,17 @@ "node": ">=10" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/iterator.prototype": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.4.tgz", @@ -9100,6 +9367,34 @@ "node": ">= 0.8.0" } }, + "node_modules/lexical": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.29.0.tgz", + "integrity": "sha512-eoBHUEn0LmExKeK6x2cFKU0FPaMk2Bc5HgiCzTiv5ymKtwWw7LeKcxaNPmLxRRdQpcWV1IMKjayAbw7Lt/Gu7w==", + "license": "MIT" + }, + "node_modules/lib0": { + "version": "0.2.102", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.102.tgz", + "integrity": "sha512-g70kydI0I1sZU0ChO8mBbhw0oUW/8U0GHzygpvEIx8k+jgOpqnTSb/E+70toYVqHxBhrERD21TwD5QcZJQ40ZQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -10857,6 +11152,15 @@ } } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -11142,6 +11446,22 @@ "react": "^18.3.1" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -13587,9 +13907,9 @@ } }, "node_modules/vite": { - "version": "5.4.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", - "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", + "version": "5.4.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz", + "integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14131,6 +14451,24 @@ "node": ">=8" } }, + "node_modules/yjs": { + "version": "13.6.24", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.24.tgz", + "integrity": "sha512-xn/pYLTZa3uD1uDG8lpxfLRo5SR/rp0frdASOl2a71aYNvUXdWcLtVL91s2y7j+Q8ppmjZ9H3jsGVgoFMbT2VA==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 95ad59c7d..e750783b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@hcaptcha/react-hcaptcha": "^1.11.0", "@headlessui/react": "^1.7.19", "@hookform/resolvers": "^3.9.1", + "@lexical/react": "^0.29.0", "@lottiefiles/dotlottie-react": "^0.12.0", "@octokit/rest": "^21.0.2", "@peculiar/x509": "^1.12.3", @@ -70,6 +71,7 @@ "jspdf": "^2.5.2", "jsrp": "^0.2.4", "jwt-decode": "^4.0.0", + "lexical": "^0.29.0", "ms": "^2.1.3", "nprogress": "^0.2.0", "picomatch": "^4.0.2", diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 7e310c00a..b6a74a9ab 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -12,6 +12,7 @@ import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; +import { VercelSyncFields } from "./VercelSyncFields"; export const SecretSyncDestinationFields = () => { const { watch } = useFormContext(); @@ -37,6 +38,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.TerraformCloud: return ; + case SecretSync.Vercel: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx new file mode 100644 index 000000000..5c328079d --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx @@ -0,0 +1,195 @@ +import { useMemo } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { + TVercelConnectionApp, + useVercelConnectionListOrganizations +} from "@app/hooks/api/appConnections/vercel"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +const vercelEnvironments = [ + { name: "Development", slug: "development" }, + { name: "Preview", slug: "preview" }, + { name: "Production", slug: "production" } +]; + +export const VercelSyncFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Vercel } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentApp = watch("destinationConfig.app"); + const currentEnv = watch("destinationConfig.env"); + + const { data: projects, isLoading: isProjectsLoading } = useVercelConnectionListOrganizations( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const selectedProject = projects + ?.find((project) => project.apps.some((app) => app.id === currentApp)) + ?.apps.find((app) => app.id === currentApp); + + const allApps = + projects?.flatMap((project) => + project.apps.map((app) => ({ ...app, project: project.name, projectId: project.id })) + ) || []; + + const environmentOptions = useMemo(() => { + return vercelEnvironments + .map((env) => ({ + key: env.slug, + type: env.slug, + name: env.name + })) + .concat( + selectedProject?.envs?.map((env) => ({ + key: env.id, + type: env.type, + name: env.slug + })) || [] + ); + }, [currentApp]); + + const previewBranchOptions = + selectedProject?.previewBranches?.map((branch) => ({ + id: branch, + name: branch + })) || []; + + const isPreviewEnvironment = currentEnv === "preview"; + + return ( + <> + { + setValue("destinationConfig.app", ""); + setValue("destinationConfig.appName", ""); + setValue("destinationConfig.env", "production"); + setValue("destinationConfig.branch", ""); + }} + /> + + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + app.id === value) ?? null} + onChange={(option) => { + const appId = (option as SingleValue)?.id ?? null; + onChange(appId); + setValue("destinationConfig.branch", ""); + setValue( + "destinationConfig.teamId", + (option as SingleValue)?.projectId || "" + ); + setValue( + "destinationConfig.appName", + (option as SingleValue)?.name || "" + ); + }} + options={allApps} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + groupBy="project" + /> +
+ )} + /> + + ( + + env.key === value)?.key, + type: environmentOptions.find((env) => env.key === value)?.type, + name: environmentOptions.find((env) => env.key === value)?.name + } + : null + } + onChange={(option) => { + const envKey = (option as any)?.key ?? null; + onChange(envKey); + + setValue("destinationConfig.branch", ""); + }} + options={environmentOptions} + placeholder="Select an environment..." + getOptionLabel={(option) => option.name || option.key || ""} + getOptionValue={(option) => option.key || ""} + /> + + )} + /> + + {isPreviewEnvironment && ( + ( + + branch.id === value) ?? null} + onChange={(option) => onChange((option as SingleValue<{ id: string }>)?.id || "")} + options={previewBranchOptions} + placeholder="Select a branch..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option?.id || ""} + isClearable + /> + + )} + /> + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 14edc4e06..cd92527b7 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -40,6 +40,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Databricks: case SecretSync.Humanitec: case SecretSync.TerraformCloud: + case SecretSync.Vercel: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 7aa689d98..c2a81988b 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -22,6 +22,7 @@ import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; +import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); @@ -76,6 +77,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.TerraformCloud: DestinationFieldsComponent = ; break; + case SecretSync.Vercel: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx new file mode 100644 index 000000000..43e83cf96 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx @@ -0,0 +1,23 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +export const VercelSyncReviewFields = () => { + const { watch } = useFormContext(); + const envId = watch("destinationConfig.env"); + const branchId = watch("destinationConfig.branch"); + const appName = watch("destinationConfig.appName"); + + return ( + <> + {appName} + {envId} + {envId === VercelEnvironmentType.Preview && branchId && ( + {branchId} + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 300e2472f..750b610c1 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -10,6 +10,7 @@ import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-desti import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; +import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, @@ -20,7 +21,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AzureAppConfigurationSyncDestinationSchema, DatabricksSyncDestinationSchema, HumanitecSyncDestinationSchema, - TerraformCloudSyncDestinationSchema + TerraformCloudSyncDestinationSchema, + VercelSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts new file mode 100644 index 000000000..9d3678803 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +export const VercelSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Vercel), + destinationConfig: z.object({ + app: z.string().trim().min(1, "Project required"), + appName: z.string().trim().min(1, "Project required"), + env: z.nativeEnum(VercelEnvironmentType).or(z.string()), + branch: z.string().trim().optional(), + teamId: z.string().trim() + }) + }) +); diff --git a/frontend/src/components/v2/Editor/Editor.tsx b/frontend/src/components/v2/Editor/Editor.tsx new file mode 100644 index 000000000..e9c49cbac --- /dev/null +++ b/frontend/src/components/v2/Editor/Editor.tsx @@ -0,0 +1,159 @@ +/* eslint-disable no-underscore-dangle */ +import { forwardRef, InputHTMLAttributes } from "react"; +import { InitialConfigType, LexicalComposer } from "@lexical/react/LexicalComposer"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; +import { ReactNode } from "@tanstack/react-router"; +import { cva, VariantProps } from "cva"; +import { EditorState, LexicalEditor } from "lexical"; +import { twMerge } from "tailwind-merge"; + +import { HighlightNode } from "./EditorHighlight"; +import { EditorPlaceholderPlugin } from "./EditorPlaceholderPlugin"; + +// Catch any errors that occur during Lexical updates and log them +// or throw them as needed. If you don't throw them, Lexical will +// try to recover gracefully without losing user data. +function onError(error: Error) { + console.error(error); +} + +const inputVariants = cva( + "input w-full py-[0.375rem] text-gray-400 placeholder:text-sm placeholder-gray-500 placeholder-opacity-50 outline-none focus:ring-2 hover:ring-bunker-400/60 duration-100", + { + variants: { + size: { + xs: ["text-xs"], + sm: ["text-sm"], + md: ["text-md"], + lg: ["text-lg"] + }, + isRounded: { + true: ["rounded-md"], + false: "" + }, + variant: { + filled: ["bg-mineshaft-900", "text-gray-400"], + outline: ["bg-transparent"], + plain: "bg-transparent outline-none" + }, + isError: { + true: "focus:ring-red/50 placeholder-red-300", + false: "focus:ring-primary-400/50 focus:ring-1" + } + }, + compoundVariants: [] + } +); + +const inputParentContainerVariants = cva("inline-flex font-inter items-center border relative", { + variants: { + isRounded: { + true: ["rounded-md"], + false: "" + }, + isError: { + true: "border-red", + false: "border-mineshaft-500" + }, + isFullWidth: { + true: "w-full", + false: "" + }, + variant: { + filled: ["bg-bunker-800", "text-gray-400"], + outline: ["bg-transparent"], + plain: "border-none" + } + } +}); + +type Props = Omit< + InputHTMLAttributes, + "size" | "onChange" | "placeholder" | "aria-placeholder" +> & + VariantProps & { + children?: ReactNode; + namespace?: string; + placeholder?: string; + isFullWidth?: boolean; + isRequired?: boolean; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + isDisabled?: boolean; + isReadOnly?: boolean; + containerClassName?: string; + onChange: (editorState: EditorState, editor: LexicalEditor, tags: Set) => void; + initialValue?: string; + }; + +export const Editor = forwardRef( + ( + { + children, + namespace = "infisical-editor", + className, + containerClassName, + isRounded = true, + isFullWidth = true, + isDisabled, + isError = false, + isRequired, + leftIcon, + rightIcon, + variant = "filled", + size = "md", + isReadOnly, + placeholder, + onChange, + ...props + }, + ref + ) => { + const initialConfig: InitialConfigType = { + namespace, + onError, + nodes: [HighlightNode] + }; + + return ( +
+ {leftIcon && {leftIcon}} + + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + + {children} + + {rightIcon && {rightIcon}} +
+ ); + } +); diff --git a/frontend/src/components/v2/Editor/EditorHighlight.tsx b/frontend/src/components/v2/Editor/EditorHighlight.tsx new file mode 100644 index 000000000..bb57828fb --- /dev/null +++ b/frontend/src/components/v2/Editor/EditorHighlight.tsx @@ -0,0 +1,127 @@ +/* eslint-disable no-underscore-dangle,@typescript-eslint/class-methods-use-this */ +import { useCallback, useEffect } from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useLexicalTextEntity } from "@lexical/react/useLexicalTextEntity"; +import { + $applyNodeReplacement, + EditorConfig, + LexicalNode, + SerializedTextNode, + Spread, + TextNode +} from "lexical"; + +type HighlightTheme = { contentClassName: string }; +type Trigger = { startTrigger: string; endTrigger: string }; + +export type SerializedHighlightNode = Spread< + { + __highlightTheme: HighlightTheme; + __trigger: Trigger; + }, + SerializedTextNode +>; + +export class HighlightNode extends TextNode { + __highlightTheme: HighlightTheme; + __trigger: Trigger; + + constructor( + text: string, + highlightTheme: HighlightTheme = { + contentClassName: "ph-no-capture text-yellow-200/80" + }, + trigger: Trigger = { startTrigger: "${", endTrigger: "}" }, + key?: string + ) { + super(text, key); + this.__highlightTheme = highlightTheme; + this.__trigger = trigger; + } + + static getType(): string { + return "highlight"; + } + + static clone(node: HighlightNode): HighlightNode { + return new HighlightNode(node.__text, node.__highlightTheme, node.__trigger, node.__key); + } + + static importJSON(serializedNode: SerializedHighlightNode): HighlightNode { + return $applyNodeReplacement(new HighlightNode("")).updateFromJSON(serializedNode); + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + dom.style.cursor = "default"; + dom.className = this.__highlightTheme.contentClassName; + return dom; + } + + canInsertTextBefore(): boolean { + return false; + } + + canInsertTextAfter(): boolean { + return false; + } + + isTextEntity(): true { + return true; + } +} + +export function $createKeywordNode(keyword: string = ""): HighlightNode { + return $applyNodeReplacement(new HighlightNode(keyword)); +} + +export function $isKeywordNode(node: LexicalNode | null | undefined): boolean { + return node instanceof HighlightNode; +} + +type Props = { + contentClassName?: string; + startTrigger?: string; + endTrigger?: string; +}; + +export const EditorHighlightPlugin = ({ + endTrigger = "}", + startTrigger = "${", + contentClassName = "ph-no-capture text-yellow-200/80" +}: Props) => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([HighlightNode])) { + throw new Error("HighlightsPlugin: HighlightsNode not registered on editor"); + } + }, [editor]); + + const createKeywordNode = useCallback((textNode: TextNode): HighlightNode => { + return $applyNodeReplacement( + new HighlightNode( + textNode.getTextContent(), + { contentClassName }, + { startTrigger, endTrigger } + ) + ); + }, []); + + const getKeywordMatch = useCallback((text: string) => { + for (let i = 0; i < text.length; i += 1) { + if (text.slice(i, i + 2) === startTrigger) { + const closingBracketIndex = text.indexOf(endTrigger, i + 2); + if (closingBracketIndex !== -1) { + return { start: i, end: closingBracketIndex + 1 }; + } + return null; + } + } + return null; + }, []); + + useLexicalTextEntity(getKeywordMatch, HighlightNode, createKeywordNode); + + return null; +}; diff --git a/frontend/src/components/v2/Editor/EditorPlaceholderPlugin.tsx b/frontend/src/components/v2/Editor/EditorPlaceholderPlugin.tsx new file mode 100644 index 000000000..7bddf17de --- /dev/null +++ b/frontend/src/components/v2/Editor/EditorPlaceholderPlugin.tsx @@ -0,0 +1,22 @@ +import { useEffect } from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useLexicalIsTextContentEmpty } from "@lexical/react/useLexicalIsTextContentEmpty"; + +export const EditorPlaceholderPlugin = ({ placeholder }: { placeholder: string | undefined }) => { + const [editor] = useLexicalComposerContext(); + const isEmpty = useLexicalIsTextContentEmpty(editor); + + /* Set the placeholder on root. */ + useEffect(() => { + const rootElement = editor.getRootElement() as HTMLElement; + if (rootElement) { + if (isEmpty && placeholder) { + rootElement.setAttribute("placeholder", placeholder); + } else { + rootElement.removeAttribute("placeholder"); + } + } + }, [editor, isEmpty]); // eslint-disable-line + + return null; +}; diff --git a/frontend/src/components/v2/Editor/index.tsx b/frontend/src/components/v2/Editor/index.tsx new file mode 100644 index 000000000..6da88bf75 --- /dev/null +++ b/frontend/src/components/v2/Editor/index.tsx @@ -0,0 +1,2 @@ +export { Editor } from "./Editor"; +export { EditorHighlightPlugin } from "./EditorHighlight"; diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 092fd6697..9dcf72e40 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -11,6 +11,7 @@ export * from "./DatePicker"; export * from "./DeleteActionModal"; export * from "./Drawer"; export * from "./Dropdown"; +export * from "./Editor"; export * from "./EmailServiceSetupModal"; export * from "./EmptyState"; export * from "./FilterableSelect"; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 2fbbbc717..7c94dc3bc 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -13,7 +13,8 @@ import { MsSqlConnectionMethod, PostgresConnectionMethod, TAppConnection, - TerraformCloudConnectionMethod + TerraformCloudConnectionMethod, + VercelConnectionMethod } from "@app/hooks/api/appConnections/types"; export const APP_CONNECTION_MAP: Record = { @@ -31,6 +32,7 @@ export const APP_CONNECTION_MAP: Record = { [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, [SecretSync.Humanitec]: AppConnection.Humanitec, - [SecretSync.TerraformCloud]: AppConnection.TerraformCloud + [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, + [SecretSync.Vercel]: AppConnection.Vercel }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 17f56874e..c7505617c 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -7,6 +7,7 @@ export enum AppConnection { Databricks = "databricks", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", + Vercel = "vercel", Postgres = "postgres", MsSql = "mssql" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 545c1b5b0..52474be6d 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -43,6 +43,10 @@ export type TTerraformCloudConnectionOption = TAppConnectionOptionBase & { app: AppConnection.TerraformCloud; }; +export type TVercelConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Vercel; +}; + export type TPostgresConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Postgres; }; @@ -60,6 +64,7 @@ export type TAppConnectionOption = | TDatabricksConnectionOption | THumanitecConnectionOption | TTerraformCloudConnectionOption + | TVercelConnectionOption | TPostgresConnectionOption | TMsSqlConnectionOption; @@ -72,6 +77,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Databricks]: TDatabricksConnectionOption; [AppConnection.Humanitec]: THumanitecConnectionOption; [AppConnection.TerraformCloud]: TTerraformCloudConnectionOption; + [AppConnection.Vercel]: TVercelConnectionOption; [AppConnection.Postgres]: TPostgresConnectionOption; [AppConnection.MsSql]: TMsSqlConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 267ad89f1..41f4630b7 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -10,6 +10,7 @@ import { THumanitecConnection } from "./humanitec-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TPostgresConnection } from "./postgres-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; +import { TVercelConnection } from "./vercel-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; @@ -21,6 +22,7 @@ export * from "./humanitec-connection"; export * from "./mssql-connection"; export * from "./postgres-connection"; export * from "./terraform-cloud-connection"; +export * from "./vercel-connection"; export type TAppConnection = | TAwsConnection @@ -31,6 +33,7 @@ export type TAppConnection = | TDatabricksConnection | THumanitecConnection | TTerraformCloudConnection + | TVercelConnection | TPostgresConnection | TMsSqlConnection; @@ -68,6 +71,7 @@ export type TAppConnectionMap = { [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; [AppConnection.TerraformCloud]: TTerraformCloudConnection; + [AppConnection.Vercel]: TVercelConnection; [AppConnection.Postgres]: TPostgresConnection; [AppConnection.MsSql]: TMsSqlConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/vercel-connection.ts b/frontend/src/hooks/api/appConnections/types/vercel-connection.ts new file mode 100644 index 000000000..d733639ab --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/vercel-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum VercelConnectionMethod { + ApiToken = "api-token" +} + +export type TVercelConnection = TRootAppConnection & { app: AppConnection.Vercel } & { + method: VercelConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/vercel/index.ts b/frontend/src/hooks/api/appConnections/vercel/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/vercel/queries.tsx b/frontend/src/hooks/api/appConnections/vercel/queries.tsx new file mode 100644 index 000000000..fa66adcdb --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TVercelConnectionOrganization } from "./types"; + +const vercelConnectionKeys = { + all: [...appConnectionKeys.all, "vercel"] as const, + listOrganizations: (connectionId: string) => + [...vercelConnectionKeys.all, "organizations", connectionId] as const +}; + +export const useVercelConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TVercelConnectionOrganization[], + unknown, + TVercelConnectionOrganization[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: vercelConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/vercel/${connectionId}/projects` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/vercel/types.ts b/frontend/src/hooks/api/appConnections/vercel/types.ts new file mode 100644 index 000000000..1e25ce9ee --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/types.ts @@ -0,0 +1,30 @@ +export type TVercelApp = { + id: string; + name: string; + envs: { id: string; name: string }[]; +}; + +export type TVercelConnectionEnvironment = { + id: string; + slug: string; + type: string; + target?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; +}; + +export type TVercelConnectionApp = { + id: string; + name: string; + envs?: TVercelConnectionEnvironment[]; + previewBranches?: string[]; + projectId: string; +}; + +export type TVercelConnectionOrganization = { + id: string; + name: string; + slug: string; + apps: TVercelConnectionApp[]; +}; diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 261556752..f3b9fa012 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -46,5 +46,6 @@ export { useGetIdentityTokenAuth, useGetIdentityTokensTokenAuth, useGetIdentityUniversalAuth, - useGetIdentityUniversalAuthClientSecrets + useGetIdentityUniversalAuthClientSecrets, + useSearchIdentities } from "./queries"; diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 005882359..6b8a1d1cc 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -15,11 +15,13 @@ import { IdentityMembershipOrg, IdentityOidcAuth, IdentityTokenAuth, - IdentityUniversalAuth + IdentityUniversalAuth, + TSearchIdentitiesDTO } from "./types"; export const identitiesKeys = { getIdentityById: (identityId: string) => [{ identityId }, "identity"] as const, + searchIdentities: (dto: TSearchIdentitiesDTO) => ["identity", "search", dto] as const, getIdentityUniversalAuth: (identityId: string) => [{ identityId }, "identity-universal-auth"] as const, getIdentityUniversalAuthClientSecrets: (identityId: string) => @@ -53,6 +55,26 @@ export const useGetIdentityById = (identityId: string) => { }); }; +export const useSearchIdentities = (dto: TSearchIdentitiesDTO) => { + const { limit, search, offset, orderBy, orderDirection } = dto; + return useQuery({ + queryKey: identitiesKeys.searchIdentities(dto), + queryFn: async () => { + const { data } = await apiRequest.post<{ + identities: IdentityMembershipOrg[]; + totalCount: number; + }>("/api/v1/identities/search", { + limit, + offset, + orderBy, + orderDirection, + search + }); + return data; + } + }); +}; + export const useGetIdentityProjectMemberships = (identityId: string) => { return useQuery({ enabled: Boolean(identityId), diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 35a9870bc..ca06219aa 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -1,3 +1,5 @@ +import { OrderByDirection } from "../generic/types"; +import { OrgIdentityOrderBy } from "../organization/types"; import { TOrgRole } from "../roles/types"; import { ProjectUserMembershipTemporaryMode, Workspace } from "../workspace/types"; import { IdentityAuthMethod, IdentityJwtConfigurationType } from "./enums"; @@ -540,3 +542,14 @@ export type TProjectIdentitiesList = { identityMemberships: IdentityMembership[]; totalCount: number; }; + +export type TSearchIdentitiesDTO = { + limit?: number; + offset?: number; + orderBy?: OrgIdentityOrderBy; + orderDirection?: OrderByDirection; + search: { + name?: { $contains: string }; + role?: { $in: string[] }; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 0d6a92bf0..7a3226c6f 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -7,7 +7,8 @@ export enum SecretSync { AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", Humanitec = "humanitec", - TerraformCloud = "terraform-cloud" + TerraformCloud = "terraform-cloud", + Vercel = "vercel" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 81192f55d..5ea78a3dd 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -10,6 +10,7 @@ import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TGcpSync } from "./gcp-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; +import { TVercelSync } from "./vercel-sync"; export type TSecretSyncOption = { name: string; @@ -26,7 +27,8 @@ export type TSecretSync = | TAzureAppConfigurationSync | TDatabricksSync | THumanitecSync - | TTerraformCloudSync; + | TTerraformCloudSync + | TVercelSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts b/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts new file mode 100644 index 000000000..ffae61e23 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts @@ -0,0 +1,27 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export const VercelEnvironmentType = { + Development: "development", + Preview: "preview", + Production: "production" +} as const; + +export type VercelEnvironment = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; + +export type TVercelSync = TRootSecretSync & { + destination: SecretSync.Vercel; + destinationConfig: { + app: string; + env: VercelEnvironment | string; + branch?: string; + appName?: string; + teamId: string; + }; + connection: { + app: AppConnection.Vercel; + name: string; + id: string; + }; +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 3c9610c04..1c5b0c465 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -191,3 +191,10 @@ html { #nprogress .bar { @apply bg-primary-400; } + +[contentEditable="true"]:before { + content: attr(placeholder); + position: absolute; + top: 0.5rem; + @apply text-sm text-gray-500 opacity-50; +} diff --git a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx index 3e6ca67f6..a9242c252 100644 --- a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx @@ -33,14 +33,12 @@ import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/Identity import { AuthPanel } from "./components/AuthPanel"; import { EncryptionPanel } from "./components/EncryptionPanel"; import { IntegrationPanel } from "./components/IntegrationPanel"; -import { RateLimitPanel } from "./components/RateLimitPanel"; import { UserPanel } from "./components/UserPanel"; enum TabSections { Settings = "settings", Encryption = "encryption", Auth = "auth", - RateLimit = "rate-limit", Integrations = "integrations", Users = "users", Identities = "identities", @@ -163,7 +161,6 @@ export const OverviewPage = () => { General Encryption Authentication - Rate Limit Integrations User Identities Machine Identities @@ -262,7 +259,6 @@ export const OverviewPage = () => { { - console.log("clearing"); onChange(""); }} > @@ -403,9 +399,6 @@ export const OverviewPage = () => { - - - diff --git a/frontend/src/pages/admin/OverviewPage/components/RateLimitPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/RateLimitPanel.tsx deleted file mode 100644 index 74264132e..000000000 --- a/frontend/src/pages/admin/OverviewPage/components/RateLimitPanel.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { Controller, useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; -import { createNotification } from "@app/components/notifications"; -import { Button, ContentLoader, FormControl, Input } from "@app/components/v2"; -import { useSubscription } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { useGetRateLimit, useUpdateRateLimit } from "@app/hooks/api"; - -const formSchema = z.object({ - readRateLimit: z.number(), - writeRateLimit: z.number(), - secretsRateLimit: z.number(), - authRateLimit: z.number(), - inviteUserRateLimit: z.number(), - mfaRateLimit: z.number(), - publicEndpointLimit: z.number() -}); - -type TRateLimitForm = z.infer; - -export const RateLimitPanel = () => { - const { data: rateLimit, isPending } = useGetRateLimit(); - const { subscription } = useSubscription(); - const { mutateAsync: updateRateLimit } = useUpdateRateLimit(); - const { handlePopUpToggle, handlePopUpOpen, popUp } = usePopUp(["upgradePlan"] as const); - - const { - control, - handleSubmit, - formState: { isSubmitting, isDirty } - } = useForm({ - resolver: zodResolver(formSchema), - values: { - // eslint-disable-next-line - readRateLimit: rateLimit?.readRateLimit ?? 600, - writeRateLimit: rateLimit?.writeRateLimit ?? 200, - secretsRateLimit: rateLimit?.secretsRateLimit ?? 60, - authRateLimit: rateLimit?.authRateLimit ?? 60, - inviteUserRateLimit: rateLimit?.inviteUserRateLimit ?? 30, - mfaRateLimit: rateLimit?.mfaRateLimit ?? 20, - publicEndpointLimit: rateLimit?.publicEndpointLimit ?? 30 - } - }); - - const onRateLimitFormSubmit = async (formData: TRateLimitForm) => { - try { - if (subscription && !subscription.customRateLimits) { - handlePopUpOpen("upgradePlan"); - return; - } - - const { - readRateLimit, - writeRateLimit, - secretsRateLimit, - authRateLimit, - inviteUserRateLimit, - mfaRateLimit, - publicEndpointLimit - } = formData; - - await updateRateLimit({ - readRateLimit, - writeRateLimit, - secretsRateLimit, - authRateLimit, - inviteUserRateLimit, - mfaRateLimit, - publicEndpointLimit - }); - createNotification({ - text: "Rate limits have been successfully updated. Please allow at least 10 minutes for the changes to take effect.", - type: "success" - }); - } catch (e) { - console.error(e); - createNotification({ - type: "error", - text: "Failed to update rate limiting setting." - }); - } - }; - - return isPending ? ( - - ) : ( -
-
-
Configure rate limits
- ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> -
- - handlePopUpToggle("upgradePlan", isOpen)} - text="You can configure custom rate limits if you switch to Infisical's Enterprise plan." - /> - - ); -}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index cf57aebbb..ddfcc7a7c 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -1,7 +1,10 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; import { faArrowDown, faArrowUp, faEllipsis, + faFilter, faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons"; @@ -12,14 +15,19 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { + Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, EmptyState, + FormControl, IconButton, Input, Pagination, + Popover, + PopoverContent, + PopoverTrigger, Select, SelectItem, Spinner, @@ -30,11 +38,12 @@ import { Td, Th, THead, + Tooltip, Tr } from "@app/components/v2"; import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { usePagination, useResetPageHelper } from "@app/hooks"; -import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api"; +import { useGetOrgRoles, useSearchIdentities, useUpdateIdentity } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrgIdentityOrderBy } from "@app/hooks/api/organization/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -68,22 +77,22 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { page, setPerPage } = usePagination(OrgIdentityOrderBy.Name); + const [filteredRoles, setFilteredRoles] = useState([]); const organizationId = currentOrg?.id || ""; const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); - const { data, isPending, isFetching } = useGetIdentityMembershipOrgs( - { - organizationId, - offset, - limit, - orderDirection, - orderBy, - search: debouncedSearch - }, - { placeholderData: (prevData) => prevData } - ); + const { data, isPending, isFetching } = useSearchIdentities({ + offset, + limit, + orderDirection, + orderBy, + search: { + name: debouncedSearch ? { $contains: debouncedSearch } : undefined, + role: filteredRoles?.length ? { $in: filteredRoles } : undefined + } + }); const { totalCount = 0 } = data ?? {}; useResetPageHelper({ @@ -91,6 +100,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { offset, setPage }); + const filterForm = useForm<{ roles: string }>(); const { data: roles } = useGetOrgRoles(organizationId); @@ -132,13 +142,78 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { return (
- setSearch(e.target.value)} - leftIcon={} - placeholder="Search identities by name..." - /> +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search identities by name..." + /> +
+ + + + + + + + + +
+ Advance Filter +
+
{ + setFilteredRoles(el.roles?.split(",")?.filter(Boolean) || []); + })} + > + ( + + + + )} + /> +
+ + {Boolean(filteredRoles.length) && ( + + )} +
+ +
+
+
+
@@ -190,7 +265,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { {isPending && } {!isPending && - data?.identityMemberships.map(({ identity: { id, name }, role, customRole }) => { + data?.identities?.map(({ identity: { id, name }, role, customRole }) => { return ( { onChangePerPage={(newPerPage) => setPerPage(newPerPage)} /> )} - {!isPending && data && data?.identityMemberships.length === 0 && ( + {!isPending && data && data?.identities.length === 0 && ( 0 + debouncedSearch.trim().length > 0 || filteredRoles?.length > 0 ? "No identities match search filter" : "No identities have been created in this organization" } diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index ba59b8368..a79da885b 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -258,7 +258,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
- Email + Username { e.stopPropagation(); - if (currentOrg?.scimEnabled) { + if (currentOrg?.scimEnabled && isActive) { createNotification({ text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", type: "error" diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index a36cc0e85..8295847a3 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -19,6 +19,7 @@ import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; +import { VercelConnectionForm } from "./VercelConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; @@ -73,6 +74,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.TerraformCloud: return ; + case AppConnection.Vercel: + return ; case AppConnection.Postgres: return ; case AppConnection.MsSql: @@ -129,6 +132,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.TerraformCloud: return ; + case AppConnection.Vercel: + return ; case AppConnection.Postgres: return ; case AppConnection.MsSql: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/VercelConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/VercelConnectionForm.tsx new file mode 100644 index 000000000..d655a5c6a --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/VercelConnectionForm.tsx @@ -0,0 +1,135 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + TVercelConnection, + VercelConnectionMethod +} from "@app/hooks/api/appConnections/types/vercel-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TVercelConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Vercel) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(VercelConnectionMethod.ApiToken), + credentials: z.object({ + apiToken: z.string().trim().min(1, "Service API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const VercelConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Vercel, + method: VercelConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx index fa4249fc4..d50baf135 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx @@ -115,8 +115,29 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { }); } else { const inviteeEmails = selectedMembers - .map((member) => member?.user.username as string) - .filter(Boolean); + .map((member) => { + if (!member) return null; + + if (member.user.email) { + return member.user.email; + } + + if (member.user.username) { + return member.user.username; + } + + return null; + }) + .filter(Boolean) as string[]; + + if (inviteeEmails.length !== selectedMembers.length) { + createNotification({ + text: "Failed to add users to project. One or more users were invalid.", + type: "error" + }); + return; + } + if (inviteeEmails.length || newInvitees.length) { await addMembersToProject({ inviteeEmails: [...inviteeEmails, ...newInvitees], diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 80ae5dd2e..825a34a33 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -86,7 +86,20 @@ const Page = () => { title={identityMembershipDetails?.identity?.name} description={`Identity joined on ${identityMembershipDetails?.createdAt && formatRelative(new Date(identityMembershipDetails?.createdAt || ""), new Date())}`} > -
+
+ { return ; case SecretSync.TerraformCloud: return ; + case SecretSync.Vercel: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/VercelSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/VercelSyncDestinationCol.tsx new file mode 100644 index 000000000..a0c267a89 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/VercelSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TVercelSync } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TVercelSync; +}; + +export const VercelSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index e6d92352a..d8b4d844d 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -82,6 +82,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { secondaryText = destinationConfig.workspaceName; } break; + case SecretSync.Vercel: + primaryText = destinationConfig.appName || destinationConfig.app; + secondaryText = destinationConfig.env; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index 4ebc2f1c0..0477da7a2 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -19,6 +19,7 @@ import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinat import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationCol"; +import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection"; type Props = { secretSync: TSecretSync; @@ -61,6 +62,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.TerraformCloud: DestinationComponents = ; break; + case SecretSync.Vercel: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/VercelSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/VercelSyncDestinationSection.tsx new file mode 100644 index 000000000..bc4a8531d --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/VercelSyncDestinationSection.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from "react"; + +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TVercelSync, VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +type Props = { + secretSync: TVercelSync; +}; + +export const VercelSyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + let Components: ReactNode; + if (destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch) { + Components = ( + <> + + {destinationConfig.appName || destinationConfig.app} + + {destinationConfig.env} + {destinationConfig.branch} + + ); + } else { + Components = ( + <> + + {destinationConfig.appName || destinationConfig.app} + + {destinationConfig.env} + + ); + } + + return Components; +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index 528a09068..fe3bca550 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -49,6 +49,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.Databricks: case SecretSync.Humanitec: case SecretSync.TerraformCloud: + case SecretSync.Vercel: AdditionalSyncOptionsComponent = null; break; default: