PKI revamp: UI improvements

This commit is contained in:
Carlos Monastyrski
2025-10-10 19:49:15 -03:00
parent ef9a1961f1
commit 8905885254
45 changed files with 1156 additions and 2089 deletions
+14 -14
View File
@@ -17,9 +17,9 @@ import {
TAccessApprovalRequestsReviewersInsert, TAccessApprovalRequestsReviewersInsert,
TAccessApprovalRequestsReviewersUpdate, TAccessApprovalRequestsReviewersUpdate,
TAccessApprovalRequestsUpdate, TAccessApprovalRequestsUpdate,
TApiEnrollmentConfigs, TPkiApiEnrollmentConfigs,
TApiEnrollmentConfigsInsert, TPkiApiEnrollmentConfigsInsert,
TApiEnrollmentConfigsUpdate, TPkiApiEnrollmentConfigsUpdate,
TApiKeys, TApiKeys,
TApiKeysInsert, TApiKeysInsert,
TApiKeysUpdate, TApiKeysUpdate,
@@ -80,9 +80,9 @@ import {
TDynamicSecrets, TDynamicSecrets,
TDynamicSecretsInsert, TDynamicSecretsInsert,
TDynamicSecretsUpdate, TDynamicSecretsUpdate,
TEstEnrollmentConfigs, TPkiEstEnrollmentConfigs,
TEstEnrollmentConfigsInsert, TPkiEstEnrollmentConfigsInsert,
TEstEnrollmentConfigsUpdate, TPkiEstEnrollmentConfigsUpdate,
TExternalCertificateAuthorities, TExternalCertificateAuthorities,
TExternalCertificateAuthoritiesInsert, TExternalCertificateAuthoritiesInsert,
TExternalCertificateAuthoritiesUpdate, TExternalCertificateAuthoritiesUpdate,
@@ -678,15 +678,15 @@ declare module "knex/types/tables" {
TCertificateProfilesInsert, TCertificateProfilesInsert,
TCertificateProfilesUpdate TCertificateProfilesUpdate
>; >;
[TableName.EstEnrollmentConfig]: KnexOriginal.CompositeTableType< [TableName.PkiEstEnrollmentConfig]: KnexOriginal.CompositeTableType<
TEstEnrollmentConfigs, TPkiEstEnrollmentConfigs,
TEstEnrollmentConfigsInsert, TPkiEstEnrollmentConfigsInsert,
TEstEnrollmentConfigsUpdate TPkiEstEnrollmentConfigsUpdate
>; >;
[TableName.ApiEnrollmentConfig]: KnexOriginal.CompositeTableType< [TableName.PkiApiEnrollmentConfig]: KnexOriginal.CompositeTableType<
TApiEnrollmentConfigs, TPkiApiEnrollmentConfigs,
TApiEnrollmentConfigsInsert, TPkiApiEnrollmentConfigsInsert,
TApiEnrollmentConfigsUpdate TPkiApiEnrollmentConfigsUpdate
>; >;
[TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType< [TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType<
TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigs,
@@ -10,7 +10,7 @@ export async function up(knex: Knex): Promise<void> {
t.string("projectId").notNullable(); t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.string("name", 64).notNullable(); t.string("slug").notNullable();
t.string("description"); t.string("description");
t.jsonb("attributes"); t.jsonb("attributes");
@@ -22,13 +22,15 @@ export async function up(knex: Knex): Promise<void> {
t.jsonb("keyAlgorithm"); t.jsonb("keyAlgorithm");
t.timestamps(true, true, true); t.timestamps(true, true, true);
t.unique(["slug", "projectId"]);
}); });
await createOnUpdateTrigger(knex, TableName.CertificateTemplateV2); await createOnUpdateTrigger(knex, TableName.CertificateTemplateV2);
} }
if (!(await knex.schema.hasTable(TableName.EstEnrollmentConfig))) { if (!(await knex.schema.hasTable(TableName.PkiEstEnrollmentConfig))) {
await knex.schema.createTable(TableName.EstEnrollmentConfig, (t) => { await knex.schema.createTable(TableName.PkiEstEnrollmentConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.boolean("disableBootstrapCaValidation").defaultTo(false); t.boolean("disableBootstrapCaValidation").defaultTo(false);
@@ -38,11 +40,11 @@ export async function up(knex: Knex): Promise<void> {
t.timestamps(true, true, true); t.timestamps(true, true, true);
}); });
await createOnUpdateTrigger(knex, TableName.EstEnrollmentConfig); await createOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig);
} }
if (!(await knex.schema.hasTable(TableName.ApiEnrollmentConfig))) { if (!(await knex.schema.hasTable(TableName.PkiApiEnrollmentConfig))) {
await knex.schema.createTable(TableName.ApiEnrollmentConfig, (t) => { await knex.schema.createTable(TableName.PkiApiEnrollmentConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.boolean("autoRenew").defaultTo(false); t.boolean("autoRenew").defaultTo(false);
@@ -51,7 +53,7 @@ export async function up(knex: Knex): Promise<void> {
t.timestamps(true, true, true); t.timestamps(true, true, true);
}); });
await createOnUpdateTrigger(knex, TableName.ApiEnrollmentConfig); await createOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig);
} }
if (!(await knex.schema.hasTable(TableName.CertificateProfile))) { if (!(await knex.schema.hasTable(TableName.CertificateProfile))) {
@@ -61,25 +63,24 @@ export async function up(knex: Knex): Promise<void> {
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.uuid("caId").notNullable(); t.uuid("caId").notNullable();
t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); t.foreign("caId").references("id").inTable(TableName.CertificateAuthority);
t.uuid("certificateTemplateId").notNullable(); t.uuid("certificateTemplateId").notNullable();
t.foreign("certificateTemplateId").references("id").inTable(TableName.CertificateTemplateV2).onDelete("CASCADE"); t.foreign("certificateTemplateId").references("id").inTable(TableName.CertificateTemplateV2);
t.string("name", 64).notNullable();
t.string("slug").notNullable(); t.string("slug").notNullable();
t.string("description"); t.string("description");
t.string("enrollmentType").notNullable().checkIn(["api", "est"]); t.string("enrollmentType").notNullable().checkIn(["api", "est"]);
t.uuid("estConfigId"); t.uuid("estConfigId");
t.foreign("estConfigId").references("id").inTable(TableName.EstEnrollmentConfig).onDelete("SET NULL"); t.foreign("estConfigId").references("id").inTable(TableName.PkiEstEnrollmentConfig).onDelete("SET NULL");
t.uuid("apiConfigId"); t.uuid("apiConfigId");
t.foreign("apiConfigId").references("id").inTable(TableName.ApiEnrollmentConfig).onDelete("SET NULL"); t.foreign("apiConfigId").references("id").inTable(TableName.PkiApiEnrollmentConfig).onDelete("SET NULL");
t.timestamps(true, true, true); t.timestamps(true, true, true);
t.unique(["slug", "projectId"], { indexName: "certificate_profiles_slug_project_id_unique" }); t.unique(["slug", "projectId"]);
}); });
await createOnUpdateTrigger(knex, TableName.CertificateProfile); await createOnUpdateTrigger(knex, TableName.CertificateProfile);
@@ -89,7 +90,7 @@ export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable(TableName.Certificate, (t) => { await knex.schema.alterTable(TableName.Certificate, (t) => {
t.uuid("profileId"); t.uuid("profileId");
t.foreign("profileId").references("id").inTable(TableName.CertificateProfile).onDelete("SET NULL"); t.foreign("profileId").references("id").inTable(TableName.CertificateProfile).onDelete("SET NULL");
t.index("profileId", "idx_certificates_profile_id"); t.index("profileId");
}); });
} }
} }
@@ -98,7 +99,7 @@ export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.Certificate, "profileId")) { if (await knex.schema.hasColumn(TableName.Certificate, "profileId")) {
await knex.schema.alterTable(TableName.Certificate, (t) => { await knex.schema.alterTable(TableName.Certificate, (t) => {
t.dropForeign(["profileId"]); t.dropForeign(["profileId"]);
t.dropIndex("profileId", "idx_certificates_profile_id"); t.dropIndex("profileId");
t.dropColumn("profileId"); t.dropColumn("profileId");
}); });
} }
@@ -106,11 +107,11 @@ export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.CertificateProfile); await knex.schema.dropTableIfExists(TableName.CertificateProfile);
await dropOnUpdateTrigger(knex, TableName.CertificateProfile); await dropOnUpdateTrigger(knex, TableName.CertificateProfile);
await knex.schema.dropTableIfExists(TableName.ApiEnrollmentConfig); await knex.schema.dropTableIfExists(TableName.PkiApiEnrollmentConfig);
await dropOnUpdateTrigger(knex, TableName.ApiEnrollmentConfig); await dropOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig);
await knex.schema.dropTableIfExists(TableName.EstEnrollmentConfig); await knex.schema.dropTableIfExists(TableName.PkiEstEnrollmentConfig);
await dropOnUpdateTrigger(knex, TableName.EstEnrollmentConfig); await dropOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig);
await knex.schema.dropTableIfExists(TableName.CertificateTemplateV2); await knex.schema.dropTableIfExists(TableName.CertificateTemplateV2);
await dropOnUpdateTrigger(knex, TableName.CertificateTemplateV2); await dropOnUpdateTrigger(knex, TableName.CertificateTemplateV2);
@@ -12,7 +12,6 @@ export const CertificateProfilesSchema = z.object({
projectId: z.string(), projectId: z.string(),
caId: z.string().uuid(), caId: z.string().uuid(),
certificateTemplateId: z.string().uuid(), certificateTemplateId: z.string().uuid(),
name: z.string(),
slug: z.string(), slug: z.string(),
description: z.string().nullable().optional(), description: z.string().nullable().optional(),
enrollmentType: z.string(), enrollmentType: z.string(),
@@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
export const CertificateTemplatesV2Schema = z.object({ export const CertificateTemplatesV2Schema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
projectId: z.string(), projectId: z.string(),
name: z.string(), slug: z.string(),
description: z.string().nullable().optional(), description: z.string().nullable().optional(),
attributes: z.unknown().nullable().optional(), attributes: z.unknown().nullable().optional(),
keyUsages: z.unknown().nullable().optional(), keyUsages: z.unknown().nullable().optional(),
+2 -2
View File
@@ -3,7 +3,6 @@ export * from "./access-approval-policies-approvers";
export * from "./access-approval-policies-bypassers"; export * from "./access-approval-policies-bypassers";
export * from "./access-approval-requests"; export * from "./access-approval-requests";
export * from "./access-approval-requests-reviewers"; export * from "./access-approval-requests-reviewers";
export * from "./api-enrollment-configs";
export * from "./api-keys"; export * from "./api-keys";
export * from "./app-connections"; export * from "./app-connections";
export * from "./audit-log-streams"; export * from "./audit-log-streams";
@@ -24,7 +23,6 @@ export * from "./certificate-templates-v2";
export * from "./certificates"; export * from "./certificates";
export * from "./dynamic-secret-leases"; export * from "./dynamic-secret-leases";
export * from "./dynamic-secrets"; export * from "./dynamic-secrets";
export * from "./est-enrollment-configs";
export * from "./external-certificate-authorities"; export * from "./external-certificate-authorities";
export * from "./external-group-org-role-mappings"; export * from "./external-group-org-role-mappings";
export * from "./external-kms"; export * from "./external-kms";
@@ -92,8 +90,10 @@ export * from "./pam-folders";
export * from "./pam-resources"; export * from "./pam-resources";
export * from "./pam-sessions"; export * from "./pam-sessions";
export * from "./pki-alerts"; export * from "./pki-alerts";
export * from "./pki-api-enrollment-configs";
export * from "./pki-collection-items"; export * from "./pki-collection-items";
export * from "./pki-collections"; export * from "./pki-collections";
export * from "./pki-est-enrollment-configs";
export * from "./pki-subscribers"; export * from "./pki-subscribers";
export * from "./pki-syncs"; export * from "./pki-syncs";
export * from "./project-bots"; export * from "./project-bots";
+2 -2
View File
@@ -25,8 +25,8 @@ export enum TableName {
CertificateTemplate = "certificate_templates", CertificateTemplate = "certificate_templates",
CertificateTemplateV2 = "certificate_templates_v2", CertificateTemplateV2 = "certificate_templates_v2",
CertificateProfile = "certificate_profiles", CertificateProfile = "certificate_profiles",
EstEnrollmentConfig = "est_enrollment_configs", PkiEstEnrollmentConfig = "pki_est_enrollment_configs",
ApiEnrollmentConfig = "api_enrollment_configs", PkiApiEnrollmentConfig = "pki_api_enrollment_configs",
PkiSubscriber = "pki_subscribers", PkiSubscriber = "pki_subscribers",
PkiAlert = "pki_alerts", PkiAlert = "pki_alerts",
PkiCollection = "pki_collections", PkiCollection = "pki_collections",
@@ -7,7 +7,7 @@ import { z } from "zod";
import { TImmutableDBKeys } from "./models"; import { TImmutableDBKeys } from "./models";
export const ApiEnrollmentConfigsSchema = z.object({ export const PkiApiEnrollmentConfigsSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
autoRenew: z.boolean().default(false).nullable().optional(), autoRenew: z.boolean().default(false).nullable().optional(),
autoRenewDays: z.number().nullable().optional(), autoRenewDays: z.number().nullable().optional(),
@@ -15,6 +15,8 @@ export const ApiEnrollmentConfigsSchema = z.object({
updatedAt: z.date() updatedAt: z.date()
}); });
export type TApiEnrollmentConfigs = z.infer<typeof ApiEnrollmentConfigsSchema>; export type TPkiApiEnrollmentConfigs = z.infer<typeof PkiApiEnrollmentConfigsSchema>;
export type TApiEnrollmentConfigsInsert = Omit<z.input<typeof ApiEnrollmentConfigsSchema>, TImmutableDBKeys>; export type TPkiApiEnrollmentConfigsInsert = Omit<z.input<typeof PkiApiEnrollmentConfigsSchema>, TImmutableDBKeys>;
export type TApiEnrollmentConfigsUpdate = Partial<Omit<z.input<typeof ApiEnrollmentConfigsSchema>, TImmutableDBKeys>>; export type TPkiApiEnrollmentConfigsUpdate = Partial<
Omit<z.input<typeof PkiApiEnrollmentConfigsSchema>, TImmutableDBKeys>
>;
@@ -9,7 +9,7 @@ import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models"; import { TImmutableDBKeys } from "./models";
export const EstEnrollmentConfigsSchema = z.object({ export const PkiEstEnrollmentConfigsSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
disableBootstrapCaValidation: z.boolean().default(false).nullable().optional(), disableBootstrapCaValidation: z.boolean().default(false).nullable().optional(),
hashedPassphrase: z.string(), hashedPassphrase: z.string(),
@@ -18,6 +18,8 @@ export const EstEnrollmentConfigsSchema = z.object({
updatedAt: z.date() updatedAt: z.date()
}); });
export type TEstEnrollmentConfigs = z.infer<typeof EstEnrollmentConfigsSchema>; export type TPkiEstEnrollmentConfigs = z.infer<typeof PkiEstEnrollmentConfigsSchema>;
export type TEstEnrollmentConfigsInsert = Omit<z.input<typeof EstEnrollmentConfigsSchema>, TImmutableDBKeys>; export type TPkiEstEnrollmentConfigsInsert = Omit<z.input<typeof PkiEstEnrollmentConfigsSchema>, TImmutableDBKeys>;
export type TEstEnrollmentConfigsUpdate = Partial<Omit<z.input<typeof EstEnrollmentConfigsSchema>, TImmutableDBKeys>>; export type TPkiEstEnrollmentConfigsUpdate = Partial<
Omit<z.input<typeof PkiEstEnrollmentConfigsSchema>, TImmutableDBKeys>
>;
@@ -352,14 +352,10 @@ export enum EventType {
UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template",
DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template",
GET_CERTIFICATE_TEMPLATE = "get-certificate-template", GET_CERTIFICATE_TEMPLATE = "get-certificate-template",
LIST_CERTIFICATE_TEMPLATES = "list-certificate-templates",
CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config",
UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config",
GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config",
CREATE_CERTIFICATE_TEMPLATE_V2 = "create-certificate-template-v2",
UPDATE_CERTIFICATE_TEMPLATE_V2 = "update-certificate-template-v2",
DELETE_CERTIFICATE_TEMPLATE_V2 = "delete-certificate-template-v2",
GET_CERTIFICATE_TEMPLATE_V2 = "get-certificate-template-v2",
LIST_CERTIFICATE_TEMPLATES_V2 = "list-certificate-templates-v2",
CREATE_CERTIFICATE_PROFILE = "create-certificate-profile", CREATE_CERTIFICATE_PROFILE = "create-certificate-profile",
UPDATE_CERTIFICATE_PROFILE = "update-certificate-profile", UPDATE_CERTIFICATE_PROFILE = "update-certificate-profile",
DELETE_CERTIFICATE_PROFILE = "delete-certificate-profile", DELETE_CERTIFICATE_PROFILE = "delete-certificate-profile",
@@ -2525,46 +2521,6 @@ interface LoadProjectKmsBackupEvent {
metadata: Record<string, string>; // no metadata yet metadata: Record<string, string>; // no metadata yet
} }
interface CreateCertificateTemplate {
type: EventType.CREATE_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
caId: string;
pkiCollectionId?: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
};
}
interface GetCertificateTemplate {
type: EventType.GET_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
};
}
interface UpdateCertificateTemplate {
type: EventType.UPDATE_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
caId: string;
pkiCollectionId?: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
};
}
interface DeleteCertificateTemplate {
type: EventType.DELETE_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
};
}
interface OrgAdminAccessProjectEvent { interface OrgAdminAccessProjectEvent {
type: EventType.ORG_ADMIN_ACCESS_PROJECT; type: EventType.ORG_ADMIN_ACCESS_PROJECT;
metadata: { metadata: {
@@ -2611,8 +2567,8 @@ interface GetCertificateTemplateEstConfig {
}; };
} }
interface CreateCertificateTemplateV2 { interface CreateCertificateTemplate {
type: EventType.CREATE_CERTIFICATE_TEMPLATE_V2; type: EventType.CREATE_CERTIFICATE_TEMPLATE;
metadata: { metadata: {
certificateTemplateId: string; certificateTemplateId: string;
name: string; name: string;
@@ -2620,30 +2576,32 @@ interface CreateCertificateTemplateV2 {
}; };
} }
interface UpdateCertificateTemplateV2 { interface UpdateCertificateTemplate {
type: EventType.UPDATE_CERTIFICATE_TEMPLATE_V2; type: EventType.UPDATE_CERTIFICATE_TEMPLATE;
metadata: { metadata: {
certificateTemplateId: string; certificateTemplateId: string;
name: string; name: string;
}; };
} }
interface DeleteCertificateTemplateV2 { interface DeleteCertificateTemplate {
type: EventType.DELETE_CERTIFICATE_TEMPLATE_V2; type: EventType.DELETE_CERTIFICATE_TEMPLATE;
metadata: { metadata: {
certificateTemplateId: string; certificateTemplateId: string;
name: string;
}; };
} }
interface GetCertificateTemplateV2 { interface GetCertificateTemplate {
type: EventType.GET_CERTIFICATE_TEMPLATE_V2; type: EventType.GET_CERTIFICATE_TEMPLATE;
metadata: { metadata: {
certificateTemplateId: string; certificateTemplateId: string;
name: string;
}; };
} }
interface ListCertificateTemplatesV2 { interface ListCertificateTemplates {
type: EventType.LIST_CERTIFICATE_TEMPLATES_V2; type: EventType.LIST_CERTIFICATE_TEMPLATES;
metadata: { metadata: {
projectId: string; projectId: string;
}; };
@@ -2710,7 +2668,7 @@ interface OrderCertificateFromProfile {
metadata: { metadata: {
certificateProfileId: string; certificateProfileId: string;
orderId: string; orderId: string;
identifiers: string[]; subjectAlternativeNames: string[];
}; };
} }
@@ -4167,18 +4125,14 @@ export type Event =
| LoadProjectKmsBackupEvent | LoadProjectKmsBackupEvent
| OrgAdminAccessProjectEvent | OrgAdminAccessProjectEvent
| OrgAdminBypassSSOEvent | OrgAdminBypassSSOEvent
| CreateCertificateTemplate
| UpdateCertificateTemplate
| GetCertificateTemplate
| DeleteCertificateTemplate
| CreateCertificateTemplateEstConfig | CreateCertificateTemplateEstConfig
| UpdateCertificateTemplateEstConfig | UpdateCertificateTemplateEstConfig
| GetCertificateTemplateEstConfig | GetCertificateTemplateEstConfig
| CreateCertificateTemplateV2 | CreateCertificateTemplate
| UpdateCertificateTemplateV2 | UpdateCertificateTemplate
| DeleteCertificateTemplateV2 | DeleteCertificateTemplate
| GetCertificateTemplateV2 | GetCertificateTemplate
| ListCertificateTemplatesV2 | ListCertificateTemplates
| CreateCertificateProfile | CreateCertificateProfile
| UpdateCertificateProfile | UpdateCertificateProfile
| DeleteCertificateProfile | DeleteCertificateProfile
@@ -455,6 +455,7 @@ const buildMemberPermissionRules = () => {
// double check if all CRUD are needed for CA and Certificates // double check if all CRUD are needed for CA and Certificates
can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities); can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities);
can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates);
can( can(
[ [
@@ -9,7 +9,6 @@ import { AuthMode } from "@app/services/auth/auth-type";
import { import {
createCertificateProfileSchema, createCertificateProfileSchema,
deleteCertificateProfileSchema, deleteCertificateProfileSchema,
getCertificateProfileByIdSchema,
listCertificateProfilesSchema, listCertificateProfilesSchema,
updateCertificateProfileSchema updateCertificateProfileSchema
} from "@app/services/certificate-profile/certificate-profile-schemas"; } from "@app/services/certificate-profile/certificate-profile-schemas";
@@ -49,7 +48,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
type: EventType.CREATE_CERTIFICATE_PROFILE, type: EventType.CREATE_CERTIFICATE_PROFILE,
metadata: { metadata: {
certificateProfileId: certificateProfile.id, certificateProfileId: certificateProfile.id,
name: certificateProfile.name, name: certificateProfile.slug,
projectId: certificateProfile.projectId, projectId: certificateProfile.projectId,
enrollmentType: certificateProfile.enrollmentType enrollmentType: certificateProfile.enrollmentType
} }
@@ -125,7 +124,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles], tags: [ApiDocsTags.PkiCertificateProfiles],
params: getCertificateProfileByIdSchema, params: z.object({
id: z.string().min(1)
}),
querystring: z.object({ querystring: z.object({
includeMetrics: z.coerce.boolean().optional().default(false), includeMetrics: z.coerce.boolean().optional().default(false),
expiringDays: z.coerce.number().min(1).max(365).optional().default(7) expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
@@ -262,7 +263,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles], tags: [ApiDocsTags.PkiCertificateProfiles],
params: getCertificateProfileByIdSchema, params: z.object({
id: z.string().min(1)
}),
body: updateCertificateProfileSchema, body: updateCertificateProfileSchema,
response: { response: {
200: z.object({ 200: z.object({
@@ -288,7 +291,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
type: EventType.UPDATE_CERTIFICATE_PROFILE, type: EventType.UPDATE_CERTIFICATE_PROFILE,
metadata: { metadata: {
certificateProfileId: certificateProfile.id, certificateProfileId: certificateProfile.id,
name: certificateProfile.name name: certificateProfile.slug
} }
} }
}); });
@@ -347,7 +350,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles], tags: [ApiDocsTags.PkiCertificateProfiles],
params: getCertificateProfileByIdSchema, params: z.object({
id: z.string().min(1)
}),
querystring: z.object({ querystring: z.object({
offset: z.number().min(0).default(0), offset: z.number().min(0).default(0),
limit: z.number().min(1).max(100).default(20), limit: z.number().min(1).max(100).default(20),
@@ -52,7 +52,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
event: { event: {
type: EventType.GET_CERTIFICATE_TEMPLATE, type: EventType.GET_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name
} }
} }
}); });
@@ -116,12 +117,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
type: EventType.CREATE_CERTIFICATE_TEMPLATE, type: EventType.CREATE_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id, certificateTemplateId: certificateTemplate.id,
caId: certificateTemplate.caId,
pkiCollectionId: certificateTemplate.pkiCollectionId as string,
name: certificateTemplate.name, name: certificateTemplate.name,
commonName: certificateTemplate.commonName, projectId: certificateTemplate.projectId
subjectAlternativeName: certificateTemplate.subjectAlternativeName,
ttl: certificateTemplate.ttl
} }
} }
}); });
@@ -184,12 +181,7 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
type: EventType.UPDATE_CERTIFICATE_TEMPLATE, type: EventType.UPDATE_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id, certificateTemplateId: certificateTemplate.id,
caId: certificateTemplate.caId, name: certificateTemplate.name
pkiCollectionId: certificateTemplate.pkiCollectionId as string,
name: certificateTemplate.name,
commonName: certificateTemplate.commonName,
subjectAlternativeName: certificateTemplate.subjectAlternativeName,
ttl: certificateTemplate.ttl
} }
} }
}); });
@@ -230,7 +222,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
event: { event: {
type: EventType.DELETE_CERTIFICATE_TEMPLATE, type: EventType.DELETE_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name
} }
} }
}); });
@@ -48,10 +48,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
...req.auditLogInfo, ...req.auditLogInfo,
projectId, projectId,
event: { event: {
type: EventType.CREATE_CERTIFICATE_TEMPLATE_V2, type: EventType.CREATE_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id, certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name, name: certificateTemplate.slug,
projectId: certificateTemplate.projectId projectId: certificateTemplate.projectId
} }
} }
@@ -92,7 +92,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
...req.auditLogInfo, ...req.auditLogInfo,
projectId: req.query.projectId, projectId: req.query.projectId,
event: { event: {
type: EventType.LIST_CERTIFICATE_TEMPLATES_V2, type: EventType.LIST_CERTIFICATE_TEMPLATES,
metadata: { metadata: {
projectId: req.query.projectId projectId: req.query.projectId
} }
@@ -133,9 +133,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
...req.auditLogInfo, ...req.auditLogInfo,
projectId: certificateTemplate.projectId, projectId: certificateTemplate.projectId,
event: { event: {
type: EventType.GET_CERTIFICATE_TEMPLATE_V2, type: EventType.GET_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug
} }
} }
}); });
@@ -176,10 +177,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
...req.auditLogInfo, ...req.auditLogInfo,
projectId: certificateTemplate.projectId, projectId: certificateTemplate.projectId,
event: { event: {
type: EventType.UPDATE_CERTIFICATE_TEMPLATE_V2, type: EventType.UPDATE_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id, certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name name: certificateTemplate.slug
} }
} }
}); });
@@ -218,9 +219,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
...req.auditLogInfo, ...req.auditLogInfo,
projectId: certificateTemplate.projectId, projectId: certificateTemplate.projectId,
event: { event: {
type: EventType.DELETE_CERTIFICATE_TEMPLATE_V2, type: EventType.DELETE_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug
} }
} }
}); });
@@ -196,7 +196,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
body: z.object({ body: z.object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
identifiers: z subjectAlternativeNames: z
.array( .array(
z.object({ z.object({
type: z.enum(["dns", "ip"]), type: z.enum(["dns", "ip"]),
@@ -217,7 +217,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
200: z.object({ 200: z.object({
orderId: z.string(), orderId: z.string(),
status: z.enum(["pending", "processing", "valid", "invalid"]), status: z.enum(["pending", "processing", "valid", "invalid"]),
identifiers: z.array( subjectAlternativeNames: z.array(
z.object({ z.object({
type: z.enum(["dns", "ip"]), type: z.enum(["dns", "ip"]),
value: z.string(), value: z.string(),
@@ -256,7 +256,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
profileId: req.body.profileId, profileId: req.body.profileId,
certificateOrder: { certificateOrder: {
identifiers: req.body.identifiers, subjectAlternativeNames: req.body.subjectAlternativeNames,
validity: { validity: {
ttl: req.body.ttl ttl: req.body.ttl
}, },
@@ -286,7 +286,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
metadata: { metadata: {
certificateProfileId: req.body.profileId, certificateProfileId: req.body.profileId,
orderId: data.orderId, orderId: data.orderId,
identifiers: req.body.identifiers.map((id) => `${id.type}:${id.value}`) subjectAlternativeNames: req.body.subjectAlternativeNames.map((san) => `${san.type}:${san.value}`)
} }
} }
}); });
@@ -68,18 +68,24 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
(tx || db).ref("name").withSchema(TableName.CertificateAuthority).as("caName"), (tx || db).ref("name").withSchema(TableName.CertificateAuthority).as("caName"),
(tx || db).ref("id").withSchema(TableName.CertificateTemplateV2).as("templateId"), (tx || db).ref("id").withSchema(TableName.CertificateTemplateV2).as("templateId"),
(tx || db).ref("projectId").withSchema(TableName.CertificateTemplateV2).as("templateProjectId"), (tx || db).ref("projectId").withSchema(TableName.CertificateTemplateV2).as("templateProjectId"),
(tx || db).ref("name").withSchema(TableName.CertificateTemplateV2).as("templateName"), (tx || db).ref("slug").withSchema(TableName.CertificateTemplateV2).as("templateName"),
(tx || db).ref("description").withSchema(TableName.CertificateTemplateV2).as("templateDescription"), (tx || db).ref("description").withSchema(TableName.CertificateTemplateV2).as("templateDescription"),
(tx || db).ref("id").withSchema(TableName.EstEnrollmentConfig).as("estConfigId"), (tx || db).ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"),
(tx || db) (tx || db)
.ref("disableBootstrapCaValidation") .ref("disableBootstrapCaValidation")
.withSchema(TableName.EstEnrollmentConfig) .withSchema(TableName.PkiEstEnrollmentConfig)
.as("estConfigDisableBootstrapCaValidation"), .as("estConfigDisableBootstrapCaValidation"),
(tx || db).ref("hashedPassphrase").withSchema(TableName.EstEnrollmentConfig).as("estConfigHashedPassphrase"), (tx || db)
(tx || db).ref("encryptedCaChain").withSchema(TableName.EstEnrollmentConfig).as("estConfigEncryptedCaChain"), .ref("hashedPassphrase")
(tx || db).ref("id").withSchema(TableName.ApiEnrollmentConfig).as("apiConfigId"), .withSchema(TableName.PkiEstEnrollmentConfig)
(tx || db).ref("autoRenew").withSchema(TableName.ApiEnrollmentConfig).as("apiConfigAutoRenew"), .as("estConfigHashedPassphrase"),
(tx || db).ref("autoRenewDays").withSchema(TableName.ApiEnrollmentConfig).as("apiConfigAutoRenewDays") (tx || db)
.ref("encryptedCaChain")
.withSchema(TableName.PkiEstEnrollmentConfig)
.as("estConfigEncryptedCaChain"),
(tx || db).ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"),
(tx || db).ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"),
(tx || db).ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenewDays")
) )
.leftJoin( .leftJoin(
TableName.CertificateAuthority, TableName.CertificateAuthority,
@@ -92,14 +98,14 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
`${TableName.CertificateTemplateV2}.id` `${TableName.CertificateTemplateV2}.id`
) )
.leftJoin( .leftJoin(
TableName.EstEnrollmentConfig, TableName.PkiEstEnrollmentConfig,
`${TableName.CertificateProfile}.estConfigId`, `${TableName.CertificateProfile}.estConfigId`,
`${TableName.EstEnrollmentConfig}.id` `${TableName.PkiEstEnrollmentConfig}.id`
) )
.leftJoin( .leftJoin(
TableName.ApiEnrollmentConfig, TableName.PkiApiEnrollmentConfig,
`${TableName.CertificateProfile}.apiConfigId`, `${TableName.CertificateProfile}.apiConfigId`,
`${TableName.ApiEnrollmentConfig}.id` `${TableName.PkiApiEnrollmentConfig}.id`
) )
.where(`${TableName.CertificateProfile}.id`, id) .where(`${TableName.CertificateProfile}.id`, id)
.first(); .first();
@@ -151,9 +157,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
if (search) { if (search) {
query = query.where((builder) => { query = query.where((builder) => {
void builder void builder
.whereILike(`${TableName.CertificateProfile}.name`, `%${search}%`) .whereILike(`${TableName.CertificateProfile}.slug`, `%${search}%`)
.orWhereILike(`${TableName.CertificateProfile}.description`, `%${search}%`) .orWhereILike(`${TableName.CertificateProfile}.description`, `%${search}%`);
.orWhereILike(`${TableName.CertificateProfile}.slug`, `%${search}%`);
}); });
} }
@@ -225,10 +230,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
if (search) { if (search) {
query = query.where((builder) => { query = query.where((builder) => {
void builder void builder.orWhereILike("description", `%${search}%`).orWhereILike("slug", `%${search}%`);
.whereILike("name", `%${search}%`)
.orWhereILike("description", `%${search}%`)
.orWhereILike("slug", `%${search}%`);
}); });
} }
@@ -249,7 +251,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => { const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => {
try { try {
const certificateProfile = await (tx || db)(TableName.CertificateProfile).where({ name, projectId }).first(); const certificateProfile = await (tx || db)(TableName.CertificateProfile)
.where({ slug: name, projectId })
.first();
return certificateProfile; return certificateProfile;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find certificate profile by name and project id" }); throw new DatabaseError({ error, name: "Find certificate profile by name and project id" });
@@ -8,7 +8,6 @@ export const createCertificateProfileSchema = z
projectId: z.string().min(1), projectId: z.string().min(1),
caId: z.string().uuid(), caId: z.string().uuid(),
certificateTemplateId: z.string().uuid(), certificateTemplateId: z.string().uuid(),
name: z.string().min(1).max(255),
slug: z slug: z
.string() .string()
.min(1) .min(1)
@@ -46,7 +45,6 @@ export const createCertificateProfileSchema = z
); );
export const updateCertificateProfileSchema = z.object({ export const updateCertificateProfileSchema = z.object({
name: z.string().min(1).max(255).optional(),
slug: z slug: z
.string() .string()
.min(1) .min(1)
@@ -80,7 +80,6 @@ describe("CertificateProfileService", () => {
const sampleProfile: TCertificateProfile = { const sampleProfile: TCertificateProfile = {
id: "profile-123", id: "profile-123",
projectId: "project-123", projectId: "project-123",
name: "Test Profile",
description: "Test certificate profile", description: "Test certificate profile",
slug: "test-profile", slug: "test-profile",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
@@ -174,9 +173,8 @@ describe("CertificateProfileService", () => {
describe("createProfile", () => { describe("createProfile", () => {
const validProfileData = { const validProfileData = {
name: "New Profile",
description: "New test profile",
slug: "new-profile", slug: "new-profile",
description: "New test profile",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
caId: "ca-123", caId: "ca-123",
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
@@ -204,9 +202,8 @@ describe("CertificateProfileService", () => {
expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123");
expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("new-profile", "project-123"); expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("new-profile", "project-123");
expect(mockCertificateProfileDAL.create).toHaveBeenCalledWith({ expect(mockCertificateProfileDAL.create).toHaveBeenCalledWith({
name: "New Profile",
description: "New test profile",
slug: "new-profile", slug: "new-profile",
description: "New test profile",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
caId: "ca-123", caId: "ca-123",
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
@@ -273,9 +270,8 @@ describe("CertificateProfileService", () => {
it("should throw ForbiddenRequestError for API enrollment without API config", async () => { it("should throw ForbiddenRequestError for API enrollment without API config", async () => {
const invalidData = { const invalidData = {
name: "Invalid Profile",
description: "Invalid test profile",
slug: "invalid-profile", slug: "invalid-profile",
description: "Invalid test profile",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
caId: "ca-123", caId: "ca-123",
certificateTemplateId: "template-123" certificateTemplateId: "template-123"
@@ -292,9 +288,8 @@ describe("CertificateProfileService", () => {
it("should create profile with API enrollment", async () => { it("should create profile with API enrollment", async () => {
const apiProfileData = { const apiProfileData = {
name: "API Profile",
description: "Profile with API enrollment",
slug: "api-profile", slug: "api-profile",
description: "Profile with API enrollment",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
caId: "ca-123", caId: "ca-123",
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
@@ -317,7 +312,7 @@ describe("CertificateProfileService", () => {
describe("updateProfile", () => { describe("updateProfile", () => {
const updateData = { const updateData = {
name: "Updated Profile", slug: "updated-profile",
description: "Updated description" description: "Updated description"
}; };
@@ -333,7 +328,7 @@ describe("CertificateProfileService", () => {
data: updateData data: updateData
}); });
expect(result.name).toBe("Updated Profile"); expect(result.slug).toBe("updated-profile");
expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123");
expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData); expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData);
}); });
@@ -697,9 +692,8 @@ describe("CertificateProfileService", () => {
describe("profile configuration validation", () => { describe("profile configuration validation", () => {
it("should validate EST enrollment configuration", async () => { it("should validate EST enrollment configuration", async () => {
const estProfileData = { const estProfileData = {
name: "EST Profile",
description: "Profile with EST enrollment",
slug: "est-profile", slug: "est-profile",
description: "Profile with EST enrollment",
enrollmentType: EnrollmentType.EST, enrollmentType: EnrollmentType.EST,
caId: "ca-123", caId: "ca-123",
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
@@ -737,9 +731,8 @@ describe("CertificateProfileService", () => {
vi.clearAllMocks(); vi.clearAllMocks();
const duplicateSlugData = { const duplicateSlugData = {
name: "Different Profile Name", slug: "different-profile-name",
description: "Profile with duplicate slug", description: "Profile with duplicate slug",
slug: "test-profile",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
caId: "ca-123", caId: "ca-123",
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
@@ -763,9 +756,8 @@ describe("CertificateProfileService", () => {
it("should validate auto-renewal configuration", async () => { it("should validate auto-renewal configuration", async () => {
const autoRenewData = { const autoRenewData = {
name: "Auto Renew Profile", slug: "auto-renew-profile",
description: "Profile with auto-renewal", description: "Profile with auto-renewal",
slug: "auto-renew",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
caId: "ca-123", caId: "ca-123",
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
@@ -987,9 +979,8 @@ describe("CertificateProfileService", () => {
it("should handle invalid template reference during profile creation", async () => { it("should handle invalid template reference during profile creation", async () => {
const profileData = { const profileData = {
name: "Invalid Template Profile", slug: "invalid-template-profile",
description: "Profile with invalid template", description: "Profile with invalid template",
slug: "invalid-template",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
caId: "ca-123", caId: "ca-123",
certificateTemplateId: "nonexistent-template", certificateTemplateId: "nonexistent-template",
@@ -1013,9 +1004,8 @@ describe("CertificateProfileService", () => {
it("should handle concurrent profile creation conflicts", async () => { it("should handle concurrent profile creation conflicts", async () => {
const conflictingData = { const conflictingData = {
name: "Concurrent Profile",
description: "Profile created concurrently",
slug: "concurrent-profile", slug: "concurrent-profile",
description: "Profile created concurrently",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
caId: "ca-123", caId: "ca-123",
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
@@ -1042,9 +1032,8 @@ describe("CertificateProfileService", () => {
describe("permission and security", () => { describe("permission and security", () => {
it("should validate project ownership for cross-project template access", async () => { it("should validate project ownership for cross-project template access", async () => {
const crossProjectData = { const crossProjectData = {
name: "Cross Project Profile", slug: "cross-project-profile",
description: "Profile using template from different project", description: "Profile using template from different project",
slug: "cross-project",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
caId: "ca-123", caId: "ca-123",
certificateTemplateId: "template-456", certificateTemplateId: "template-456",
@@ -1056,7 +1045,7 @@ describe("CertificateProfileService", () => {
const foreignTemplate = { const foreignTemplate = {
id: "template-456", id: "template-456",
projectId: "different-project-456", projectId: "different-project-456",
name: "Foreign Template" slug: "foreign-template"
}; };
(mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(foreignTemplate); (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(foreignTemplate);
@@ -1072,9 +1061,8 @@ describe("CertificateProfileService", () => {
it("should validate slug format constraints", async () => { it("should validate slug format constraints", async () => {
const invalidSlugData = { const invalidSlugData = {
name: "Invalid Slug Profile", slug: "invalid-slug-profile",
description: "Profile with invalid slug format", description: "Profile with invalid slug format",
slug: "Invalid_Slug_With_Underscores_And_Caps",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
caId: "ca-123", caId: "ca-123",
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
@@ -277,7 +277,28 @@ export const certificateProfileServiceFactory = ({
}); });
} }
const updatedProfile = await certificateProfileDAL.updateById(profileId, data); const { estConfig, apiConfig, ...profileUpdateData } = data;
if (estConfig && existingProfile.estConfigId) {
await estEnrollmentConfigDAL.updateById(existingProfile.estConfigId, {
disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation,
...(estConfig.passphrase && {
hashedPassphrase: await crypto.hashing().createHash(estConfig.passphrase, getConfig().SALT_ROUNDS)
}),
...(estConfig.caChain && {
encryptedCaChain: Buffer.from(estConfig.caChain, "base64")
})
});
}
if (apiConfig && existingProfile.apiConfigId) {
await apiEnrollmentConfigDAL.updateById(existingProfile.apiConfigId, {
autoRenew: apiConfig.autoRenew,
autoRenewDays: apiConfig.autoRenewDays
});
}
const updatedProfile = await certificateProfileDAL.updateById(profileId, profileUpdateData);
return convertDalToService(updatedProfile); return convertDalToService(updatedProfile);
}; };
@@ -19,6 +19,15 @@ export type TCertificateProfileInsert = Omit<TCertificateProfilesInsert, "enroll
export type TCertificateProfileUpdate = Omit<TCertificateProfilesUpdate, "enrollmentType"> & { export type TCertificateProfileUpdate = Omit<TCertificateProfilesUpdate, "enrollmentType"> & {
enrollmentType?: EnrollmentType; enrollmentType?: EnrollmentType;
estConfig?: {
disableBootstrapCaValidation?: boolean;
passphrase?: string;
caChain?: string;
};
apiConfig?: {
autoRenew?: boolean;
autoRenewDays?: number;
};
}; };
export type TCertificateProfileWithConfigs = TCertificateProfile & { export type TCertificateProfileWithConfigs = TCertificateProfile & {
@@ -139,7 +139,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
if (search) { if (search) {
query = query.where((builder) => { query = query.where((builder) => {
void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); void builder.whereILike("slug", `%${search}%`).orWhereILike("description", `%${search}%`);
}); });
} }
@@ -165,7 +165,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
if (search) { if (search) {
query = query.where((builder) => { query = query.where((builder) => {
void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); void builder.whereILike("slug", `%${search}%`).orWhereILike("description", `%${search}%`);
}); });
} }
@@ -176,10 +176,10 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
} }
}; };
const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => { const findBySlugAndProjectId = async (slug: string, projectId: string, tx?: Knex) => {
try { try {
const certificateTemplateV2 = await (tx || db)(TableName.CertificateTemplateV2) const certificateTemplateV2 = await (tx || db)(TableName.CertificateTemplateV2)
.where({ name, projectId }) .where({ slug, projectId })
.first(); .first();
if (!certificateTemplateV2) { if (!certificateTemplateV2) {
@@ -188,7 +188,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
return parseJsonFields(certificateTemplateV2); return parseJsonFields(certificateTemplateV2);
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find certificate template v2 by name and project id" }); throw new DatabaseError({ error, name: "Find certificate template v2 by slug and project id" });
} }
}; };
@@ -213,7 +213,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
findById, findById,
findByProjectId, findByProjectId,
countByProjectId, countByProjectId,
findByNameAndProjectId, findBySlugAndProjectId,
isTemplateInUse isTemplateInUse
}; };
}; };
@@ -1,3 +1,4 @@
import RE2 from "re2";
import { z } from "zod"; import { z } from "zod";
const attributeTypeSchema = z.enum(["common_name"]); const attributeTypeSchema = z.enum(["common_name"]);
@@ -87,7 +88,11 @@ export const templateV2KeyAlgorithmSchema = z.object({
export const createCertificateTemplateV2Schema = z.object({ export const createCertificateTemplateV2Schema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
name: z.string().min(1).max(255), slug: z
.string()
.min(1)
.max(255)
.regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(), attributes: z.array(templateV2AttributeSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(), keyUsages: templateV2KeyUsagesSchema.optional(),
@@ -99,7 +104,12 @@ export const createCertificateTemplateV2Schema = z.object({
}); });
export const updateCertificateTemplateV2Schema = z.object({ export const updateCertificateTemplateV2Schema = z.object({
name: z.string().min(1).max(255).optional(), slug: z
.string()
.min(1)
.max(255)
.regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens")
.optional(),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(), attributes: z.array(templateV2AttributeSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(), keyUsages: templateV2KeyUsagesSchema.optional(),
@@ -114,6 +124,11 @@ export const getCertificateTemplateV2ByIdSchema = z.object({
id: z.string().uuid() id: z.string().uuid()
}); });
export const getCertificateTemplateV2BySlugSchema = z.object({
projectId: z.string().min(1),
slug: z.string().min(1)
});
export const listCertificateTemplatesV2Schema = z.object({ export const listCertificateTemplatesV2Schema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
offset: z.coerce.number().min(0).default(0), offset: z.coerce.number().min(0).default(0),
@@ -27,6 +27,7 @@ describe("CertificateTemplateV2Service", () => {
let service: TCertificateTemplateV2ServiceFactory; let service: TCertificateTemplateV2ServiceFactory;
const mockCertificateTemplateV2DAL = { const mockCertificateTemplateV2DAL = {
findBySlugAndProjectId: vi.fn(),
create: vi.fn(), create: vi.fn(),
findById: vi.fn(), findById: vi.fn(),
updateById: vi.fn(), updateById: vi.fn(),
@@ -99,7 +100,7 @@ describe("CertificateTemplateV2Service", () => {
const sampleTemplate: TCertificateTemplateV2 = { const sampleTemplate: TCertificateTemplateV2 = {
id: "template-123", id: "template-123",
projectId: "project-123", projectId: "project-123",
name: "Web Server Template", slug: "web-server-template",
description: "Template for web server certificates", description: "Template for web server certificates",
...samplePolicy, ...samplePolicy,
createdAt: new Date(), createdAt: new Date(),
@@ -136,6 +137,7 @@ describe("CertificateTemplateV2Service", () => {
}); });
mockCertificateTemplateV2DAL.findByNameAndProjectId.mockResolvedValue(null); mockCertificateTemplateV2DAL.findByNameAndProjectId.mockResolvedValue(null);
mockCertificateTemplateV2DAL.findBySlugAndProjectId.mockResolvedValue(null);
service = certificateTemplateV2ServiceFactory({ service = certificateTemplateV2ServiceFactory({
certificateTemplateV2DAL: mockCertificateTemplateV2DAL as TCertificateTemplateV2DALFactory, certificateTemplateV2DAL: mockCertificateTemplateV2DAL as TCertificateTemplateV2DALFactory,
@@ -149,7 +151,7 @@ describe("CertificateTemplateV2Service", () => {
describe("createTemplateV2", () => { describe("createTemplateV2", () => {
const createData: Omit<TCertificateTemplateV2Insert, "projectId"> = { const createData: Omit<TCertificateTemplateV2Insert, "projectId"> = {
name: "Test Template", slug: "test-template",
description: "Test description", description: "Test description",
...samplePolicy ...samplePolicy
}; };
@@ -239,7 +241,7 @@ describe("CertificateTemplateV2Service", () => {
describe("updateTemplateV2", () => { describe("updateTemplateV2", () => {
it("should update template with valid data", async () => { it("should update template with valid data", async () => {
const updateData = { name: "Updated Template Name" }; const updateData = { slug: "updated-template-name" };
const updatedTemplate = { ...sampleTemplate, ...updateData }; const updatedTemplate = { ...sampleTemplate, ...updateData };
mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate);
@@ -263,7 +265,7 @@ describe("CertificateTemplateV2Service", () => {
service.updateTemplateV2({ service.updateTemplateV2({
...mockActor, ...mockActor,
templateId: "nonexistent-template", templateId: "nonexistent-template",
data: { name: "Updated Name" } data: { slug: "updated-name" }
}) })
).rejects.toThrow(NotFoundError); ).rejects.toThrow(NotFoundError);
}); });
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify";
import RE2 from "re2"; import RE2 from "re2";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
@@ -8,6 +9,7 @@ import {
ProjectPermissionSub ProjectPermissionSub
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal";
@@ -115,6 +117,28 @@ export const certificateTemplateV2ServiceFactory = ({
); );
}; };
const generateTemplateSlug = (baseSlug?: string): string => {
if (baseSlug) {
return slugify(baseSlug);
}
return slugify(alphaNumericNanoId(12));
};
const ensureUniqueSlug = async (projectId: string, desiredSlug: string, templateId?: string): Promise<string> => {
const existingTemplate = await certificateTemplateV2DAL.findBySlugAndProjectId(desiredSlug, projectId);
if (!existingTemplate || (templateId && existingTemplate.id === templateId)) {
return desiredSlug;
}
const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`;
const existingAlternative = await certificateTemplateV2DAL.findBySlugAndProjectId(alternativeSlug, projectId);
if (!existingAlternative) {
return alternativeSlug;
}
const randomSlug = slugify(alphaNumericNanoId(12));
return randomSlug;
};
const validateRequestAgainstPolicy = ( const validateRequestAgainstPolicy = (
template: TCertificateTemplateV2, template: TCertificateTemplateV2,
request: TCertificateRequest request: TCertificateRequest
@@ -361,8 +385,12 @@ export const certificateTemplateV2ServiceFactory = ({
keyAlgorithm: data.keyAlgorithm keyAlgorithm: data.keyAlgorithm
}); });
const slug = data.slug || generateTemplateSlug();
const uniqueSlug = await ensureUniqueSlug(projectId, slug);
const template = await certificateTemplateV2DAL.create({ const template = await certificateTemplateV2DAL.create({
...data, ...data,
slug: uniqueSlug,
projectId projectId
}); });
@@ -417,7 +445,13 @@ export const certificateTemplateV2ServiceFactory = ({
validateTemplatePolicy(mergedPolicy); validateTemplatePolicy(mergedPolicy);
} }
const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, data); const updateData = { ...data };
if (data.slug && typeof data.slug === "string" && data.slug !== existingTemplate.slug) {
const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, data.slug, templateId);
updateData.slug = uniqueSlug;
}
const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, updateData);
if (!updatedTemplate) { if (!updatedTemplate) {
throw new NotFoundError({ message: "Failed to update certificate template" }); throw new NotFoundError({ message: "Failed to update certificate template" });
} }
@@ -459,6 +493,43 @@ export const certificateTemplateV2ServiceFactory = ({
return template; return template;
}; };
const getTemplateV2BySlug = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
slug
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
slug: string;
}): Promise<TCertificateTemplateV2> => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Read,
ProjectPermissionSub.CertificateTemplates
);
const template = await certificateTemplateV2DAL.findBySlugAndProjectId(slug, projectId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
return template;
};
const listTemplatesV2 = async ({ const listTemplatesV2 = async ({
actor, actor,
actorId, actorId,
@@ -571,6 +642,7 @@ export const certificateTemplateV2ServiceFactory = ({
createTemplateV2, createTemplateV2,
updateTemplateV2, updateTemplateV2,
getTemplateV2ById, getTemplateV2ById,
getTemplateV2BySlug,
listTemplatesV2, listTemplatesV2,
deleteTemplateV2, deleteTemplateV2,
validateCertificateRequest validateCertificateRequest
@@ -74,7 +74,7 @@ export type TCertificateTemplateV2Insert = Omit<
export type TCertificateTemplateV2Update = Partial< export type TCertificateTemplateV2Update = Partial<
Pick< Pick<
TCertificateTemplateV2, TCertificateTemplateV2,
| "name" | "slug"
| "description" | "description"
| "attributes" | "attributes"
| "keyUsages" | "keyUsages"
@@ -347,7 +347,7 @@ describe("CertificateV3Service", () => {
describe("orderCertificateFromProfile", () => { describe("orderCertificateFromProfile", () => {
const mockCertificateOrder = { const mockCertificateOrder = {
identifiers: [{ type: "dns" as const, value: "example.com" }], subjectAlternativeNames: [{ type: "dns" as const, value: "example.com" }],
validity: { ttl: "30d" }, validity: { ttl: "30d" },
commonName: "example.com", commonName: "example.com",
keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE], keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE],
@@ -411,8 +411,8 @@ describe("CertificateV3Service", () => {
expect(result).toHaveProperty("orderId"); expect(result).toHaveProperty("orderId");
expect(result).toHaveProperty("status", "valid"); expect(result).toHaveProperty("status", "valid");
expect(result).toHaveProperty("certificate"); expect(result).toHaveProperty("certificate");
expect(result.identifiers).toHaveLength(1); expect(result.subjectAlternativeNames).toHaveLength(1);
expect(result.identifiers[0]).toEqual({ expect(result.subjectAlternativeNames[0]).toEqual({
type: "dns", type: "dns",
value: "example.com", value: "example.com",
status: "valid" status: "valid"
@@ -293,9 +293,9 @@ export const certificateV3ServiceFactory = ({
commonName: certificateOrder.commonName, commonName: certificateOrder.commonName,
keyUsages: certificateOrder.keyUsages, keyUsages: certificateOrder.keyUsages,
extendedKeyUsages: certificateOrder.extendedKeyUsages, extendedKeyUsages: certificateOrder.extendedKeyUsages,
subjectAlternativeNames: certificateOrder.identifiers.map((id) => ({ subjectAlternativeNames: certificateOrder.subjectAlternativeNames.map((san) => ({
type: id.type === "dns" ? ("dns_name" as const) : ("ip_address" as const), type: san.type === "dns" ? ("dns_name" as const) : ("ip_address" as const),
value: id.value value: san.value
})), })),
validity: certificateOrder.validity, validity: certificateOrder.validity,
notBefore: certificateOrder.notBefore, notBefore: certificateOrder.notBefore,
@@ -334,16 +334,16 @@ export const certificateV3ServiceFactory = ({
}); });
const orderId = randomUUID(); const orderId = randomUUID();
const identifiers = certificateOrder.identifiers.map((id) => ({ const subjectAlternativeNames = certificateOrder.subjectAlternativeNames.map((san) => ({
type: id.type, type: san.type,
value: id.value, value: san.value,
status: "valid" as const status: "valid" as const
})); }));
const authorizations = certificateOrder.identifiers.map((id) => ({ const authorizations = certificateOrder.subjectAlternativeNames.map((san) => ({
identifier: { identifier: {
type: id.type, type: san.type,
value: id.value value: san.value
}, },
status: "valid" as const, status: "valid" as const,
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
@@ -360,7 +360,7 @@ export const certificateV3ServiceFactory = ({
return { return {
orderId, orderId,
status: "valid", status: "valid",
identifiers, subjectAlternativeNames,
authorizations, authorizations,
finalize: `/api/v3/certificates/orders/${orderId}/finalize`, finalize: `/api/v3/certificates/orders/${orderId}/finalize`,
certificate: certificateResult.certificate certificate: certificateResult.certificate
@@ -35,7 +35,7 @@ export type TSignCertificateFromProfileDTO = {
export type TOrderCertificateFromProfileDTO = { export type TOrderCertificateFromProfileDTO = {
profileId: string; profileId: string;
certificateOrder: { certificateOrder: {
identifiers: Array<{ subjectAlternativeNames: Array<{
type: "dns" | "ip"; type: "dns" | "ip";
value: string; value: string;
}>; }>;
@@ -64,7 +64,7 @@ export type TCertificateFromProfileResponse = {
export type TCertificateOrderResponse = { export type TCertificateOrderResponse = {
orderId: string; orderId: string;
status: "pending" | "processing" | "valid" | "invalid"; status: "pending" | "processing" | "valid" | "invalid";
identifiers: Array<{ subjectAlternativeNames: Array<{
type: "dns" | "ip"; type: "dns" | "ip";
value: string; value: string;
status: "pending" | "processing" | "valid" | "invalid"; status: "pending" | "processing" | "valid" | "invalid";
@@ -10,11 +10,11 @@ import { TApiEnrollmentConfigInsert, TApiEnrollmentConfigUpdate } from "./enroll
export type TApiEnrollmentConfigDALFactory = ReturnType<typeof apiEnrollmentConfigDALFactory>; export type TApiEnrollmentConfigDALFactory = ReturnType<typeof apiEnrollmentConfigDALFactory>;
export const apiEnrollmentConfigDALFactory = (db: TDbClient) => { export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const apiEnrollmentConfigOrm = ormify(db, TableName.ApiEnrollmentConfig); const apiEnrollmentConfigOrm = ormify(db, TableName.PkiApiEnrollmentConfig);
const create = async (data: TApiEnrollmentConfigInsert, tx?: Knex) => { const create = async (data: TApiEnrollmentConfigInsert, tx?: Knex) => {
try { try {
const [apiConfig] = await (tx || db)(TableName.ApiEnrollmentConfig).insert(data).returning("*"); const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).insert(data).returning("*");
return apiConfig; return apiConfig;
} catch (error) { } catch (error) {
@@ -24,7 +24,7 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const updateById = async (id: string, data: TApiEnrollmentConfigUpdate, tx?: Knex) => { const updateById = async (id: string, data: TApiEnrollmentConfigUpdate, tx?: Knex) => {
try { try {
const [apiConfig] = await (tx || db)(TableName.ApiEnrollmentConfig).where({ id }).update(data).returning("*"); const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).update(data).returning("*");
return apiConfig; return apiConfig;
} catch (error) { } catch (error) {
@@ -34,7 +34,7 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const deleteById = async (id: string, tx?: Knex) => { const deleteById = async (id: string, tx?: Knex) => {
try { try {
const [apiConfig] = await (tx || db)(TableName.ApiEnrollmentConfig).where({ id }).del().returning("*"); const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).del().returning("*");
return apiConfig; return apiConfig;
} catch (error) { } catch (error) {
@@ -44,7 +44,7 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const findById = async (id: string, tx?: Knex) => { const findById = async (id: string, tx?: Knex) => {
try { try {
const apiConfig = await (tx || db)(TableName.ApiEnrollmentConfig).where({ id }).first(); const apiConfig = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).first();
return apiConfig; return apiConfig;
} catch (error) { } catch (error) {
@@ -60,15 +60,15 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const profiles = await (tx || db)(TableName.CertificateProfile) const profiles = await (tx || db)(TableName.CertificateProfile)
.join( .join(
TableName.ApiEnrollmentConfig, TableName.PkiApiEnrollmentConfig,
`${TableName.CertificateProfile}.apiConfigId`, `${TableName.CertificateProfile}.apiConfigId`,
`${TableName.ApiEnrollmentConfig}.id` `${TableName.PkiApiEnrollmentConfig}.id`
) )
.where(`${TableName.ApiEnrollmentConfig}.autoRenew`, true) .where(`${TableName.PkiApiEnrollmentConfig}.autoRenew`, true)
.where((query) => { .where((query) => {
void query void query
.whereNull(`${TableName.ApiEnrollmentConfig}.autoRenewDays`) .whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`)
.orWhere(`${TableName.ApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays); .orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays);
}) })
.select((tx || db).ref("id").withSchema(TableName.CertificateProfile)) .select((tx || db).ref("id").withSchema(TableName.CertificateProfile))
.select((tx || db).ref("name").withSchema(TableName.CertificateProfile)) .select((tx || db).ref("name").withSchema(TableName.CertificateProfile))
@@ -1,21 +1,21 @@
import { import {
TApiEnrollmentConfigs, TPkiApiEnrollmentConfigs,
TApiEnrollmentConfigsInsert, TPkiApiEnrollmentConfigsInsert,
TApiEnrollmentConfigsUpdate TPkiApiEnrollmentConfigsUpdate
} from "@app/db/schemas/api-enrollment-configs"; } from "@app/db/schemas/pki-api-enrollment-configs";
import { import {
TEstEnrollmentConfigs, TPkiEstEnrollmentConfigs,
TEstEnrollmentConfigsInsert, TPkiEstEnrollmentConfigsInsert,
TEstEnrollmentConfigsUpdate TPkiEstEnrollmentConfigsUpdate
} from "@app/db/schemas/est-enrollment-configs"; } from "@app/db/schemas/pki-est-enrollment-configs";
export type TEstEnrollmentConfig = TEstEnrollmentConfigs; export type TEstEnrollmentConfig = TPkiEstEnrollmentConfigs;
export type TEstEnrollmentConfigInsert = TEstEnrollmentConfigsInsert; export type TEstEnrollmentConfigInsert = TPkiEstEnrollmentConfigsInsert;
export type TEstEnrollmentConfigUpdate = TEstEnrollmentConfigsUpdate; export type TEstEnrollmentConfigUpdate = TPkiEstEnrollmentConfigsUpdate;
export type TApiEnrollmentConfig = TApiEnrollmentConfigs; export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs;
export type TApiEnrollmentConfigInsert = TApiEnrollmentConfigsInsert; export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert;
export type TApiEnrollmentConfigUpdate = TApiEnrollmentConfigsUpdate; export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate;
export interface TEstConfigData { export interface TEstConfigData {
disableBootstrapCaValidation: boolean; disableBootstrapCaValidation: boolean;
@@ -10,11 +10,11 @@ import { TEstEnrollmentConfigInsert, TEstEnrollmentConfigUpdate } from "./enroll
export type TEstEnrollmentConfigDALFactory = ReturnType<typeof estEnrollmentConfigDALFactory>; export type TEstEnrollmentConfigDALFactory = ReturnType<typeof estEnrollmentConfigDALFactory>;
export const estEnrollmentConfigDALFactory = (db: TDbClient) => { export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
const estEnrollmentConfigOrm = ormify(db, TableName.EstEnrollmentConfig); const estEnrollmentConfigOrm = ormify(db, TableName.PkiEstEnrollmentConfig);
const create = async (data: TEstEnrollmentConfigInsert, tx?: Knex) => { const create = async (data: TEstEnrollmentConfigInsert, tx?: Knex) => {
try { try {
const [estConfig] = await (tx || db)(TableName.EstEnrollmentConfig).insert(data).returning("*"); const [estConfig] = await (tx || db)(TableName.PkiEstEnrollmentConfig).insert(data).returning("*");
return estConfig; return estConfig;
} catch (error) { } catch (error) {
@@ -24,7 +24,7 @@ export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
const updateById = async (id: string, data: TEstEnrollmentConfigUpdate, tx?: Knex) => { const updateById = async (id: string, data: TEstEnrollmentConfigUpdate, tx?: Knex) => {
try { try {
const [estConfig] = await (tx || db)(TableName.EstEnrollmentConfig).where({ id }).update(data).returning("*"); const [estConfig] = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).update(data).returning("*");
return estConfig; return estConfig;
} catch (error) { } catch (error) {
@@ -34,7 +34,7 @@ export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
const deleteById = async (id: string, tx?: Knex) => { const deleteById = async (id: string, tx?: Knex) => {
try { try {
const [estConfig] = await (tx || db)(TableName.EstEnrollmentConfig).where({ id }).del().returning("*"); const [estConfig] = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).del().returning("*");
return estConfig; return estConfig;
} catch (error) { } catch (error) {
@@ -44,7 +44,7 @@ export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
const findById = async (id: string, tx?: Knex) => { const findById = async (id: string, tx?: Knex) => {
try { try {
const estConfig = await (tx || db)(TableName.EstEnrollmentConfig).where({ id }).first(); const estConfig = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).first();
return estConfig; return estConfig;
} catch (error) { } catch (error) {
@@ -3,7 +3,6 @@ export type TCertificateProfile = {
projectId: string; projectId: string;
caId: string; caId: string;
certificateTemplateId: string; certificateTemplateId: string;
name: string;
slug: string; slug: string;
description?: string; description?: string;
enrollmentType: "api" | "est"; enrollmentType: "api" | "est";
@@ -24,7 +23,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & {
certificateTemplate?: { certificateTemplate?: {
id: string; id: string;
projectId: string; projectId: string;
name: string; slug: string;
description?: string; description?: string;
}; };
estConfig?: { estConfig?: {
@@ -44,7 +43,6 @@ export type TCreateCertificateProfileDTO = {
projectId: string; projectId: string;
caId: string; caId: string;
certificateTemplateId: string; certificateTemplateId: string;
name: string;
slug: string; slug: string;
description?: string; description?: string;
enrollmentType: "api" | "est"; enrollmentType: "api" | "est";
@@ -61,7 +59,7 @@ export type TCreateCertificateProfileDTO = {
export type TUpdateCertificateProfileDTO = { export type TUpdateCertificateProfileDTO = {
profileId: string; profileId: string;
name?: string; slug?: string;
description?: string; description?: string;
estConfig?: { estConfig?: {
disableBootstrapCaValidation?: boolean; disableBootstrapCaValidation?: boolean;
@@ -167,7 +167,7 @@ export type TCertificateTemplateV2Policy = {
export type TCertificateTemplateV2New = { export type TCertificateTemplateV2New = {
id: string; id: string;
projectId: string; projectId: string;
name: string; slug: string;
description?: string; description?: string;
attributes: any; attributes: any;
keyUsages: any; keyUsages: any;
@@ -182,7 +182,7 @@ export type TCertificateTemplateV2New = {
export type TCreateCertificateTemplateV2NewDTO = { export type TCreateCertificateTemplateV2NewDTO = {
projectId: string; projectId: string;
name: string; slug: string;
description?: string; description?: string;
attributes: TCertificateTemplateV2Policy["attributes"]; attributes: TCertificateTemplateV2Policy["attributes"];
keyUsages: TCertificateTemplateV2Policy["keyUsages"]; keyUsages: TCertificateTemplateV2Policy["keyUsages"];
@@ -195,7 +195,7 @@ export type TCreateCertificateTemplateV2NewDTO = {
export type TUpdateCertificateTemplateV2NewDTO = { export type TUpdateCertificateTemplateV2NewDTO = {
templateId: string; templateId: string;
name?: string; slug?: string;
description?: string; description?: string;
attributes?: TCertificateTemplateV2Policy["attributes"]; attributes?: TCertificateTemplateV2Policy["attributes"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
@@ -15,6 +15,7 @@ import {
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
Button, Button,
Checkbox,
FormControl, FormControl,
FormLabel, FormLabel,
IconButton, IconButton,
@@ -26,7 +27,7 @@ import {
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { useCreateCertificateV3, useGetCert, useListWorkspacePkiCollections } from "@app/hooks/api"; import { useCreateCertificateV3, useGetCert } from "@app/hooks/api";
import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles"; import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
import { import {
certKeyAlgorithms, certKeyAlgorithms,
@@ -44,53 +45,15 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertificateContent } from "./CertificateContent"; import { CertificateContent } from "./CertificateContent";
type TriStateToggleProps = {
value: boolean | undefined;
onChange: (value: boolean | undefined) => void;
leftLabel: string;
rightLabel: string;
};
const TriStateToggle = ({ value, onChange, leftLabel, rightLabel }: TriStateToggleProps) => {
return (
<div className="flex gap-x-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Button
variant="outline_bg"
onClick={() => {
onChange(value === false ? undefined : false);
}}
size="xs"
className={`${
value === false ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
{leftLabel}
</Button>
<Button
variant="outline_bg"
onClick={() => {
onChange(value === true ? undefined : true);
}}
size="xs"
className={`${
value === true ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
{rightLabel}
</Button>
</div>
);
};
const schema = z.object({ const schema = z.object({
profileId: z.string().min(1, "Profile is required"), profileId: z.string().min(1, "Profile is required"),
collectionId: z.string().optional(),
friendlyName: z.string(), friendlyName: z.string(),
subjectAttributes: z subjectAttributes: z
.array( .array(
z.object({ z.object({
type: z.enum(["common_name"]), type: z.enum(["common_name"]),
value: z.string().min(1, "Value is required") value: z.string().min(1, "Value is required"),
include: z.enum(["mandatory", "optional", "prohibit"]).optional()
}) })
) )
.min(1, "At least one subject attribute is required"), .min(1, "At least one subject attribute is required"),
@@ -98,7 +61,8 @@ const schema = z.object({
.array( .array(
z.object({ z.object({
type: z.enum(["dns", "ip", "email", "uri"]), type: z.enum(["dns", "ip", "email", "uri"]),
value: z.string().min(1, "Value is required") value: z.string().min(1, "Value is required"),
include: z.enum(["mandatory", "optional", "prohibit"]).optional()
}) })
) )
.default([]), .default([]),
@@ -159,10 +123,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
includeMetrics: false includeMetrics: false
}); });
const { data: collectionsData } = useListWorkspacePkiCollections({
projectId: currentProject?.id || ""
});
const { mutateAsync: createCertificate } = useCreateCertificateV3(); const { mutateAsync: createCertificate } = useCreateCertificateV3();
const { const {
@@ -461,7 +421,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
const onFormSubmit = async ({ const onFormSubmit = async ({
profileId, profileId,
friendlyName, friendlyName,
collectionId,
subjectAttributes, subjectAttributes,
altNames, altNames,
ttl, ttl,
@@ -481,7 +440,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({ const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({
profileId, profileId,
projectSlug: currentProject.slug, projectSlug: currentProject.slug,
pkiCollectionId: collectionId,
friendlyName, friendlyName,
commonName: getAttributeValue("common_name"), commonName: getAttributeValue("common_name"),
altNames: altNames altNames: altNames
@@ -628,34 +586,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
> >
{profilesData?.certificateProfiles?.map((profile) => ( {profilesData?.certificateProfiles?.map((profile) => (
<SelectItem key={profile.id} value={profile.id}> <SelectItem key={profile.id} value={profile.id}>
{profile.name} {profile.slug}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="collectionId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="PKI Collection (Optional)"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue=""
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
placeholder="Select a collection (optional)"
position="popper"
>
{collectionsData?.collections?.map((collection: any) => (
<SelectItem key={collection.id} value={collection.id}>
{collection.name}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
@@ -701,10 +632,25 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
onChange(newValue); onChange(newValue);
}} }}
className="w-48" className="w-48"
position="popper"
> >
<SelectItem value="common_name">Common Name</SelectItem> <SelectItem value="common_name">Common Name</SelectItem>
</Select> </Select>
<Select
value={attr.include || "optional"}
onValueChange={(newInclude) => {
const newValue = [...value];
newValue[index] = {
...attr,
include: newInclude as "mandatory" | "optional" | "prohibit"
};
onChange(newValue);
}}
className="w-32"
>
<SelectItem value="mandatory">Mandatory</SelectItem>
<SelectItem value="optional">Optional</SelectItem>
<SelectItem value="prohibit">Prohibited</SelectItem>
</Select>
<Input <Input
value={attr.value} value={attr.value}
onChange={(e) => { onChange={(e) => {
@@ -769,14 +715,29 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
}; };
onChange(newValue); onChange(newValue);
}} }}
className="w-32" className="w-24"
position="popper"
> >
<SelectItem value="dns">DNS</SelectItem> <SelectItem value="dns">DNS</SelectItem>
<SelectItem value="ip">IP</SelectItem> <SelectItem value="ip">IP</SelectItem>
<SelectItem value="email">Email</SelectItem> <SelectItem value="email">Email</SelectItem>
<SelectItem value="uri">URI</SelectItem> <SelectItem value="uri">URI</SelectItem>
</Select> </Select>
<Select
value={san.include || "optional"}
onValueChange={(newInclude) => {
const newValue = [...value];
newValue[index] = {
...san,
include: newInclude as "mandatory" | "optional" | "prohibit"
};
onChange(newValue);
}}
className="w-32"
>
<SelectItem value="mandatory">Mandatory</SelectItem>
<SelectItem value="optional">Optional</SelectItem>
<SelectItem value="prohibit">Prohibited</SelectItem>
</Select>
<Input <Input
value={san.value} value={san.value}
onChange={(e) => { onChange={(e) => {
@@ -908,11 +869,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
</div> </div>
</div> </div>
<Accordion type="single" collapsible> <Accordion type="single" collapsible className="w-full">
<AccordionItem value="key-usages"> <AccordionItem value="key-usages">
<AccordionTrigger>Key Usages</AccordionTrigger> <AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-2 gap-2 pl-2">
{KEY_USAGES_OPTIONS.filter(({ value }) => { {KEY_USAGES_OPTIONS.filter(({ value }) => {
if (allowedKeyUsages.length === 0) return true; if (allowedKeyUsages.length === 0) return true;
const templateToEnumMap = { const templateToEnumMap = {
@@ -933,15 +894,18 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
<Controller <Controller
key={label} key={label}
control={control} control={control}
name={`keyUsages.${value}`} name={`keyUsages.${value}` as any}
render={({ field }) => ( render={({ field }) => (
<div className="flex items-center justify-between"> <div className="flex items-center space-x-3">
<span className="text-sm text-mineshaft-300">{label}</span> <Checkbox
<TriStateToggle id={`key-usage-${value}`}
value={field.value} isChecked={field.value || false}
onChange={field.onChange} onCheckedChange={(checked) => field.onChange(checked)}
leftLabel="None" />
rightLabel="Include" <FormLabel
id={`key-usage-${value}`}
className="cursor-pointer text-sm text-mineshaft-300"
label={label}
/> />
</div> </div>
)} )}
@@ -954,7 +918,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
<AccordionItem value="extended-key-usages"> <AccordionItem value="extended-key-usages">
<AccordionTrigger>Extended Key Usages</AccordionTrigger> <AccordionTrigger>Extended Key Usages</AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-2 gap-2 pl-2">
{EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => { {EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => {
if (allowedExtendedKeyUsages.length === 0) return true; if (allowedExtendedKeyUsages.length === 0) return true;
const templateToEnumMap = { const templateToEnumMap = {
@@ -973,15 +937,18 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
<Controller <Controller
key={label} key={label}
control={control} control={control}
name={`extendedKeyUsages.${value}`} name={`extendedKeyUsages.${value}` as any}
render={({ field }) => ( render={({ field }) => (
<div className="flex items-center justify-between"> <div className="flex items-center space-x-3">
<span className="text-sm text-mineshaft-300">{label}</span> <Checkbox
<TriStateToggle id={`ext-key-usage-${value}`}
value={field.value} isChecked={field.value || false}
onChange={field.onChange} onCheckedChange={(checked) => field.onChange(checked)}
leftLabel="None" />
rightLabel="Include" <FormLabel
id={`ext-key-usage-${value}`}
className="cursor-pointer text-sm text-mineshaft-300"
label={label}
/> />
</div> </div>
)} )}
@@ -14,7 +14,6 @@ import {
} from "@app/hooks/api/certificateProfiles"; } from "@app/hooks/api/certificateProfiles";
import { CreateProfileModal } from "./CreateProfileModal"; import { CreateProfileModal } from "./CreateProfileModal";
import { EditProfileModal } from "./EditProfileModal";
import { ProfileList } from "./ProfileList"; import { ProfileList } from "./ProfileList";
export const CertificateProfilesTab = () => { export const CertificateProfilesTab = () => {
@@ -89,23 +88,24 @@ export const CertificateProfilesTab = () => {
{selectedProfile && ( {selectedProfile && (
<> <>
<EditProfileModal <CreateProfileModal
isOpen={isEditModalOpen} isOpen={isEditModalOpen}
onClose={() => { onClose={() => {
setIsEditModalOpen(false); setIsEditModalOpen(false);
setSelectedProfile(null); setSelectedProfile(null);
}} }}
profile={selectedProfile} profile={selectedProfile}
mode="edit"
/> />
<DeleteActionModal <DeleteActionModal
isOpen={isDeleteModalOpen} isOpen={isDeleteModalOpen}
title={`Delete Certificate Profile ${selectedProfile.name}?`} title={`Delete Certificate Profile ${selectedProfile.slug}?`}
onChange={(isOpen) => { onChange={(isOpen) => {
setIsDeleteModalOpen(isOpen); setIsDeleteModalOpen(isOpen);
if (!isOpen) setSelectedProfile(null); if (!isOpen) setSelectedProfile(null);
}} }}
deleteKey={selectedProfile.name} deleteKey={selectedProfile.slug}
onDeleteApproved={handleDeleteConfirm} onDeleteApproved={handleDeleteConfirm}
/> />
</> </>
@@ -1,5 +1,3 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -18,12 +16,15 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import { useCreateCertificateProfile } from "@app/hooks/api/certificateProfiles"; import {
TCertificateProfileWithDetails,
useCreateCertificateProfile,
useUpdateCertificateProfile
} from "@app/hooks/api/certificateProfiles";
import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries"; import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
const schema = z const createSchema = z
.object({ .object({
name: z.string().trim().min(1, "Profile name is required"),
slug: z.string().trim().min(1, "Profile slug is required"), slug: z.string().trim().min(1, "Profile slug is required"),
description: z.string().optional(), description: z.string().optional(),
enrollmentType: z.enum(["api", "est"]), enrollmentType: z.enum(["api", "est"]),
@@ -58,14 +59,52 @@ const schema = z
} }
); );
export type FormData = z.infer<typeof schema>; const editSchema = z
.object({
slug: z.string().trim().min(1, "Profile slug is required"),
description: z.string().optional(),
enrollmentType: z.enum(["api", "est"]),
certificateAuthorityId: z.string().optional(),
certificateTemplateId: z.string().optional(),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().optional(),
passphrase: z.string().optional(),
caChain: z.string().optional()
})
.optional(),
apiConfig: z
.object({
autoRenew: z.boolean().optional(),
autoRenewDays: z.number().min(1).max(365).optional()
})
.optional()
})
.refine(
(data) => {
if (data.enrollmentType === "est" && !data.estConfig) {
return false;
}
if (data.enrollmentType === "api" && !data.apiConfig) {
return false;
}
return true;
},
{
message: "Configuration is required for selected enrollment type"
}
);
export type FormData = z.infer<typeof createSchema>;
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
profile?: TCertificateProfileWithDetails;
mode?: "create" | "edit";
} }
export const CreateProfileModal = ({ isOpen, onClose }: Props) => { export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }: Props) => {
const { currentProject } = useProject(); const { currentProject } = useProject();
const { data: caData } = useListCasByProjectId(currentProject?.id || ""); const { data: caData } = useListCasByProjectId(currentProject?.id || "");
@@ -76,21 +115,33 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
}); });
const createProfile = useCreateCertificateProfile(); const createProfile = useCreateCertificateProfile();
const updateProfile = useUpdateCertificateProfile();
const isEdit = mode === "edit" && profile;
const certificateAuthorities = caData || []; const certificateAuthorities = caData || [];
const certificateTemplates = templateData?.certificateTemplates || []; const certificateTemplates = templateData?.certificateTemplates || [];
const { const { control, handleSubmit, reset, watch, setValue } = useForm<FormData>({
control, resolver: zodResolver(isEdit ? editSchema : createSchema),
handleSubmit, defaultValues: isEdit
reset, ? {
watch, slug: profile.slug,
setValue, description: profile.description || "",
formState: { isSubmitting } enrollmentType: profile.enrollmentType,
} = useForm<FormData>({ certificateAuthorityId: profile.caId,
resolver: zodResolver(schema), certificateTemplateId: profile.certificateTemplateId,
defaultValues: { estConfig: {
name: "", disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false,
passphrase: "",
caChain: ""
},
apiConfig: {
autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
}
}
: {
slug: "", slug: "",
description: "", description: "",
enrollmentType: "api", enrollmentType: "api",
@@ -103,28 +154,31 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
} }
}); });
const watchedName = watch("name");
const watchedEnrollmentType = watch("enrollmentType"); const watchedEnrollmentType = watch("enrollmentType");
const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation");
const watchedAutoRenew = watch("apiConfig.autoRenew"); const watchedAutoRenew = watch("apiConfig.autoRenew");
useEffect(() => {
if (watchedName && !watch("slug")) {
const slug = watchedName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
setValue("slug", slug);
}
}, [watchedName, setValue, watch]);
const onFormSubmit = async (data: FormData) => { const onFormSubmit = async (data: FormData) => {
try { try {
if (!currentProject?.id) return; if (!currentProject?.id && !isEdit) return;
const payload: any = { if (isEdit) {
projectId: currentProject.id, const updateData: any = {
name: data.name, profileId: profile.id,
name: data.slug,
description: data.description
};
if (data.enrollmentType === "est" && data.estConfig) {
updateData.estConfig = data.estConfig;
} else if (data.enrollmentType === "api" && data.apiConfig) {
updateData.apiConfig = data.apiConfig;
}
await updateProfile.mutateAsync(updateData);
} else {
const createData: any = {
projectId: currentProject!.id,
slug: data.slug, slug: data.slug,
description: data.description, description: data.description,
enrollmentType: data.enrollmentType, enrollmentType: data.enrollmentType,
@@ -133,23 +187,25 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
}; };
if (data.enrollmentType === "est" && data.estConfig) { if (data.enrollmentType === "est" && data.estConfig) {
payload.estConfig = data.estConfig; createData.estConfig = data.estConfig;
} else if (data.enrollmentType === "api" && data.apiConfig) { } else if (data.enrollmentType === "api" && data.apiConfig) {
payload.apiConfig = data.apiConfig; createData.apiConfig = data.apiConfig;
}
await createProfile.mutateAsync(createData);
} }
await createProfile.mutateAsync(payload);
createNotification({ createNotification({
text: "Certificate profile created successfully", text: `Certificate profile ${isEdit ? "updated" : "created"} successfully`,
type: "success" type: "success"
}); });
reset(); reset();
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Error creating profile:", error); console.error(`Error ${isEdit ? "updating" : "creating"} profile:`, error);
createNotification({ createNotification({
text: "Failed to create certificate profile", text: `Failed to ${isEdit ? "update" : "create"} certificate profile`,
type: "error" type: "error"
}); });
} }
@@ -166,25 +222,14 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
}} }}
> >
<ModalContent <ModalContent
title="Create Certificate Profile" title={isEdit ? "Edit Certificate Profile" : "Create Certificate Profile"}
subTitle="Configure a new certificate profile for unified certificate issuance" subTitle={
isEdit
? `Update configuration for ${profile?.slug}`
: "Configure a new certificate profile for unified certificate issuance"
}
> >
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Profile Name"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Enter profile name" />
</FormControl>
)}
/>
<Controller <Controller
control={control} control={control}
name="slug" name="slug"
@@ -195,7 +240,7 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<Input {...field} placeholder="auto-generated-from-name" /> <Input {...field} placeholder="your-profile-name" isDisabled={Boolean(isEdit)} />
</FormControl> </FormControl>
)} )}
/> />
@@ -226,6 +271,7 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
placeholder="Select a certificate authority" placeholder="Select a certificate authority"
className="w-full" className="w-full"
position="popper" position="popper"
isDisabled={Boolean(isEdit)}
> >
{certificateAuthorities.map((ca: any) => ( {certificateAuthorities.map((ca: any) => (
<SelectItem key={ca.id} value={ca.id}> <SelectItem key={ca.id} value={ca.id}>
@@ -269,10 +315,11 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
placeholder="Select a certificate template" placeholder="Select a certificate template"
className="w-full" className="w-full"
position="popper" position="popper"
isDisabled={Boolean(isEdit)}
> >
{certificateTemplates.map((template) => ( {certificateTemplates.map((template) => (
<SelectItem key={template.id} value={template.id}> <SelectItem key={template.id} value={template.id}>
{template.name} {template.slug}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
@@ -290,7 +337,13 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<Select {...field} onValueChange={onChange} className="w-full" position="popper"> <Select
{...field}
onValueChange={onChange}
className="w-full"
position="popper"
isDisabled={Boolean(isEdit)}
>
<SelectItem value="api">API</SelectItem> <SelectItem value="api">API</SelectItem>
<SelectItem value="est">EST</SelectItem> <SelectItem value="est">EST</SelectItem>
</Select> </Select>
@@ -314,12 +367,9 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
onCheckedChange={onChange} onCheckedChange={onChange}
/> />
<div className="space-y-1"> <div className="space-y-1">
<label <span className="text-sm font-medium text-mineshaft-100">
htmlFor="disableBootstrapCaValidation"
className="text-sm font-medium text-mineshaft-100"
>
Disable Bootstrap CA Validation Disable Bootstrap CA Validation
</label> </span>
<p className="text-xs text-bunker-300"> <p className="text-xs text-bunker-300">
Skip CA certificate validation during EST bootstrap phase Skip CA certificate validation during EST bootstrap phase
</p> </p>
@@ -335,7 +385,7 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="EST Passphrase" label="EST Passphrase"
isRequired isRequired={!isEdit}
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
@@ -356,7 +406,7 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="CA Chain Certificate" label="CA Chain Certificate"
isRequired isRequired={!isEdit}
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
@@ -424,10 +474,18 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
)} )}
<div className="flex gap-3"> <div className="flex gap-3">
<Button type="submit" colorSchema="primary" isLoading={isSubmitting}> <Button
Create type="submit"
colorSchema="primary"
isLoading={isEdit ? updateProfile.isPending : createProfile.isPending}
>
{isEdit ? "Save Changes" : "Create"}
</Button> </Button>
<Button variant="outline_bg" onClick={onClose} disabled={isSubmitting}> <Button
variant="outline_bg"
onClick={onClose}
disabled={isEdit ? updateProfile.isPending : createProfile.isPending}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -1,303 +0,0 @@
import { useEffect, useState } from "react";
import { faSave } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useProject } from "@app/context";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import {
TCertificateProfileWithDetails,
useUpdateCertificateProfile
} from "@app/hooks/api/certificateProfiles";
import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
interface Props {
isOpen: boolean;
onClose: () => void;
profile: TCertificateProfileWithDetails;
}
export const EditProfileModal = ({ isOpen, onClose, profile }: Props) => {
const { currentProject } = useProject();
const updateProfile = useUpdateCertificateProfile();
const { data: caData } = useListCasByProjectId(currentProject?.id || "");
const { data: templateData } = useListCertificateTemplatesV2({
projectId: currentProject?.id || "",
limit: 100,
offset: 0
});
const certificateAuthorities = caData || [];
const certificateTemplates = templateData?.certificateTemplates || [];
const [formData, setFormData] = useState({
name: "",
slug: "",
description: "",
enrollmentType: "api" as "api" | "est",
certificateAuthorityId: "",
certificateTemplateId: "",
estConfig: {
disableBootstrapCaValidation: false,
passphrase: "",
caChain: ""
},
apiConfig: {
autoRenew: false,
autoRenewDays: 30
}
});
useEffect(() => {
if (profile) {
setFormData({
name: profile.name,
slug: profile.slug,
description: profile.description || "",
enrollmentType: profile.enrollmentType,
certificateAuthorityId: profile.caId,
certificateTemplateId: profile.certificateTemplateId,
estConfig: {
disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false,
passphrase: "",
caChain: ""
},
apiConfig: {
autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
}
});
}
}, [profile]);
const handleInputChange = (field: string, value: string | boolean | number) => {
if (field.includes(".")) {
const [parent, child] = field.split(".");
setFormData((prev) => ({
...prev,
[parent]: {
...(prev as any)[parent],
[child]: value
}
}));
} else {
setFormData((prev) => ({
...prev,
[field]: value
}));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name) {
return;
}
try {
const payload: any = {
profileId: profile.id,
name: formData.name,
description: formData.description
};
if (formData.enrollmentType === "est") {
payload.estConfig = {
disableBootstrapCaValidation: formData.estConfig.disableBootstrapCaValidation,
passphrase: formData.estConfig.passphrase,
caChain: formData.estConfig.caChain
};
} else if (formData.enrollmentType === "api") {
payload.apiConfig = {
autoRenew: formData.apiConfig.autoRenew,
autoRenewDays: formData.apiConfig.autoRenewDays
};
}
await updateProfile.mutateAsync(payload);
createNotification({
text: "Certificate profile updated successfully",
type: "success"
});
onClose();
} catch (error) {
console.error("Error updating profile:", error);
createNotification({
text: "Failed to update certificate profile",
type: "error"
});
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onClose}>
<ModalContent
title="Edit Certificate Profile"
subTitle={`Update configuration for ${profile?.name}`}
>
<form onSubmit={handleSubmit} className="space-y-4">
<FormControl label="Profile Name" isRequired>
<Input
placeholder="Enter profile name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
/>
</FormControl>
<FormControl label="Profile Slug" isRequired>
<Input
placeholder="profile-slug"
value={formData.slug}
onChange={(e) => handleInputChange("slug", e.target.value)}
disabled
/>
</FormControl>
<FormControl label="Description">
<TextArea
placeholder="Enter profile description"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
rows={3}
/>
</FormControl>
<FormControl label="Enrollment Type">
<Select
value={formData.enrollmentType}
onValueChange={(value) => handleInputChange("enrollmentType", value)}
isDisabled
>
<SelectItem value="api">API - Programmatic certificate enrollment</SelectItem>
<SelectItem value="est">EST - RFC 7030 certificate enrollment</SelectItem>
</Select>
</FormControl>
<FormControl label="Certificate Authority">
<Select
value={formData.certificateAuthorityId}
onValueChange={(value) => handleInputChange("certificateAuthorityId", value)}
placeholder="Select a certificate authority"
isDisabled
>
{certificateAuthorities.map((ca: any) => (
<SelectItem key={ca.id} value={ca.id}>
{ca.friendlyName || ca.name || ca.commonName}
</SelectItem>
))}
</Select>
</FormControl>
<FormControl label="Certificate Template">
<Select
value={formData.certificateTemplateId}
onValueChange={(value) => handleInputChange("certificateTemplateId", value)}
placeholder="Select a certificate template"
isDisabled
>
{certificateTemplates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</Select>
</FormControl>
{/* EST Configuration */}
{formData.enrollmentType === "est" && (
<div className="space-y-4 rounded border border-mineshaft-600 p-4">
<FormControl>
<Checkbox
id="disableBootstrapCaValidation"
isChecked={formData.estConfig.disableBootstrapCaValidation}
onCheckedChange={(checked) =>
handleInputChange("estConfig.disableBootstrapCaValidation", checked)
}
>
Disable Bootstrap CA Validation
</Checkbox>
</FormControl>
<FormControl label="EST Passphrase" isRequired>
<Input
type="password"
placeholder="Enter EST passphrase"
value={formData.estConfig.passphrase}
onChange={(e) => handleInputChange("estConfig.passphrase", e.target.value)}
/>
</FormControl>
<FormControl label="CA Chain" isRequired>
<TextArea
placeholder="Enter CA chain (PEM format)"
value={formData.estConfig.caChain}
onChange={(e) => handleInputChange("estConfig.caChain", e.target.value)}
rows={6}
className="font-mono"
/>
</FormControl>
</div>
)}
{/* API Configuration */}
{formData.enrollmentType === "api" && (
<div className="space-y-4 rounded border border-mineshaft-600 p-4">
<FormControl>
<Checkbox
id="autoRenew"
isChecked={formData.apiConfig.autoRenew}
onCheckedChange={(checked) => handleInputChange("apiConfig.autoRenew", checked)}
>
Enable Auto-Renewal
</Checkbox>
</FormControl>
<FormControl label="Auto-Renewal Days">
<Input
type="number"
placeholder="30"
min="1"
max="365"
value={formData.apiConfig.autoRenewDays}
onChange={(e) =>
handleInputChange("apiConfig.autoRenewDays", parseInt(e.target.value, 10) || 30)
}
/>
</FormControl>
</div>
)}
<div className="flex gap-3 pt-4">
<Button
type="submit"
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faSave} />}
isLoading={updateProfile.isPending}
disabled={!formData.name}
>
Save Changes
</Button>
<Button variant="outline_bg" onClick={onClose} disabled={updateProfile.isPending}>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -34,7 +34,7 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
const profiles = data?.certificateProfiles || []; const profiles = data?.certificateProfiles || [];
if (isLoading) { if (isLoading) {
return <TableSkeleton columns={7} innerKey="certificate-profiles" />; return <TableSkeleton columns={6} innerKey="certificate-profiles" />;
} }
if (!profiles || profiles.length === 0) { if (!profiles || profiles.length === 0) {
@@ -51,7 +51,6 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
<Th>Certificate Authority</Th> <Th>Certificate Authority</Th>
<Th>Template</Th> <Th>Template</Th>
<Th>Certificates</Th> <Th>Certificates</Th>
<Th>Created</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
@@ -57,24 +57,17 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
return <Badge variant={variant}>{label}</Badge>; return <Badge variant={variant}>{label}</Badge>;
}; };
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString();
};
return ( return (
<Tr key={profile.id} className="h-10 transition-colors duration-100 hover:bg-mineshaft-700"> <Tr key={profile.id} className="h-10 transition-colors duration-100 hover:bg-mineshaft-700">
<Td> <Td>
<div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="font-medium text-mineshaft-100">{profile.name}</div> <div className="font-medium text-mineshaft-100">{profile.slug}</div>
{profile.description && ( {profile.description && (
<Tooltip content={profile.description}> <Tooltip content={profile.description}>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" /> <FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</Tooltip> </Tooltip>
)} )}
</div> </div>
<div className="text-xs text-bunker-300">{profile.slug}</div>
</div>
</Td> </Td>
<Td className="text-center">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td> <Td className="text-center">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td>
<Td className="text-center"> <Td className="text-center">
@@ -84,64 +77,47 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
</Td> </Td>
<Td> <Td>
<span className="text-sm text-mineshaft-300"> <span className="text-sm text-mineshaft-300">
{templateData?.name || profile.certificateTemplateId} {templateData?.slug || profile.certificateTemplateId}
</span> </span>
</Td> </Td>
<Td> <Td>
<div className="flex gap-2 text-xs"> <div className="flex flex-wrap gap-1">
{profile.metrics ? ( {profile.metrics ? (
profile.metrics.totalCertificates === 0 ? ( profile.metrics.totalCertificates === 0 ? (
<span className="text-bunker-300">No certificates attached</span> <Badge variant="primary" className="text-xs">
No certificates
</Badge>
) : ( ) : (
<> <>
{profile.metrics.activeCertificates > 0 && ( {profile.metrics.activeCertificates > 0 && (
<span className="text-green-400"> <Badge variant="success" className="text-xs">
{profile.metrics.activeCertificates} active {profile.metrics.activeCertificates} active
</span> </Badge>
)} )}
{profile.metrics.expiringCertificates > 0 && ( {profile.metrics.expiringCertificates > 0 && (
<> <Badge variant="primary" className="text-xs">
{profile.metrics.activeCertificates > 0 && (
<span className="text-gray-400">•</span>
)}
<span className="text-yellow-400">
{profile.metrics.expiringCertificates} expiring {profile.metrics.expiringCertificates} expiring
</span> </Badge>
</>
)} )}
{profile.metrics.expiredCertificates > 0 && ( {profile.metrics.expiredCertificates > 0 && (
<> <Badge variant="danger" className="text-xs">
{(profile.metrics.activeCertificates > 0 ||
profile.metrics.expiringCertificates > 0) && (
<span className="text-gray-400">•</span>
)}
<span className="text-red-300">
{profile.metrics.expiredCertificates} expired {profile.metrics.expiredCertificates} expired
</span> </Badge>
</>
)} )}
{profile.metrics.revokedCertificates > 0 && ( {profile.metrics.revokedCertificates > 0 && (
<> <Badge variant="danger" className="text-xs">
{(profile.metrics.activeCertificates > 0 ||
profile.metrics.expiringCertificates > 0 ||
profile.metrics.expiredCertificates > 0) && (
<span className="text-gray-400">•</span>
)}
<span className="text-red-400">
{profile.metrics.revokedCertificates} revoked {profile.metrics.revokedCertificates} revoked
</span> </Badge>
</>
)} )}
</> </>
) )
) : ( ) : (
<span className="text-bunker-300">No metrics available</span> <Badge variant="primary" className="text-xs">
No metrics
</Badge>
)} )}
</div> </div>
</Td> </Td>
<Td>
<span className="text-sm text-bunker-300">{formatDate(profile.createdAt)}</span>
</Td>
<Td className="text-right"> <Td className="text-right">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg"> <DropdownMenuTrigger asChild className="rounded-lg">
@@ -1,4 +1,3 @@
export { CertificateProfilesTab } from "./CertificateProfilesTab"; export { CertificateProfilesTab } from "./CertificateProfilesTab";
export { CreateProfileModal } from "./CreateProfileModal"; export { CreateProfileModal } from "./CreateProfileModal";
export { EditProfileModal } from "./EditProfileModal";
export { ProfileList } from "./ProfileList"; export { ProfileList } from "./ProfileList";
@@ -12,7 +12,6 @@ import { useDeleteCertificateTemplateV2New } from "@app/hooks/api/certificateTem
import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types"; import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
import { CreateTemplateModal } from "./CreateTemplateModal"; import { CreateTemplateModal } from "./CreateTemplateModal";
import { EditTemplateModal } from "./EditTemplateModal";
import { TemplateList } from "./TemplateList"; import { TemplateList } from "./TemplateList";
export const CertificateTemplatesV2Tab = () => { export const CertificateTemplatesV2Tab = () => {
@@ -87,23 +86,24 @@ export const CertificateTemplatesV2Tab = () => {
{selectedTemplate && ( {selectedTemplate && (
<> <>
<EditTemplateModal <CreateTemplateModal
isOpen={isEditModalOpen} isOpen={isEditModalOpen}
onClose={() => { onClose={() => {
setIsEditModalOpen(false); setIsEditModalOpen(false);
setSelectedTemplate(null); setSelectedTemplate(null);
}} }}
template={selectedTemplate} template={selectedTemplate}
mode="edit"
/> />
<DeleteActionModal <DeleteActionModal
isOpen={isDeleteModalOpen} isOpen={isDeleteModalOpen}
title={`Delete Certificate Template ${selectedTemplate.name}?`} title={`Delete Certificate Template ${selectedTemplate.slug}?`}
onChange={(isOpen) => { onChange={(isOpen) => {
setIsDeleteModalOpen(isOpen); setIsDeleteModalOpen(isOpen);
if (!isOpen) setSelectedTemplate(null); if (!isOpen) setSelectedTemplate(null);
}} }}
deleteKey={selectedTemplate.name} deleteKey={selectedTemplate.slug}
onDeleteApproved={handleDeleteConfirm} onDeleteApproved={handleDeleteConfirm}
/> />
</> </>
@@ -1,4 +1,3 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -6,8 +5,14 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button, Button,
Checkbox,
FormControl, FormControl,
IconButton,
Input, Input,
Modal, Modal,
ModalContent, ModalContent,
@@ -16,8 +21,13 @@ import {
TextArea TextArea
} from "@app/components/v2"; } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { useCreateCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/mutations"; import {
useCreateCertificateTemplateV2New,
useUpdateCertificateTemplateV2New
} from "@app/hooks/api/certificateTemplates/mutations";
import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
import { INCLUDE_OPTIONS, SAN_TYPES, SUBJECT_ATTRIBUTE_TYPES } from "./shared/utils";
import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared"; import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared";
export type FormData = TemplateFormData; export type FormData = TemplateFormData;
@@ -25,22 +35,34 @@ export type FormData = TemplateFormData;
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
template?: TCertificateTemplateV2New;
mode?: "create" | "edit";
} }
const ATTRIBUTE_TYPES = [{ value: "common_name", label: "Common Name (CN)" }]; const ATTRIBUTE_TYPE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_TYPES)[number], string> = {
common_name: "Common Name (CN)",
organization_name: "Organization (O)",
organization_unit: "Organizational Unit (OU)",
locality: "Locality (L)",
state: "State/Province (ST)",
country: "Country (C)",
email: "Email Address",
street_address: "Street Address",
postal_code: "Postal Code"
};
const SAN_TYPES = [ const SAN_TYPE_LABELS: Record<(typeof SAN_TYPES)[number], string> = {
{ value: "dns_name", label: "DNS Name" }, dns_name: "DNS Name",
{ value: "ip_address", label: "IP Address" }, ip_address: "IP Address",
{ value: "email", label: "Email" }, email: "Email",
{ value: "uri", label: "URI" } uri: "URI"
]; };
const INCLUDE_TYPES = [ const INCLUDE_TYPE_LABELS: Record<(typeof INCLUDE_OPTIONS)[number], string> = {
{ value: "mandatory", label: "Mandatory", color: "red" }, mandatory: "Mandatory",
{ value: "optional", label: "Optional", color: "blue" }, optional: "Optional",
{ value: "prohibit", label: "Prohibited", color: "gray" } prohibit: "Prohibited"
]; };
const SIGNATURE_ALGORITHMS = [ const SIGNATURE_ALGORITHMS = [
"SHA256-RSA", "SHA256-RSA",
@@ -60,15 +82,41 @@ const KEY_ALGORITHMS = [
"ECDSA-P521" "ECDSA-P521"
]; ];
export const CreateTemplateModal = ({ isOpen, onClose }: Props) => { export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" }: Props) => {
const { currentProject } = useProject(); const { currentProject } = useProject();
const createTemplate = useCreateCertificateTemplateV2New(); const createTemplate = useCreateCertificateTemplateV2New();
const [activeTab, setActiveTab] = useState<string>("basic"); const updateTemplate = useUpdateCertificateTemplateV2New();
const isEdit = mode === "edit" && template;
const { control, handleSubmit, reset, watch, setValue } = useForm<FormData>({ const { control, handleSubmit, reset, watch, setValue } = useForm<FormData>({
resolver: zodResolver(templateSchema), resolver: zodResolver(templateSchema),
defaultValues: { defaultValues: isEdit
name: "", ? {
slug: template.slug,
description: template.description || "",
attributes: template.attributes || [],
keyUsages: {
requiredUsages: template.keyUsages?.requiredUsages?.all || [],
optionalUsages: template.keyUsages?.optionalUsages?.all || []
},
extendedKeyUsages: {
requiredUsages: template.extendedKeyUsages?.requiredUsages?.all || [],
optionalUsages: template.extendedKeyUsages?.optionalUsages?.all || []
},
subjectAlternativeNames: template.subjectAlternativeNames || [],
validity: template.validity || { maxDuration: { value: 365, unit: "days" } },
signatureAlgorithm: template.signatureAlgorithm || {
allowedAlgorithms: ["SHA256-RSA"],
defaultAlgorithm: "SHA256-RSA"
},
keyAlgorithm: template.keyAlgorithm || {
allowedKeyTypes: ["RSA-2048"],
defaultKeyType: "RSA-2048"
}
}
: {
slug: "",
description: "", description: "",
attributes: [], attributes: [],
keyUsages: { requiredUsages: [], optionalUsages: [] }, keyUsages: { requiredUsages: [], optionalUsages: [] },
@@ -95,11 +143,12 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
const onFormSubmit = async (data: FormData) => { const onFormSubmit = async (data: FormData) => {
try { try {
if (!currentProject?.id) return; if (!currentProject?.id && !isEdit) return;
const templateData = { if (isEdit) {
projectId: currentProject.id, const updateData = {
name: data.name, templateId: template.id,
name: data.slug,
description: data.description, description: data.description,
attributes: data.attributes || [], attributes: data.attributes || [],
keyUsages: { keyUsages: {
@@ -123,20 +172,48 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
defaultKeyType: data.keyAlgorithm?.defaultKeyType || "RSA-2048" defaultKeyType: data.keyAlgorithm?.defaultKeyType || "RSA-2048"
} }
}; };
await updateTemplate.mutateAsync(updateData);
await createTemplate.mutateAsync(templateData); } else {
const createData = {
projectId: currentProject!.id,
slug: data.slug,
description: data.description,
attributes: data.attributes || [],
keyUsages: {
requiredUsages: { all: data.keyUsages?.requiredUsages || [] },
optionalUsages: { all: data.keyUsages?.optionalUsages || [] }
},
extendedKeyUsages: {
requiredUsages: { all: data.extendedKeyUsages?.requiredUsages || [] },
optionalUsages: { all: data.extendedKeyUsages?.optionalUsages || [] }
},
subjectAlternativeNames: data.subjectAlternativeNames || [],
validity: {
maxDuration: data.validity?.maxDuration || { value: 365, unit: "days" as const }
},
signatureAlgorithm: {
allowedAlgorithms: data.signatureAlgorithm?.allowedAlgorithms || ["SHA256-RSA"],
defaultAlgorithm: data.signatureAlgorithm?.defaultAlgorithm || "SHA256-RSA"
},
keyAlgorithm: {
allowedKeyTypes: data.keyAlgorithm?.allowedKeyTypes || ["RSA-2048"],
defaultKeyType: data.keyAlgorithm?.defaultKeyType || "RSA-2048"
}
};
await createTemplate.mutateAsync(createData);
}
createNotification({ createNotification({
text: "Certificate template created successfully", text: `Certificate template ${isEdit ? "updated" : "created"} successfully`,
type: "success" type: "success"
}); });
reset(); reset();
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Error creating template:", error); console.error(`Error ${isEdit ? "updating" : "creating"} template:`, error);
createNotification({ createNotification({
text: "Failed to create certificate template", text: `Failed to ${isEdit ? "update" : "create"} certificate template`,
type: "error" type: "error"
}); });
} }
@@ -144,8 +221,8 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
const addAttribute = () => { const addAttribute = () => {
const newAttribute = { const newAttribute = {
type: "common_name" as const, type: SUBJECT_ATTRIBUTE_TYPES[0],
include: "optional" as const, include: INCLUDE_OPTIONS[1],
value: [] value: []
}; };
setValue("attributes", [...watchedAttributes, newAttribute]); setValue("attributes", [...watchedAttributes, newAttribute]);
@@ -158,8 +235,8 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
const addSan = () => { const addSan = () => {
const newSan = { const newSan = {
type: "dns_name" as const, type: SAN_TYPES[0],
include: "optional" as const, include: INCLUDE_OPTIONS[1],
value: [] value: []
}; };
setValue("subjectAlternativeNames", [...watchedSans, newSan]); setValue("subjectAlternativeNames", [...watchedSans, newSan]);
@@ -208,14 +285,6 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
} as any); } as any);
}; };
const tabs = [
{ id: "basic", label: "Basic Info" },
{ id: "attributes", label: "Subject Attributes" },
{ id: "san", label: "Subject Alternative Names" },
{ id: "usages", label: "Key Usages" },
{ id: "constraints", label: "Constraints" }
];
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
@@ -228,35 +297,22 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
> >
<ModalContent <ModalContent
className="max-w-4xl" className="max-w-4xl"
title="Create Certificate Template V2" title={isEdit ? "Edit Certificate Template V2" : "Create Certificate Template V2"}
subTitle="Define comprehensive certificate policies, validation rules, and constraints" subTitle={
isEdit
? `Update configuration for ${template?.slug}`
: "Define comprehensive certificate policies, validation rules, and constraints"
}
> >
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
{/* Tab Navigation */} <Accordion type="multiple" defaultValue={["basic"]} className="w-full">
<div className="flex border-b border-mineshaft-600"> <AccordionItem value="basic">
{tabs.map((tab) => ( <AccordionTrigger>Basic Information</AccordionTrigger>
<button <AccordionContent>
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? "border-primary-500 text-primary-400"
: "border-transparent text-bunker-300 hover:text-mineshaft-200"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div className="max-h-80 overflow-y-auto">
{activeTab === "basic" && (
<div className="space-y-4"> <div className="space-y-4">
<Controller <Controller
control={control} control={control}
name="name" name="slug"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Template Name" label="Template Name"
@@ -283,9 +339,12 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
)} )}
/> />
</div> </div>
)} </AccordionContent>
</AccordionItem>
{activeTab === "attributes" && ( <AccordionItem value="attributes">
<AccordionTrigger>Subject Attributes</AccordionTrigger>
<AccordionContent>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
@@ -298,7 +357,7 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
</Button> </Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-2">
{watchedAttributes.length === 0 ? ( {watchedAttributes.length === 0 ? (
<div className="py-8 text-center text-bunker-300"> <div className="py-8 text-center text-bunker-300">
No subject attributes configured yet. Click &quot;Add Attribute&quot; to get No subject attributes configured yet. Click &quot;Add Attribute&quot; to get
@@ -308,8 +367,15 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
watchedAttributes.map((attr, index) => ( watchedAttributes.map((attr, index) => (
<div <div
key={`attr-${attr.type}`} key={`attr-${attr.type}`}
className="flex items-center gap-3 rounded border border-mineshaft-600 p-3" className="flex flex-col space-y-2 rounded-md border border-mineshaft-600 p-4"
> >
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-mineshaft-200">
{ATTRIBUTE_TYPE_LABELS[attr.type] || attr.type}
</span>
</div>
<div className="flex gap-3">
<Select <Select
value={attr.type} value={attr.type}
onValueChange={(value) => { onValueChange={(value) => {
@@ -317,12 +383,11 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
newAttributes[index] = { ...attr, type: value as any }; newAttributes[index] = { ...attr, type: value as any };
setValue("attributes", newAttributes); setValue("attributes", newAttributes);
}} }}
className="w-56"
position="popper" position="popper"
> >
{ATTRIBUTE_TYPES.map((type) => ( {SUBJECT_ATTRIBUTE_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}> <SelectItem key={type} value={type}>
{type.label} {ATTRIBUTE_TYPE_LABELS[type]}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
@@ -334,12 +399,11 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
newAttributes[index] = { ...attr, include: value as any }; newAttributes[index] = { ...attr, include: value as any };
setValue("attributes", newAttributes); setValue("attributes", newAttributes);
}} }}
className="w-36"
position="popper" position="popper"
> >
{INCLUDE_TYPES.map((type) => ( {INCLUDE_OPTIONS.map((type) => (
<SelectItem key={type.value} value={type.value}> <SelectItem key={type} value={type}>
{type.label} {INCLUDE_TYPE_LABELS[type]}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
@@ -355,26 +419,26 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
}; };
setValue("attributes", newAttributes); setValue("attributes", newAttributes);
}} }}
className="flex-1"
/> />
<IconButton
<Button ariaLabel="delete attribute"
type="button" variant="plain"
onClick={() => removeAttribute(index)} onClick={() => removeAttribute(index)}
variant="outline"
size="sm"
colorSchema="danger"
> >
<FontAwesomeIcon icon={faTrash} /> <FontAwesomeIcon icon={faTrash} className="text-red-500" />
</Button> </IconButton>
</div>
</div> </div>
)) ))
)} )}
</div> </div>
</div> </div>
)} </AccordionContent>
</AccordionItem>
{activeTab === "san" && ( <AccordionItem value="san">
<AccordionTrigger>Subject Alternative Names</AccordionTrigger>
<AccordionContent>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
@@ -387,18 +451,19 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
</Button> </Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-2">
{watchedSans.length === 0 ? ( {watchedSans.length === 0 ? (
<div className="py-8 text-center text-bunker-300"> <div className="py-8 text-center text-bunker-300">
No subject alternative names configured yet. Click &quot;Add SAN&quot; to get No subject alternative names configured yet. Click &quot;Add SAN&quot; to
started. get started.
</div> </div>
) : ( ) : (
watchedSans.map((san, index) => ( watchedSans.map((san, index) => (
<div <div
key={`san-${san.type}`} key={`san-${san.type}`}
className="flex items-center gap-3 rounded border border-mineshaft-600 p-3" className="flex flex-col space-y-4 rounded-md border border-mineshaft-600 p-4"
> >
<div className="flex gap-3">
<Select <Select
value={san.type} value={san.type}
onValueChange={(value) => { onValueChange={(value) => {
@@ -406,12 +471,11 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
newSans[index] = { ...san, type: value as any }; newSans[index] = { ...san, type: value as any };
setValue("subjectAlternativeNames", newSans); setValue("subjectAlternativeNames", newSans);
}} }}
className="w-36"
position="popper" position="popper"
> >
{SAN_TYPES.map((type) => ( {SAN_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}> <SelectItem key={type} value={type}>
{type.label} {SAN_TYPE_LABELS[type]}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
@@ -423,12 +487,11 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
newSans[index] = { ...san, include: value as any }; newSans[index] = { ...san, include: value as any };
setValue("subjectAlternativeNames", newSans); setValue("subjectAlternativeNames", newSans);
}} }}
className="w-36"
position="popper" position="popper"
> >
{INCLUDE_TYPES.map((type) => ( {INCLUDE_OPTIONS.map((type) => (
<SelectItem key={type.value} value={type.value}> <SelectItem key={type} value={type}>
{type.label} {INCLUDE_TYPE_LABELS[type]}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
@@ -444,35 +507,41 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
}; };
setValue("subjectAlternativeNames", newSans); setValue("subjectAlternativeNames", newSans);
}} }}
className="flex-1"
/> />
<div className="flex items-center justify-between">
<Button <IconButton
type="button"
onClick={() => removeSan(index)} onClick={() => removeSan(index)}
variant="outline"
size="sm" size="sm"
colorSchema="danger" variant="plain"
ariaLabel="Remove SAN"
> >
<FontAwesomeIcon icon={faTrash} /> <FontAwesomeIcon icon={faTrash} className="text-red-500" />
</Button> </IconButton>
</div>
</div>
</div> </div>
)) ))
)} )}
</div> </div>
</div> </div>
)} </AccordionContent>
</AccordionItem>
{activeTab === "usages" && ( <AccordionItem value="usages">
<AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent>
<KeyUsagesSection <KeyUsagesSection
watchedKeyUsages={watchedKeyUsages} watchedKeyUsages={watchedKeyUsages}
watchedExtendedKeyUsages={watchedExtendedKeyUsages} watchedExtendedKeyUsages={watchedExtendedKeyUsages}
toggleKeyUsage={toggleKeyUsage} toggleKeyUsage={toggleKeyUsage}
toggleExtendedKeyUsage={toggleExtendedKeyUsage} toggleExtendedKeyUsage={toggleExtendedKeyUsage}
/> />
)} </AccordionContent>
</AccordionItem>
{activeTab === "constraints" && ( <AccordionItem value="constraints">
<AccordionTrigger>Constraints</AccordionTrigger>
<AccordionContent>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@@ -531,28 +600,27 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<div className="space-y-2"> <div className="grid grid-cols-2 gap-2 pl-2">
<div className="flex flex-wrap gap-2">
{SIGNATURE_ALGORITHMS.map((alg) => { {SIGNATURE_ALGORITHMS.map((alg) => {
const isSelected = field.value?.includes(alg); const isSelected = field.value?.includes(alg);
return ( return (
<Button <div key={alg} className="flex items-center space-x-3">
key={alg} <Checkbox
type="button" id={`sig-alg-${alg}`}
size="xs" isChecked={isSelected}
variant={isSelected ? "solid" : "outline"} onCheckedChange={(checked) => {
colorSchema={isSelected ? "primary" : "gray"}
onClick={() => {
const current = field.value || []; const current = field.value || [];
let newValue; let newValue;
if (isSelected) { if (checked && !isSelected) {
newValue = [...current, alg];
} else if (!checked && isSelected) {
if (current.length > 1) { if (current.length > 1) {
newValue = current.filter((a) => a !== alg); newValue = current.filter((a) => a !== alg);
} else { } else {
return; return;
} }
} else { } else {
newValue = [...current, alg]; return;
} }
field.onChange(newValue); field.onChange(newValue);
@@ -566,13 +634,17 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
); );
} }
}} }}
/>
<label
htmlFor={`sig-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200"
> >
{alg} {alg}
</Button> </label>
</div>
); );
})} })}
</div> </div>
</div>
</FormControl> </FormControl>
)} )}
/> />
@@ -613,28 +685,27 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<div className="space-y-2"> <div className="grid grid-cols-2 gap-2 pl-2">
<div className="flex flex-wrap gap-2">
{KEY_ALGORITHMS.map((alg) => { {KEY_ALGORITHMS.map((alg) => {
const isSelected = field.value?.includes(alg); const isSelected = field.value?.includes(alg);
return ( return (
<Button <div key={alg} className="flex items-center space-x-3">
key={alg} <Checkbox
type="button" id={`key-alg-${alg}`}
size="xs" isChecked={isSelected}
variant={isSelected ? "solid" : "outline"} onCheckedChange={(checked) => {
colorSchema={isSelected ? "primary" : "gray"}
onClick={() => {
const current = field.value || []; const current = field.value || [];
let newValue; let newValue;
if (isSelected) { if (checked && !isSelected) {
newValue = [...current, alg];
} else if (!checked && isSelected) {
if (current.length > 1) { if (current.length > 1) {
newValue = current.filter((a) => a !== alg); newValue = current.filter((a) => a !== alg);
} else { } else {
return; return;
} }
} else { } else {
newValue = [...current, alg]; return;
} }
field.onChange(newValue); field.onChange(newValue);
@@ -643,13 +714,17 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
setValue("keyAlgorithm.defaultKeyType", newValue[0]); setValue("keyAlgorithm.defaultKeyType", newValue[0]);
} }
}} }}
/>
<label
htmlFor={`key-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200"
> >
{alg} {alg}
</Button> </label>
</div>
); );
})} })}
</div> </div>
</div>
</FormControl> </FormControl>
)} )}
/> />
@@ -681,14 +756,23 @@ export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
</div> </div>
</div> </div>
</div> </div>
)} </AccordionContent>
</div> </AccordionItem>
</Accordion>
<div className="flex gap-3"> <div className="flex gap-3">
<Button type="submit" colorSchema="primary" isLoading={createTemplate.isPending}> <Button
Create type="submit"
colorSchema="primary"
isLoading={isEdit ? updateTemplate.isPending : createTemplate.isPending}
>
{isEdit ? "Save Changes" : "Create"}
</Button> </Button>
<Button variant="outline_bg" onClick={onClose} disabled={createTemplate.isPending}> <Button
variant="outline_bg"
onClick={onClose}
disabled={isEdit ? updateTemplate.isPending : createTemplate.isPending}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -1,778 +0,0 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faPlus, 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 {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useUpdateCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/mutations";
import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
import { KeyUsagesSection } from "./shared";
const attributeSchema = z.object({
type: z.enum(["common_name"]),
include: z.enum(["mandatory", "optional", "prohibit"]),
value: z.array(z.string()).optional()
});
const sanSchema = z.object({
type: z.enum(["dns_name", "ip_address", "email", "uri"]),
include: z.enum(["mandatory", "optional", "prohibit"]),
value: z.array(z.string()).optional()
});
const schema = z.object({
name: z.string().trim().min(1, "Template name is required"),
description: z.string().optional(),
attributes: z.array(attributeSchema).optional(),
keyUsages: z
.object({
requiredUsages: z.array(z.string()),
optionalUsages: z.array(z.string())
})
.optional(),
extendedKeyUsages: z
.object({
requiredUsages: z.array(z.string()),
optionalUsages: z.array(z.string())
})
.optional(),
subjectAlternativeNames: z.array(sanSchema).optional(),
validity: z
.object({
maxDuration: z.object({
value: z.number().positive(),
unit: z.enum(["days", "months", "years"])
}),
minDuration: z
.object({
value: z.number().positive(),
unit: z.enum(["days", "months", "years"])
})
.optional()
})
.optional(),
signatureAlgorithm: z
.object({
allowedAlgorithms: z.array(z.string()).min(1),
defaultAlgorithm: z.string()
})
.optional(),
keyAlgorithm: z
.object({
allowedKeyTypes: z.array(z.string()).min(1),
defaultKeyType: z.string()
})
.optional()
});
export type FormData = z.infer<typeof schema>;
interface Props {
isOpen: boolean;
onClose: () => void;
template: TCertificateTemplateV2New;
}
const ATTRIBUTE_TYPES = [
{ value: "common_name", label: "Common Name (CN)" }
];
const SAN_TYPES = [
{ value: "dns_name", label: "DNS Name" },
{ value: "ip_address", label: "IP Address" },
{ value: "email", label: "Email" },
{ value: "uri", label: "URI" }
];
const INCLUDE_TYPES = [
{ value: "mandatory", label: "Mandatory", color: "red" },
{ value: "optional", label: "Optional", color: "blue" },
{ value: "prohibit", label: "Prohibited", color: "gray" }
];
const SIGNATURE_ALGORITHMS = [
"SHA256-RSA",
"SHA384-RSA",
"SHA512-RSA",
"SHA256-ECDSA",
"SHA384-ECDSA",
"SHA512-ECDSA"
];
const KEY_ALGORITHMS = [
"RSA-2048",
"RSA-3072",
"RSA-4096",
"ECDSA-P256",
"ECDSA-P384",
"ECDSA-P521"
];
export const EditTemplateModal = ({ isOpen, onClose, template }: Props) => {
const updateTemplate = useUpdateCertificateTemplateV2New();
const [activeTab, setActiveTab] = useState<string>("basic");
const getFormDefaultValues = () => {
if (!template) {
return {
name: "",
description: "",
attributes: [],
keyUsages: { requiredUsages: [], optionalUsages: [] },
extendedKeyUsages: { requiredUsages: [], optionalUsages: [] },
subjectAlternativeNames: [],
validity: { maxDuration: { value: 365, unit: "days" as const } },
signatureAlgorithm: { allowedAlgorithms: ["SHA256-RSA"], defaultAlgorithm: "SHA256-RSA" },
keyAlgorithm: { allowedKeyTypes: ["RSA-2048"], defaultKeyType: "RSA-2048" }
};
}
const backendKeyUsages = template.keyUsages || {
requiredUsages: { all: [] },
optionalUsages: { all: [] }
};
const backendExtendedKeyUsages = template.extendedKeyUsages || {
requiredUsages: { all: [] },
optionalUsages: { all: [] }
};
return {
name: template.name,
description: template.description || "",
attributes: template.attributes || [],
keyUsages: {
requiredUsages: backendKeyUsages.requiredUsages?.all || [],
optionalUsages: backendKeyUsages.optionalUsages?.all || []
},
extendedKeyUsages: {
requiredUsages: backendExtendedKeyUsages.requiredUsages?.all || [],
optionalUsages: backendExtendedKeyUsages.optionalUsages?.all || []
},
subjectAlternativeNames: template.subjectAlternativeNames || [],
validity: template.validity || { maxDuration: { value: 365, unit: "days" as const } },
signatureAlgorithm: template.signatureAlgorithm || {
allowedAlgorithms: ["SHA256-RSA"],
defaultAlgorithm: "SHA256-RSA"
},
keyAlgorithm: template.keyAlgorithm || {
allowedKeyTypes: ["RSA-2048"],
defaultKeyType: "RSA-2048"
}
};
};
const { control, handleSubmit, reset, watch, setValue } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: getFormDefaultValues()
});
const watchedAttributes = watch("attributes") || [];
const watchedSans = watch("subjectAlternativeNames") || [];
const watchedKeyUsages = watch("keyUsages");
const watchedExtendedKeyUsages = watch("extendedKeyUsages");
useEffect(() => {
if (template) {
reset(getFormDefaultValues());
}
}, [template, reset]);
const onFormSubmit = async (data: FormData) => {
try {
const templateData = {
templateId: template.id,
name: data.name,
description: data.description,
attributes: data.attributes || [],
keyUsages: {
requiredUsages: { all: data.keyUsages?.requiredUsages || [] },
optionalUsages: { all: data.keyUsages?.optionalUsages || [] }
},
extendedKeyUsages: {
requiredUsages: { all: data.extendedKeyUsages?.requiredUsages || [] },
optionalUsages: { all: data.extendedKeyUsages?.optionalUsages || [] }
},
subjectAlternativeNames: data.subjectAlternativeNames || [],
validity: data.validity || {
maxDuration: { value: 365, unit: "days" as const }
},
signatureAlgorithm: data.signatureAlgorithm || {
allowedAlgorithms: ["SHA256-RSA"],
defaultAlgorithm: "SHA256-RSA"
},
keyAlgorithm: data.keyAlgorithm || {
allowedKeyTypes: ["RSA-2048"],
defaultKeyType: "RSA-2048"
}
};
await updateTemplate.mutateAsync(templateData);
createNotification({
text: "Certificate template updated successfully",
type: "success"
});
onClose();
} catch (error) {
console.error("Error updating template:", error);
createNotification({
text: "Failed to update certificate template",
type: "error"
});
}
};
const addAttribute = () => {
const newAttribute = {
type: "common_name" as const,
include: "optional" as const,
value: []
};
setValue("attributes", [...watchedAttributes, newAttribute]);
};
const removeAttribute = (index: number) => {
const newAttributes = watchedAttributes.filter((_, i) => i !== index);
setValue("attributes", newAttributes);
};
const addSan = () => {
const newSan = {
type: "dns_name" as const,
include: "optional" as const,
value: []
};
setValue("subjectAlternativeNames", [...watchedSans, newSan]);
};
const removeSan = (index: number) => {
const newSans = watchedSans.filter((_, i) => i !== index);
setValue("subjectAlternativeNames", newSans);
};
const toggleKeyUsage = (usage: string, type: "required" | "optional") => {
const current = watchedKeyUsages || { requiredUsages: [], optionalUsages: [] };
const otherType = type === "required" ? "optional" : "required";
const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
const otherList = Array.isArray(current[`${otherType}Usages`])
? current[`${otherType}Usages`]
: [];
const newOtherList = otherList.filter((u) => u !== usage);
const newCurrentList = currentList.includes(usage)
? currentList.filter((u) => u !== usage)
: [...currentList, usage];
setValue("keyUsages", {
[`${type}Usages`]: newCurrentList,
[`${otherType}Usages`]: newOtherList
} as any);
};
const toggleExtendedKeyUsage = (usage: string, type: "required" | "optional") => {
const current = watchedExtendedKeyUsages || { requiredUsages: [], optionalUsages: [] };
const otherType = type === "required" ? "optional" : "required";
const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
const otherList = Array.isArray(current[`${otherType}Usages`])
? current[`${otherType}Usages`]
: [];
const newOtherList = otherList.filter((u) => u !== usage);
const newCurrentList = currentList.includes(usage)
? currentList.filter((u) => u !== usage)
: [...currentList, usage];
setValue("extendedKeyUsages", {
[`${type}Usages`]: newCurrentList,
[`${otherType}Usages`]: newOtherList
} as any);
};
const tabs = [
{ id: "basic", label: "Basic Info" },
{ id: "attributes", label: "Subject Attributes" },
{ id: "san", label: "Subject Alternative Names" },
{ id: "usages", label: "Key Usages" },
{ id: "constraints", label: "Constraints" }
];
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) {
reset();
}
onClose();
}}
>
<ModalContent
className="max-w-4xl"
title="Edit Certificate Template"
subTitle={`Update configuration for ${template?.name}`}
>
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
{/* Tab Navigation */}
<div className="flex border-b border-mineshaft-600">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? "border-primary-500 text-primary-400"
: "border-transparent text-bunker-300 hover:text-mineshaft-200"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div className="max-h-80 overflow-y-auto">
{activeTab === "basic" && (
<div className="space-y-4">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Template Name"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Enter template name" className="w-full" />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description"
isError={Boolean(error)}
errorText={error?.message}
>
<TextArea {...field} placeholder="Enter template description" rows={3} />
</FormControl>
)}
/>
</div>
)}
{activeTab === "attributes" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button
type="button"
onClick={addAttribute}
size="sm"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Attribute
</Button>
</div>
<div className="space-y-3">
{watchedAttributes.length === 0 ? (
<div className="py-8 text-center text-bunker-300">
No subject attributes configured yet. Click &quot;Add Attribute&quot; to get
started.
</div>
) : (
watchedAttributes.map((attr, index) => (
<div
key={`attr-${attr.type}`}
className="flex items-center gap-3 rounded border border-mineshaft-600 p-3"
>
<Select
value={attr.type}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = { ...attr, type: value as any };
setValue("attributes", newAttributes);
}}
className="w-56"
>
{ATTRIBUTE_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</Select>
<Select
value={attr.include}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = { ...attr, include: value as any };
setValue("attributes", newAttributes);
}}
className="w-36"
>
{INCLUDE_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (optional)"
value={attr.value?.[0] || ""}
onChange={(e) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
value: e.target.value ? [e.target.value] : []
};
setValue("attributes", newAttributes);
}}
className="flex-1"
/>
<Button
type="button"
onClick={() => removeAttribute(index)}
variant="outline"
size="sm"
colorSchema="danger"
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</div>
))
)}
</div>
</div>
)}
{activeTab === "san" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button
type="button"
onClick={addSan}
size="sm"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add SAN
</Button>
</div>
<div className="space-y-3">
{watchedSans.length === 0 ? (
<div className="py-8 text-center text-bunker-300">
No subject alternative names configured yet. Click &quot;Add SAN&quot; to get
started.
</div>
) : (
watchedSans.map((san, index) => (
<div
key={`san-${san.type}`}
className="flex items-center gap-3 rounded border border-mineshaft-600 p-3"
>
<Select
value={san.type}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = { ...san, type: value as any };
setValue("subjectAlternativeNames", newSans);
}}
className="w-36"
>
{SAN_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</Select>
<Select
value={san.include}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = { ...san, include: value as any };
setValue("subjectAlternativeNames", newSans);
}}
className="w-36"
>
{INCLUDE_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (optional)"
value={san.value?.[0] || ""}
onChange={(e) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
value: e.target.value ? [e.target.value] : []
};
setValue("subjectAlternativeNames", newSans);
}}
className="flex-1"
/>
<Button
type="button"
onClick={() => removeSan(index)}
variant="outline"
size="sm"
colorSchema="danger"
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</div>
))
)}
</div>
</div>
)}
{activeTab === "usages" && (
<KeyUsagesSection
watchedKeyUsages={watchedKeyUsages}
watchedExtendedKeyUsages={watchedExtendedKeyUsages}
toggleKeyUsage={toggleKeyUsage}
toggleExtendedKeyUsage={toggleExtendedKeyUsage}
/>
)}
{activeTab === "constraints" && (
<div className="space-y-4">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="validity.maxDuration.value"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Max Duration"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} type="number" placeholder="365" className="w-full" />
</FormControl>
)}
/>
<Controller
control={control}
name="validity.maxDuration.unit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Unit"
isError={Boolean(error)}
errorText={error?.message}
>
<Select {...field} onValueChange={field.onChange} className="w-full">
<SelectItem value="days">Days</SelectItem>
<SelectItem value="months">Months</SelectItem>
<SelectItem value="years">Years</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
</div>
<div className="space-y-3">
<div className="space-y-4">
<Controller
control={control}
name="signatureAlgorithm.allowedAlgorithms"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Signature Algorithms"
isError={Boolean(error)}
errorText={error?.message}
>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{SIGNATURE_ALGORITHMS.map((alg) => {
const isSelected = field.value?.includes(alg);
return (
<Button
key={alg}
type="button"
size="xs"
variant={isSelected ? "solid" : "outline"}
colorSchema={isSelected ? "primary" : "gray"}
onClick={() => {
const current = field.value || [];
let newValue;
if (isSelected) {
if (current.length > 1) {
newValue = current.filter((a) => a !== alg);
} else {
return;
}
} else {
newValue = [...current, alg];
}
field.onChange(newValue);
const currentDefault = watch(
"signatureAlgorithm.defaultAlgorithm"
);
if (!newValue.includes(currentDefault)) {
setValue(
"signatureAlgorithm.defaultAlgorithm",
newValue[0]
);
}
}}
>
{alg}
</Button>
);
})}
</div>
</div>
</FormControl>
)}
/>
<Controller
control={control}
name="signatureAlgorithm.defaultAlgorithm"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Default Signature Algorithm"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
value={field.value || ""}
onValueChange={field.onChange}
className="w-full"
>
{(watch("signatureAlgorithm.allowedAlgorithms") || []).map(
(alg: string) => (
<SelectItem key={alg} value={alg}>
{alg}
</SelectItem>
)
)}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="keyAlgorithm.allowedKeyTypes"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Key Algorithms"
isError={Boolean(error)}
errorText={error?.message}
>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{KEY_ALGORITHMS.map((alg) => {
const isSelected = field.value?.includes(alg);
return (
<Button
key={alg}
type="button"
size="xs"
variant={isSelected ? "solid" : "outline"}
colorSchema={isSelected ? "primary" : "gray"}
onClick={() => {
const current = field.value || [];
let newValue;
if (isSelected) {
if (current.length > 1) {
newValue = current.filter((a) => a !== alg);
} else {
return;
}
} else {
newValue = [...current, alg];
}
field.onChange(newValue);
const currentDefault = watch("keyAlgorithm.defaultKeyType");
if (!newValue.includes(currentDefault)) {
setValue("keyAlgorithm.defaultKeyType", newValue[0]);
}
}}
>
{alg}
</Button>
);
})}
</div>
</div>
</FormControl>
)}
/>
<Controller
control={control}
name="keyAlgorithm.defaultKeyType"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Default Key Algorithm"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
value={field.value || ""}
onValueChange={field.onChange}
className="w-full"
>
{(watch("keyAlgorithm.allowedKeyTypes") || []).map((alg: string) => (
<SelectItem key={alg} value={alg}>
{alg}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
</div>
</div>
)}
</div>
<div className="flex gap-3">
<Button type="submit" colorSchema="primary" isLoading={updateTemplate.isPending}>
Save Changes
</Button>
<Button variant="outline_bg" onClick={onClose} disabled={updateTemplate.isPending}>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -82,7 +82,7 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
> >
<Td> <Td>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="font-medium">{template.name}</div> <div className="font-medium">{template.slug}</div>
{template.description && ( {template.description && (
<Tooltip content={template.description}> <Tooltip content={template.description}>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" /> <FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
@@ -1,4 +1,3 @@
export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab"; export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
export { CreateTemplateModal } from "./CreateTemplateModal"; export { CreateTemplateModal } from "./CreateTemplateModal";
export { EditTemplateModal } from "./EditTemplateModal";
export { TemplateList } from "./TemplateList"; export { TemplateList } from "./TemplateList";
@@ -15,7 +15,7 @@ export const sanSchema = z.object({
}); });
export const templateSchema = z.object({ export const templateSchema = z.object({
name: z.string().trim().min(1, "Template name is required"), slug: z.string().trim().min(1, "Template name is required"),
description: z.string().optional(), description: z.string().optional(),
attributes: z.array(attributeSchema).optional(), attributes: z.array(attributeSchema).optional(),
keyUsages: z keyUsages: z