diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 7eb0c6cb7..d79c224e5 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -18,6 +18,7 @@ import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-con import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; import { RateLimitConfiguration } from "@app/ee/services/rate-limit/rate-limit-types"; @@ -189,6 +190,7 @@ declare module "fastify" { cmek: TCmekServiceFactory; migration: TExternalMigrationServiceFactory; externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory; + projectTemplate: TProjectTemplateServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index fb78bce4d..9cfb78dd7 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -200,6 +200,9 @@ import { TProjectSlackConfigsInsert, TProjectSlackConfigsUpdate, TProjectsUpdate, + TProjectTemplates, + TProjectTemplatesInsert, + TProjectTemplatesUpdate, TProjectUserAdditionalPrivilege, TProjectUserAdditionalPrivilegeInsert, TProjectUserAdditionalPrivilegeUpdate, @@ -818,5 +821,10 @@ declare module "knex/types/tables" { TExternalGroupOrgRoleMappingsInsert, TExternalGroupOrgRoleMappingsUpdate >; + [TableName.ProjectTemplates]: KnexOriginal.CompositeTableType< + TProjectTemplates, + TProjectTemplatesInsert, + TProjectTemplatesUpdate + >; } } diff --git a/backend/src/db/migrations/20241101174939_project-templates.ts b/backend/src/db/migrations/20241101174939_project-templates.ts new file mode 100644 index 000000000..c75978ce3 --- /dev/null +++ b/backend/src/db/migrations/20241101174939_project-templates.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.ProjectTemplates))) { + await knex.schema.createTable(TableName.ProjectTemplates, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name", 32).notNullable(); + t.string("description").nullable(); + t.jsonb("roles").notNullable(); + t.jsonb("environments").notNullable(); + t.uuid("orgId").notNullable().references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + t.unique(["orgId", "name"]); + }); + + await createOnUpdateTrigger(knex, TableName.ProjectTemplates); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.ProjectTemplates)) { + await dropOnUpdateTrigger(knex, TableName.ProjectTemplates); + + await knex.schema.dropTable(TableName.ProjectTemplates); + } +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 4fcf26c1a..680d8df5b 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -64,6 +64,7 @@ export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; export * from "./project-slack-configs"; +export * from "./project-templates"; export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; export * from "./projects"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 8fb916358..5bab447fd 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -41,6 +41,7 @@ export enum TableName { ProjectUserAdditionalPrivilege = "project_user_additional_privilege", ProjectUserMembershipRole = "project_user_membership_roles", ProjectKeys = "project_keys", + ProjectTemplates = "project_templates", Secret = "secrets", SecretReference = "secret_references", SecretSharing = "secret_sharing", diff --git a/backend/src/db/schemas/project-roles.ts b/backend/src/db/schemas/project-roles.ts index e10f6fd4c..37eb58fcb 100644 --- a/backend/src/db/schemas/project-roles.ts +++ b/backend/src/db/schemas/project-roles.ts @@ -15,7 +15,8 @@ export const ProjectRolesSchema = z.object({ permissions: z.unknown(), createdAt: z.date(), updatedAt: z.date(), - projectId: z.string() + projectId: z.string(), + version: z.number().default(1) }); export type TProjectRoles = z.infer; diff --git a/backend/src/db/schemas/project-templates.ts b/backend/src/db/schemas/project-templates.ts new file mode 100644 index 000000000..b3645aa32 --- /dev/null +++ b/backend/src/db/schemas/project-templates.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProjectTemplatesSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + roles: z.unknown(), + environments: z.unknown(), + orgId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProjectTemplates = z.infer; +export type TProjectTemplatesInsert = Omit, TImmutableDBKeys>; +export type TProjectTemplatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 1bf896a85..5e3a0eafe 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,3 +1,5 @@ +import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-template-router"; + import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; @@ -92,4 +94,6 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerExternalKmsRouter, { prefix: "/external-kms" }); + + await server.register(registerProjectTemplateRouter, { prefix: "/project-templates" }); }; diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index dc53a125b..ba2c0aa9f 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -192,7 +192,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - roles: ProjectRolesSchema.omit({ permissions: true }).array() + roles: ProjectRolesSchema.omit({ permissions: true, version: true }).array() }) } }, @@ -225,7 +225,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - role: SanitizedRoleSchemaV1 + role: SanitizedRoleSchemaV1.omit({ version: true }) }) } }, diff --git a/backend/src/ee/routes/v1/project-template-router.ts b/backend/src/ee/routes/v1/project-template-router.ts new file mode 100644 index 000000000..17a2b85a4 --- /dev/null +++ b/backend/src/ee/routes/v1/project-template-router.ts @@ -0,0 +1,303 @@ +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +import { ProjectMembershipRole, ProjectTemplatesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; +import { + DefaultProjectTemplateIdentifier, + ProjectTemplateDefaultEnvironments +} from "@app/ee/services/project-template/project-template-constants"; +import { ProjectTemplates } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const SlugSchema = z + .string() + .trim() + .min(1) + .max(32) + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Must be valid slug format" + }); + +const isReservedRoleSlug = (slug: string) => + Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); + +const isReservedRoleName = (name: string) => + ["custom", "admin", "viewer", "developer", "no access"].includes(name.toLowerCase()); + +const SanitizedProjectTemplateSchema = ProjectTemplatesSchema.extend({ + roles: z + .object({ + name: z.string().trim().min(1), + slug: SlugSchema, + permissions: UnpackedPermissionSchema.array() + }) + .array(), + environments: z + .object({ + name: z.string().trim().min(1), + slug: SlugSchema, + position: z.number().min(1) + }) + .array() +}); + +const ProjectTemplateRolesSchema = z + .object({ + name: z.string().trim().min(1), + slug: SlugSchema, + permissions: ProjectPermissionV2Schema.array() + }) + .array() + .superRefine((roles, ctx) => { + if (!roles.length) return; + + if (new Set(roles.map((v) => v.slug)).size !== roles.length) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Role slugs must be unique" }); + + if (new Set(roles.map((v) => v.name)).size !== roles.length) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Role names must be unique" }); + + roles.forEach((role) => { + if (isReservedRoleSlug(role.slug)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Role slug "${role.slug}" is reserved` }); + + if (isReservedRoleName(role.name)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Role name "${role.name}" is reserved` }); + }); + }); + +const ProjectTemplateEnvironmentsSchema = z + .object({ + name: z.string().trim().min(1), + slug: SlugSchema, + position: z.number().min(1) + }) + .array() + .min(1) + .superRefine((environments, ctx) => { + if (new Set(environments.map((v) => v.name)).size !== environments.length) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Environment names must be unique" }); + + if (new Set(environments.map((v) => v.slug)).size !== environments.length) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Environment slugs must be unique" }); + + if ( + environments.some((env) => env.position < 1 || env.position > environments.length) || + new Set(environments.map((env) => env.position)).size !== environments.length + ) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "One or more of the positions specified is invalid. Positions must be sequential starting from 1." + }); + }); + +export const registerProjectTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List project templates for the current organization.", + response: { + 200: z.object({ + projectTemplates: SanitizedProjectTemplateSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const projectTemplates = await server.services.projectTemplate.listProjectTemplatesByOrg(req.permission); + + const auditTemplates = projectTemplates.filter((template) => template.name !== DefaultProjectTemplateIdentifier); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_PROJECT_TEMPLATES, + metadata: { + count: auditTemplates.length, + templateIds: auditTemplates.map((template) => template.id) + } + } + }); + + return { projectTemplates }; + } + }); + + server.route({ + method: "GET", + url: "/:templateId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get a project template by ID.", + params: z.object({ + templateId: z.string().uuid() + }), + response: { + 200: z.object({ + projectTemplate: SanitizedProjectTemplateSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const projectTemplate = await server.services.projectTemplate.findProjectTemplatesById( + req.params.templateId, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_PROJECT_TEMPLATE, + metadata: { + templateId: req.params.templateId + } + } + }); + + return { projectTemplate }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create a project template.", + body: z.object({ + name: SlugSchema.refine((val) => val !== DefaultProjectTemplateIdentifier, { + message: `The project template name "${DefaultProjectTemplateIdentifier}" is reserved.` + }).describe(ProjectTemplates.CREATE.name), + description: z.string().trim().optional().describe(ProjectTemplates.CREATE.description), + roles: ProjectTemplateRolesSchema.default([]).describe(ProjectTemplates.CREATE.roles), + environments: ProjectTemplateEnvironmentsSchema.default(ProjectTemplateDefaultEnvironments).describe( + ProjectTemplates.CREATE.environments + ) + }), + response: { + 200: z.object({ + projectTemplate: SanitizedProjectTemplateSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const projectTemplate = await server.services.projectTemplate.createProjectTemplate(req.body, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.CREATE_PROJECT_TEMPLATE, + metadata: req.body + } + }); + + return { projectTemplate }; + } + }); + + server.route({ + method: "PATCH", + url: "/:templateId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update a project template.", + params: z.object({ templateId: z.string().uuid().describe(ProjectTemplates.UPDATE.templateId) }), + body: z.object({ + name: SlugSchema.refine((val) => val !== DefaultProjectTemplateIdentifier, { + message: `The project template name "${DefaultProjectTemplateIdentifier}" is reserved.` + }) + .optional() + .describe(ProjectTemplates.UPDATE.name), + description: z.string().trim().optional().describe(ProjectTemplates.UPDATE.description), + roles: ProjectTemplateRolesSchema.optional().describe(ProjectTemplates.UPDATE.roles), + environments: ProjectTemplateEnvironmentsSchema.optional().describe(ProjectTemplates.UPDATE.environments) + }), + response: { + 200: z.object({ + projectTemplate: SanitizedProjectTemplateSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const projectTemplate = await server.services.projectTemplate.updateProjectTemplateById( + req.params.templateId, + req.body, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_PROJECT_TEMPLATE, + metadata: { + templateId: req.params.templateId, + ...req.body + } + } + }); + + return { projectTemplate }; + } + }); + + server.route({ + method: "DELETE", + url: "/:templateId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete a project template.", + params: z.object({ templateId: z.string().uuid().describe(ProjectTemplates.DELETE.templateId) }), + + response: { + 200: z.object({ + projectTemplate: SanitizedProjectTemplateSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const projectTemplate = await server.services.projectTemplate.deleteProjectTemplateById( + req.params.templateId, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.DELETE_PROJECT_TEMPLATE, + metadata: { + templateId: req.params.templateId + } + } + }); + + return { projectTemplate }; + } + }); +}; diff --git a/backend/src/ee/routes/v2/project-role-router.ts b/backend/src/ee/routes/v2/project-role-router.ts index d4cdf3381..70511ce87 100644 --- a/backend/src/ee/routes/v2/project-role-router.ts +++ b/backend/src/ee/routes/v2/project-role-router.ts @@ -186,7 +186,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - roles: ProjectRolesSchema.omit({ permissions: true }).array() + roles: ProjectRolesSchema.omit({ permissions: true, version: true }).array() }) } }, @@ -219,7 +219,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - role: SanitizedRoleSchema + role: SanitizedRoleSchema.omit({ version: true }) }) } }, diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 2744b34c2..63e0cf3c3 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1,3 +1,7 @@ +import { + TCreateProjectTemplateDTO, + TUpdateProjectTemplateDTO +} from "@app/ee/services/project-template/project-template-types"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; @@ -192,7 +196,13 @@ export enum EventType { CMEK_ENCRYPT = "cmek-encrypt", CMEK_DECRYPT = "cmek-decrypt", UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping", - GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping" + GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping", + GET_PROJECT_TEMPLATES = "get-project-templates", + GET_PROJECT_TEMPLATE = "get-project-template", + CREATE_PROJECT_TEMPLATE = "create-project-template", + UPDATE_PROJECT_TEMPLATE = "update-project-template", + DELETE_PROJECT_TEMPLATE = "delete-project-template", + APPLY_PROJECT_TEMPLATE = "apply-project-template" } interface UserActorMetadata { @@ -1618,6 +1628,46 @@ interface UpdateExternalGroupOrgRoleMappingsEvent { }; } +interface GetProjectTemplatesEvent { + type: EventType.GET_PROJECT_TEMPLATES; + metadata: { + count: number; + templateIds: string[]; + }; +} + +interface GetProjectTemplateEvent { + type: EventType.GET_PROJECT_TEMPLATE; + metadata: { + templateId: string; + }; +} + +interface CreateProjectTemplateEvent { + type: EventType.CREATE_PROJECT_TEMPLATE; + metadata: TCreateProjectTemplateDTO; +} + +interface UpdateProjectTemplateEvent { + type: EventType.UPDATE_PROJECT_TEMPLATE; + metadata: TUpdateProjectTemplateDTO & { templateId: string }; +} + +interface DeleteProjectTemplateEvent { + type: EventType.DELETE_PROJECT_TEMPLATE; + metadata: { + templateId: string; + }; +} + +interface ApplyProjectTemplateEvent { + type: EventType.APPLY_PROJECT_TEMPLATE; + metadata: { + templateId: string; + projectId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1766,4 +1816,10 @@ export type Event = | CmekEncryptEvent | CmekDecryptEvent | GetExternalGroupOrgRoleMappingsEvent - | UpdateExternalGroupOrgRoleMappingsEvent; + | UpdateExternalGroupOrgRoleMappingsEvent + | GetProjectTemplatesEvent + | GetProjectTemplateEvent + | CreateProjectTemplateEvent + | UpdateProjectTemplateEvent + | DeleteProjectTemplateEvent + | ApplyProjectTemplateEvent; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index aff592845..5eff1cdcf 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -9,7 +9,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { OrderByDirection, ProjectServiceActor } from "@app/lib/types"; +import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; @@ -457,7 +457,7 @@ export const dynamicSecretServiceFactory = ({ const listDynamicSecretsByFolderIds = async ( { folderMappings, filters, projectId }: TListDynamicSecretsByFolderMappingsDTO, - actor: ProjectServiceActor + actor: OrgServiceActor ) => { const { permission } = await permissionService.getProjectPermission( actor.type, diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 2a32bb5d4..7e7139a6b 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -123,7 +123,7 @@ export const groupServiceFactory = ({ const plan = await licenseService.getPlan(actorOrgId); if (!plan.groups) throw new BadRequestError({ - message: "Failed to update group due to plan restrictio Upgrade plan to update group." + message: "Failed to update group due to plan restriction Upgrade plan to update group." }); const group = await groupDAL.findOne({ orgId: actorOrgId, id }); diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 031c9f1a2..0864ab33c 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -47,7 +47,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ secretsLimit: 40 }, pkiEst: false, - enforceMfa: false + enforceMfa: false, + projectTemplates: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index c331679b5..0ba54afc3 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -65,6 +65,7 @@ export type TFeatureSet = { }; pkiEst: boolean; enforceMfa: boolean; + projectTemplates: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index b9a4980be..e0da494c6 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -26,7 +26,8 @@ export enum OrgPermissionSubjects { Identity = "identity", Kms = "kms", AdminConsole = "organization-admin-console", - AuditLogs = "audit-logs" + AuditLogs = "audit-logs", + ProjectTemplates = "project-templates" } export type OrgPermissionSet = @@ -45,6 +46,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] + | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; const buildAdminPermission = () => { @@ -118,6 +120,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.AuditLogs); can(OrgPermissionActions.Delete, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); + can(OrgPermissionActions.Create, OrgPermissionSubjects.ProjectTemplates); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates); + can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); return rules; diff --git a/backend/src/ee/services/project-template/project-template-constants.ts b/backend/src/ee/services/project-template/project-template-constants.ts new file mode 100644 index 000000000..f69b429f3 --- /dev/null +++ b/backend/src/ee/services/project-template/project-template-constants.ts @@ -0,0 +1,25 @@ +import { TUnpackedPermission } from "@app/ee/services/project-template/project-template-types"; +import { getPredefinedRoles } from "@app/services/project-role/project-role-fns"; + +export const ProjectTemplateDefaultEnvironments = [ + { name: "Development", slug: "dev", position: 1 }, + { name: "Staging", slug: "staging", position: 2 }, + { name: "Production", slug: "prod", position: 3 } +]; + +export const DefaultProjectTemplateIdentifier = "default"; + +export const getDefaultProjectTemplate = (orgId: string) => ({ + id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", + name: DefaultProjectTemplateIdentifier, + createdAt: new Date(), + updatedAt: new Date(), + description: "Infisical's default project template", + environments: ProjectTemplateDefaultEnvironments, + roles: [...getPredefinedRoles("project-template")].map(({ name, slug, permissions }) => ({ + name, + slug, + permissions: permissions as TUnpackedPermission[] + })), + orgId +}); diff --git a/backend/src/ee/services/project-template/project-template-dal.ts b/backend/src/ee/services/project-template/project-template-dal.ts new file mode 100644 index 000000000..4dbbea279 --- /dev/null +++ b/backend/src/ee/services/project-template/project-template-dal.ts @@ -0,0 +1,7 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProjectTemplateDALFactory = ReturnType; + +export const projectTemplateDALFactory = (db: TDbClient) => ormify(db, TableName.ProjectTemplates); diff --git a/backend/src/ee/services/project-template/project-template-service.ts b/backend/src/ee/services/project-template/project-template-service.ts new file mode 100644 index 000000000..b51c19044 --- /dev/null +++ b/backend/src/ee/services/project-template/project-template-service.ts @@ -0,0 +1,205 @@ +import { ForbiddenError } from "@casl/ability"; +import { packRules } from "@casl/ability/extra"; + +import { TProjectTemplates } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getDefaultProjectTemplate } from "@app/ee/services/project-template/project-template-constants"; +import { + TCreateProjectTemplateDTO, + TProjectTemplateEnvironment, + TProjectTemplateRole, + TUnpackedPermission, + TUpdateProjectTemplateDTO +} from "@app/ee/services/project-template/project-template-types"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { unpackPermissions } from "@app/server/routes/santizedSchemas/permission"; +import { getPredefinedRoles } from "@app/services/project-role/project-role-fns"; + +import { TProjectTemplateDALFactory } from "./project-template-dal"; + +type TProjectTemplatesServiceFactoryDep = { + licenseService: TLicenseServiceFactory; + permissionService: TPermissionServiceFactory; + projectTemplateDAL: TProjectTemplateDALFactory; +}; + +export type TProjectTemplateServiceFactory = ReturnType; + +const $unpackProjectTemplate = ({ roles, environments, ...rest }: TProjectTemplates) => ({ + ...rest, + environments: environments as TProjectTemplateEnvironment[], + roles: [ + ...getPredefinedRoles("project-template").map(({ name, slug, permissions }) => ({ + name, + slug, + permissions: permissions as TUnpackedPermission[] + })), + ...(roles as TProjectTemplateRole[]).map((role) => ({ + ...role, + permissions: unpackPermissions(role.permissions) + })) + ] +}); + +export const projectTemplateServiceFactory = ({ + licenseService, + permissionService, + projectTemplateDAL +}: TProjectTemplatesServiceFactoryDep) => { + const listProjectTemplatesByOrg = async (actor: OrgServiceActor) => { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); + + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.projectTemplates) + throw new BadRequestError({ + message: "Failed to access project templates due to plan restriction. Upgrade plan to access project templates." + }); + + const projectTemplates = await projectTemplateDAL.find({ + orgId: actor.orgId + }); + + return [ + getDefaultProjectTemplate(actor.orgId), + ...projectTemplates.map((template) => $unpackProjectTemplate(template)) + ]; + }; + + const findProjectTemplatesById = async (id: string, actor: OrgServiceActor) => { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); + + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.projectTemplates) + throw new BadRequestError({ + message: "Failed to access project template due to plan restriction. Upgrade plan to access project templates." + }); + + const [projectTemplate] = await projectTemplateDAL.find({ + id, + orgId: actor.orgId // ensure from user org + }); + + if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with ID ${id}` }); + + return { + ...$unpackProjectTemplate(projectTemplate), + packedRoles: projectTemplate.roles as TProjectTemplateRole[] // preserve packed for when applying template + }; + }; + + const createProjectTemplate = async ( + { roles, environments, ...params }: TCreateProjectTemplateDTO, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.ProjectTemplates); + + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.projectTemplates) + throw new BadRequestError({ + message: "Failed to create project template due to plan restriction. Upgrade plan to access project templates." + }); + + const projectTemplate = await projectTemplateDAL.create({ + ...params, + roles: JSON.stringify(roles.map((role) => ({ ...role, permissions: packRules(role.permissions) }))), + environments: JSON.stringify(environments), + orgId: actor.orgId + }); + + return $unpackProjectTemplate(projectTemplate); + }; + + const updateProjectTemplateById = async ( + id: string, + { roles, environments, ...params }: TUpdateProjectTemplateDTO, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); + + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.projectTemplates) + throw new BadRequestError({ + message: "Failed to update project template due to plan restriction. Upgrade plan to access project templates." + }); + + const projectTemplate = await projectTemplateDAL.updateById(id, { + ...params, + roles: roles + ? JSON.stringify(roles.map((role) => ({ ...role, permissions: packRules(role.permissions) }))) + : undefined, + environments: environments ? JSON.stringify(environments) : undefined + }); + + return $unpackProjectTemplate(projectTemplate); + }; + + const deleteProjectTemplateById = async (id: string, actor: OrgServiceActor) => { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates); + + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.projectTemplates) + throw new BadRequestError({ + message: "Failed to delete project template due to plan restriction. Upgrade plan to access project templates." + }); + + const projectTemplate = await projectTemplateDAL.deleteById(id); + + return $unpackProjectTemplate(projectTemplate); + }; + + return { + listProjectTemplatesByOrg, + createProjectTemplate, + updateProjectTemplateById, + deleteProjectTemplateById, + findProjectTemplatesById + }; +}; diff --git a/backend/src/ee/services/project-template/project-template-types.ts b/backend/src/ee/services/project-template/project-template-types.ts new file mode 100644 index 000000000..c07187ff4 --- /dev/null +++ b/backend/src/ee/services/project-template/project-template-types.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { TProjectEnvironments } from "@app/db/schemas"; +import { TProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; +import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; + +export type TProjectTemplateEnvironment = Pick; + +export type TProjectTemplateRole = { + slug: string; + name: string; + permissions: TProjectPermissionV2Schema[]; +}; + +export type TCreateProjectTemplateDTO = { + name: string; + description?: string; + roles: TProjectTemplateRole[]; + environments: TProjectTemplateEnvironment[]; +}; + +export type TUpdateProjectTemplateDTO = Partial; + +export type TUnpackedPermission = z.infer; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index c6228e35a..7038c46ef 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -391,7 +391,8 @@ export const PROJECTS = { CREATE: { organizationSlug: "The slug of the organization to create the project in.", projectName: "The name of the project to create.", - slug: "An optional slug for the project." + slug: "An optional slug for the project.", + templateId: "The ID of the project template, if specified, to apply to this project." }, DELETE: { workspaceId: "The ID of the project to delete." @@ -1438,3 +1439,22 @@ export const KMS = { ciphertext: "The ciphertext to be decrypted (base64 encoded)." } }; + +export const ProjectTemplates = { + CREATE: { + name: "The name of the project template to be created. Must be slug-friendly.", + description: "An optional description of the project template.", + roles: "The roles to be created when the template is applied to a project.", + environments: "The environments to be created when the template is applied to a project." + }, + UPDATE: { + templateId: "The ID of the project template to be updated.", + name: "The updated name of the project template. Must be slug-friendly.", + description: "The updated description of the project template.", + roles: "The updated roles to be created when the template is applied to a project.", + environments: "The updated environments to be created when the template is applied to a project." + }, + DELETE: { + templateId: "The ID of the project template to be deleted." + } +}; diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index cb26021c4..6ebf91f36 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -58,7 +58,7 @@ export enum OrderByDirection { DESC = "desc" } -export type ProjectServiceActor = { +export type OrgServiceActor = { type: ActorType; id: string; authMethod: ActorAuthMethod; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 27f12f07a..b2d097092 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -43,6 +43,8 @@ import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal"; +import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; @@ -340,6 +342,8 @@ export const registerRoutes = async ( const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db); + const projectTemplateDAL = projectTemplateDALFactory(db); + const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, @@ -732,6 +736,12 @@ export const registerRoutes = async ( permissionService }); + const projectTemplateService = projectTemplateServiceFactory({ + licenseService, + permissionService, + projectTemplateDAL + }); + const projectService = projectServiceFactory({ permissionService, projectDAL, @@ -758,7 +768,8 @@ export const registerRoutes = async ( projectBotDAL, certificateTemplateDAL, projectSlackConfigDAL, - slackIntegrationDAL + slackIntegrationDAL, + projectTemplateService }); const projectEnvService = projectEnvServiceFactory({ @@ -1336,7 +1347,8 @@ export const registerRoutes = async ( slack: slackService, workflowIntegration: workflowIntegrationService, migration: migrationService, - externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService + externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService, + projectTemplate: projectTemplateService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 61e175cfd..7b4e8a93a 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -169,7 +169,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) .optional() .describe(PROJECTS.CREATE.slug), - kmsKeyId: z.string().optional() + kmsKeyId: z.string().optional(), + templateId: z.string().uuid().optional().describe(PROJECTS.CREATE.templateId) }), response: { 200: z.object({ @@ -186,7 +187,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, workspaceName: req.body.projectName, slug: req.body.slug, - kmsKeyId: req.body.kmsKeyId + kmsKeyId: req.body.kmsKeyId, + templateId: req.body.templateId }); await server.services.telemetry.sendPostHogEvents({ @@ -199,6 +201,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + if (req.body.templateId) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.APPLY_PROJECT_TEMPLATE, + metadata: { + templateId: req.body.templateId, + projectId: project.id + } + } + }); + } + return { project }; } }); diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index adf71febf..c8e1b932a 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -3,7 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { ProjectServiceActor } from "@app/lib/types"; +import { OrgServiceActor } from "@app/lib/types"; import { TCmekDecryptDTO, TCmekEncryptDTO, @@ -23,7 +23,7 @@ type TCmekServiceFactoryDep = { export type TCmekServiceFactory = ReturnType; export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TCmekServiceFactoryDep) => { - const createCmek = async ({ projectId, ...dto }: TCreateCmekDTO, actor: ProjectServiceActor) => { + const createCmek = async ({ projectId, ...dto }: TCreateCmekDTO, actor: OrgServiceActor) => { const { permission } = await permissionService.getProjectPermission( actor.type, actor.id, @@ -43,7 +43,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC return cmek; }; - const updateCmekById = async ({ keyId, ...data }: TUpdabteCmekByIdDTO, actor: ProjectServiceActor) => { + const updateCmekById = async ({ keyId, ...data }: TUpdabteCmekByIdDTO, actor: OrgServiceActor) => { const key = await kmsDAL.findById(keyId); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); @@ -65,7 +65,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC return cmek; }; - const deleteCmekById = async (keyId: string, actor: ProjectServiceActor) => { + const deleteCmekById = async (keyId: string, actor: OrgServiceActor) => { const key = await kmsDAL.findById(keyId); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); @@ -87,10 +87,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC return cmek; }; - const listCmeksByProjectId = async ( - { projectId, ...filters }: TListCmeksByProjectIdDTO, - actor: ProjectServiceActor - ) => { + const listCmeksByProjectId = async ({ projectId, ...filters }: TListCmeksByProjectIdDTO, actor: OrgServiceActor) => { const { permission } = await permissionService.getProjectPermission( actor.type, actor.id, @@ -106,7 +103,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC return { cmeks, totalCount }; }; - const cmekEncrypt = async ({ keyId, plaintext }: TCmekEncryptDTO, actor: ProjectServiceActor) => { + const cmekEncrypt = async ({ keyId, plaintext }: TCmekEncryptDTO, actor: OrgServiceActor) => { const key = await kmsDAL.findById(keyId); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); @@ -132,7 +129,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC return cipherTextBlob.toString("base64"); }; - const cmekDecrypt = async ({ keyId, ciphertext }: TCmekDecryptDTO, actor: ProjectServiceActor) => { + const cmekDecrypt = async ({ keyId, ciphertext }: TCmekDecryptDTO, actor: OrgServiceActor) => { const key = await kmsDAL.findById(keyId); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); diff --git a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts index 0b7be1d4f..b38611419 100644 --- a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts +++ b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts @@ -3,7 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectServiceActor } from "@app/lib/types"; +import { OrgServiceActor } from "@app/lib/types"; import { constructGroupOrgMembershipRoleMappings } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-fns"; import { TSyncExternalGroupOrgMembershipRoleMappingsDTO } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-types"; import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; @@ -25,7 +25,7 @@ export const externalGroupOrgRoleMappingServiceFactory = ({ permissionService, orgRoleDAL }: TExternalGroupOrgRoleMappingServiceFactoryDep) => { - const listExternalGroupOrgRoleMappings = async (actor: ProjectServiceActor) => { + const listExternalGroupOrgRoleMappings = async (actor: OrgServiceActor) => { const { permission } = await permissionService.getOrgPermission( actor.type, actor.id, @@ -46,7 +46,7 @@ export const externalGroupOrgRoleMappingServiceFactory = ({ const updateExternalGroupOrgRoleMappings = async ( dto: TSyncExternalGroupOrgMembershipRoleMappingsDTO, - actor: ProjectServiceActor + actor: OrgServiceActor ) => { const { permission } = await permissionService.getOrgPermission( actor.type, diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 49198fcb1..0542cf47a 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -6,6 +6,7 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; @@ -94,7 +95,7 @@ type TProjectServiceFactoryDep = { orgDAL: Pick; keyStore: Pick; projectBotDAL: Pick; - projectRoleDAL: Pick; + projectRoleDAL: Pick; kmsService: Pick< TKmsServiceFactory, | "updateProjectSecretManagerKmsKey" @@ -104,6 +105,7 @@ type TProjectServiceFactoryDep = { | "getProjectSecretManagerKmsKeyId" | "deleteInternalKms" >; + projectTemplateService: TProjectTemplateServiceFactory; }; export type TProjectServiceFactory = ReturnType; @@ -134,7 +136,8 @@ export const projectServiceFactory = ({ kmsService, projectBotDAL, projectSlackConfigDAL, - slackIntegrationDAL + slackIntegrationDAL, + projectTemplateService }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -148,7 +151,8 @@ export const projectServiceFactory = ({ slug: projectSlug, kmsKeyId, tx: trx, - createDefaultEnvs = true + createDefaultEnvs = true, + templateId }: TCreateProjectDTO) => { const organization = await orgDAL.findOne({ id: actorOrgId }); @@ -183,6 +187,25 @@ export const projectServiceFactory = ({ } } + let projectTemplate: Awaited> | null = null; + + if (templateId) { + if (!plan.projectTemplates) + throw new BadRequestError({ + message: + "Failed to apply project template due to plan restriction. Upgrade plan to access project templates." + }); + + projectTemplate = await projectTemplateService.findProjectTemplatesById(templateId, { + id: actorId, + orgId: organization.id, + type: actor, + authMethod: actorAuthMethod + }); + + if (!projectTemplate) throw new NotFoundError({ message: `Project template with ID ${templateId} not found.` }); + } + const project = await projectDAL.create( { name: workspaceName, @@ -210,7 +233,24 @@ export const projectServiceFactory = ({ // set default environments and root folder for provided environments let envs: TProjectEnvironments[] = []; - if (createDefaultEnvs) { + if (projectTemplate) { + envs = await projectEnvDAL.insertMany( + projectTemplate.environments.map((env) => ({ ...env, projectId: project.id })), + tx + ); + await folderDAL.insertMany( + envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })), + tx + ); + await projectRoleDAL.insertMany( + projectTemplate.packedRoles.map((role) => ({ + ...role, + permissions: JSON.stringify(role.permissions), + projectId: project.id + })), + tx + ); + } else if (createDefaultEnvs) { envs = await projectEnvDAL.insertMany( DEFAULT_PROJECT_ENVS.map((el, i) => ({ ...el, projectId: project.id, position: i + 1 })), tx diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index d35fcb24f..99f8708fe 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -32,6 +32,7 @@ export type TCreateProjectDTO = { slug?: string; kmsKeyId?: string; createDefaultEnvs?: boolean; + templateId?: string; tx?: Knex; }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index ae9bfb9cf..d787520a2 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -7,7 +7,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { OrderByDirection, ProjectServiceActor } from "@app/lib/types"; +import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -514,7 +514,7 @@ export const secretFolderServiceFactory = ({ const getFoldersDeepByEnvs = async ( { projectId, environments, secretPath }: TGetFoldersDeepByEnvsDTO, - actor: ProjectServiceActor + actor: OrgServiceActor ) => { // folder list is allowed to be read by anyone // permission to check does user have access diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 67fc8c6f8..d62d09f7a 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -27,7 +27,7 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { groupBy, pick } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { ProjectServiceActor } from "@app/lib/types"; +import { OrgServiceActor } from "@app/lib/types"; import { TGetSecretsRawByFolderMappingsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; import { ActorType } from "../auth/auth-type"; @@ -2849,7 +2849,7 @@ export const secretServiceFactory = ({ const getSecretsRawByFolderMappings = async ( params: Omit, - actor: ProjectServiceActor + actor: OrgServiceActor ) => { const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(params.projectId); diff --git a/docs/api-reference/endpoints/project-templates/create.mdx b/docs/api-reference/endpoints/project-templates/create.mdx new file mode 100644 index 000000000..d4eeb3712 --- /dev/null +++ b/docs/api-reference/endpoints/project-templates/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/project-templates" +--- + + + You can read more about the role's permissions field in the [permissions documentation](/internals/permissions). + \ No newline at end of file diff --git a/docs/api-reference/endpoints/project-templates/delete.mdx b/docs/api-reference/endpoints/project-templates/delete.mdx new file mode 100644 index 000000000..36078b30a --- /dev/null +++ b/docs/api-reference/endpoints/project-templates/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/project-templates/{templateId}" +--- diff --git a/docs/api-reference/endpoints/project-templates/get-by-id.mdx b/docs/api-reference/endpoints/project-templates/get-by-id.mdx new file mode 100644 index 000000000..4e5317aee --- /dev/null +++ b/docs/api-reference/endpoints/project-templates/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By ID" +openapi: "GET /api/v1/project-templates/{templateId}" +--- diff --git a/docs/api-reference/endpoints/project-templates/list.mdx b/docs/api-reference/endpoints/project-templates/list.mdx new file mode 100644 index 000000000..d0bee0bb9 --- /dev/null +++ b/docs/api-reference/endpoints/project-templates/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/project-templates" +--- diff --git a/docs/api-reference/endpoints/project-templates/update.mdx b/docs/api-reference/endpoints/project-templates/update.mdx new file mode 100644 index 000000000..1b3e25a20 --- /dev/null +++ b/docs/api-reference/endpoints/project-templates/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/project-templates/{templateId}" +--- + + + You can read more about the role's permissions field in the [permissions documentation](/internals/permissions). + \ No newline at end of file diff --git a/docs/documentation/platform/project-templates.mdx b/docs/documentation/platform/project-templates.mdx new file mode 100644 index 000000000..d7648a58f --- /dev/null +++ b/docs/documentation/platform/project-templates.mdx @@ -0,0 +1,147 @@ +--- +title: "Project Templates" +sidebarTitle: "Project Templates" +description: "Learn how to manage and apply project templates" +--- + +## Concept + +Project Templates streamline your ability to set up projects by providing customizable templates to configure projects quickly with a predefined set of environments and roles. + + + Project Templates is a paid feature. + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +## Workflow + +The typical workflow for using Project Templates consists of the following steps: + +1. Creating a project template: As part of this step, you will configure a set of environments and roles to be created when applying this template to a project. +2. Using a project template: When creating new projects, optionally specify a project template to provision the project with the configured roles and environments. + + + Note that this workflow can be executed via the Infisical UI or through the API. + + +## Guide to Creating a Project Template + +In the following steps, we'll explore how to set up a project template. + + + + + + Navigate to the Project Templates tab on the Organization Settings page and tap on the **Add Template** button. + ![project template add button](/images/platform/project-templates/project-template-add-button.png) + + Specify your template details. Here's some guidance on each field: + + - Name: A slug-friendly name for the template. + - Description: An optional description of the intended usage of this template. + + ![project template create modal](/images/platform/project-templates/project-template-create.png) + + + Once your template is created, you'll be directed to the configuration section. + ![project template edit form](/images/platform/project-templates/project-template-edit-form.png) + + Customize the environments and roles to your needs. + ![project template customized](/images/platform/project-templates/project-template-customized.png) + + + Be sure to save your environment and role changes. + + + + + + To create a project template, make an API request to the [Create Project Template](/api-reference/endpoints/project-templates/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/project-templates \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-project-template", + "description": "...", + "environments": "[...]", + "roles": "[...]", + }' + ``` + + ### Sample response + + ```bash Response + { + "projectTemplate": { + "id": "", + "name": "my-project-template", + "description": "...", + "environments": "[...]", + "roles": "[...]", + "orgId": "", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + } + } + ``` + + + + +## Guide to Using a Project Template + +In the following steps, we'll explore how to use a project template when creating a project. + + + + When creating a new project, select the desired template from the dropdown menu in the create project modal. + ![kms key options](/images/platform/project-templates/project-template-apply.png) + + Your project will be provisioned with the configured template roles and environments. + + + To use a project template, make an API request to the [Create Project](/api-reference/endpoints/workspaces/create-workspace) API endpoint with the specified template ID included. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v2/workspace \ + --header 'Content-Type: application/json' \ + --data '{ + "projectName": "My Project", + "templateId": "", + }' + ``` + + ### Sample response + + ```bash Response + { + "project": { + "id": "", + "environments": "[...]", // configured environments + ... + } + } + ``` + + + Note that configured roles are not included in the project response. + + + + + +## FAQ + + + + No. Project templates only apply at the time of project creation. + + diff --git a/docs/mint.json b/docs/mint.json index b8bf6c4fa..942eb5fa3 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -1,6 +1,6 @@ { "name": "Infisical", - "openapi": "https://app.infisical.com/api/docs/json", + "openapi": "http://localhost:8080/api/docs/json", "logo": { "dark": "/logo/dark.svg", "light": "/logo/light.svg", @@ -191,6 +191,7 @@ "documentation/platform/dynamic-secrets/snowflake" ] }, + "documentation/platform/project-templates", { "group": "Workflow Integrations", "pages": [ @@ -644,6 +645,16 @@ "api-reference/endpoints/project-roles/list" ] }, + { + "group": "Project Templates", + "pages": [ + "api-reference/endpoints/project-templates/create", + "api-reference/endpoints/project-templates/update", + "api-reference/endpoints/project-templates/delete", + "api-reference/endpoints/project-templates/get-by-id", + "api-reference/endpoints/project-templates/list" + ] + }, { "group": "Environments", "pages": [ diff --git a/frontend/src/components/v2/FormControl/FormControl.tsx b/frontend/src/components/v2/FormControl/FormControl.tsx index 0455d3996..45f3f9cd1 100644 --- a/frontend/src/components/v2/FormControl/FormControl.tsx +++ b/frontend/src/components/v2/FormControl/FormControl.tsx @@ -1,4 +1,4 @@ -import { cloneElement, ReactNode } from "react"; +import { cloneElement, ReactElement, ReactNode } from "react"; import { faExclamationTriangle, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as Label from "@radix-ui/react-label"; @@ -82,7 +82,7 @@ export type FormControlProps = { children: JSX.Element; className?: string; icon?: ReactNode; - tooltipText?: string; + tooltipText?: ReactElement | string; }; export const FormControl = ({ diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index c950ec179..41a2e7e3c 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -22,7 +22,8 @@ export enum OrgPermissionSubjects { Identity = "identity", Kms = "kms", AdminConsole = "organization-admin-console", - AuditLogs = "audit-logs" + AuditLogs = "audit-logs", + ProjectTemplates = "project-templates" } export enum OrgPermissionAdminConsoleAction { @@ -45,6 +46,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] - | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]; + | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] + | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/helpers/roles.ts b/frontend/src/helpers/roles.ts index e1c3556cc..de6291a13 100644 --- a/frontend/src/helpers/roles.ts +++ b/frontend/src/helpers/roles.ts @@ -1,3 +1,5 @@ +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; + enum OrgMembershipRole { Admin = "admin", Member = "member", @@ -18,3 +20,6 @@ export const formatProjectRoleName = (name: string) => { if (name === ProjectMemberRole.Member) return "developer"; return name; }; + +export const isCustomProjectRole = (slug: string) => + !Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); diff --git a/frontend/src/hooks/api/projectTemplates/index.ts b/frontend/src/hooks/api/projectTemplates/index.ts new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend/src/hooks/api/projectTemplates/index.ts @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/projectTemplates/mutations.tsx b/frontend/src/hooks/api/projectTemplates/mutations.tsx new file mode 100644 index 000000000..7056f738e --- /dev/null +++ b/frontend/src/hooks/api/projectTemplates/mutations.tsx @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { projectTemplateKeys } from "@app/hooks/api/projectTemplates/queries"; +import { + TCreateProjectTemplateDTO, + TDeleteProjectTemplateDTO, + TProjectTemplateResponse, + TUpdateProjectTemplateDTO +} from "@app/hooks/api/projectTemplates/types"; + +export const useCreateProjectTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (payload: TCreateProjectTemplateDTO) => { + const { data } = await apiRequest.post( + "/api/v1/project-templates", + payload + ); + + return data.projectTemplate; + }, + onSuccess: () => queryClient.invalidateQueries(projectTemplateKeys.list()) + }); +}; + +export const useUpdateProjectTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ templateId, ...params }: TUpdateProjectTemplateDTO) => { + const { data } = await apiRequest.patch( + `/api/v1/project-templates/${templateId}`, + params + ); + + return data.projectTemplate; + }, + onSuccess: (_, { templateId }) => { + queryClient.invalidateQueries(projectTemplateKeys.list()); + queryClient.invalidateQueries(projectTemplateKeys.byId(templateId)); + } + }); +}; + +export const useDeleteProjectTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ templateId }: TDeleteProjectTemplateDTO) => { + const { data } = await apiRequest.delete(`/api/v1/project-templates/${templateId}`); + + return data; + }, + onSuccess: (_, { templateId }) => { + queryClient.invalidateQueries(projectTemplateKeys.list()); + queryClient.invalidateQueries(projectTemplateKeys.byId(templateId)); + } + }); +}; diff --git a/frontend/src/hooks/api/projectTemplates/queries.tsx b/frontend/src/hooks/api/projectTemplates/queries.tsx new file mode 100644 index 000000000..f5863915a --- /dev/null +++ b/frontend/src/hooks/api/projectTemplates/queries.tsx @@ -0,0 +1,61 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { + TListProjectTemplates, + TProjectTemplate, + TProjectTemplateResponse +} from "@app/hooks/api/projectTemplates/types"; + +export const projectTemplateKeys = { + all: ["project-template"] as const, + list: () => [...projectTemplateKeys.all, "list"] as const, + byId: (templateId: string) => [...projectTemplateKeys.all, templateId] as const +}; + +export const useListProjectTemplates = ( + options?: Omit< + UseQueryOptions< + TProjectTemplate[], + unknown, + TProjectTemplate[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: projectTemplateKeys.list(), + queryFn: async () => { + const { data } = await apiRequest.get("/api/v1/project-templates"); + + return data.projectTemplates; + }, + ...options + }); +}; + +export const useGetProjectTemplateById = ( + templateId: string, + options?: Omit< + UseQueryOptions< + TProjectTemplate, + unknown, + TProjectTemplate, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: projectTemplateKeys.byId(templateId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/project-templates/${templateId}` + ); + + return data.projectTemplate; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/projectTemplates/types.ts b/frontend/src/hooks/api/projectTemplates/types.ts new file mode 100644 index 000000000..82b5970f8 --- /dev/null +++ b/frontend/src/hooks/api/projectTemplates/types.ts @@ -0,0 +1,29 @@ +import { TProjectRole } from "@app/hooks/api/roles/types"; + +export type TProjectTemplate = { + id: string; + name: string; + description?: string; + roles: Pick[]; + environments: { name: string; slug: string; position: number }[]; + createdAt: string; + updatedAt: string; +}; + +export type TListProjectTemplates = { projectTemplates: TProjectTemplate[] }; +export type TProjectTemplateResponse = { projectTemplate: TProjectTemplate }; + +export type TCreateProjectTemplateDTO = { + name: string; + description?: string; +}; + +export type TUpdateProjectTemplateDTO = Partial< + Pick +> & { templateId: string }; + +export type TDeleteProjectTemplateDTO = { + templateId: string; +}; + +export const DefaultProjectTemplateIdentifier = "default"; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 3506295fa..1ab01ef05 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -43,4 +43,5 @@ export type SubscriptionPlan = { externalKms: boolean; pkiEst: boolean; enforceMfa: boolean; + projectTemplates: boolean; }; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 7b190092c..c48b7378b 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -208,19 +208,21 @@ export const useGetWorkspaceIntegrations = (workspaceId: string) => export const createWorkspace = ({ projectName, - kmsKeyId + kmsKeyId, + templateId }: CreateWorkspaceDTO): Promise<{ data: { project: Workspace } }> => { - return apiRequest.post("/api/v2/workspace", { projectName, kmsKeyId }); + return apiRequest.post("/api/v2/workspace", { projectName, kmsKeyId, templateId }); }; export const useCreateWorkspace = () => { const queryClient = useQueryClient(); return useMutation<{ data: { project: Workspace } }, {}, CreateWorkspaceDTO>({ - mutationFn: async ({ projectName, kmsKeyId }) => + mutationFn: async ({ projectName, kmsKeyId, templateId }) => createWorkspace({ projectName, - kmsKeyId + kmsKeyId, + templateId }), onSuccess: () => { queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index aa57a6fd4..2a45d9b85 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -57,6 +57,7 @@ export type TGetUpgradeProjectStatusDTO = { export type CreateWorkspaceDTO = { projectName: string; kmsKeyId?: string; + templateId?: string; }; export type RenameWorkspaceDTO = { workspaceID: string; newWorkspaceName: string }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 01eb4c58e..e9337f19c 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -21,6 +21,7 @@ import { faEnvelope, faInfinity, faInfo, + faInfoCircle, faMobile, faPlus, faQuestion, @@ -78,6 +79,10 @@ import { useSelectOrganization } from "@app/hooks/api"; import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; +import { + DefaultProjectTemplateIdentifier, + useListProjectTemplates +} from "@app/hooks/api/projectTemplates"; import { Workspace } from "@app/hooks/api/types"; import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; @@ -124,7 +129,8 @@ const formSchema = yup.object({ .trim() .max(64, "Too long, maximum length is 64 characters"), addMembers: yup.bool().required().label("Add Members"), - kmsKeyId: yup.string().label("KMS Key ID") + kmsKeyId: yup.string().label("KMS Key ID"), + templateId: yup.string().label("Project Template ID") }); type TAddProjectFormData = yup.InferType; @@ -273,7 +279,21 @@ export const AppLayout = ({ children }: LayoutProps) => { putUserInOrg(); }, [router.query.id]); - const onCreateProject = async ({ name, addMembers, kmsKeyId }: TAddProjectFormData) => { + const canReadProjectTemplates = permission.can( + OrgPermissionActions.Read, + OrgPermissionSubjects.ProjectTemplates + ); + + const { data: projectTemplates = [] } = useListProjectTemplates({ + enabled: canReadProjectTemplates && subscription?.projectTemplates + }); + + const onCreateProject = async ({ + name, + addMembers, + kmsKeyId, + templateId + }: TAddProjectFormData) => { // type check if (!currentOrg) return; if (!user) return; @@ -284,7 +304,8 @@ export const AppLayout = ({ children }: LayoutProps) => { } } = await createWs.mutateAsync({ projectName: name, - kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined + kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined, + templateId: templateId === DefaultProjectTemplateIdentifier ? undefined : templateId }); if (addMembers) { @@ -909,20 +930,73 @@ export const AppLayout = ({ children }: LayoutProps) => { subTitle="This project will contain your secrets and configurations." >
- ( - - - - )} - /> +
+ ( + + + + )} + /> + ( + + {(isAllowed) => ( + } + tooltipText={ + <> +

+ Create this project from a template to provision it with custom + environments and roles. +

+ {subscription && !subscription.projectTemplates && ( +

Project templates are a paid feature.

+ )} + + } + > + +
+ )} +
+ )} + /> +
val.toLowerCase() === val, "Must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Invalid slug format" + }); diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 948e72392..d7daa1bf3 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -19,6 +19,7 @@ import { faExclamationCircle, faFileShield, faHandPeace, + faInfoCircle, faList, faMagnifyingGlass, faNetworkWired, @@ -69,6 +70,10 @@ import { useRegisterUserAction } from "@app/hooks/api"; import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; +import { + DefaultProjectTemplateIdentifier, + useListProjectTemplates +} from "@app/hooks/api/projectTemplates"; // import { fetchUserWsKey } from "@app/hooks/api/keys/queries"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { Workspace } from "@app/hooks/api/types"; @@ -482,7 +487,8 @@ const formSchema = yup.object({ .trim() .max(64, "Too long, maximum length is 64 characters"), addMembers: yup.bool().required().label("Add Members"), - kmsKeyId: yup.string().label("KMS Key ID") + kmsKeyId: yup.string().label("KMS Key ID"), + templateId: yup.string().label("Project Template ID") }); type TAddProjectFormData = yup.InferType; @@ -537,7 +543,12 @@ const OrganizationPage = () => { enabled: permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.Kms) }); - const onCreateProject = async ({ name, addMembers, kmsKeyId }: TAddProjectFormData) => { + const onCreateProject = async ({ + name, + addMembers, + kmsKeyId, + templateId + }: TAddProjectFormData) => { // type check if (!currentOrg) return; if (!user) return; @@ -548,7 +559,8 @@ const OrganizationPage = () => { } } = await createWs.mutateAsync({ projectName: name, - kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined + kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined, + templateId: templateId === DefaultProjectTemplateIdentifier ? undefined : templateId }); if (addMembers) { @@ -579,6 +591,15 @@ const OrganizationPage = () => { const { subscription } = useSubscription(); + const canReadProjectTemplates = permission.can( + OrgPermissionActions.Read, + OrgPermissionSubjects.ProjectTemplates + ); + + const { data: projectTemplates = [] } = useListProjectTemplates({ + enabled: canReadProjectTemplates && subscription?.projectTemplates + }); + const isAddingProjectsAllowed = subscription?.workspaceLimit ? subscription.workspacesUsed < subscription.workspaceLimit : true; @@ -1037,20 +1058,73 @@ const OrganizationPage = () => { subTitle="This project will contain your secrets and configurations." > - ( - - - - )} - /> +
+ ( + + + + )} + /> + ( + + {(isAllowed) => ( + } + tooltipText={ + <> +

+ Create this project from a template to provision it with custom + environments and roles. +

+ {subscription && !subscription.projectTemplates && ( +

Project templates are a paid feature.

+ )} + + } + > + +
+ )} +
+ )} + /> +
{ switch (option) { case "secret-scanning": @@ -53,6 +61,8 @@ const getPermissionList = (option: string) => { return INCIDENT_CONTACTS_PERMISSIONS; case "member": return MEMBERS_PERMISSIONS; + case OrgPermissionSubjects.ProjectTemplates: + return PROJECT_TEMPLATES_PERMISSIONS; default: return PERMISSIONS; } diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 98dc1c4c1..976b35636 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -68,7 +68,8 @@ const SIMPLE_PERMISSION_OPTIONS = [ { title: "External KMS", formName: OrgPermissionSubjects.Kms - } + }, + { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates } ] as const; type Props = { diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx index 58c403579..9d6e699cb 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx @@ -86,6 +86,7 @@ export const GeneralPermissionPolicies = field.onChange(val === "true")} containerClassName="w-full" className="w-full" + isDisabled={isDisabled} > Allow Forbid diff --git a/frontend/src/views/SecretOverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx b/frontend/src/views/SecretOverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx index 737dc1c87..2f0390467 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx @@ -172,22 +172,26 @@ export const QuickSearchSecretItem = ({ }} key={secret.id} > - {!isSingleEnv && ( - - {envSlugMap.get(secret.env)?.name} - - )} -

- {secret.value || "EMPTY"} -

-

- *************************** -

+ +
+ {!isSingleEnv && ( + + {envSlugMap.get(secret.env)?.name} + + )} +

+ {secret.value || "EMPTY"} +

+

+ *************************** +

+
+
))} diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index 875f8cdf4..e8c106cde 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -4,6 +4,7 @@ import { Tab } from "@headlessui/react"; import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { ProjectTemplatesTab } from "@app/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab"; import { AuditLogStreamsTab } from "../AuditLogStreamTab"; import { ImportTab } from "../ImportTab"; @@ -18,7 +19,8 @@ const tabs = [ { name: "Encryption", key: "tab-org-encryption" }, { name: "Workflow Integrations", key: "workflow-integrations" }, { name: "Audit Log Streams", key: "tag-audit-log-streams" }, - { name: "Import", key: "tab-import" } + { name: "Import", key: "tab-import" }, + { name: "Project Templates", key: "project-templates" } ]; export const OrgTabGroup = () => { const { query } = useRouter(); @@ -69,10 +71,13 @@ export const OrgTabGroup = () => { - - - + + + + + + ); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/ProjectTemplatesTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/ProjectTemplatesTab.tsx new file mode 100644 index 000000000..016c1439c --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/ProjectTemplatesTab.tsx @@ -0,0 +1,9 @@ +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { withPermission } from "@app/hoc"; + +import { ProjectTemplatesSection } from "./components"; + +export const ProjectTemplatesTab = withPermission(() => , { + action: OrgPermissionActions.Read, + subject: OrgPermissionSubjects.ProjectTemplates +}); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx new file mode 100644 index 000000000..09537ce57 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx @@ -0,0 +1,49 @@ +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal } from "@app/components/v2"; +import { TProjectTemplate, useDeleteProjectTemplate } from "@app/hooks/api/projectTemplates"; + +type Props = { + template?: TProjectTemplate; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +export const DeleteProjectTemplateModal = ({ isOpen, onOpenChange, template }: Props) => { + const deleteTemplate = useDeleteProjectTemplate(); + + if (!template) return null; + + const { id: templateId, name } = template; + + const handleDeleteProjectTemplate = async () => { + try { + await deleteTemplate.mutateAsync({ + templateId + }); + + createNotification({ + text: "Successfully removed project template", + type: "success" + }); + + onOpenChange(false); + } catch (err) { + console.error(err); + + createNotification({ + text: "Failed remove project template", + type: "error" + }); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/EditProjectTemplateSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/EditProjectTemplateSection.tsx new file mode 100644 index 000000000..c52440e32 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/EditProjectTemplateSection.tsx @@ -0,0 +1,53 @@ +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, EmptyState, Spinner } from "@app/components/v2"; +import { + DefaultProjectTemplateIdentifier, + TProjectTemplate, + useGetProjectTemplateById +} from "@app/hooks/api/projectTemplates"; + +import { EditProjectTemplate } from "./components"; + +type Props = { + template: TProjectTemplate; + onBack: () => void; +}; + +export const EditProjectTemplateSection = ({ template, onBack }: Props) => { + const isDefault = template.name === DefaultProjectTemplateIdentifier; + + const { data: projectTemplate, isLoading } = useGetProjectTemplateById(template.id, { + initialData: template, + enabled: !isDefault + }); + + return ( +
+ + {/* eslint-disable-next-line no-nested-ternary */} + {isLoading ? ( +
+ +
+ ) : projectTemplate ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx new file mode 100644 index 000000000..820403306 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx @@ -0,0 +1,139 @@ +import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + IconButton +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { TProjectTemplate, useDeleteProjectTemplate } from "@app/hooks/api/projectTemplates"; + +import { ProjectTemplateDetailsModal } from "../../ProjectTemplateDetailsModal"; +import { ProjectTemplateEnvironmentsForm } from "./ProjectTemplateEnvironmentsForm"; +import { ProjectTemplateRolesSection } from "./ProjectTemplateRolesSection"; + +type Props = { + projectTemplate: TProjectTemplate; + onBack: () => void; + isDefault: boolean; +}; + +export const EditProjectTemplate = ({ isDefault, projectTemplate, onBack }: Props) => { + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "removeTemplate", + "editDetails" + ] as const); + + const { id: templateId, name, description } = projectTemplate; + + const deleteProjectTemplate = useDeleteProjectTemplate(); + + const handleRemoveTemplate = async () => { + try { + await deleteProjectTemplate.mutateAsync({ + templateId + }); + createNotification({ + text: "Successfully removed project template", + type: "success" + }); + onBack(); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to remove project template", + type: "error" + }); + } + handlePopUpClose("removeTemplate"); + }; + + return ( + <> +
+
+

{name}

+

{description || "Project Template"}

+
+ {!isDefault && ( + + + + + + + + + {(isAllowed) => ( + handlePopUpOpen("editDetails")} + disabled={!isAllowed} + > + Edit Details + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("removeTemplate"); + }} + disabled={!isAllowed} + > + Delete Template + + )} + + + + )} +
+ + + handlePopUpToggle("editDetails", isOpen)} + projectTemplate={projectTemplate} + /> + handlePopUpToggle("removeTemplate", isOpen)} + onDeleteApproved={handleRemoveTemplate} + /> + + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx new file mode 100644 index 000000000..fae1c9881 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx @@ -0,0 +1,211 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { faChevronLeft, faPlus, faSave } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent, ModalTrigger } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { isCustomProjectRole } from "@app/helpers/roles"; +import { usePopUp } from "@app/hooks"; +import { TProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates"; +import { slugSchema } from "@app/lib/schemas"; +import { GeneralPermissionPolicies } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies"; +import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule"; +import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState"; +import { + formRolePermission2API, + PROJECT_PERMISSION_OBJECT, + projectRoleFormSchema, + rolePermission2Form +} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; +import { renderConditionalComponents } from "@app/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection"; + +type Props = { + projectTemplate: TProjectTemplate; + role?: TProjectTemplate["roles"][number]; + onGoBack: () => void; + isDisabled?: boolean; +}; + +const formSchema = z.object({ + slug: slugSchema, + name: z.string().trim().min(1), + permissions: projectRoleFormSchema.shape.permissions +}); + +type TFormSchema = z.infer; + +export const ProjectTemplateEditRoleForm = ({ + onGoBack, + projectTemplate, + role, + isDisabled +}: Props) => { + const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const); + + const formMethods = useForm({ + values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined, + resolver: zodResolver(formSchema) + }); + + const { + handleSubmit, + control, + formState: { isDirty, isSubmitting } + } = formMethods; + + const updateProjectTemplate = useUpdateProjectTemplate(); + + const onSubmit = async (form: TFormSchema) => { + try { + await updateProjectTemplate.mutateAsync({ + templateId: projectTemplate.id, + roles: [ + ...projectTemplate.roles.filter( + (r) => r.slug !== role?.slug && isCustomProjectRole(r.slug) // filter out default roles as well + ), + { + ...form, + permissions: formRolePermission2API(form.permissions) + } + ] + }); + onGoBack(); + createNotification({ + text: "Template roles successfully updated", + type: "success" + }); + } catch (e: any) { + console.error(e); + createNotification({ + text: "Failed to update template roles", + type: "error" + }); + } + }; + + return ( + + +
+ + {!isDisabled && ( +
+ {isDirty && ( + + )} +
+ + handlePopUpToggle("createPolicy", isOpen)} + > + + + + + handlePopUpToggle("createPolicy")} /> + + +
+
+ )} +
+
+ {isDisabled ? ( +
+ {role?.name} + {role?.slug} +
+ ) : ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ )} +
+
+
Policies
+ + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( + + {renderConditionalComponents(subject, isDisabled)} + + ))} +
+
+ + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx new file mode 100644 index 000000000..fa213fc81 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx @@ -0,0 +1,280 @@ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faArrowDown, faArrowUp, faPlus, faSave, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + FormControl, + IconButton, + Input, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { TProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates"; +import { slugSchema } from "@app/lib/schemas"; + +type Props = { + projectTemplate: TProjectTemplate; + isDefault: boolean; +}; + +const formSchema = z.object({ + environments: z + .object({ + name: z.string().trim().min(1), + slug: slugSchema + }) + .array() +}); + +type TFormSchema = z.infer; + +export const ProjectTemplateEnvironmentsForm = ({ projectTemplate, isDefault }: Props) => { + const { + control, + handleSubmit, + formState: { isDirty, errors }, + reset + } = useForm({ + defaultValues: { + environments: projectTemplate.environments + }, + resolver: zodResolver(formSchema) + }); + + const { + fields: environments, + move, + remove, + append + } = useFieldArray({ control, name: "environments" }); + + const updateProjectTemplate = useUpdateProjectTemplate(); + + const onFormSubmit = async (form: TFormSchema) => { + try { + const { environments: updatedEnvs } = await updateProjectTemplate.mutateAsync({ + environments: form.environments.map((env, index) => ({ + ...env, + position: index + 1 + })), + templateId: projectTemplate.id + }); + + reset({ environments: updatedEnvs }); + + createNotification({ + text: "Project template updated successfully", + type: "success" + }); + } catch (e: any) { + console.error(e); + createNotification({ + text: e.message ?? "Failed to update project template", + type: "error" + }); + } + }; + + return ( +
+
+
+

Project Environments

+ {!isDefault && ( +

+ Add, rename, remove and reorder environments for this project template +

+ )} +
+ {!isDefault && ( + + {(isAllowed) => ( + + )} + + )} +
+ {errors.environments && ( + {errors.environments.message} + )} + + + + + + + {!isDefault && ( + + )} + + + + {environments.map(({ id, name, slug }, pos) => ( + + + + {!isDefault && ( + + )} + + ))} + +
Friendly NameSlug +
+ + {(isAllowed) => ( + append({ name: "", slug: "" })} + colorSchema="primary" + className="ml-auto" + variant="outline_bg" + size="xs" + ariaLabel="Add environment" + isDisabled={!isAllowed} + > + + + )} + +
+
+ {isDefault ? ( + name + ) : ( + + {(isAllowed) => ( + ( + + + + )} + /> + )} + + )} + + {isDefault ? ( + slug + ) : ( + + {(isAllowed) => ( + ( + + + + )} + /> + )} + + )} + + + {(isAllowed) => ( + move(pos, pos + 1)} + colorSchema="primary" + variant="plain" + ariaLabel="Increase position" + isDisabled={pos === environments.length - 1 || !isAllowed} + > + + + )} + + + {(isAllowed) => ( + move(pos, pos - 1)} + colorSchema="primary" + variant="plain" + ariaLabel="Decrease position" + isDisabled={pos === 0 || !isAllowed} + > + + + )} + + + {(isAllowed) => ( + remove(pos)} + colorSchema="danger" + variant="plain" + ariaLabel="Remove environment" + isDisabled={!isAllowed || environments.length === 1} + > + + + )} + +
+
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateRolesSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateRolesSection.tsx new file mode 100644 index 000000000..488b524e8 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateRolesSection.tsx @@ -0,0 +1,223 @@ +import { faPlus, faTrash, faUnlock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AnimatePresence, motion } from "framer-motion"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + EmptyState, + IconButton, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrgPermission } from "@app/context"; +import { isCustomProjectRole } from "@app/helpers/roles"; +import { usePopUp } from "@app/hooks"; +import { TProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates"; +import { TProjectRole } from "@app/hooks/api/roles/types"; + +import { ProjectTemplateEditRoleForm } from "./ProjectTemplateEditRoleForm"; + +type Props = { + projectTemplate: TProjectTemplate; + isDefaultTemplate: boolean; +}; + +export const ProjectTemplateRolesSection = ({ projectTemplate, isDefaultTemplate }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "removeRole", + "editRole" + ] as const); + + const { permission } = useOrgPermission(); + + const { roles } = projectTemplate; + + const updateProjectTemplate = useUpdateProjectTemplate(); + + const handleRemoveRole = async (slug: string) => { + try { + await updateProjectTemplate.mutateAsync({ + templateId: projectTemplate.id, + roles: projectTemplate.roles.filter( + (role) => role.slug !== slug && isCustomProjectRole(role.slug) // filter out default roles as well + ) + }); + + createNotification({ + text: "Successfully removed role from template", + type: "success" + }); + handlePopUpClose("removeRole"); + } catch (e) { + console.error(e); + createNotification({ + text: "Error removing role from template", + type: "error" + }); + } + }; + + const editRole = popUp?.editRole?.data as TProjectRole; + const roleToDelete = popUp?.removeRole?.data as TProjectRole; + + return ( +
+ + {popUp?.editRole.isOpen ? ( + + handlePopUpClose("editRole")} + projectTemplate={projectTemplate} + role={editRole} + isDisabled={ + permission.cannot( + OrgPermissionActions.Edit, + OrgPermissionSubjects.ProjectTemplates + ) || + (editRole && !isCustomProjectRole(editRole.slug)) + } + /> +
+ + ) : ( + +
+
+
+

Project Roles

+

+ {isDefaultTemplate + ? "Click a role to view the associated permissions" + : "Add, edit and remove roles for this project template"} +

+
+ {!isDefaultTemplate && ( + + {(isAllowed) => ( + + )} + + )} +
+
+ + + + + + + + + + {roles.length ? ( + roles.map((role) => { + return ( + { + if (evt.key === "Enter") { + handlePopUpOpen("editRole", role); + } + }} + onClick={() => handlePopUpOpen("editRole", role)} + > + + + + + ); + }) + ) : ( + + + + )} + +
NameSlug +
{role.name}{role.slug} + {isCustomProjectRole(role.slug) && ( +
+ + {(isAllowed) => ( + { + e.stopPropagation(); + e.preventDefault(); + handlePopUpOpen("removeRole", role); + }} + > + + + )} + +
+ )} +
+ +
+
+
+ handlePopUpToggle("removeRole", isOpen)} + onDeleteApproved={() => handleRemoveRole(roleToDelete?.slug)} + /> +
+
+ + )} + +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/index.tsx new file mode 100644 index 000000000..5c3e6e57f --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/index.tsx @@ -0,0 +1 @@ +export * from "./EditProjectTemplate"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/index.tsx new file mode 100644 index 000000000..cd6925f94 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/index.tsx @@ -0,0 +1 @@ +export * from "./EditProjectTemplateSection"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx new file mode 100644 index 000000000..1f65df795 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx @@ -0,0 +1,151 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalClose, + ModalContent, + TextArea +} from "@app/components/v2"; +import { + TProjectTemplate, + useCreateProjectTemplate, + useUpdateProjectTemplate +} from "@app/hooks/api/projectTemplates"; + +const formSchema = z.object({ + name: z + .string() + .trim() + .min(1) + .max(32) + .toLowerCase() + .refine((v) => slugify(v) === v, { + message: "Name must be in slug format" + }), + description: z.string().max(500).optional() +}); + +export type FormData = z.infer; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: (template: TProjectTemplate) => void; + projectTemplate?: TProjectTemplate; +}; + +type FormProps = { + projectTemplate?: TProjectTemplate; + onComplete: (template: TProjectTemplate) => void; +}; + +const ProjectTemplateForm = ({ onComplete, projectTemplate }: FormProps) => { + const createProjectTemplate = useCreateProjectTemplate(); + const updateProjectTemplate = useUpdateProjectTemplate(); + + const { + handleSubmit, + register, + formState: { isSubmitting, errors } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: projectTemplate?.name, + description: projectTemplate?.description + } + }); + + const onFormSubmit = async (data: FormData) => { + const mutation = projectTemplate + ? updateProjectTemplate.mutateAsync({ templateId: projectTemplate.id, ...data }) + : createProjectTemplate.mutateAsync(data); + try { + const template = await mutation; + createNotification({ + text: `Successfully ${ + projectTemplate ? "updated template details" : "created project template" + }`, + type: "success" + }); + + onComplete(template); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${ + projectTemplate ? "update template details" : "create project template" + }`, + type: "error" + }); + } + }; + + return ( +
+ + + + +