mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-05 17:26:36 +00:00
Merge pull request #2701 from scott-ray-wilson/project-templates-feature
Feature: Project Templates
This commit is contained in:
Vendored
+2
@@ -18,6 +18,7 @@ import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-con
|
|||||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
|
import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-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 { 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 { TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service";
|
||||||
import { RateLimitConfiguration } from "@app/ee/services/rate-limit/rate-limit-types";
|
import { RateLimitConfiguration } from "@app/ee/services/rate-limit/rate-limit-types";
|
||||||
@@ -189,6 +190,7 @@ declare module "fastify" {
|
|||||||
cmek: TCmekServiceFactory;
|
cmek: TCmekServiceFactory;
|
||||||
migration: TExternalMigrationServiceFactory;
|
migration: TExternalMigrationServiceFactory;
|
||||||
externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory;
|
externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory;
|
||||||
|
projectTemplate: TProjectTemplateServiceFactory;
|
||||||
};
|
};
|
||||||
// this is exclusive use for middlewares in which we need to inject data
|
// this is exclusive use for middlewares in which we need to inject data
|
||||||
// everywhere else access using service layer
|
// everywhere else access using service layer
|
||||||
|
|||||||
Vendored
+8
@@ -200,6 +200,9 @@ import {
|
|||||||
TProjectSlackConfigsInsert,
|
TProjectSlackConfigsInsert,
|
||||||
TProjectSlackConfigsUpdate,
|
TProjectSlackConfigsUpdate,
|
||||||
TProjectsUpdate,
|
TProjectsUpdate,
|
||||||
|
TProjectTemplates,
|
||||||
|
TProjectTemplatesInsert,
|
||||||
|
TProjectTemplatesUpdate,
|
||||||
TProjectUserAdditionalPrivilege,
|
TProjectUserAdditionalPrivilege,
|
||||||
TProjectUserAdditionalPrivilegeInsert,
|
TProjectUserAdditionalPrivilegeInsert,
|
||||||
TProjectUserAdditionalPrivilegeUpdate,
|
TProjectUserAdditionalPrivilegeUpdate,
|
||||||
@@ -818,5 +821,10 @@ declare module "knex/types/tables" {
|
|||||||
TExternalGroupOrgRoleMappingsInsert,
|
TExternalGroupOrgRoleMappingsInsert,
|
||||||
TExternalGroupOrgRoleMappingsUpdate
|
TExternalGroupOrgRoleMappingsUpdate
|
||||||
>;
|
>;
|
||||||
|
[TableName.ProjectTemplates]: KnexOriginal.CompositeTableType<
|
||||||
|
TProjectTemplates,
|
||||||
|
TProjectTemplatesInsert,
|
||||||
|
TProjectTemplatesUpdate
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "@app/db/schemas";
|
||||||
|
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
await createOnUpdateTrigger(knex, TableName.ProjectTemplates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
if (await knex.schema.hasTable(TableName.ProjectTemplates)) {
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.ProjectTemplates);
|
||||||
|
|
||||||
|
await knex.schema.dropTable(TableName.ProjectTemplates);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ export * from "./project-keys";
|
|||||||
export * from "./project-memberships";
|
export * from "./project-memberships";
|
||||||
export * from "./project-roles";
|
export * from "./project-roles";
|
||||||
export * from "./project-slack-configs";
|
export * from "./project-slack-configs";
|
||||||
|
export * from "./project-templates";
|
||||||
export * from "./project-user-additional-privilege";
|
export * from "./project-user-additional-privilege";
|
||||||
export * from "./project-user-membership-roles";
|
export * from "./project-user-membership-roles";
|
||||||
export * from "./projects";
|
export * from "./projects";
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export enum TableName {
|
|||||||
ProjectUserAdditionalPrivilege = "project_user_additional_privilege",
|
ProjectUserAdditionalPrivilege = "project_user_additional_privilege",
|
||||||
ProjectUserMembershipRole = "project_user_membership_roles",
|
ProjectUserMembershipRole = "project_user_membership_roles",
|
||||||
ProjectKeys = "project_keys",
|
ProjectKeys = "project_keys",
|
||||||
|
ProjectTemplates = "project_templates",
|
||||||
Secret = "secrets",
|
Secret = "secrets",
|
||||||
SecretReference = "secret_references",
|
SecretReference = "secret_references",
|
||||||
SecretSharing = "secret_sharing",
|
SecretSharing = "secret_sharing",
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export const ProjectRolesSchema = z.object({
|
|||||||
permissions: z.unknown(),
|
permissions: z.unknown(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
projectId: z.string()
|
projectId: z.string(),
|
||||||
|
version: z.number().default(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TProjectRoles = z.infer<typeof ProjectRolesSchema>;
|
export type TProjectRoles = z.infer<typeof ProjectRolesSchema>;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TProjectTemplates = z.infer<typeof ProjectTemplatesSchema>;
|
||||||
|
export type TProjectTemplatesInsert = Omit<z.input<typeof ProjectTemplatesSchema>, TImmutableDBKeys>;
|
||||||
|
export type TProjectTemplatesUpdate = Partial<Omit<z.input<typeof ProjectTemplatesSchema>, TImmutableDBKeys>>;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-template-router";
|
||||||
|
|
||||||
import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router";
|
import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router";
|
||||||
import { registerAccessApprovalRequestRouter } from "./access-approval-request-router";
|
import { registerAccessApprovalRequestRouter } from "./access-approval-request-router";
|
||||||
import { registerAuditLogStreamRouter } from "./audit-log-stream-router";
|
import { registerAuditLogStreamRouter } from "./audit-log-stream-router";
|
||||||
@@ -92,4 +94,6 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
|||||||
await server.register(registerExternalKmsRouter, {
|
await server.register(registerExternalKmsRouter, {
|
||||||
prefix: "/external-kms"
|
prefix: "/external-kms"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await server.register(registerProjectTemplateRouter, { prefix: "/project-templates" });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
|
|||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
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: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
role: SanitizedRoleSchemaV1
|
role: SanitizedRoleSchemaV1.omit({ version: true })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
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 { ProjectTemplateDefaultEnvironments } from "@app/ee/services/project-template/project-template-constants";
|
||||||
|
import { isInfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-fns";
|
||||||
|
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 MAX_JSON_SIZE_LIMIT_IN_BYTES = 32_768;
|
||||||
|
|
||||||
|
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 (Buffer.byteLength(JSON.stringify(roles)) > MAX_JSON_SIZE_LIMIT_IN_BYTES)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Size limit exceeded" });
|
||||||
|
|
||||||
|
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 (Buffer.byteLength(JSON.stringify(environments)) > MAX_JSON_SIZE_LIMIT_IN_BYTES)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Size limit exceeded" });
|
||||||
|
|
||||||
|
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) => !isInfisicalProjectTemplate(template.name));
|
||||||
|
|
||||||
|
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.findProjectTemplateById(
|
||||||
|
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) => !isInfisicalProjectTemplate(val), {
|
||||||
|
message: `The requested project template name is reserved.`
|
||||||
|
}).describe(ProjectTemplates.CREATE.name),
|
||||||
|
description: z.string().max(256).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) => !isInfisicalProjectTemplate(val), {
|
||||||
|
message: `The requested project template name is reserved.`
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.describe(ProjectTemplates.UPDATE.name),
|
||||||
|
description: z.string().max(256).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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -186,7 +186,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
|
|||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
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: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
role: SanitizedRoleSchema
|
role: SanitizedRoleSchema.omit({ version: true })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
TCreateProjectTemplateDTO,
|
||||||
|
TUpdateProjectTemplateDTO
|
||||||
|
} from "@app/ee/services/project-template/project-template-types";
|
||||||
import { SymmetricEncryption } from "@app/lib/crypto/cipher";
|
import { SymmetricEncryption } from "@app/lib/crypto/cipher";
|
||||||
import { TProjectPermission } from "@app/lib/types";
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
import { ActorType } from "@app/services/auth/auth-type";
|
import { ActorType } from "@app/services/auth/auth-type";
|
||||||
@@ -192,7 +196,13 @@ export enum EventType {
|
|||||||
CMEK_ENCRYPT = "cmek-encrypt",
|
CMEK_ENCRYPT = "cmek-encrypt",
|
||||||
CMEK_DECRYPT = "cmek-decrypt",
|
CMEK_DECRYPT = "cmek-decrypt",
|
||||||
UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping",
|
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 {
|
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: {
|
||||||
|
template: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type Event =
|
export type Event =
|
||||||
| GetSecretsEvent
|
| GetSecretsEvent
|
||||||
| GetSecretEvent
|
| GetSecretEvent
|
||||||
@@ -1766,4 +1816,10 @@ export type Event =
|
|||||||
| CmekEncryptEvent
|
| CmekEncryptEvent
|
||||||
| CmekDecryptEvent
|
| CmekDecryptEvent
|
||||||
| GetExternalGroupOrgRoleMappingsEvent
|
| GetExternalGroupOrgRoleMappingsEvent
|
||||||
| UpdateExternalGroupOrgRoleMappingsEvent;
|
| UpdateExternalGroupOrgRoleMappingsEvent
|
||||||
|
| GetProjectTemplatesEvent
|
||||||
|
| GetProjectTemplateEvent
|
||||||
|
| CreateProjectTemplateEvent
|
||||||
|
| UpdateProjectTemplateEvent
|
||||||
|
| DeleteProjectTemplateEvent
|
||||||
|
| ApplyProjectTemplateEvent;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@app/ee/services/permission/project-permission";
|
} from "@app/ee/services/permission/project-permission";
|
||||||
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
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 { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||||
|
|
||||||
@@ -457,7 +457,7 @@ export const dynamicSecretServiceFactory = ({
|
|||||||
|
|
||||||
const listDynamicSecretsByFolderIds = async (
|
const listDynamicSecretsByFolderIds = async (
|
||||||
{ folderMappings, filters, projectId }: TListDynamicSecretsByFolderMappingsDTO,
|
{ folderMappings, filters, projectId }: TListDynamicSecretsByFolderMappingsDTO,
|
||||||
actor: ProjectServiceActor
|
actor: OrgServiceActor
|
||||||
) => {
|
) => {
|
||||||
const { permission } = await permissionService.getProjectPermission(
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
actor.type,
|
actor.type,
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const groupServiceFactory = ({
|
|||||||
const plan = await licenseService.getPlan(actorOrgId);
|
const plan = await licenseService.getPlan(actorOrgId);
|
||||||
if (!plan.groups)
|
if (!plan.groups)
|
||||||
throw new BadRequestError({
|
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 });
|
const group = await groupDAL.findOne({ orgId: actorOrgId, id });
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
|||||||
secretsLimit: 40
|
secretsLimit: 40
|
||||||
},
|
},
|
||||||
pkiEst: false,
|
pkiEst: false,
|
||||||
enforceMfa: false
|
enforceMfa: false,
|
||||||
|
projectTemplates: false
|
||||||
});
|
});
|
||||||
|
|
||||||
export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => {
|
export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export type TFeatureSet = {
|
|||||||
};
|
};
|
||||||
pkiEst: boolean;
|
pkiEst: boolean;
|
||||||
enforceMfa: boolean;
|
enforceMfa: boolean;
|
||||||
|
projectTemplates: false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TOrgPlansTableDTO = {
|
export type TOrgPlansTableDTO = {
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export enum OrgPermissionSubjects {
|
|||||||
Identity = "identity",
|
Identity = "identity",
|
||||||
Kms = "kms",
|
Kms = "kms",
|
||||||
AdminConsole = "organization-admin-console",
|
AdminConsole = "organization-admin-console",
|
||||||
AuditLogs = "audit-logs"
|
AuditLogs = "audit-logs",
|
||||||
|
ProjectTemplates = "project-templates"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrgPermissionSet =
|
export type OrgPermissionSet =
|
||||||
@@ -45,6 +46,7 @@ export type OrgPermissionSet =
|
|||||||
| [OrgPermissionActions, OrgPermissionSubjects.Identity]
|
| [OrgPermissionActions, OrgPermissionSubjects.Identity]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
||||||
|
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
|
||||||
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole];
|
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole];
|
||||||
|
|
||||||
const buildAdminPermission = () => {
|
const buildAdminPermission = () => {
|
||||||
@@ -118,6 +120,11 @@ const buildAdminPermission = () => {
|
|||||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.AuditLogs);
|
can(OrgPermissionActions.Edit, OrgPermissionSubjects.AuditLogs);
|
||||||
can(OrgPermissionActions.Delete, 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);
|
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
|
||||||
|
|
||||||
return rules;
|
return rules;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const ProjectTemplateDefaultEnvironments = [
|
||||||
|
{ name: "Development", slug: "dev", position: 1 },
|
||||||
|
{ name: "Staging", slug: "staging", position: 2 },
|
||||||
|
{ name: "Production", slug: "prod", position: 3 }
|
||||||
|
];
|
||||||
@@ -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<typeof projectTemplateDALFactory>;
|
||||||
|
|
||||||
|
export const projectTemplateDALFactory = (db: TDbClient) => ormify(db, TableName.ProjectTemplates);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ProjectTemplateDefaultEnvironments } from "@app/ee/services/project-template/project-template-constants";
|
||||||
|
import {
|
||||||
|
InfisicalProjectTemplate,
|
||||||
|
TUnpackedPermission
|
||||||
|
} from "@app/ee/services/project-template/project-template-types";
|
||||||
|
import { getPredefinedRoles } from "@app/services/project-role/project-role-fns";
|
||||||
|
|
||||||
|
export const getDefaultProjectTemplate = (orgId: string) => ({
|
||||||
|
id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // random ID to appease zod
|
||||||
|
name: InfisicalProjectTemplate.Default,
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isInfisicalProjectTemplate = (template: string) =>
|
||||||
|
Object.values(InfisicalProjectTemplate).includes(template as InfisicalProjectTemplate);
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
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-fns";
|
||||||
|
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<typeof projectTemplateServiceFactory>;
|
||||||
|
|
||||||
|
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 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 { permission } = await permissionService.getOrgPermission(
|
||||||
|
actor.type,
|
||||||
|
actor.id,
|
||||||
|
actor.orgId,
|
||||||
|
actor.authMethod,
|
||||||
|
actor.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates);
|
||||||
|
|
||||||
|
const projectTemplates = await projectTemplateDAL.find({
|
||||||
|
orgId: actor.orgId
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
getDefaultProjectTemplate(actor.orgId),
|
||||||
|
...projectTemplates.map((template) => $unpackProjectTemplate(template))
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findProjectTemplateByName = async (name: string, actor: OrgServiceActor) => {
|
||||||
|
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.findOne({ name, orgId: actor.orgId });
|
||||||
|
|
||||||
|
if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with Name "${name}"` });
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
|
actor.type,
|
||||||
|
actor.id,
|
||||||
|
projectTemplate.orgId,
|
||||||
|
actor.authMethod,
|
||||||
|
actor.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...$unpackProjectTemplate(projectTemplate),
|
||||||
|
packedRoles: projectTemplate.roles as TProjectTemplateRole[] // preserve packed for when applying template
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findProjectTemplateById = async (id: string, actor: OrgServiceActor) => {
|
||||||
|
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.findById(id);
|
||||||
|
|
||||||
|
if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with ID ${id}` });
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
|
actor.type,
|
||||||
|
actor.id,
|
||||||
|
projectTemplate.orgId,
|
||||||
|
actor.authMethod,
|
||||||
|
actor.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...$unpackProjectTemplate(projectTemplate),
|
||||||
|
packedRoles: projectTemplate.roles as TProjectTemplateRole[] // preserve packed for when applying template
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createProjectTemplate = async (
|
||||||
|
{ roles, environments, ...params }: TCreateProjectTemplateDTO,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
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 { permission } = await permissionService.getOrgPermission(
|
||||||
|
actor.type,
|
||||||
|
actor.id,
|
||||||
|
actor.orgId,
|
||||||
|
actor.authMethod,
|
||||||
|
actor.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.ProjectTemplates);
|
||||||
|
|
||||||
|
const isConflictingName = Boolean(
|
||||||
|
await projectTemplateDAL.findOne({
|
||||||
|
name: params.name,
|
||||||
|
orgId: actor.orgId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isConflictingName)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `A project template with the name "${params.name}" already exists.`
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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.findById(id);
|
||||||
|
|
||||||
|
if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with ID ${id}` });
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
|
actor.type,
|
||||||
|
actor.id,
|
||||||
|
projectTemplate.orgId,
|
||||||
|
actor.authMethod,
|
||||||
|
actor.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates);
|
||||||
|
|
||||||
|
if (params.name && projectTemplate.name !== params.name) {
|
||||||
|
const isConflictingName = Boolean(
|
||||||
|
await projectTemplateDAL.findOne({
|
||||||
|
name: params.name,
|
||||||
|
orgId: projectTemplate.orgId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isConflictingName)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `A project template with the name "${params.name}" already exists.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProjectTemplate = 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(updatedProjectTemplate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteProjectTemplateById = async (id: string, actor: OrgServiceActor) => {
|
||||||
|
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.findById(id);
|
||||||
|
|
||||||
|
if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with ID ${id}` });
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
|
actor.type,
|
||||||
|
actor.id,
|
||||||
|
projectTemplate.orgId,
|
||||||
|
actor.authMethod,
|
||||||
|
actor.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates);
|
||||||
|
|
||||||
|
const deletedProjectTemplate = await projectTemplateDAL.deleteById(id);
|
||||||
|
|
||||||
|
return $unpackProjectTemplate(deletedProjectTemplate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
listProjectTemplatesByOrg,
|
||||||
|
createProjectTemplate,
|
||||||
|
updateProjectTemplateById,
|
||||||
|
deleteProjectTemplateById,
|
||||||
|
findProjectTemplateById,
|
||||||
|
findProjectTemplateByName
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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<TProjectEnvironments, "name" | "slug" | "position">;
|
||||||
|
|
||||||
|
export type TProjectTemplateRole = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
permissions: TProjectPermissionV2Schema[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreateProjectTemplateDTO = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
roles: TProjectTemplateRole[];
|
||||||
|
environments: TProjectTemplateEnvironment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TUpdateProjectTemplateDTO = Partial<TCreateProjectTemplateDTO>;
|
||||||
|
|
||||||
|
export type TUnpackedPermission = z.infer<typeof UnpackedPermissionSchema>;
|
||||||
|
|
||||||
|
export enum InfisicalProjectTemplate {
|
||||||
|
Default = "default"
|
||||||
|
}
|
||||||
@@ -391,7 +391,8 @@ export const PROJECTS = {
|
|||||||
CREATE: {
|
CREATE: {
|
||||||
organizationSlug: "The slug of the organization to create the project in.",
|
organizationSlug: "The slug of the organization to create the project in.",
|
||||||
projectName: "The name of the project to create.",
|
projectName: "The name of the project to create.",
|
||||||
slug: "An optional slug for the project."
|
slug: "An optional slug for the project.",
|
||||||
|
template: "The name of the project template, if specified, to apply to this project."
|
||||||
},
|
},
|
||||||
DELETE: {
|
DELETE: {
|
||||||
workspaceId: "The ID of the project to delete."
|
workspaceId: "The ID of the project to delete."
|
||||||
@@ -1438,3 +1439,22 @@ export const KMS = {
|
|||||||
ciphertext: "The ciphertext to be decrypted (base64 encoded)."
|
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."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export enum OrderByDirection {
|
|||||||
DESC = "desc"
|
DESC = "desc"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectServiceActor = {
|
export type OrgServiceActor = {
|
||||||
type: ActorType;
|
type: ActorType;
|
||||||
id: string;
|
id: string;
|
||||||
authMethod: ActorAuthMethod;
|
authMethod: ActorAuthMethod;
|
||||||
|
|||||||
@@ -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 { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
|
||||||
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
|
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
|
||||||
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
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 { 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 { 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";
|
import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal";
|
||||||
@@ -340,6 +342,8 @@ export const registerRoutes = async (
|
|||||||
|
|
||||||
const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db);
|
const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db);
|
||||||
|
|
||||||
|
const projectTemplateDAL = projectTemplateDALFactory(db);
|
||||||
|
|
||||||
const permissionService = permissionServiceFactory({
|
const permissionService = permissionServiceFactory({
|
||||||
permissionDAL,
|
permissionDAL,
|
||||||
orgRoleDAL,
|
orgRoleDAL,
|
||||||
@@ -732,6 +736,12 @@ export const registerRoutes = async (
|
|||||||
permissionService
|
permissionService
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectTemplateService = projectTemplateServiceFactory({
|
||||||
|
licenseService,
|
||||||
|
permissionService,
|
||||||
|
projectTemplateDAL
|
||||||
|
});
|
||||||
|
|
||||||
const projectService = projectServiceFactory({
|
const projectService = projectServiceFactory({
|
||||||
permissionService,
|
permissionService,
|
||||||
projectDAL,
|
projectDAL,
|
||||||
@@ -758,7 +768,8 @@ export const registerRoutes = async (
|
|||||||
projectBotDAL,
|
projectBotDAL,
|
||||||
certificateTemplateDAL,
|
certificateTemplateDAL,
|
||||||
projectSlackConfigDAL,
|
projectSlackConfigDAL,
|
||||||
slackIntegrationDAL
|
slackIntegrationDAL,
|
||||||
|
projectTemplateService
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectEnvService = projectEnvServiceFactory({
|
const projectEnvService = projectEnvServiceFactory({
|
||||||
@@ -1336,7 +1347,8 @@ export const registerRoutes = async (
|
|||||||
slack: slackService,
|
slack: slackService,
|
||||||
workflowIntegration: workflowIntegrationService,
|
workflowIntegration: workflowIntegrationService,
|
||||||
migration: migrationService,
|
migration: migrationService,
|
||||||
externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService
|
externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService,
|
||||||
|
projectTemplate: projectTemplateService
|
||||||
});
|
});
|
||||||
|
|
||||||
const cronJobs: CronJob[] = [];
|
const cronJobs: CronJob[] = [];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ProjectKeysSchema
|
ProjectKeysSchema
|
||||||
} from "@app/db/schemas";
|
} from "@app/db/schemas";
|
||||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
|
import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types";
|
||||||
import { PROJECTS } from "@app/lib/api-docs";
|
import { PROJECTS } from "@app/lib/api-docs";
|
||||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
||||||
@@ -169,7 +170,15 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.describe(PROJECTS.CREATE.slug),
|
.describe(PROJECTS.CREATE.slug),
|
||||||
kmsKeyId: z.string().optional()
|
kmsKeyId: z.string().optional(),
|
||||||
|
template: z
|
||||||
|
.string()
|
||||||
|
.refine((v) => slugify(v) === v, {
|
||||||
|
message: "Template name must be in slug format"
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default(InfisicalProjectTemplate.Default)
|
||||||
|
.describe(PROJECTS.CREATE.template)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
@@ -186,7 +195,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
actorAuthMethod: req.permission.authMethod,
|
actorAuthMethod: req.permission.authMethod,
|
||||||
workspaceName: req.body.projectName,
|
workspaceName: req.body.projectName,
|
||||||
slug: req.body.slug,
|
slug: req.body.slug,
|
||||||
kmsKeyId: req.body.kmsKeyId
|
kmsKeyId: req.body.kmsKeyId,
|
||||||
|
template: req.body.template
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.services.telemetry.sendPostHogEvents({
|
await server.services.telemetry.sendPostHogEvents({
|
||||||
@@ -199,6 +209,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (req.body.template) {
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
orgId: req.permission.orgId,
|
||||||
|
event: {
|
||||||
|
type: EventType.APPLY_PROJECT_TEMPLATE,
|
||||||
|
metadata: {
|
||||||
|
template: req.body.template,
|
||||||
|
projectId: project.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { project };
|
return { project };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ForbiddenError } from "@casl/ability";
|
|||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { ProjectServiceActor } from "@app/lib/types";
|
import { OrgServiceActor } from "@app/lib/types";
|
||||||
import {
|
import {
|
||||||
TCmekDecryptDTO,
|
TCmekDecryptDTO,
|
||||||
TCmekEncryptDTO,
|
TCmekEncryptDTO,
|
||||||
@@ -23,7 +23,7 @@ type TCmekServiceFactoryDep = {
|
|||||||
export type TCmekServiceFactory = ReturnType<typeof cmekServiceFactory>;
|
export type TCmekServiceFactory = ReturnType<typeof cmekServiceFactory>;
|
||||||
|
|
||||||
export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TCmekServiceFactoryDep) => {
|
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(
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
actor.type,
|
actor.type,
|
||||||
actor.id,
|
actor.id,
|
||||||
@@ -43,7 +43,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC
|
|||||||
return cmek;
|
return cmek;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCmekById = async ({ keyId, ...data }: TUpdabteCmekByIdDTO, actor: ProjectServiceActor) => {
|
const updateCmekById = async ({ keyId, ...data }: TUpdabteCmekByIdDTO, actor: OrgServiceActor) => {
|
||||||
const key = await kmsDAL.findById(keyId);
|
const key = await kmsDAL.findById(keyId);
|
||||||
|
|
||||||
if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` });
|
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;
|
return cmek;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCmekById = async (keyId: string, actor: ProjectServiceActor) => {
|
const deleteCmekById = async (keyId: string, actor: OrgServiceActor) => {
|
||||||
const key = await kmsDAL.findById(keyId);
|
const key = await kmsDAL.findById(keyId);
|
||||||
|
|
||||||
if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` });
|
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;
|
return cmek;
|
||||||
};
|
};
|
||||||
|
|
||||||
const listCmeksByProjectId = async (
|
const listCmeksByProjectId = async ({ projectId, ...filters }: TListCmeksByProjectIdDTO, actor: OrgServiceActor) => {
|
||||||
{ projectId, ...filters }: TListCmeksByProjectIdDTO,
|
|
||||||
actor: ProjectServiceActor
|
|
||||||
) => {
|
|
||||||
const { permission } = await permissionService.getProjectPermission(
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
actor.type,
|
actor.type,
|
||||||
actor.id,
|
actor.id,
|
||||||
@@ -106,7 +103,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC
|
|||||||
return { cmeks, totalCount };
|
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);
|
const key = await kmsDAL.findById(keyId);
|
||||||
|
|
||||||
if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` });
|
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");
|
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);
|
const key = await kmsDAL.findById(keyId);
|
||||||
|
|
||||||
if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` });
|
if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` });
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@ import { ForbiddenError } from "@casl/ability";
|
|||||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { 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 { 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 { TSyncExternalGroupOrgMembershipRoleMappingsDTO } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-types";
|
||||||
import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal";
|
import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal";
|
||||||
@@ -25,7 +25,7 @@ export const externalGroupOrgRoleMappingServiceFactory = ({
|
|||||||
permissionService,
|
permissionService,
|
||||||
orgRoleDAL
|
orgRoleDAL
|
||||||
}: TExternalGroupOrgRoleMappingServiceFactoryDep) => {
|
}: TExternalGroupOrgRoleMappingServiceFactoryDep) => {
|
||||||
const listExternalGroupOrgRoleMappings = async (actor: ProjectServiceActor) => {
|
const listExternalGroupOrgRoleMappings = async (actor: OrgServiceActor) => {
|
||||||
const { permission } = await permissionService.getOrgPermission(
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
actor.type,
|
actor.type,
|
||||||
actor.id,
|
actor.id,
|
||||||
@@ -46,7 +46,7 @@ export const externalGroupOrgRoleMappingServiceFactory = ({
|
|||||||
|
|
||||||
const updateExternalGroupOrgRoleMappings = async (
|
const updateExternalGroupOrgRoleMappings = async (
|
||||||
dto: TSyncExternalGroupOrgMembershipRoleMappingsDTO,
|
dto: TSyncExternalGroupOrgMembershipRoleMappingsDTO,
|
||||||
actor: ProjectServiceActor
|
actor: OrgServiceActor
|
||||||
) => {
|
) => {
|
||||||
const { permission } = await permissionService.getOrgPermission(
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
actor.type,
|
actor.type,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service
|
|||||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
|
import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service";
|
||||||
|
import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types";
|
||||||
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
||||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||||
@@ -94,7 +96,7 @@ type TProjectServiceFactoryDep = {
|
|||||||
orgDAL: Pick<TOrgDALFactory, "findOne">;
|
orgDAL: Pick<TOrgDALFactory, "findOne">;
|
||||||
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
|
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
|
||||||
projectBotDAL: Pick<TProjectBotDALFactory, "create">;
|
projectBotDAL: Pick<TProjectBotDALFactory, "create">;
|
||||||
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
|
projectRoleDAL: Pick<TProjectRoleDALFactory, "find" | "insertMany">;
|
||||||
kmsService: Pick<
|
kmsService: Pick<
|
||||||
TKmsServiceFactory,
|
TKmsServiceFactory,
|
||||||
| "updateProjectSecretManagerKmsKey"
|
| "updateProjectSecretManagerKmsKey"
|
||||||
@@ -104,6 +106,7 @@ type TProjectServiceFactoryDep = {
|
|||||||
| "getProjectSecretManagerKmsKeyId"
|
| "getProjectSecretManagerKmsKeyId"
|
||||||
| "deleteInternalKms"
|
| "deleteInternalKms"
|
||||||
>;
|
>;
|
||||||
|
projectTemplateService: TProjectTemplateServiceFactory;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
|
export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
|
||||||
@@ -134,7 +137,8 @@ export const projectServiceFactory = ({
|
|||||||
kmsService,
|
kmsService,
|
||||||
projectBotDAL,
|
projectBotDAL,
|
||||||
projectSlackConfigDAL,
|
projectSlackConfigDAL,
|
||||||
slackIntegrationDAL
|
slackIntegrationDAL,
|
||||||
|
projectTemplateService
|
||||||
}: TProjectServiceFactoryDep) => {
|
}: TProjectServiceFactoryDep) => {
|
||||||
/*
|
/*
|
||||||
* Create workspace. Make user the admin
|
* Create workspace. Make user the admin
|
||||||
@@ -148,7 +152,8 @@ export const projectServiceFactory = ({
|
|||||||
slug: projectSlug,
|
slug: projectSlug,
|
||||||
kmsKeyId,
|
kmsKeyId,
|
||||||
tx: trx,
|
tx: trx,
|
||||||
createDefaultEnvs = true
|
createDefaultEnvs = true,
|
||||||
|
template = InfisicalProjectTemplate.Default
|
||||||
}: TCreateProjectDTO) => {
|
}: TCreateProjectDTO) => {
|
||||||
const organization = await orgDAL.findOne({ id: actorOrgId });
|
const organization = await orgDAL.findOne({ id: actorOrgId });
|
||||||
|
|
||||||
@@ -183,6 +188,21 @@ export const projectServiceFactory = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let projectTemplate: Awaited<ReturnType<typeof projectTemplateService.findProjectTemplateByName>> | null = null;
|
||||||
|
|
||||||
|
switch (template) {
|
||||||
|
case InfisicalProjectTemplate.Default:
|
||||||
|
projectTemplate = null;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
projectTemplate = await projectTemplateService.findProjectTemplateByName(template, {
|
||||||
|
id: actorId,
|
||||||
|
orgId: organization.id,
|
||||||
|
type: actor,
|
||||||
|
authMethod: actorAuthMethod
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const project = await projectDAL.create(
|
const project = await projectDAL.create(
|
||||||
{
|
{
|
||||||
name: workspaceName,
|
name: workspaceName,
|
||||||
@@ -210,7 +230,24 @@ export const projectServiceFactory = ({
|
|||||||
|
|
||||||
// set default environments and root folder for provided environments
|
// set default environments and root folder for provided environments
|
||||||
let envs: TProjectEnvironments[] = [];
|
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(
|
envs = await projectEnvDAL.insertMany(
|
||||||
DEFAULT_PROJECT_ENVS.map((el, i) => ({ ...el, projectId: project.id, position: i + 1 })),
|
DEFAULT_PROJECT_ENVS.map((el, i) => ({ ...el, projectId: project.id, position: i + 1 })),
|
||||||
tx
|
tx
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type TCreateProjectDTO = {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
kmsKeyId?: string;
|
kmsKeyId?: string;
|
||||||
createDefaultEnvs?: boolean;
|
createDefaultEnvs?: boolean;
|
||||||
|
template?: string;
|
||||||
tx?: Knex;
|
tx?: Knex;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
|
|||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
|
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
|
||||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
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 { TProjectDALFactory } from "../project/project-dal";
|
||||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||||
@@ -514,7 +514,7 @@ export const secretFolderServiceFactory = ({
|
|||||||
|
|
||||||
const getFoldersDeepByEnvs = async (
|
const getFoldersDeepByEnvs = async (
|
||||||
{ projectId, environments, secretPath }: TGetFoldersDeepByEnvsDTO,
|
{ projectId, environments, secretPath }: TGetFoldersDeepByEnvsDTO,
|
||||||
actor: ProjectServiceActor
|
actor: OrgServiceActor
|
||||||
) => {
|
) => {
|
||||||
// folder list is allowed to be read by anyone
|
// folder list is allowed to be read by anyone
|
||||||
// permission to check does user have access
|
// permission to check does user have access
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
|
|||||||
import { groupBy, pick } from "@app/lib/fn";
|
import { groupBy, pick } from "@app/lib/fn";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
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 { TGetSecretsRawByFolderMappingsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types";
|
||||||
|
|
||||||
import { ActorType } from "../auth/auth-type";
|
import { ActorType } from "../auth/auth-type";
|
||||||
@@ -2849,7 +2849,7 @@ export const secretServiceFactory = ({
|
|||||||
|
|
||||||
const getSecretsRawByFolderMappings = async (
|
const getSecretsRawByFolderMappings = async (
|
||||||
params: Omit<TGetSecretsRawByFolderMappingsDTO, "userId">,
|
params: Omit<TGetSecretsRawByFolderMappingsDTO, "userId">,
|
||||||
actor: ProjectServiceActor
|
actor: OrgServiceActor
|
||||||
) => {
|
) => {
|
||||||
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(params.projectId);
|
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(params.projectId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Create"
|
||||||
|
openapi: "POST /api/v1/project-templates"
|
||||||
|
---
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
You can read more about the role's permissions field in the [permissions documentation](/internals/permissions).
|
||||||
|
</Note>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Delete"
|
||||||
|
openapi: "DELETE /api/v1/project-templates/{templateId}"
|
||||||
|
---
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Get By ID"
|
||||||
|
openapi: "GET /api/v1/project-templates/{templateId}"
|
||||||
|
---
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "List"
|
||||||
|
openapi: "GET /api/v1/project-templates"
|
||||||
|
---
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "Update"
|
||||||
|
openapi: "PATCH /api/v1/project-templates/{templateId}"
|
||||||
|
---
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
You can read more about the role's permissions field in the [permissions documentation](/internals/permissions).
|
||||||
|
</Note>
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
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 [email protected] to purchase an enterprise license to use it.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
The typical workflow for using Project Templates consists of the following steps:
|
||||||
|
|
||||||
|
1. <strong>Creating a project template:</strong> 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. <strong>Using a project template:</strong> When creating new projects, optionally specify a project template to provision the project with the configured roles and environments.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Note that this workflow can be executed via the Infisical UI or through the API.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## Guide to Creating a Project Template
|
||||||
|
|
||||||
|
In the following steps, we'll explore how to set up a project template.
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Infisical UI">
|
||||||
|
<Steps>
|
||||||
|
<Step title="Creating a Project Template">
|
||||||
|
Navigate to the Project Templates tab on the Organization Settings page and tap on the **Add Template** button.
|
||||||
|

|
||||||
|
|
||||||
|
Specify your template details. Here's some guidance on each field:
|
||||||
|
|
||||||
|
- <strong>Name:</strong> A slug-friendly name for the template.
|
||||||
|
- <strong>Description:</strong> An optional description of the intended usage of this template.
|
||||||
|
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
<Step title="Configuring a Project Template">
|
||||||
|
Once your template is created, you'll be directed to the configuration section.
|
||||||
|

|
||||||
|
|
||||||
|
Customize the environments and roles to your needs.
|
||||||
|

|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Be sure to save your environment and role changes.
|
||||||
|
</Note>
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
</Tab>
|
||||||
|
<Tab title="API">
|
||||||
|
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": "<template-id>",
|
||||||
|
"name": "my-project-template",
|
||||||
|
"description": "...",
|
||||||
|
"environments": "[...]",
|
||||||
|
"roles": "[...]",
|
||||||
|
"orgId": "<org-id>",
|
||||||
|
"createdAt": "2023-11-07T05:31:56Z",
|
||||||
|
"updatedAt": "2023-11-07T05:31:56Z",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## Guide to Using a Project Template
|
||||||
|
|
||||||
|
In the following steps, we'll explore how to use a project template when creating a project.
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Infisical UI">
|
||||||
|
When creating a new project, select the desired template from the dropdown menu in the create project modal.
|
||||||
|

|
||||||
|
|
||||||
|
Your project will be provisioned with the configured template roles and environments.
|
||||||
|
</Tab>
|
||||||
|
<Tab title="API">
|
||||||
|
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 name 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",
|
||||||
|
"template": "<template-name>", // defaults to "default"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample response
|
||||||
|
|
||||||
|
```bash Response
|
||||||
|
{
|
||||||
|
"project": {
|
||||||
|
"id": "<project-id>",
|
||||||
|
"environments": "[...]", // configured environments
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Note that configured roles are not included in the project response.
|
||||||
|
</Note>
|
||||||
|
</Tab>
|
||||||
|
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
<AccordionGroup>
|
||||||
|
<Accordion title="Do changes to templates propagate to existing projects?">
|
||||||
|
No. Project templates only apply at the time of project creation.
|
||||||
|
</Accordion>
|
||||||
|
</AccordionGroup>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 951 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 579 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 562 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1015 KiB |
+12
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Infisical",
|
"name": "Infisical",
|
||||||
"openapi": "https://app.infisical.com/api/docs/json",
|
"openapi": "http://localhost:8080/api/docs/json",
|
||||||
"logo": {
|
"logo": {
|
||||||
"dark": "/logo/dark.svg",
|
"dark": "/logo/dark.svg",
|
||||||
"light": "/logo/light.svg",
|
"light": "/logo/light.svg",
|
||||||
@@ -191,6 +191,7 @@
|
|||||||
"documentation/platform/dynamic-secrets/snowflake"
|
"documentation/platform/dynamic-secrets/snowflake"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"documentation/platform/project-templates",
|
||||||
{
|
{
|
||||||
"group": "Workflow Integrations",
|
"group": "Workflow Integrations",
|
||||||
"pages": [
|
"pages": [
|
||||||
@@ -644,6 +645,16 @@
|
|||||||
"api-reference/endpoints/project-roles/list"
|
"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",
|
"group": "Environments",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
|||||||
@@ -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 { faExclamationTriangle, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import * as Label from "@radix-ui/react-label";
|
import * as Label from "@radix-ui/react-label";
|
||||||
@@ -82,7 +82,7 @@ export type FormControlProps = {
|
|||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
className?: string;
|
className?: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
tooltipText?: string;
|
tooltipText?: ReactElement | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FormControl = ({
|
export const FormControl = ({
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export enum OrgPermissionSubjects {
|
|||||||
Identity = "identity",
|
Identity = "identity",
|
||||||
Kms = "kms",
|
Kms = "kms",
|
||||||
AdminConsole = "organization-admin-console",
|
AdminConsole = "organization-admin-console",
|
||||||
AuditLogs = "audit-logs"
|
AuditLogs = "audit-logs",
|
||||||
|
ProjectTemplates = "project-templates"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum OrgPermissionAdminConsoleAction {
|
export enum OrgPermissionAdminConsoleAction {
|
||||||
@@ -45,6 +46,7 @@ export type OrgPermissionSet =
|
|||||||
| [OrgPermissionActions, OrgPermissionSubjects.Identity]
|
| [OrgPermissionActions, OrgPermissionSubjects.Identity]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
||||||
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
|
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs];
|
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
||||||
|
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates];
|
||||||
|
|
||||||
export type TOrgPermission = MongoAbility<OrgPermissionSet>;
|
export type TOrgPermission = MongoAbility<OrgPermissionSet>;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||||
|
|
||||||
enum OrgMembershipRole {
|
enum OrgMembershipRole {
|
||||||
Admin = "admin",
|
Admin = "admin",
|
||||||
Member = "member",
|
Member = "member",
|
||||||
@@ -18,3 +20,6 @@ export const formatProjectRoleName = (name: string) => {
|
|||||||
if (name === ProjectMemberRole.Member) return "developer";
|
if (name === ProjectMemberRole.Member) return "developer";
|
||||||
return name;
|
return name;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isCustomProjectRole = (slug: string) =>
|
||||||
|
!Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./mutations";
|
||||||
|
export * from "./queries";
|
||||||
|
export * from "./types";
|
||||||
@@ -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<TProjectTemplateResponse>(
|
||||||
|
"/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<TProjectTemplateResponse>(
|
||||||
|
`/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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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<typeof projectTemplateKeys.list>
|
||||||
|
>,
|
||||||
|
"queryKey" | "queryFn"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: projectTemplateKeys.list(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiRequest.get<TListProjectTemplates>("/api/v1/project-templates");
|
||||||
|
|
||||||
|
return data.projectTemplates;
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetProjectTemplateById = (
|
||||||
|
templateId: string,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
TProjectTemplate,
|
||||||
|
unknown,
|
||||||
|
TProjectTemplate,
|
||||||
|
ReturnType<typeof projectTemplateKeys.byId>
|
||||||
|
>,
|
||||||
|
"queryKey" | "queryFn"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: projectTemplateKeys.byId(templateId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiRequest.get<TProjectTemplateResponse>(
|
||||||
|
`/api/v1/project-templates/${templateId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.projectTemplate;
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { TProjectRole } from "@app/hooks/api/roles/types";
|
||||||
|
|
||||||
|
export type TProjectTemplate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
roles: Pick<TProjectRole, "slug" | "name" | "permissions">[];
|
||||||
|
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<TProjectTemplate, "name" | "description" | "roles" | "environments">
|
||||||
|
> & { templateId: string };
|
||||||
|
|
||||||
|
export type TDeleteProjectTemplateDTO = {
|
||||||
|
templateId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum InfisicalProjectTemplate {
|
||||||
|
Default = "default"
|
||||||
|
}
|
||||||
@@ -43,4 +43,5 @@ export type SubscriptionPlan = {
|
|||||||
externalKms: boolean;
|
externalKms: boolean;
|
||||||
pkiEst: boolean;
|
pkiEst: boolean;
|
||||||
enforceMfa: boolean;
|
enforceMfa: boolean;
|
||||||
|
projectTemplates: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -208,19 +208,21 @@ export const useGetWorkspaceIntegrations = (workspaceId: string) =>
|
|||||||
|
|
||||||
export const createWorkspace = ({
|
export const createWorkspace = ({
|
||||||
projectName,
|
projectName,
|
||||||
kmsKeyId
|
kmsKeyId,
|
||||||
|
template
|
||||||
}: CreateWorkspaceDTO): Promise<{ data: { project: Workspace } }> => {
|
}: CreateWorkspaceDTO): Promise<{ data: { project: Workspace } }> => {
|
||||||
return apiRequest.post("/api/v2/workspace", { projectName, kmsKeyId });
|
return apiRequest.post("/api/v2/workspace", { projectName, kmsKeyId, template });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCreateWorkspace = () => {
|
export const useCreateWorkspace = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation<{ data: { project: Workspace } }, {}, CreateWorkspaceDTO>({
|
return useMutation<{ data: { project: Workspace } }, {}, CreateWorkspaceDTO>({
|
||||||
mutationFn: async ({ projectName, kmsKeyId }) =>
|
mutationFn: async ({ projectName, kmsKeyId, template }) =>
|
||||||
createWorkspace({
|
createWorkspace({
|
||||||
projectName,
|
projectName,
|
||||||
kmsKeyId
|
kmsKeyId,
|
||||||
|
template
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
|
queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export type TGetUpgradeProjectStatusDTO = {
|
|||||||
export type CreateWorkspaceDTO = {
|
export type CreateWorkspaceDTO = {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
kmsKeyId?: string;
|
kmsKeyId?: string;
|
||||||
|
template?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RenameWorkspaceDTO = { workspaceID: string; newWorkspaceName: string };
|
export type RenameWorkspaceDTO = { workspaceID: string; newWorkspaceName: string };
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
faEnvelope,
|
faEnvelope,
|
||||||
faInfinity,
|
faInfinity,
|
||||||
faInfo,
|
faInfo,
|
||||||
|
faInfoCircle,
|
||||||
faMobile,
|
faMobile,
|
||||||
faPlus,
|
faPlus,
|
||||||
faQuestion,
|
faQuestion,
|
||||||
@@ -78,6 +79,7 @@ import {
|
|||||||
useSelectOrganization
|
useSelectOrganization
|
||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
|
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
|
||||||
|
import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
|
||||||
import { Workspace } from "@app/hooks/api/types";
|
import { Workspace } from "@app/hooks/api/types";
|
||||||
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
|
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
|
||||||
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
|
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
|
||||||
@@ -124,7 +126,8 @@ const formSchema = yup.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.max(64, "Too long, maximum length is 64 characters"),
|
.max(64, "Too long, maximum length is 64 characters"),
|
||||||
addMembers: yup.bool().required().label("Add Members"),
|
addMembers: yup.bool().required().label("Add Members"),
|
||||||
kmsKeyId: yup.string().label("KMS Key ID")
|
kmsKeyId: yup.string().label("KMS Key ID"),
|
||||||
|
template: yup.string().label("Project Template Name")
|
||||||
});
|
});
|
||||||
|
|
||||||
type TAddProjectFormData = yup.InferType<typeof formSchema>;
|
type TAddProjectFormData = yup.InferType<typeof formSchema>;
|
||||||
@@ -273,7 +276,16 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
|||||||
putUserInOrg();
|
putUserInOrg();
|
||||||
}, [router.query.id]);
|
}, [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, template }: TAddProjectFormData) => {
|
||||||
// type check
|
// type check
|
||||||
if (!currentOrg) return;
|
if (!currentOrg) return;
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -284,7 +296,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
|||||||
}
|
}
|
||||||
} = await createWs.mutateAsync({
|
} = await createWs.mutateAsync({
|
||||||
projectName: name,
|
projectName: name,
|
||||||
kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined
|
kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined,
|
||||||
|
template
|
||||||
});
|
});
|
||||||
|
|
||||||
if (addMembers) {
|
if (addMembers) {
|
||||||
@@ -909,20 +922,72 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
|||||||
subTitle="This project will contain your secrets and configurations."
|
subTitle="This project will contain your secrets and configurations."
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(onCreateProject)}>
|
<form onSubmit={handleSubmit(onCreateProject)}>
|
||||||
<Controller
|
<div className="flex gap-2">
|
||||||
control={control}
|
<Controller
|
||||||
name="name"
|
control={control}
|
||||||
defaultValue=""
|
name="name"
|
||||||
render={({ field, fieldState: { error } }) => (
|
defaultValue=""
|
||||||
<FormControl
|
render={({ field, fieldState: { error } }) => (
|
||||||
label="Project Name"
|
<FormControl
|
||||||
isError={Boolean(error)}
|
label="Project Name"
|
||||||
errorText={error?.message}
|
isError={Boolean(error)}
|
||||||
>
|
errorText={error?.message}
|
||||||
<Input {...field} placeholder="Type your project name" />
|
className="flex-1"
|
||||||
</FormControl>
|
>
|
||||||
)}
|
<Input {...field} placeholder="Type your project name" />
|
||||||
/>
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="template"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Read}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<FormControl
|
||||||
|
label="Project Template"
|
||||||
|
icon={<FontAwesomeIcon icon={faInfoCircle} size="sm" />}
|
||||||
|
tooltipText={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Create this project from a template to provision it with custom
|
||||||
|
environments and roles.
|
||||||
|
</p>
|
||||||
|
{subscription && !subscription.projectTemplates && (
|
||||||
|
<p className="pt-2">Project templates are a paid feature.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
defaultValue={InfisicalProjectTemplate.Default}
|
||||||
|
placeholder={InfisicalProjectTemplate.Default}
|
||||||
|
isDisabled={!isAllowed || !subscription?.projectTemplates}
|
||||||
|
value={value}
|
||||||
|
onValueChange={onChange}
|
||||||
|
className="w-44"
|
||||||
|
>
|
||||||
|
{projectTemplates.length
|
||||||
|
? projectTemplates.map((template) => (
|
||||||
|
<SelectItem key={template.id} value={template.name}>
|
||||||
|
{template.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
: Object.values(InfisicalProjectTemplate).map((template) => (
|
||||||
|
<SelectItem key={template} value={template}>
|
||||||
|
{template}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="mt-4 pl-1">
|
<div className="mt-4 pl-1">
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./slugSchema";
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import slugify from "@sindresorhus/slugify";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const slugSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(32)
|
||||||
|
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||||
|
.refine((v) => slugify(v) === v, {
|
||||||
|
message: "Invalid slug format"
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
faExclamationCircle,
|
faExclamationCircle,
|
||||||
faFileShield,
|
faFileShield,
|
||||||
faHandPeace,
|
faHandPeace,
|
||||||
|
faInfoCircle,
|
||||||
faList,
|
faList,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
faNetworkWired,
|
faNetworkWired,
|
||||||
@@ -69,6 +70,7 @@ import {
|
|||||||
useRegisterUserAction
|
useRegisterUserAction
|
||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
|
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
|
||||||
|
import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
|
||||||
// import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
|
// import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
|
||||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||||
import { Workspace } from "@app/hooks/api/types";
|
import { Workspace } from "@app/hooks/api/types";
|
||||||
@@ -482,7 +484,8 @@ const formSchema = yup.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.max(64, "Too long, maximum length is 64 characters"),
|
.max(64, "Too long, maximum length is 64 characters"),
|
||||||
addMembers: yup.bool().required().label("Add Members"),
|
addMembers: yup.bool().required().label("Add Members"),
|
||||||
kmsKeyId: yup.string().label("KMS Key ID")
|
kmsKeyId: yup.string().label("KMS Key ID"),
|
||||||
|
template: yup.string().label("Project Template Name")
|
||||||
});
|
});
|
||||||
|
|
||||||
type TAddProjectFormData = yup.InferType<typeof formSchema>;
|
type TAddProjectFormData = yup.InferType<typeof formSchema>;
|
||||||
@@ -537,7 +540,7 @@ const OrganizationPage = () => {
|
|||||||
enabled: permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.Kms)
|
enabled: permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.Kms)
|
||||||
});
|
});
|
||||||
|
|
||||||
const onCreateProject = async ({ name, addMembers, kmsKeyId }: TAddProjectFormData) => {
|
const onCreateProject = async ({ name, addMembers, kmsKeyId, template }: TAddProjectFormData) => {
|
||||||
// type check
|
// type check
|
||||||
if (!currentOrg) return;
|
if (!currentOrg) return;
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -548,7 +551,8 @@ const OrganizationPage = () => {
|
|||||||
}
|
}
|
||||||
} = await createWs.mutateAsync({
|
} = await createWs.mutateAsync({
|
||||||
projectName: name,
|
projectName: name,
|
||||||
kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined
|
kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined,
|
||||||
|
template
|
||||||
});
|
});
|
||||||
|
|
||||||
if (addMembers) {
|
if (addMembers) {
|
||||||
@@ -579,6 +583,15 @@ const OrganizationPage = () => {
|
|||||||
|
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
|
const canReadProjectTemplates = permission.can(
|
||||||
|
OrgPermissionActions.Read,
|
||||||
|
OrgPermissionSubjects.ProjectTemplates
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: projectTemplates = [] } = useListProjectTemplates({
|
||||||
|
enabled: canReadProjectTemplates && subscription?.projectTemplates
|
||||||
|
});
|
||||||
|
|
||||||
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
||||||
? subscription.workspacesUsed < subscription.workspaceLimit
|
? subscription.workspacesUsed < subscription.workspaceLimit
|
||||||
: true;
|
: true;
|
||||||
@@ -1037,20 +1050,72 @@ const OrganizationPage = () => {
|
|||||||
subTitle="This project will contain your secrets and configurations."
|
subTitle="This project will contain your secrets and configurations."
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(onCreateProject)}>
|
<form onSubmit={handleSubmit(onCreateProject)}>
|
||||||
<Controller
|
<div className="flex gap-2">
|
||||||
control={control}
|
<Controller
|
||||||
name="name"
|
control={control}
|
||||||
defaultValue=""
|
name="name"
|
||||||
render={({ field, fieldState: { error } }) => (
|
defaultValue=""
|
||||||
<FormControl
|
render={({ field, fieldState: { error } }) => (
|
||||||
label="Project Name"
|
<FormControl
|
||||||
isError={Boolean(error)}
|
label="Project Name"
|
||||||
errorText={error?.message}
|
isError={Boolean(error)}
|
||||||
>
|
errorText={error?.message}
|
||||||
<Input {...field} placeholder="Type your project name" />
|
className="flex-1"
|
||||||
</FormControl>
|
>
|
||||||
)}
|
<Input {...field} placeholder="Type your project name" />
|
||||||
/>
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="template"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Read}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<FormControl
|
||||||
|
label="Project Template"
|
||||||
|
icon={<FontAwesomeIcon icon={faInfoCircle} size="sm" />}
|
||||||
|
tooltipText={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Create this project from a template to provision it with custom
|
||||||
|
environments and roles.
|
||||||
|
</p>
|
||||||
|
{subscription && !subscription.projectTemplates && (
|
||||||
|
<p className="pt-2">Project templates are a paid feature.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
defaultValue={InfisicalProjectTemplate.Default}
|
||||||
|
placeholder={InfisicalProjectTemplate.Default}
|
||||||
|
isDisabled={!isAllowed || !subscription?.projectTemplates}
|
||||||
|
value={value}
|
||||||
|
onValueChange={onChange}
|
||||||
|
className="w-44"
|
||||||
|
>
|
||||||
|
{projectTemplates.length
|
||||||
|
? projectTemplates.map((template) => (
|
||||||
|
<SelectItem key={template.id} value={template.name}>
|
||||||
|
{template.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
: Object.values(InfisicalProjectTemplate).map((template) => (
|
||||||
|
<SelectItem key={template} value={template}>
|
||||||
|
{template}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="mt-4 pl-1">
|
<div className="mt-4 pl-1">
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export const formSchema = z.object({
|
|||||||
billing: generalPermissionSchema,
|
billing: generalPermissionSchema,
|
||||||
identity: generalPermissionSchema,
|
identity: generalPermissionSchema,
|
||||||
"organization-admin-console": adminConsolePermissionSchmea,
|
"organization-admin-console": adminConsolePermissionSchmea,
|
||||||
[OrgPermissionSubjects.Kms]: generalPermissionSchema
|
[OrgPermissionSubjects.Kms]: generalPermissionSchema,
|
||||||
|
[OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
});
|
});
|
||||||
|
|||||||
+10
@@ -5,6 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
|
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
|
||||||
|
import { OrgPermissionSubjects } from "@app/context";
|
||||||
import { useToggle } from "@app/hooks";
|
import { useToggle } from "@app/hooks";
|
||||||
import { TFormSchema } from "@app/views/Org/RolePage/components/OrgRoleModifySection.utils";
|
import { TFormSchema } from "@app/views/Org/RolePage/components/OrgRoleModifySection.utils";
|
||||||
|
|
||||||
@@ -43,6 +44,13 @@ const BILLING_PERMISSIONS = [
|
|||||||
{ action: "delete", label: "Remove payments" }
|
{ action: "delete", label: "Remove payments" }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const PROJECT_TEMPLATES_PERMISSIONS = [
|
||||||
|
{ action: "read", label: "View & Apply" },
|
||||||
|
{ action: "create", label: "Create" },
|
||||||
|
{ action: "edit", label: "Modify" },
|
||||||
|
{ action: "delete", label: "Remove" }
|
||||||
|
] as const;
|
||||||
|
|
||||||
const getPermissionList = (option: string) => {
|
const getPermissionList = (option: string) => {
|
||||||
switch (option) {
|
switch (option) {
|
||||||
case "secret-scanning":
|
case "secret-scanning":
|
||||||
@@ -53,6 +61,8 @@ const getPermissionList = (option: string) => {
|
|||||||
return INCIDENT_CONTACTS_PERMISSIONS;
|
return INCIDENT_CONTACTS_PERMISSIONS;
|
||||||
case "member":
|
case "member":
|
||||||
return MEMBERS_PERMISSIONS;
|
return MEMBERS_PERMISSIONS;
|
||||||
|
case OrgPermissionSubjects.ProjectTemplates:
|
||||||
|
return PROJECT_TEMPLATES_PERMISSIONS;
|
||||||
default:
|
default:
|
||||||
return PERMISSIONS;
|
return PERMISSIONS;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -68,7 +68,8 @@ const SIMPLE_PERMISSION_OPTIONS = [
|
|||||||
{
|
{
|
||||||
title: "External KMS",
|
title: "External KMS",
|
||||||
formName: OrgPermissionSubjects.Kms
|
formName: OrgPermissionSubjects.Kms
|
||||||
}
|
},
|
||||||
|
{ title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
+1
@@ -86,6 +86,7 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
|
|||||||
onValueChange={(val) => field.onChange(val === "true")}
|
onValueChange={(val) => field.onChange(val === "true")}
|
||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
isDisabled={isDisabled}
|
||||||
>
|
>
|
||||||
<SelectItem value="false">Allow</SelectItem>
|
<SelectItem value="false">Allow</SelectItem>
|
||||||
<SelectItem value="true">Forbid</SelectItem>
|
<SelectItem value="true">Forbid</SelectItem>
|
||||||
|
|||||||
+20
-16
@@ -172,22 +172,26 @@ export const QuickSearchSecretItem = ({
|
|||||||
}}
|
}}
|
||||||
key={secret.id}
|
key={secret.id}
|
||||||
>
|
>
|
||||||
{!isSingleEnv && (
|
<Tooltip side="left" sideOffset={18} content="Click to copy to clipboard">
|
||||||
<span className="text-xs text-mineshaft-400">
|
<div>
|
||||||
{envSlugMap.get(secret.env)?.name}
|
{!isSingleEnv && (
|
||||||
</span>
|
<span className="text-xs text-mineshaft-400">
|
||||||
)}
|
{envSlugMap.get(secret.env)?.name}
|
||||||
<p
|
</span>
|
||||||
className={twMerge(
|
)}
|
||||||
"hidden w-[12rem] max-w-[12rem] truncate text-sm group-hover:block",
|
<p
|
||||||
!secret.value && "text-mineshaft-400"
|
className={twMerge(
|
||||||
)}
|
"hidden w-[12rem] max-w-[12rem] truncate text-sm group-hover:block",
|
||||||
>
|
!secret.value && "text-mineshaft-400"
|
||||||
{secret.value || "EMPTY"}
|
)}
|
||||||
</p>
|
>
|
||||||
<p className="w-[12rem] text-sm group-hover:hidden">
|
{secret.value || "EMPTY"}
|
||||||
***************************
|
</p>
|
||||||
</p>
|
<p className="w-[12rem] text-sm group-hover:hidden">
|
||||||
|
***************************
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Tab } from "@headlessui/react";
|
|||||||
|
|
||||||
import { OrgPermissionCan } from "@app/components/permissions";
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||||
|
import { ProjectTemplatesTab } from "@app/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab";
|
||||||
|
|
||||||
import { AuditLogStreamsTab } from "../AuditLogStreamTab";
|
import { AuditLogStreamsTab } from "../AuditLogStreamTab";
|
||||||
import { ImportTab } from "../ImportTab";
|
import { ImportTab } from "../ImportTab";
|
||||||
@@ -18,7 +19,8 @@ const tabs = [
|
|||||||
{ name: "Encryption", key: "tab-org-encryption" },
|
{ name: "Encryption", key: "tab-org-encryption" },
|
||||||
{ name: "Workflow Integrations", key: "workflow-integrations" },
|
{ name: "Workflow Integrations", key: "workflow-integrations" },
|
||||||
{ name: "Audit Log Streams", key: "tag-audit-log-streams" },
|
{ 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 = () => {
|
export const OrgTabGroup = () => {
|
||||||
const { query } = useRouter();
|
const { query } = useRouter();
|
||||||
@@ -69,10 +71,13 @@ export const OrgTabGroup = () => {
|
|||||||
<AuditLogStreamsTab />
|
<AuditLogStreamsTab />
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
|
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
|
||||||
<Tab.Panel>
|
<Tab.Panel>
|
||||||
<ImportTab />
|
<ImportTab />
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
</OrgPermissionCan>
|
</OrgPermissionCan>
|
||||||
|
<Tab.Panel>
|
||||||
|
<ProjectTemplatesTab />
|
||||||
|
</Tab.Panel>
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
);
|
);
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||||
|
import { withPermission } from "@app/hoc";
|
||||||
|
|
||||||
|
import { ProjectTemplatesSection } from "./components";
|
||||||
|
|
||||||
|
export const ProjectTemplatesTab = withPermission(() => <ProjectTemplatesSection />, {
|
||||||
|
action: OrgPermissionActions.Read,
|
||||||
|
subject: OrgPermissionSubjects.ProjectTemplates
|
||||||
|
});
|
||||||
+49
@@ -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 (
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onChange={onOpenChange}
|
||||||
|
title={`Are you sure want to delete ${name}?`}
|
||||||
|
deleteKey="confirm"
|
||||||
|
onDeleteApproved={handleDeleteProjectTemplate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { Button, EmptyState, Spinner } from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
InfisicalProjectTemplate,
|
||||||
|
TProjectTemplate,
|
||||||
|
useGetProjectTemplateById
|
||||||
|
} from "@app/hooks/api/projectTemplates";
|
||||||
|
|
||||||
|
import { EditProjectTemplate } from "./components";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
template: TProjectTemplate;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditProjectTemplateSection = ({ template, onBack }: Props) => {
|
||||||
|
const isInfisicalTemplate = Object.values(InfisicalProjectTemplate).includes(
|
||||||
|
template.name as InfisicalProjectTemplate
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: projectTemplate, isLoading } = useGetProjectTemplateById(template.id, {
|
||||||
|
initialData: template,
|
||||||
|
enabled: !isInfisicalTemplate
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
type="submit"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||||
|
onClick={onBack}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
Back to Templates
|
||||||
|
</Button>
|
||||||
|
{/* eslint-disable-next-line no-nested-ternary */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-[60vh] w-full items-center justify-center p-24">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : projectTemplate ? (
|
||||||
|
<EditProjectTemplate
|
||||||
|
isInfisicalTemplate={isInfisicalTemplate}
|
||||||
|
projectTemplate={projectTemplate}
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="Error: Unable to find project template." className="py-12" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
import { faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
|
import { Button, DeleteActionModal } 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;
|
||||||
|
isInfisicalTemplate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditProjectTemplate = ({ isInfisicalTemplate, 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 (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-start justify-between border-b border-bunker-400 pb-4">
|
||||||
|
<div className=" flex flex-col">
|
||||||
|
<h3 className="text-xl font-semibold">{name}</h3>
|
||||||
|
<h2 className="text-sm text-mineshaft-400">{description || "Project Template"}</h2>
|
||||||
|
</div>
|
||||||
|
{!isInfisicalTemplate && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Edit}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPencil} />}
|
||||||
|
size="xs"
|
||||||
|
colorSchema="secondary"
|
||||||
|
onClick={() => handlePopUpOpen("editDetails")}
|
||||||
|
>
|
||||||
|
Edit Details
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Delete}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
onClick={() => {
|
||||||
|
handlePopUpOpen("removeTemplate");
|
||||||
|
}}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
size="xs"
|
||||||
|
colorSchema="danger"
|
||||||
|
>
|
||||||
|
Delete Template
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ProjectTemplateEnvironmentsForm
|
||||||
|
isInfisicalTemplate={isInfisicalTemplate}
|
||||||
|
projectTemplate={projectTemplate}
|
||||||
|
/>
|
||||||
|
<ProjectTemplateRolesSection
|
||||||
|
isInfisicalTemplate={isInfisicalTemplate}
|
||||||
|
projectTemplate={projectTemplate}
|
||||||
|
/>
|
||||||
|
<ProjectTemplateDetailsModal
|
||||||
|
isOpen={popUp.editDetails.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("editDetails", isOpen)}
|
||||||
|
projectTemplate={projectTemplate}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.removeTemplate.isOpen}
|
||||||
|
title={`Are you sure want to delete ${projectTemplate.name}?`}
|
||||||
|
deleteKey="confirm"
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("removeTemplate", isOpen)}
|
||||||
|
onDeleteApproved={handleRemoveTemplate}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+211
@@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const ProjectTemplateEditRoleForm = ({
|
||||||
|
onGoBack,
|
||||||
|
projectTemplate,
|
||||||
|
role,
|
||||||
|
isDisabled
|
||||||
|
}: Props) => {
|
||||||
|
const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const);
|
||||||
|
|
||||||
|
const formMethods = useForm<TFormSchema>({
|
||||||
|
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 (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||||
|
>
|
||||||
|
<FormProvider {...formMethods}>
|
||||||
|
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
|
||||||
|
<Button
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||||
|
className="text-base font-semibold text-mineshaft-200"
|
||||||
|
variant="link"
|
||||||
|
onClick={onGoBack}
|
||||||
|
>
|
||||||
|
{isDisabled ? "Back" : "Cancel"}
|
||||||
|
</Button>
|
||||||
|
{!isDisabled && (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{isDirty && (
|
||||||
|
<Button
|
||||||
|
className="mr-4 text-mineshaft-300"
|
||||||
|
variant="link"
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
onClick={onGoBack}
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
type="submit"
|
||||||
|
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
|
||||||
|
isDisabled={isSubmitting || !isDirty || isDisabled}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faSave} />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp.createPolicy.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("createPolicy", isOpen)}
|
||||||
|
>
|
||||||
|
<ModalTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-l-none"
|
||||||
|
variant="outline_bg"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
>
|
||||||
|
New Policy
|
||||||
|
</Button>
|
||||||
|
</ModalTrigger>
|
||||||
|
<ModalContent
|
||||||
|
title="New Policy"
|
||||||
|
subTitle="Policies grant additional permissions."
|
||||||
|
>
|
||||||
|
<NewPermissionRule onClose={() => handlePopUpToggle("createPolicy")} />
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 border-b border-gray-800 p-4 pt-2 first:rounded-t-md last:rounded-b-md">
|
||||||
|
{isDisabled ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-lg font-semibold">{role?.name}</span>
|
||||||
|
<span className="text-mineshaft-400">{role?.slug}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full gap-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
label="Name"
|
||||||
|
className="mb-0 flex-1"
|
||||||
|
>
|
||||||
|
<Input {...field} autoFocus placeholder="Role name..." />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
label="Slug"
|
||||||
|
className="mb-0 flex-1"
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="Role slug..." />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-2 text-lg">Policies</div>
|
||||||
|
<PermissionEmptyState />
|
||||||
|
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
|
||||||
|
<GeneralPermissionPolicies
|
||||||
|
subject={subject}
|
||||||
|
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
|
||||||
|
title={PROJECT_PERMISSION_OBJECT[subject].title}
|
||||||
|
key={`project-permission-${subject}`}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
>
|
||||||
|
{renderConditionalComponents(subject, isDisabled)}
|
||||||
|
</GeneralPermissionPolicies>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormProvider>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+281
@@ -0,0 +1,281 @@
|
|||||||
|
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;
|
||||||
|
isInfisicalTemplate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
environments: z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
slug: slugSchema
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
});
|
||||||
|
|
||||||
|
type TFormSchema = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export const ProjectTemplateEnvironmentsForm = ({
|
||||||
|
projectTemplate,
|
||||||
|
isInfisicalTemplate
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isDirty, errors },
|
||||||
|
reset
|
||||||
|
} = useForm<TFormSchema>({
|
||||||
|
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 (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
|
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Project Environments</h2>
|
||||||
|
{!isInfisicalTemplate && (
|
||||||
|
<p className="text-sm text-mineshaft-400">
|
||||||
|
Add, rename, remove and reorder environments for this project template
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isInfisicalTemplate && (
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Edit}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
colorSchema="primary"
|
||||||
|
className="ml-auto w-40"
|
||||||
|
variant={isDirty ? "solid" : "outline_bg"}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faSave} />}
|
||||||
|
isDisabled={!isAllowed || !isDirty}
|
||||||
|
>
|
||||||
|
{isDirty ? "Save" : "No"} Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.environments && (
|
||||||
|
<span className="my-4 text-sm text-red">{errors.environments.message}</span>
|
||||||
|
)}
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Friendly Name</Th>
|
||||||
|
<Th>Slug</Th>
|
||||||
|
{!isInfisicalTemplate && (
|
||||||
|
<Th>
|
||||||
|
<div className="flex w-full justify-end normal-case">
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Edit}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => append({ name: "", slug: "" })}
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="ml-auto"
|
||||||
|
variant="solid"
|
||||||
|
size="xs"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
Add Environment
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
</div>
|
||||||
|
</Th>
|
||||||
|
)}
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{environments.map(({ id, name, slug }, pos) => (
|
||||||
|
<Tr key={id}>
|
||||||
|
<Td>
|
||||||
|
{isInfisicalTemplate ? (
|
||||||
|
name
|
||||||
|
) : (
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Edit}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`environments.${pos}.name`}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
className="mb-0 flex-grow"
|
||||||
|
>
|
||||||
|
<Input isDisabled={!isAllowed} {...field} placeholder="Name..." />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{isInfisicalTemplate ? (
|
||||||
|
slug
|
||||||
|
) : (
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Edit}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`environments.${pos}.slug`}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
className="mb-0 flex-grow"
|
||||||
|
>
|
||||||
|
<Input isDisabled={!isAllowed} {...field} placeholder="Slug..." />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
{!isInfisicalTemplate && (
|
||||||
|
<Td className="flex items-center justify-end">
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Edit}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
className={`mr-3 py-2 ${
|
||||||
|
pos === environments.length - 1 ? "pointer-events-none opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => move(pos, pos + 1)}
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="Increase position"
|
||||||
|
isDisabled={pos === environments.length - 1 || !isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faArrowDown} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Edit}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
className={`mr-3 py-2 ${
|
||||||
|
pos === 0 ? "pointer-events-none opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => move(pos, pos - 1)}
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="Decrease position"
|
||||||
|
isDisabled={pos === 0 || !isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faArrowUp} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Edit}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => remove(pos)}
|
||||||
|
colorSchema="danger"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="Remove environment"
|
||||||
|
isDisabled={!isAllowed || environments.length === 1}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
</Td>
|
||||||
|
)}
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+223
@@ -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;
|
||||||
|
isInfisicalTemplate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectTemplateRolesSection = ({ projectTemplate, isInfisicalTemplate }: 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 (
|
||||||
|
<div className="relative">
|
||||||
|
<AnimatePresence>
|
||||||
|
{popUp?.editRole.isOpen ? (
|
||||||
|
<motion.div
|
||||||
|
key="edit-role"
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: 30 }}
|
||||||
|
className="absolute min-h-[10rem] w-full"
|
||||||
|
>
|
||||||
|
<ProjectTemplateEditRoleForm
|
||||||
|
onGoBack={() => handlePopUpClose("editRole")}
|
||||||
|
projectTemplate={projectTemplate}
|
||||||
|
role={editRole}
|
||||||
|
isDisabled={
|
||||||
|
permission.cannot(
|
||||||
|
OrgPermissionActions.Edit,
|
||||||
|
OrgPermissionSubjects.ProjectTemplates
|
||||||
|
) ||
|
||||||
|
(editRole && !isCustomProjectRole(editRole.slug))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="h-4 w-full" />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="role-list"
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
initial={{ opacity: 0, translateX: 0 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
|
className="absolute w-full"
|
||||||
|
>
|
||||||
|
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Project Roles</h2>
|
||||||
|
<p className="text-sm text-mineshaft-400">
|
||||||
|
{isInfisicalTemplate
|
||||||
|
? "Click a role to view the associated permissions"
|
||||||
|
: "Add, edit and remove roles for this project template"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!isInfisicalTemplate && (
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Edit}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
handlePopUpOpen("editRole");
|
||||||
|
}}
|
||||||
|
colorSchema="primary"
|
||||||
|
className="ml-auto"
|
||||||
|
variant="outline_bg"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
Add Role
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="py-4">
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Name</Th>
|
||||||
|
<Th>Slug</Th>
|
||||||
|
<Th className="w-5" />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{roles.length ? (
|
||||||
|
roles.map((role) => {
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
key={role.slug}
|
||||||
|
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(evt) => {
|
||||||
|
if (evt.key === "Enter") {
|
||||||
|
handlePopUpOpen("editRole", role);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => handlePopUpOpen("editRole", role)}
|
||||||
|
>
|
||||||
|
<Td>{role.name}</Td>
|
||||||
|
<Td>{role.slug}</Td>
|
||||||
|
<Td>
|
||||||
|
{isCustomProjectRole(role.slug) && (
|
||||||
|
<div className="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Edit}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Remove Role"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
colorSchema="danger"
|
||||||
|
ariaLabel="delete-icon"
|
||||||
|
variant="plain"
|
||||||
|
className="group relative"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
handlePopUpOpen("removeRole", role);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={2}>
|
||||||
|
<EmptyState title="No roles assigned to template" icon={faUnlock} />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.removeRole.isOpen}
|
||||||
|
deleteKey="remove"
|
||||||
|
title={`Are you sure you want to remove the role ${roleToDelete?.slug}?`}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("removeRole", isOpen)}
|
||||||
|
onDeleteApproved={() => handleRemoveRole(roleToDelete?.slug)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-full" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./EditProjectTemplate";
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./EditProjectTemplateSection";
|
||||||
+151
@@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
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<FormData>({
|
||||||
|
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 (
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
<FormControl
|
||||||
|
helperText="Name must be slug-friendly"
|
||||||
|
errorText={errors.name?.message}
|
||||||
|
isError={Boolean(errors.name?.message)}
|
||||||
|
label="Name"
|
||||||
|
>
|
||||||
|
<Input autoFocus placeholder="my-project-template" {...register("name")} />
|
||||||
|
</FormControl>
|
||||||
|
<FormControl
|
||||||
|
label="Description (optional)"
|
||||||
|
errorText={errors.description?.message}
|
||||||
|
isError={Boolean(errors.description?.message)}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
className="mr-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{projectTemplate ? "Update" : "Add"} Template
|
||||||
|
</Button>
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button colorSchema="secondary" variant="plain">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalClose>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectTemplateDetailsModal = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
projectTemplate,
|
||||||
|
onComplete
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent
|
||||||
|
title={projectTemplate ? "Edit Project Template Details" : "Create Project Template"}
|
||||||
|
>
|
||||||
|
<ProjectTemplateForm
|
||||||
|
projectTemplate={projectTemplate}
|
||||||
|
onComplete={(template) => {
|
||||||
|
if (onComplete) onComplete(template);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
|
import { Button, UpgradePlanModal } from "@app/components/v2";
|
||||||
|
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||||
|
import { TProjectTemplate } from "@app/hooks/api/projectTemplates";
|
||||||
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
import { EditProjectTemplateSection } from "./EditProjectTemplateSection";
|
||||||
|
import { ProjectTemplateDetailsModal } from "./ProjectTemplateDetailsModal";
|
||||||
|
import { ProjectTemplatesTable } from "./ProjectTemplatesTable";
|
||||||
|
|
||||||
|
export const ProjectTemplatesSection = () => {
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
const [editTemplate, setEditTemplate] = useState<TProjectTemplate | null>(null);
|
||||||
|
|
||||||
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||||
|
"upgradePlan",
|
||||||
|
"addTemplate"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<AnimatePresence>
|
||||||
|
{editTemplate ? (
|
||||||
|
<motion.div
|
||||||
|
key="edit-project-template"
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: 30 }}
|
||||||
|
className="absolute min-h-[10rem] w-full"
|
||||||
|
>
|
||||||
|
<EditProjectTemplateSection
|
||||||
|
template={editTemplate}
|
||||||
|
onBack={() => setEditTemplate(null)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="project-templates-list"
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: 30 }}
|
||||||
|
className="absolute min-h-[10rem] w-full"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="mb-6 text-bunker-300">
|
||||||
|
Create and configure templates with predefined roles and environments to streamline
|
||||||
|
project setup
|
||||||
|
</p>
|
||||||
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<div className="mb-4 flex items-start">
|
||||||
|
<p className="text-xl font-semibold text-mineshaft-100">Project Templates</p>
|
||||||
|
<Link
|
||||||
|
href="https://infisical.com/docs/documentation/platform/project-templates"
|
||||||
|
passHref
|
||||||
|
>
|
||||||
|
<a target="_blank" rel="noopener noreferrer">
|
||||||
|
<div className="ml-2 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||||
|
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||||
|
<span>Docs</span>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faArrowUpRightFromSquare}
|
||||||
|
className="ml-1.5 mb-[0.07rem] text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Create}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
type="submit"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
onClick={() => {
|
||||||
|
if (!subscription?.projectTemplates) {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePopUpOpen("addTemplate");
|
||||||
|
}}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
Add Template
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
</div>
|
||||||
|
<ProjectTemplatesTable onEdit={setEditTemplate} />
|
||||||
|
<ProjectTemplateDetailsModal
|
||||||
|
onComplete={(template) => setEditTemplate(template)}
|
||||||
|
isOpen={popUp.addTemplate.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("addTemplate", isOpen)}
|
||||||
|
/>
|
||||||
|
<UpgradePlanModal
|
||||||
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
|
text="You can create project templates if you switch to Infisical's Enterprise plan."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
faCircleInfo,
|
||||||
|
faClone,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faTrash
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tooltip,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { TProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
|
||||||
|
|
||||||
|
import { DeleteProjectTemplateModal } from "./DeleteProjectTemplateModal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onEdit: (projectTemplate: TProjectTemplate) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectTemplatesTable = ({ onEdit }: Props) => {
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
|
const { isLoading, data: projectTemplates = [] } = useListProjectTemplates({
|
||||||
|
enabled: subscription?.projectTemplates
|
||||||
|
});
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["deleteTemplate"] as const);
|
||||||
|
|
||||||
|
const filteredTemplates = useMemo(
|
||||||
|
() =>
|
||||||
|
projectTemplates?.filter((template) =>
|
||||||
|
template.name.toLowerCase().includes(search.toLowerCase().trim())
|
||||||
|
) ?? [],
|
||||||
|
[search, projectTemplates]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search templates..."
|
||||||
|
/>
|
||||||
|
<TableContainer className="mt-4">
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Name</Th>
|
||||||
|
<Th>Roles</Th>
|
||||||
|
<Th>Environments</Th>
|
||||||
|
<Th />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isLoading && (
|
||||||
|
<TableSkeleton
|
||||||
|
innerKey="project-templates-table"
|
||||||
|
columns={4}
|
||||||
|
key="project-templates"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filteredTemplates.map((template) => {
|
||||||
|
const { id, name, roles, environments, description } = template;
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
onClick={() => onEdit(template)}
|
||||||
|
className="cursor-pointer hover:bg-mineshaft-700"
|
||||||
|
key={id}
|
||||||
|
>
|
||||||
|
<Td>
|
||||||
|
{name}
|
||||||
|
{description && (
|
||||||
|
<Tooltip content={description}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
size="sm"
|
||||||
|
className="ml-2 text-mineshaft-400"
|
||||||
|
icon={faCircleInfo}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td className="pl-8">
|
||||||
|
{roles.length}
|
||||||
|
{roles.length > 0 && (
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<ul className="ml-2 list-disc">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<li key={role.name}>{role.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
size="sm"
|
||||||
|
className="ml-2 text-mineshaft-400"
|
||||||
|
icon={faCircleInfo}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td className="pl-14">
|
||||||
|
{environments.length}
|
||||||
|
{environments.length > 0 && (
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<ul className="ml-2 list-disc">
|
||||||
|
{environments
|
||||||
|
.sort((a, b) => (a.position > b.position ? 1 : -1))
|
||||||
|
.map((env) => (
|
||||||
|
<li key={env.slug}>{env.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
size="sm"
|
||||||
|
className="ml-2 text-mineshaft-400"
|
||||||
|
icon={faCircleInfo}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td className="w-5">
|
||||||
|
{name !== "default" && (
|
||||||
|
<OrgPermissionCan
|
||||||
|
I={OrgPermissionActions.Delete}
|
||||||
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePopUpOpen("deleteTemplate", template);
|
||||||
|
}}
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
ariaLabel="Delete template"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isLoading && filteredTemplates?.length === 0 && (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={5}>
|
||||||
|
<EmptyState
|
||||||
|
title={
|
||||||
|
search.trim()
|
||||||
|
? "No project templates match search"
|
||||||
|
: "No project templates found"
|
||||||
|
}
|
||||||
|
icon={faClone}
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<DeleteProjectTemplateModal
|
||||||
|
isOpen={popUp.deleteTemplate.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("deleteTemplate", isOpen)}
|
||||||
|
template={popUp.deleteTemplate.data}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectTemplatesSection } from "./ProjectTemplatesSection";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectTemplatesTab } from "./ProjectTemplatesTab";
|
||||||
Reference in New Issue
Block a user