diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts
index f139ff2c1..d303859bb 100644
--- a/backend/src/ee/services/license/__mocks__/license-fns.ts
+++ b/backend/src/ee/services/license/__mocks__/license-fns.ts
@@ -33,7 +33,8 @@ export const getDefaultOnPremFeatures = () => {
enterpriseSecretSyncs: false,
enterpriseCertificateSyncs: false,
enterpriseAppConnections: true,
- machineIdentityAuthTemplates: false
+ machineIdentityAuthTemplates: false,
+ pkiLegacyTemplates: false
};
};
diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts
index 2a3cf82cc..aba2c5e78 100644
--- a/backend/src/ee/services/license/license-fns.ts
+++ b/backend/src/ee/services/license/license-fns.ts
@@ -67,6 +67,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
fips: false,
eventSubscriptions: false,
machineIdentityAuthTemplates: false,
+ pkiLegacyTemplates: false,
pam: false
});
diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts
index 9cdcfcc3d..2276dcf36 100644
--- a/backend/src/ee/services/license/license-types.ts
+++ b/backend/src/ee/services/license/license-types.ts
@@ -78,6 +78,7 @@ export type TFeatureSet = {
enterpriseCertificateSyncs: false;
enterpriseAppConnections: false;
machineIdentityAuthTemplates: false;
+ pkiLegacyTemplates: false;
fips: false;
eventSubscriptions: false;
pam: false;
diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts
index 5afbce96f..98e8c30a6 100644
--- a/backend/src/server/routes/v1/certificate-profiles-router.ts
+++ b/backend/src/server/routes/v1/certificate-profiles-router.ts
@@ -69,10 +69,24 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
- querystring: listCertificateProfilesSchema,
+ querystring: listCertificateProfilesSchema.extend({
+ includeMetrics: z.coerce.boolean().optional().default(false),
+ expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
+ }),
response: {
200: z.object({
- certificateProfiles: CertificateProfilesSchema.array(),
+ certificateProfiles: CertificateProfilesSchema.extend({
+ metrics: z
+ .object({
+ profileId: z.string(),
+ totalCertificates: z.number(),
+ activeCertificates: z.number(),
+ expiredCertificates: z.number(),
+ expiringCertificates: z.number(),
+ revokedCertificates: z.number()
+ })
+ .optional()
+ }).array(),
totalCount: z.number()
})
}
@@ -112,6 +126,10 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
params: getCertificateProfileByIdSchema,
+ querystring: z.object({
+ includeMetrics: z.coerce.boolean().optional().default(false),
+ expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
+ }),
response: {
200: z.object({
certificateProfile: CertificateProfilesSchema.extend({
@@ -145,6 +163,16 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean(),
autoRenewDays: z.number().optional()
})
+ .optional(),
+ metrics: z
+ .object({
+ profileId: z.string(),
+ totalCertificates: z.number(),
+ activeCertificates: z.number(),
+ expiredCertificates: z.number(),
+ expiringCertificates: z.number(),
+ revokedCertificates: z.number()
+ })
.optional()
})
})
@@ -160,6 +188,20 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
profileId: req.params.id
});
+ let result = certificateProfile;
+
+ if (req.query.includeMetrics) {
+ const metrics = await server.services.certificateProfile.getProfileMetrics({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ profileId: req.params.id,
+ expiringDays: req.query.expiringDays
+ });
+ result = { ...certificateProfile, metrics };
+ }
+
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: certificateProfile.projectId,
@@ -171,7 +213,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
}
});
- return { certificateProfile };
+ return { certificateProfile: result };
}
});
@@ -322,7 +364,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
status: z.string(),
notBefore: z.date(),
notAfter: z.date(),
- isRevoked: z.boolean(),
+ revokedAt: z.date().nullable().optional(),
createdAt: z.date()
})
)
@@ -343,45 +385,4 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
return { certificates };
}
});
-
- server.route({
- method: "GET",
- url: "/:id/metrics",
- config: {
- rateLimit: readLimit
- },
- schema: {
- hide: false,
- tags: [ApiDocsTags.PkiCertificateProfiles],
- params: getCertificateProfileByIdSchema,
- querystring: z.object({
- expiringDays: z.number().min(1).max(365).default(30)
- }),
- response: {
- 200: z.object({
- metrics: z.object({
- profileId: z.string(),
- totalCertificates: z.number(),
- activeCertificates: z.number(),
- expiredCertificates: z.number(),
- expiringCertificates: z.number(),
- revokedCertificates: z.number()
- })
- })
- }
- },
- onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
- handler: async (req) => {
- const metrics = await server.services.certificateProfile.getProfileMetrics({
- actor: req.permission.type,
- actorId: req.permission.id,
- actorAuthMethod: req.permission.authMethod,
- actorOrgId: req.permission.orgId,
- profileId: req.params.id,
- expiringDays: req.query.expiringDays
- });
-
- return { metrics };
- }
- });
};
diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts
index eb85002ab..7aa19f900 100644
--- a/backend/src/server/routes/v3/certificates-router.ts
+++ b/backend/src/server/routes/v3/certificates-router.ts
@@ -33,14 +33,6 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(),
altNames: validateAltNamesField.optional(),
- organization: z.string().optional(),
- organizationUnit: z.string().optional(),
- locality: z.string().optional(),
- state: z.string().optional(),
- country: z.string().length(2).optional(),
- email: z.string().email().optional(),
- streetAddress: z.string().optional(),
- postalCode: z.string().optional(),
signatureAlgorithm: z.string().optional(),
keyAlgorithm: z.string().optional()
}),
@@ -65,14 +57,6 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
profileId: req.body.profileId,
certificateRequest: {
commonName: req.body.commonName,
- organization: req.body.organization,
- organizationUnit: req.body.organizationUnit,
- locality: req.body.locality,
- state: req.body.state,
- country: req.body.country,
- email: req.body.email,
- streetAddress: req.body.streetAddress,
- postalCode: req.body.postalCode,
keyUsages: req.body.keyUsages,
extendedKeyUsages: req.body.extendedKeyUsages,
subjectAlternativeNames: req.body.altNames
@@ -221,14 +205,6 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(),
commonName: validateTemplateRegexField.optional(),
- organization: z.string().optional(),
- organizationUnit: z.string().optional(),
- locality: z.string().optional(),
- state: z.string().optional(),
- country: z.string().length(2).optional(),
- email: z.string().email().optional(),
- streetAddress: z.string().optional(),
- postalCode: z.string().optional(),
signatureAlgorithm: z.string().optional(),
keyAlgorithm: z.string().optional()
}),
@@ -280,14 +256,6 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
ttl: req.body.ttl
},
commonName: req.body.commonName,
- organization: req.body.organization,
- organizationUnit: req.body.organizationUnit,
- locality: req.body.locality,
- state: req.body.state,
- country: req.body.country,
- email: req.body.email,
- streetAddress: req.body.streetAddress,
- postalCode: req.body.postalCode,
keyUsages: req.body.keyUsages,
extendedKeyUsages: req.body.extendedKeyUsages,
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts
index 6532f8588..2f692fba5 100644
--- a/backend/src/services/certificate-profile/certificate-profile-dal.ts
+++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts
@@ -127,11 +127,21 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
search?: string;
enrollmentType?: EnrollmentType;
caId?: string;
+ includeMetrics?: boolean;
+ expiringDays?: number;
} = {},
tx?: Knex
) => {
try {
- const { offset = 0, limit = 20, search, enrollmentType, caId } = options;
+ const {
+ offset = 0,
+ limit = 20,
+ search,
+ enrollmentType,
+ caId,
+ includeMetrics = false,
+ expiringDays = 7
+ } = options;
let query = (tx || db)(TableName.CertificateProfile).where(
`${TableName.CertificateProfile}.projectId`,
@@ -155,6 +165,38 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
query = query.where(`${TableName.CertificateProfile}.caId`, caId);
}
+ if (includeMetrics) {
+ const now = new Date();
+ const expiringDate = new Date();
+ expiringDate.setDate(now.getDate() + expiringDays);
+
+ const certificateProfiles = await query
+ .leftJoin(TableName.Certificate, `${TableName.CertificateProfile}.id`, `${TableName.Certificate}.profileId`)
+ .select(
+ selectAllTableCols(TableName.CertificateProfile),
+ db.raw("COUNT(certificates.id) as total_certificates"),
+ db.raw(
+ 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates',
+ [expiringDate]
+ ),
+ db.raw(
+ 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" <= ? THEN 1 END) as expired_certificates',
+ [now]
+ ),
+ db.raw(
+ 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? AND certificates."notAfter" <= ? THEN 1 END) as expiring_certificates',
+ [now, expiringDate]
+ ),
+ db.raw('COUNT(CASE WHEN certificates."revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates')
+ )
+ .groupBy(`${TableName.CertificateProfile}.id`)
+ .orderBy(`${TableName.CertificateProfile}.createdAt`, "desc")
+ .offset(offset)
+ .limit(limit);
+
+ return certificateProfiles;
+ }
+
const certificateProfiles = await query
.select(selectAllTableCols(TableName.CertificateProfile))
.orderBy(`${TableName.CertificateProfile}.createdAt`, "desc")
@@ -239,13 +281,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
if (status) {
switch (status) {
case "active":
- query = query.where("notAfter", ">", now).where("isRevoked", false);
+ query = query.where("notAfter", ">", now).whereNull("revokedAt");
break;
case "expired":
- query = query.where("notAfter", "<=", now).where("isRevoked", false);
+ query = query.where("notAfter", "<=", now).whereNull("revokedAt");
break;
case "revoked":
- query = query.where("isRevoked", true);
+ query = query.whereNotNull("revokedAt");
break;
default:
break;
@@ -259,7 +301,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
.select((tx || db).ref("status").withSchema(TableName.Certificate))
.select((tx || db).ref("notBefore").withSchema(TableName.Certificate))
.select((tx || db).ref("notAfter").withSchema(TableName.Certificate))
- .select((tx || db).ref("isRevoked").withSchema(TableName.Certificate))
+ .select((tx || db).ref("revokedAt").withSchema(TableName.Certificate))
.select((tx || db).ref("createdAt").withSchema(TableName.Certificate))
.orderBy("createdAt", "desc")
.offset(offset)
@@ -273,7 +315,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const getProfileMetrics = async (
profileId: string,
- expiringDays: number = 30,
+ expiringDays: number = 7,
tx?: Knex
): Promise => {
try {
@@ -285,13 +327,15 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
.where("profileId", profileId)
.select(
db.raw("COUNT(*) as total_certificates"),
- db.raw("COUNT(CASE WHEN NOT is_revoked AND not_after > ? THEN 1 END) as active_certificates", [now]),
- db.raw("COUNT(CASE WHEN NOT is_revoked AND not_after <= ? THEN 1 END) as expired_certificates", [now]),
+ db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? THEN 1 END) as active_certificates', [
+ expiringDate
+ ]),
+ db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" <= ? THEN 1 END) as expired_certificates', [now]),
db.raw(
- "COUNT(CASE WHEN NOT is_revoked AND not_after > ? AND not_after <= ? THEN 1 END) as expiring_certificates",
+ 'COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? AND "notAfter" <= ? THEN 1 END) as expiring_certificates',
[now, expiringDate]
),
- db.raw("COUNT(CASE WHEN is_revoked THEN 1 END) as revoked_certificates")
+ db.raw('COUNT(CASE WHEN "revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates')
)
.first();
diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts
index 42bdc0a2e..0f527bf8f 100644
--- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts
+++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts
@@ -466,7 +466,9 @@ describe("CertificateProfileService", () => {
limit: 20,
search: undefined,
enrollmentType: undefined,
- caId: undefined
+ caId: undefined,
+ includeMetrics: false,
+ expiringDays: 30
});
});
@@ -486,7 +488,51 @@ describe("CertificateProfileService", () => {
limit: 5,
search: "test",
enrollmentType: EnrollmentType.API,
- caId: "ca-123"
+ caId: "ca-123",
+ includeMetrics: false,
+ expiringDays: 30
+ });
+ });
+
+ it("should list profiles with metrics when includeMetrics is true", async () => {
+ const mockProfilesWithMetrics = [
+ {
+ ...sampleProfile,
+ total_certificates: 10,
+ active_certificates: 8,
+ expired_certificates: 1,
+ expiring_certificates: 1,
+ revoked_certificates: 0
+ }
+ ];
+ (mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfilesWithMetrics);
+
+ const result = await service.listProfiles({
+ ...mockActor,
+ projectId: "project-123",
+ includeMetrics: true,
+ expiringDays: 15
+ });
+
+ expect(result.profiles).toHaveLength(1);
+ expect(result.profiles[0]).toHaveProperty("metrics");
+ expect(result.profiles[0].metrics).toEqual({
+ profileId: sampleProfile.id,
+ totalCertificates: 10,
+ activeCertificates: 8,
+ expiredCertificates: 1,
+ expiringCertificates: 1,
+ revokedCertificates: 0
+ });
+
+ expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", {
+ offset: 0,
+ limit: 20,
+ search: undefined,
+ enrollmentType: undefined,
+ caId: undefined,
+ includeMetrics: true,
+ expiringDays: 15
});
});
});
diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts
index f51929865..45b073db0 100644
--- a/backend/src/services/certificate-profile/certificate-profile-service.ts
+++ b/backend/src/services/certificate-profile/certificate-profile-service.ts
@@ -23,7 +23,8 @@ import {
TCertificateProfileInsert,
TCertificateProfileMetrics,
TCertificateProfileUpdate,
- TCertificateProfileWithConfigs
+ TCertificateProfileWithConfigs,
+ TCertificateProfileWithRawMetrics
} from "./certificate-profile-types";
export type TCertificateProfileCreateData = Omit & {
@@ -48,10 +49,6 @@ const convertDalToService = (dalResult: Record): TCertificatePr
} as TCertificateProfile;
};
-const convertDalArrayToService = (dalResults: Record[]): TCertificateProfile[] => {
- return dalResults.map(convertDalToService);
-};
-
const validateEnrollmentConfig = async (data: {
enrollmentType: EnrollmentType;
estConfig?: TEstConfigData | null;
@@ -289,14 +286,18 @@ export const certificateProfileServiceFactory = ({
actorId,
actorAuthMethod,
actorOrgId,
- profileId
+ profileId,
+ includeMetrics = false,
+ expiringDays = 30
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
profileId: string;
- }): Promise => {
+ includeMetrics?: boolean;
+ expiringDays?: number;
+ }): Promise => {
const profile = await certificateProfileDAL.findById(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
@@ -315,7 +316,17 @@ export const certificateProfileServiceFactory = ({
ProjectPermissionSub.CertificateProfiles
);
- return convertDalToService(profile);
+ const converted = convertDalToService(profile);
+
+ if (includeMetrics) {
+ const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays);
+ return {
+ ...converted,
+ metrics
+ };
+ }
+
+ return converted;
};
const getProfileByIdWithConfigs = async ({
@@ -401,7 +412,9 @@ export const certificateProfileServiceFactory = ({
limit = 20,
search,
enrollmentType,
- caId
+ caId,
+ includeMetrics = false,
+ expiringDays = 30
}: {
actor: ActorType;
actorId: string;
@@ -413,8 +426,10 @@ export const certificateProfileServiceFactory = ({
search?: string;
enrollmentType?: EnrollmentType;
caId?: string;
+ includeMetrics?: boolean;
+ expiringDays?: number;
}): Promise<{
- profiles: TCertificateProfile[];
+ profiles: (TCertificateProfile & { metrics?: TCertificateProfileMetrics })[];
totalCount: number;
}> => {
const { permission } = await permissionService.getProjectPermission({
@@ -435,7 +450,9 @@ export const certificateProfileServiceFactory = ({
limit,
search,
enrollmentType,
- caId
+ caId,
+ includeMetrics,
+ expiringDays
});
const totalCount = await certificateProfileDAL.countByProjectId(projectId, {
@@ -444,8 +461,27 @@ export const certificateProfileServiceFactory = ({
caId
});
+ const convertedProfiles = profiles.map((profile) => {
+ const converted = convertDalToService(profile);
+ if (includeMetrics) {
+ const profileWithMetrics = profile as TCertificateProfileWithRawMetrics;
+ return {
+ ...converted,
+ metrics: {
+ profileId: converted.id,
+ totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10),
+ activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10),
+ expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10),
+ expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10),
+ revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10)
+ }
+ };
+ }
+ return converted;
+ });
+
return {
- profiles: convertDalArrayToService(profiles),
+ profiles: convertedProfiles,
totalCount
};
};
diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts
index 4eb8c8b90..2cf70878c 100644
--- a/backend/src/services/certificate-profile/certificate-profile-types.ts
+++ b/backend/src/services/certificate-profile/certificate-profile-types.ts
@@ -45,6 +45,7 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
autoRenew: boolean;
autoRenewDays?: number;
};
+ metrics?: TCertificateProfileMetrics;
};
export interface TCertificateProfileMetrics {
@@ -63,6 +64,14 @@ export interface TCertificateProfileCertificate {
status: string;
notBefore: Date;
notAfter: Date;
- isRevoked: boolean;
+ revokedAt: Date | null | undefined;
createdAt: Date;
}
+
+export type TCertificateProfileWithRawMetrics = TCertificateProfile & {
+ total_certificates?: string;
+ active_certificates?: string;
+ expired_certificates?: string;
+ expiring_certificates?: string;
+ revoked_certificates?: string;
+};
diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts
index e31e798eb..068f58dcb 100644
--- a/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts
+++ b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts
@@ -1,16 +1,6 @@
import { z } from "zod";
-const attributeTypeSchema = z.enum([
- "common_name",
- "organization_name",
- "organization_unit",
- "locality",
- "state",
- "country",
- "email",
- "street_address",
- "postal_code"
-]);
+const attributeTypeSchema = z.enum(["common_name"]);
const includeTypeSchema = z.enum(["mandatory", "optional", "prohibit"]);
@@ -137,14 +127,6 @@ export const deleteCertificateTemplateV2Schema = z.object({
export const certificateRequestSchema = z.object({
commonName: z.string().optional(),
- organization: z.string().optional(),
- organizationUnit: z.string().optional(),
- locality: z.string().optional(),
- state: z.string().optional(),
- country: z.string().length(2).optional(),
- email: z.string().email().optional(),
- streetAddress: z.string().optional(),
- postalCode: z.string().optional(),
keyUsages: z.array(z.string()).optional(),
extendedKeyUsages: z.array(z.string()).optional(),
subjectAlternativeNames: z
diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts
index f4b93e5a2..cb3d7614f 100644
--- a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts
+++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts
@@ -60,14 +60,6 @@ describe("CertificateTemplateV2Service", () => {
type: "common_name",
include: "mandatory",
value: ["example.com"]
- },
- {
- type: "organization_name",
- include: "optional"
- },
- {
- type: "country",
- include: "prohibit"
}
],
keyUsages: {
@@ -416,7 +408,6 @@ describe("CertificateTemplateV2Service", () => {
describe("validateCertificateRequest", () => {
const validRequest: TCertificateRequest = {
commonName: "example.com",
- organization: "Example Corp",
keyUsages: ["digital_signature", "key_encipherment"],
extendedKeyUsages: ["server_auth"],
subjectAlternativeNames: [
@@ -458,15 +449,6 @@ describe("CertificateTemplateV2Service", () => {
expect(result.errors).toContain("common_name is mandatory but not provided in request");
});
- it("should detect prohibited attributes", async () => {
- const invalidRequest = { ...validRequest, country: "US" };
-
- const result = await service.validateCertificateRequest("template-123", invalidRequest);
-
- expect(result.isValid).toBe(false);
- expect(result.errors).toContain("country is prohibited by template policy");
- });
-
it("should validate attribute values against allowed list", async () => {
const invalidRequest = { ...validRequest, commonName: "forbidden.com" };
@@ -614,14 +596,6 @@ describe("CertificateTemplateV2Service", () => {
}
});
- it("should allow optional attributes when not provided", async () => {
- const requestWithoutOrg = { ...validRequest, organization: undefined };
-
- const result = await service.validateCertificateRequest("template-123", requestWithoutOrg);
-
- expect(result.isValid).toBe(true);
- });
-
it("should allow optional key usages and extended key usages", async () => {
const requestWithOptionalUsages = {
...validRequest,
@@ -805,21 +779,14 @@ describe("CertificateTemplateV2Service", () => {
expect(result.isValid).toBe(true);
});
- it("should handle template with all fields prohibited", async () => {
+ it("should handle template with SAN fields prohibited", async () => {
const prohibitTemplate = {
...sampleTemplate,
attributes: [
{
- type: "organization_name",
- include: "prohibit" as const
- },
- {
- type: "locality",
- include: "prohibit" as const
- },
- {
- type: "country",
- include: "prohibit" as const
+ type: "common_name",
+ include: "mandatory" as const,
+ value: ["example.com"]
}
],
keyUsages: {
@@ -845,9 +812,6 @@ describe("CertificateTemplateV2Service", () => {
const requestWithProhibited = {
commonName: "example.com",
- organization: "Test Org",
- locality: "Test City",
- country: "US",
keyUsages: ["digital_signature"],
extendedKeyUsages: ["server_auth"],
subjectAlternativeNames: [
@@ -859,9 +823,6 @@ describe("CertificateTemplateV2Service", () => {
const result = await service.validateCertificateRequest("template-123", requestWithProhibited);
expect(result.isValid).toBe(false);
- expect(result.errors).toContain("organization_name is prohibited by template policy");
- expect(result.errors).toContain("locality is prohibited by template policy");
- expect(result.errors).toContain("country is prohibited by template policy");
expect(result.errors).toContain("email SAN is prohibited by template policy");
expect(result.errors).toContain("uri SAN is prohibited by template policy");
});
@@ -874,16 +835,6 @@ describe("CertificateTemplateV2Service", () => {
type: "common_name",
include: "mandatory" as const,
value: ["example.com", "test.com"]
- },
- {
- type: "organization_name",
- include: "optional" as const,
- value: ["Example Corp", "Test Corp"]
- },
- {
- type: "country",
- include: "mandatory" as const,
- value: ["US", "CA"]
}
],
subjectAlternativeNames: [
@@ -898,8 +849,6 @@ describe("CertificateTemplateV2Service", () => {
const validConstrainedRequest = {
commonName: "example.com",
- organization: "Example Corp",
- country: "US",
keyUsages: ["digital_signature", "key_encipherment"],
extendedKeyUsages: ["server_auth"],
validity: { ttl: "30d" }
@@ -910,8 +859,6 @@ describe("CertificateTemplateV2Service", () => {
const invalidConstrainedRequest = {
commonName: "forbidden.com",
- organization: "Forbidden Corp",
- country: "FR",
keyUsages: ["digital_signature", "key_encipherment"],
extendedKeyUsages: ["server_auth"],
validity: { ttl: "30d" }
@@ -920,10 +867,6 @@ describe("CertificateTemplateV2Service", () => {
const invalidResult = await service.validateCertificateRequest("template-123", invalidConstrainedRequest);
expect(invalidResult.isValid).toBe(false);
expect(invalidResult.errors).toContain("common_name value 'forbidden.com' is not in allowed values list");
- expect(invalidResult.errors).toContain(
- "organization_name value 'Forbidden Corp' does not match allowed patterns: Example Corp, Test Corp"
- );
- expect(invalidResult.errors).toContain("country value 'FR' is not in allowed values list");
});
it("should validate SAN value constraints with multiple types", async () => {
diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts
index 2ede3053c..7aaf9028e 100644
--- a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts
+++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts
@@ -72,22 +72,6 @@ export const certificateTemplateV2ServiceFactory = ({
switch (attrType) {
case "common_name":
return request.commonName;
- case "organization_name":
- return request.organization;
- case "organization_unit":
- return request.organizationUnit;
- case "locality":
- return request.locality;
- case "state":
- return request.state;
- case "country":
- return request.country;
- case "email":
- return request.email;
- case "street_address":
- return request.streetAddress;
- case "postal_code":
- return request.postalCode;
default:
return undefined;
}
@@ -145,8 +129,25 @@ export const certificateTemplateV2ServiceFactory = ({
if (!requestValue) {
errors.push(`${attrPolicy.type} is mandatory but not provided in request`);
} else if (attrPolicy.value && attrPolicy.value.length > 0) {
- if (!attrPolicy.value.includes(requestValue)) {
- errors.push(`${attrPolicy.type} value '${requestValue}' is not in allowed values list`);
+ // Check if the request value matches any allowed pattern
+ const hasWildcards = attrPolicy.value.some((val) => val.includes("*"));
+ const isValidValue = attrPolicy.value.some((allowedValue) => {
+ if (allowedValue.includes("*")) {
+ // Handle wildcard patterns
+ const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*");
+ const regex = new RE2(`^${pattern}$`);
+ return regex.test(requestValue);
+ }
+ return allowedValue === requestValue;
+ });
+ if (!isValidValue) {
+ if (hasWildcards) {
+ errors.push(
+ `${attrPolicy.type} value '${requestValue}' does not match allowed patterns: ${attrPolicy.value.join(", ")}`
+ );
+ } else {
+ errors.push(`${attrPolicy.type} value '${requestValue}' is not in allowed values list`);
+ }
}
}
}
@@ -156,18 +157,24 @@ export const certificateTemplateV2ServiceFactory = ({
}
if (attrPolicy.include === "optional" && requestValue && attrPolicy.value && attrPolicy.value.length > 0) {
+ const hasWildcards = attrPolicy.value.some((val) => val.includes("*"));
const isValidValue = attrPolicy.value.some((allowedValue) => {
if (allowedValue.includes("*")) {
- const pattern = allowedValue.replace(/\*/g, "[^.]*");
+ // Handle wildcard patterns - escape dots and replace * with .*
+ const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*");
const regex = new RE2(`^${pattern}$`);
return regex.test(requestValue);
}
return allowedValue === requestValue;
});
if (!isValidValue) {
- errors.push(
- `${attrPolicy.type} value '${requestValue}' does not match allowed patterns: ${attrPolicy.value.join(", ")}`
- );
+ if (hasWildcards) {
+ errors.push(
+ `${attrPolicy.type} value '${requestValue}' does not match allowed patterns: ${attrPolicy.value.join(", ")}`
+ );
+ } else {
+ errors.push(`${attrPolicy.type} value '${requestValue}' is not in allowed values list`);
+ }
}
}
});
@@ -216,9 +223,25 @@ export const certificateTemplateV2ServiceFactory = ({
if (requestSans.length === 0) {
errors.push(`${sanPolicy.type} SAN is mandatory but not provided in request`);
} else if (sanPolicy.value && sanPolicy.value.length > 0) {
+ const hasWildcards = sanPolicy.value.some((val) => val.includes("*"));
requestSans.forEach((san) => {
- if (!sanPolicy.value!.includes(san.value)) {
- errors.push(`${sanPolicy.type} SAN value '${san.value}' is not in allowed values list`);
+ const isValidValue = sanPolicy.value!.some((allowedValue) => {
+ if (allowedValue.includes("*")) {
+ // Handle wildcard patterns - escape dots and replace * with .*
+ const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*");
+ const regex = new RE2(`^${pattern}$`);
+ return regex.test(san.value);
+ }
+ return allowedValue === san.value;
+ });
+ if (!isValidValue) {
+ if (hasWildcards) {
+ errors.push(
+ `${sanPolicy.type} SAN value '${san.value}' does not match allowed patterns: ${sanPolicy.value!.join(", ")}`
+ );
+ } else {
+ errors.push(`${sanPolicy.type} SAN value '${san.value}' is not in allowed values list`);
+ }
}
});
}
@@ -229,19 +252,25 @@ export const certificateTemplateV2ServiceFactory = ({
}
if (sanPolicy.include === "optional" && sanPolicy.value && sanPolicy.value.length > 0) {
+ const hasWildcards = sanPolicy.value.some((val) => val.includes("*"));
requestSans.forEach((san) => {
const isValidValue = sanPolicy.value!.some((allowedValue) => {
if (allowedValue.includes("*")) {
- const pattern = allowedValue.replace(/\*/g, "[^.]*");
+ // Handle wildcard patterns - escape dots and replace * with .*
+ const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*");
const regex = new RE2(`^${pattern}$`);
return regex.test(san.value);
}
return allowedValue === san.value;
});
if (!isValidValue) {
- errors.push(
- `${sanPolicy.type} SAN value '${san.value}' does not match allowed patterns: ${sanPolicy.value!.join(", ")}`
- );
+ if (hasWildcards) {
+ errors.push(
+ `${sanPolicy.type} SAN value '${san.value}' does not match allowed patterns: ${sanPolicy.value!.join(", ")}`
+ );
+ } else {
+ errors.push(`${sanPolicy.type} SAN value '${san.value}' is not in allowed values list`);
+ }
}
});
}
diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts
index 5cc947d90..cf9f52425 100644
--- a/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts
+++ b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts
@@ -2,16 +2,7 @@ import { TCertificateTemplatesV2, TCertificateTemplatesV2Insert } from "@app/db/
export interface TTemplateV2Policy {
attributes: Array<{
- type:
- | "common_name"
- | "organization_name"
- | "organization_unit"
- | "locality"
- | "state"
- | "country"
- | "email"
- | "street_address"
- | "postal_code";
+ type: "common_name";
include: "mandatory" | "optional" | "prohibit";
value?: string[];
}>;
@@ -97,14 +88,6 @@ export type TCertificateTemplateV2Update = Partial<
export interface TCertificateRequest {
commonName?: string;
- organization?: string;
- organizationUnit?: string;
- locality?: string;
- state?: string;
- country?: string;
- email?: string;
- streetAddress?: string;
- postalCode?: string;
keyUsages?: string[];
extendedKeyUsages?: string[];
subjectAlternativeNames?: Array<{
diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts
index 22eb3d8ca..dfd4acce3 100644
--- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts
+++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts
@@ -76,7 +76,6 @@ describe("CertificateV3Service", () => {
describe("issueCertificateFromProfile", () => {
const mockCertificateRequest = {
commonName: "test.example.com",
- organization: "Test Org",
keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE],
extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH],
validity: { ttl: "30d" },
diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts
index 6fb16aa14..32ba7b958 100644
--- a/backend/src/services/certificate-v3/certificate-v3-service.ts
+++ b/backend/src/services/certificate-v3/certificate-v3-service.ts
@@ -291,14 +291,6 @@ export const certificateV3ServiceFactory = ({
const certificateRequest = {
commonName: certificateOrder.commonName,
- organization: certificateOrder.organization,
- organizationUnit: certificateOrder.organizationUnit,
- locality: certificateOrder.locality,
- state: certificateOrder.state,
- country: certificateOrder.country,
- email: certificateOrder.email,
- streetAddress: certificateOrder.streetAddress,
- postalCode: certificateOrder.postalCode,
keyUsages: certificateOrder.keyUsages,
extendedKeyUsages: certificateOrder.extendedKeyUsages,
subjectAlternativeNames: certificateOrder.identifiers.map((id) => ({
diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts
index b05cf2d28..bd0ec2e4d 100644
--- a/backend/src/services/certificate-v3/certificate-v3-types.ts
+++ b/backend/src/services/certificate-v3/certificate-v3-types.ts
@@ -6,14 +6,6 @@ export type TIssueCertificateFromProfileDTO = {
profileId: string;
certificateRequest: {
commonName?: string;
- organization?: string;
- organizationUnit?: string;
- locality?: string;
- state?: string;
- country?: string;
- email?: string;
- streetAddress?: string;
- postalCode?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
subjectAlternativeNames?: Array<{
@@ -51,14 +43,6 @@ export type TOrderCertificateFromProfileDTO = {
ttl: string;
};
commonName?: string;
- organization?: string;
- organizationUnit?: string;
- locality?: string;
- state?: string;
- country?: string;
- email?: string;
- streetAddress?: string;
- postalCode?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
notBefore?: Date;
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx
index 84801b000..0969e446d 100644
--- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx
+++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx
@@ -199,7 +199,10 @@ export const SecretSyncReviewFields = () => {
{duplicateProjectId && (
- Duplicate found in project ID: {duplicateProjectId}
+ Duplicate found in project ID:{" "}
+
+ {duplicateProjectId}
+
)}
diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx
index 36526ec18..a93e0caf9 100644
--- a/frontend/src/hooks/api/ca/index.tsx
+++ b/frontend/src/hooks/api/ca/index.tsx
@@ -2,6 +2,7 @@ export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from
export {
useCreateCa,
useCreateCertificate,
+ useCreateCertificateV3,
useDeleteCa,
useImportCaCertificate,
useRenewCa,
diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx
index a14a0244b..a486df62e 100644
--- a/frontend/src/hooks/api/ca/mutations.tsx
+++ b/frontend/src/hooks/api/ca/mutations.tsx
@@ -9,6 +9,8 @@ import {
TCreateCertificateAuthorityDTO,
TCreateCertificateDTO,
TCreateCertificateResponse,
+ TCreateCertificateV3DTO,
+ TCreateCertificateV3Response,
TDeleteCertificateAuthorityDTO,
TImportCaCertificateDTO,
TImportCaCertificateResponse,
@@ -148,6 +150,24 @@ export const useCreateCertificate = () => {
});
};
+export const useCreateCertificateV3 = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (body) => {
+ const { data } = await apiRequest.post(
+ "/api/v3/certificates/issue-certificate",
+ body
+ );
+ return data;
+ },
+ onSuccess: (_, { projectSlug }) => {
+ queryClient.invalidateQueries({
+ queryKey: projectKeys.forProjectCertificates(projectSlug)
+ });
+ }
+ });
+};
+
export const useRenewCa = () => {
const queryClient = useQueryClient();
return useMutation({
diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts
index 0443dd5e9..78396a904 100644
--- a/frontend/src/hooks/api/ca/types.ts
+++ b/frontend/src/hooks/api/ca/types.ts
@@ -171,6 +171,32 @@ export type TCreateCertificateResponse = {
serialNumber: string;
};
+export type TCreateCertificateV3DTO = {
+ projectSlug: string;
+ profileId: string;
+ pkiCollectionId?: string;
+ friendlyName?: string;
+ commonName: string;
+ organization?: string;
+ organizationUnit?: string;
+ locality?: string;
+ state?: string;
+ country?: string;
+ email?: string;
+ streetAddress?: string;
+ postalCode?: string;
+ altNames: string;
+ ttl: string;
+ notBefore?: string;
+ notAfter?: string;
+ keyUsages: CertKeyUsage[];
+ extendedKeyUsages: CertExtendedKeyUsage[];
+ signatureAlgorithm?: string;
+ keyAlgorithm?: string;
+};
+
+export type TCreateCertificateV3Response = TCreateCertificateResponse;
+
export type TRenewCaDTO = {
projectSlug: string;
caId: string;
diff --git a/frontend/src/hooks/api/certificateProfiles/index.ts b/frontend/src/hooks/api/certificateProfiles/index.ts
new file mode 100644
index 000000000..dc5c17efa
--- /dev/null
+++ b/frontend/src/hooks/api/certificateProfiles/index.ts
@@ -0,0 +1,14 @@
+export {
+ useCreateCertificateProfile,
+ useDeleteCertificateProfile,
+ useUpdateCertificateProfile
+} from "./mutations";
+export {
+ certificateProfileKeys,
+ useGetCertificateProfileById,
+ useGetCertificateProfileBySlug,
+ useGetProfileCertificates,
+ useGetProfileMetrics,
+ useListCertificateProfiles
+} from "./queries";
+export type * from "./types";
diff --git a/frontend/src/hooks/api/certificateProfiles/mutations.tsx b/frontend/src/hooks/api/certificateProfiles/mutations.tsx
new file mode 100644
index 000000000..ca784ed0d
--- /dev/null
+++ b/frontend/src/hooks/api/certificateProfiles/mutations.tsx
@@ -0,0 +1,71 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+
+import { certificateProfileKeys } from "./queries";
+import {
+ TCertificateProfile,
+ TCreateCertificateProfileDTO,
+ TDeleteCertificateProfileDTO,
+ TUpdateCertificateProfileDTO
+} from "./types";
+
+export const useCreateCertificateProfile = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (data) => {
+ const { data: response } = await apiRequest.post<{
+ certificateProfile: TCertificateProfile;
+ }>("/api/v1/pki/certificate-profiles", data);
+ return response.certificateProfile;
+ },
+ onSuccess: (_, { projectId }) => {
+ queryClient.invalidateQueries({
+ queryKey: certificateProfileKeys.list({ projectId })
+ });
+ }
+ });
+};
+
+export const useUpdateCertificateProfile = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ profileId, ...data }) => {
+ const { data: response } = await apiRequest.patch<{
+ certificateProfile: TCertificateProfile;
+ }>(`/api/v1/pki/certificate-profiles/${profileId}`, data);
+ return response.certificateProfile;
+ },
+ onSuccess: (profile, { profileId }) => {
+ queryClient.invalidateQueries({
+ queryKey: certificateProfileKeys.list({ projectId: profile.projectId })
+ });
+ queryClient.invalidateQueries({
+ queryKey: certificateProfileKeys.getById(profileId)
+ });
+ }
+ });
+};
+
+export const useDeleteCertificateProfile = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ profileId }) => {
+ const { data: response } = await apiRequest.delete<{
+ certificateProfile: TCertificateProfile;
+ }>(`/api/v1/pki/certificate-profiles/${profileId}`);
+ return response.certificateProfile;
+ },
+ onSuccess: (profile, { profileId }) => {
+ queryClient.invalidateQueries({
+ queryKey: certificateProfileKeys.list({ projectId: profile.projectId })
+ });
+ queryClient.removeQueries({
+ queryKey: certificateProfileKeys.getById(profileId)
+ });
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/certificateProfiles/queries.tsx b/frontend/src/hooks/api/certificateProfiles/queries.tsx
new file mode 100644
index 000000000..a1d1d259e
--- /dev/null
+++ b/frontend/src/hooks/api/certificateProfiles/queries.tsx
@@ -0,0 +1,154 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+
+import {
+ TCertificateProfile,
+ TCertificateProfileMetrics,
+ TCertificateProfileWithDetails,
+ TGetCertificateProfileByIdDTO,
+ TGetCertificateProfileBySlugDTO,
+ TGetProfileCertificatesDTO,
+ TGetProfileMetricsDTO,
+ TListCertificateProfilesDTO,
+ TProfileCertificate
+} from "./types";
+
+export const certificateProfileKeys = {
+ list: (params: {
+ projectId: string;
+ limit?: number;
+ offset?: number;
+ search?: string;
+ includeMetrics?: boolean;
+ expiringDays?: number;
+ }) => ["certificate-profiles", "list", params],
+ getById: (profileId: string) => ["certificate-profiles", "get-by-id", profileId],
+ getBySlug: (projectId: string, slug: string) => [
+ "certificate-profiles",
+ "get-by-slug",
+ projectId,
+ slug
+ ],
+ getCertificates: (profileId: string, params?: Omit) => [
+ "certificate-profiles",
+ "certificates",
+ profileId,
+ params
+ ],
+ getMetrics: (profileId: string, params?: Omit) => [
+ "certificate-profiles",
+ "metrics",
+ profileId,
+ params
+ ]
+};
+
+export const useListCertificateProfiles = ({
+ projectId,
+ limit = 20,
+ offset = 0,
+ search,
+ includeMetrics = false,
+ expiringDays = 7
+}: TListCertificateProfilesDTO) => {
+ return useQuery({
+ queryKey: certificateProfileKeys.list({
+ projectId,
+ limit,
+ offset,
+ search,
+ includeMetrics,
+ expiringDays
+ }),
+ queryFn: async () => {
+ const { data } = await apiRequest.get<{
+ certificateProfiles: TCertificateProfile[];
+ totalCount: number;
+ }>("/api/v1/pki/certificate-profiles", {
+ params: {
+ projectId,
+ limit,
+ offset,
+ search,
+ includeMetrics,
+ expiringDays
+ }
+ });
+ return data;
+ },
+ enabled: Boolean(projectId)
+ });
+};
+
+export const useGetCertificateProfileById = ({ profileId }: TGetCertificateProfileByIdDTO) => {
+ return useQuery({
+ queryKey: certificateProfileKeys.getById(profileId),
+ queryFn: async () => {
+ const { data } = await apiRequest.get<{
+ certificateProfile: TCertificateProfileWithDetails;
+ }>(`/api/v1/pki/certificate-profiles/${profileId}`);
+ return data.certificateProfile;
+ },
+ enabled: Boolean(profileId)
+ });
+};
+
+export const useGetCertificateProfileBySlug = ({
+ projectId,
+ slug
+}: TGetCertificateProfileBySlugDTO) => {
+ return useQuery({
+ queryKey: certificateProfileKeys.getBySlug(projectId, slug),
+ queryFn: async () => {
+ const { data } = await apiRequest.get<{
+ certificateProfile: TCertificateProfile;
+ }>(`/api/v1/pki/certificate-profiles/slug/${slug}`, {
+ params: { projectId }
+ });
+ return data.certificateProfile;
+ },
+ enabled: Boolean(projectId && slug)
+ });
+};
+
+export const useGetProfileCertificates = ({
+ profileId,
+ offset = 0,
+ limit = 20,
+ status,
+ search
+}: TGetProfileCertificatesDTO) => {
+ return useQuery({
+ queryKey: certificateProfileKeys.getCertificates(profileId, { offset, limit, status, search }),
+ queryFn: async () => {
+ const { data } = await apiRequest.get<{
+ certificates: TProfileCertificate[];
+ }>(`/api/v1/pki/certificate-profiles/${profileId}/certificates`, {
+ params: {
+ offset,
+ limit,
+ status,
+ search
+ }
+ });
+ return data.certificates;
+ },
+ enabled: Boolean(profileId)
+ });
+};
+
+export const useGetProfileMetrics = ({ profileId, expiringDays = 7 }: TGetProfileMetricsDTO) => {
+ return useQuery({
+ queryKey: certificateProfileKeys.getMetrics(profileId, { expiringDays }),
+ queryFn: async () => {
+ const { data } = await apiRequest.get<{
+ metrics: TCertificateProfileMetrics;
+ }>(`/api/v1/pki/certificate-profiles/${profileId}/metrics`, {
+ params: { expiringDays }
+ });
+ return data.metrics;
+ },
+ enabled: Boolean(profileId)
+ });
+};
diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts
new file mode 100644
index 000000000..05307eb9a
--- /dev/null
+++ b/frontend/src/hooks/api/certificateProfiles/types.ts
@@ -0,0 +1,130 @@
+export type TCertificateProfile = {
+ id: string;
+ projectId: string;
+ caId: string;
+ certificateTemplateId: string;
+ name: string;
+ slug: string;
+ description?: string;
+ enrollmentType: "api" | "est";
+ estConfigId?: string;
+ apiConfigId?: string;
+ createdAt: string;
+ updatedAt: string;
+ metrics?: TCertificateProfileMetrics;
+};
+
+export type TCertificateProfileWithDetails = TCertificateProfile & {
+ certificateAuthority?: {
+ id: string;
+ projectId: string;
+ status: string;
+ name: string;
+ };
+ certificateTemplate?: {
+ id: string;
+ projectId: string;
+ name: string;
+ description?: string;
+ };
+ estConfig?: {
+ id: string;
+ disableBootstrapCaValidation: boolean;
+ hashedPassphrase: string;
+ encryptedCaChain: any;
+ };
+ apiConfig?: {
+ id: string;
+ autoRenew: boolean;
+ autoRenewDays?: number;
+ };
+};
+
+export type TCreateCertificateProfileDTO = {
+ projectId: string;
+ caId: string;
+ certificateTemplateId: string;
+ name: string;
+ slug: string;
+ description?: string;
+ enrollmentType: "api" | "est";
+ estConfig?: {
+ disableBootstrapCaValidation?: boolean;
+ passphrase: string;
+ caChain: string;
+ };
+ apiConfig?: {
+ autoRenew?: boolean;
+ autoRenewDays?: number;
+ };
+};
+
+export type TUpdateCertificateProfileDTO = {
+ profileId: string;
+ name?: string;
+ description?: string;
+ estConfig?: {
+ disableBootstrapCaValidation?: boolean;
+ passphrase?: string;
+ caChain?: string;
+ };
+ apiConfig?: {
+ autoRenew?: boolean;
+ autoRenewDays?: number;
+ };
+};
+
+export type TDeleteCertificateProfileDTO = {
+ profileId: string;
+};
+
+export type TListCertificateProfilesDTO = {
+ projectId: string;
+ limit?: number;
+ offset?: number;
+ search?: string;
+ includeMetrics?: boolean;
+ expiringDays?: number;
+};
+
+export type TGetCertificateProfileByIdDTO = {
+ profileId: string;
+};
+
+export type TGetCertificateProfileBySlugDTO = {
+ projectId: string;
+ slug: string;
+};
+
+export type TCertificateProfileMetrics = {
+ profileId: string;
+ totalCertificates: number;
+ activeCertificates: number;
+ expiredCertificates: number;
+ expiringCertificates: number;
+ revokedCertificates: number;
+};
+
+export type TProfileCertificate = {
+ id: string;
+ serialNumber: string;
+ cn: string;
+ status: string;
+ notBefore: Date;
+ notAfter: Date;
+ isRevoked: boolean;
+ createdAt: Date;
+};
+
+export type TGetProfileCertificatesDTO = {
+ profileId: string;
+ offset?: number;
+ limit?: number;
+ status?: "active" | "expired" | "revoked";
+ search?: string;
+};
+
+export type TGetProfileMetricsDTO = {
+ profileId: string;
+ expiringDays?: number;
+};
diff --git a/frontend/src/hooks/api/certificateTemplates/mutations.tsx b/frontend/src/hooks/api/certificateTemplates/mutations.tsx
index 24a7d0e5f..69c446050 100644
--- a/frontend/src/hooks/api/certificateTemplates/mutations.tsx
+++ b/frontend/src/hooks/api/certificateTemplates/mutations.tsx
@@ -7,13 +7,17 @@ import { projectKeys } from "../projects";
import { certTemplateKeys } from "./queries";
import {
TCertificateTemplate,
+ TCertificateTemplateV2New,
TCreateCertificateTemplateDTO,
TCreateCertificateTemplateV2DTO,
+ TCreateCertificateTemplateV2NewDTO,
TCreateEstConfigDTO,
TDeleteCertificateTemplateDTO,
TDeleteCertificateTemplateV2DTO,
+ TDeleteCertificateTemplateV2NewDTO,
TUpdateCertificateTemplateDTO,
TUpdateCertificateTemplateV2DTO,
+ TUpdateCertificateTemplateV2NewDTO,
TUpdateEstConfigDTO
} from "./types";
@@ -163,3 +167,60 @@ export const useUpdateEstConfig = () => {
}
});
};
+
+export const useCreateCertificateTemplateV2New = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (data) => {
+ const { data: response } = await apiRequest.post<{
+ certificateTemplate: TCertificateTemplateV2New;
+ }>("/api/v2/certificate-templates", data);
+ return response.certificateTemplate;
+ },
+ onSuccess: (_, { projectId }) => {
+ queryClient.invalidateQueries({
+ queryKey: certTemplateKeys.listTemplatesV2({ projectId })
+ });
+ }
+ });
+};
+
+export const useUpdateCertificateTemplateV2New = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ templateId, ...data }) => {
+ const { data: response } = await apiRequest.patch<{
+ certificateTemplate: TCertificateTemplateV2New;
+ }>(`/api/v2/certificate-templates/${templateId}`, data);
+ return response.certificateTemplate;
+ },
+ onSuccess: (template, { templateId }) => {
+ queryClient.invalidateQueries({
+ queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId })
+ });
+ queryClient.invalidateQueries({
+ queryKey: certTemplateKeys.getTemplateV2ById(templateId)
+ });
+ }
+ });
+};
+
+export const useDeleteCertificateTemplateV2New = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ templateId }) => {
+ const { data: response } = await apiRequest.delete<{
+ certificateTemplate: TCertificateTemplateV2New;
+ }>(`/api/v2/certificate-templates/${templateId}`);
+ return response.certificateTemplate;
+ },
+ onSuccess: (template, { templateId }) => {
+ queryClient.invalidateQueries({
+ queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId })
+ });
+ queryClient.removeQueries({
+ queryKey: certTemplateKeys.getTemplateV2ById(templateId)
+ });
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/certificateTemplates/queries.tsx b/frontend/src/hooks/api/certificateTemplates/queries.tsx
index 435345ad9..bef7aa67d 100644
--- a/frontend/src/hooks/api/certificateTemplates/queries.tsx
+++ b/frontend/src/hooks/api/certificateTemplates/queries.tsx
@@ -5,8 +5,11 @@ import { apiRequest } from "@app/config/request";
import {
TCertificateTemplate,
TCertificateTemplateV2,
+ TCertificateTemplateV2New,
TEstConfig,
- TListCertificateTemplatesDTO
+ TGetCertificateTemplateV2ByIdDTO,
+ TListCertificateTemplatesDTO,
+ TListCertificateTemplatesV2DTO
} from "./types";
export const certTemplateKeys = {
@@ -16,7 +19,16 @@ export const certTemplateKeys = {
projectId,
el
],
- getEstConfig: (id: string) => [{ id }, "cert-template-est-config"]
+ getEstConfig: (id: string) => [{ id }, "cert-template-est-config"],
+ listTemplatesV2: ({
+ projectId,
+ ...el
+ }: {
+ limit?: number;
+ offset?: number;
+ projectId: string;
+ }) => ["list-templates-v2", projectId, el],
+ getTemplateV2ById: (id: string) => ["cert-template-v2", id]
};
export const useGetCertTemplate = (id: string) => {
@@ -68,3 +80,42 @@ export const useGetEstConfig = (certificateTemplateId: string) => {
enabled: Boolean(certificateTemplateId)
});
};
+
+export const useListCertificateTemplatesV2 = ({
+ projectId,
+ limit = 20,
+ offset = 0
+}: TListCertificateTemplatesV2DTO) => {
+ return useQuery({
+ queryKey: certTemplateKeys.listTemplatesV2({ projectId, limit, offset }),
+ queryFn: async () => {
+ const { data } = await apiRequest.get<{
+ certificateTemplates: TCertificateTemplateV2New[];
+ totalCount: number;
+ }>("/api/v2/certificate-templates", {
+ params: {
+ projectId,
+ limit,
+ offset
+ }
+ });
+ return data;
+ },
+ enabled: Boolean(projectId)
+ });
+};
+
+export const useGetCertificateTemplateV2ById = ({
+ templateId
+}: TGetCertificateTemplateV2ByIdDTO) => {
+ return useQuery({
+ queryKey: certTemplateKeys.getTemplateV2ById(templateId),
+ queryFn: async () => {
+ const { data } = await apiRequest.get<{
+ certificateTemplate: TCertificateTemplateV2New;
+ }>(`/api/v2/certificate-templates/${templateId}`);
+ return data.certificateTemplate;
+ },
+ enabled: Boolean(templateId)
+ });
+};
diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts
index 1c2a47178..f6ad1c8cb 100644
--- a/frontend/src/hooks/api/certificateTemplates/types.ts
+++ b/frontend/src/hooks/api/certificateTemplates/types.ts
@@ -121,3 +121,101 @@ export type TListCertificateTemplatesDTO = {
offset?: number;
projectId: string;
};
+
+export type TCertificateTemplateV2Policy = {
+ attributes: Array<{
+ type:
+ | "common_name"
+ | "organization_name"
+ | "organization_unit"
+ | "locality"
+ | "state"
+ | "country"
+ | "email"
+ | "street_address"
+ | "postal_code";
+ include: "mandatory" | "optional" | "prohibit";
+ value?: string[];
+ }>;
+ keyUsages: {
+ requiredUsages: { all: string[] };
+ optionalUsages: { all: string[] };
+ };
+ extendedKeyUsages: {
+ requiredUsages: { all: string[] };
+ optionalUsages: { all: string[] };
+ };
+ subjectAlternativeNames: Array<{
+ type: "dns_name" | "ip_address" | "email" | "uri";
+ include: "mandatory" | "optional" | "prohibit";
+ value?: string[];
+ }>;
+ validity: {
+ maxDuration: { value: number; unit: "days" | "months" | "years" };
+ minDuration?: { value: number; unit: "days" | "months" | "years" };
+ };
+ signatureAlgorithm: {
+ allowedAlgorithms: string[];
+ defaultAlgorithm: string;
+ };
+ keyAlgorithm: {
+ allowedKeyTypes: string[];
+ defaultKeyType: string;
+ };
+};
+
+export type TCertificateTemplateV2New = {
+ id: string;
+ projectId: string;
+ name: string;
+ description?: string;
+ attributes: any;
+ keyUsages: any;
+ extendedKeyUsages: any;
+ subjectAlternativeNames: any;
+ validity: any;
+ signatureAlgorithm: any;
+ keyAlgorithm: any;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type TCreateCertificateTemplateV2NewDTO = {
+ projectId: string;
+ name: string;
+ description?: string;
+ attributes: TCertificateTemplateV2Policy["attributes"];
+ keyUsages: TCertificateTemplateV2Policy["keyUsages"];
+ extendedKeyUsages: TCertificateTemplateV2Policy["extendedKeyUsages"];
+ subjectAlternativeNames: TCertificateTemplateV2Policy["subjectAlternativeNames"];
+ validity: TCertificateTemplateV2Policy["validity"];
+ signatureAlgorithm: TCertificateTemplateV2Policy["signatureAlgorithm"];
+ keyAlgorithm: TCertificateTemplateV2Policy["keyAlgorithm"];
+};
+
+export type TUpdateCertificateTemplateV2NewDTO = {
+ templateId: string;
+ name?: string;
+ description?: string;
+ attributes?: TCertificateTemplateV2Policy["attributes"];
+ keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
+ extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
+ subjectAlternativeNames?: TCertificateTemplateV2Policy["subjectAlternativeNames"];
+ validity?: TCertificateTemplateV2Policy["validity"];
+ signatureAlgorithm?: TCertificateTemplateV2Policy["signatureAlgorithm"];
+ keyAlgorithm?: TCertificateTemplateV2Policy["keyAlgorithm"];
+};
+
+export type TDeleteCertificateTemplateV2NewDTO = {
+ templateId: string;
+};
+
+export type TListCertificateTemplatesV2DTO = {
+ projectId: string;
+ limit?: number;
+ offset?: number;
+};
+
+export type TGetCertificateTemplateV2ByIdDTO = {
+ templateId: string;
+};
diff --git a/frontend/src/hooks/api/certificates/constants.tsx b/frontend/src/hooks/api/certificates/constants.tsx
index 0384ea6cd..8647fafd5 100644
--- a/frontend/src/hooks/api/certificates/constants.tsx
+++ b/frontend/src/hooks/api/certificates/constants.tsx
@@ -24,6 +24,7 @@ export const getCertStatusBadgeVariant = (status: CertStatus) => {
export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = {
[CertKeyAlgorithm.RSA_2048]: "RSA 2048",
+ [CertKeyAlgorithm.RSA_3072]: "RSA 3072",
[CertKeyAlgorithm.RSA_4096]: "RSA 4096",
[CertKeyAlgorithm.ECDSA_P256]: "ECDSA P256",
[CertKeyAlgorithm.ECDSA_P384]: "ECDSA P384"
@@ -31,6 +32,7 @@ export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = {
export const certKeyAlgorithms = [
{ label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 },
+ { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_3072], value: CertKeyAlgorithm.RSA_3072 },
{ label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_4096], value: CertKeyAlgorithm.RSA_4096 },
{
label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P256],
@@ -96,3 +98,12 @@ export const EXTENDED_KEY_USAGES_OPTIONS = [
{ value: CertExtendedKeyUsage.CODE_SIGNING, label: "Code Signing" },
{ value: CertExtendedKeyUsage.TIMESTAMPING, label: "Timestamping" }
] as const;
+
+export const SIGNATURE_ALGORITHMS_OPTIONS = [
+ { value: "RSA-SHA256", label: "RSA-SHA256" },
+ { value: "RSA-SHA384", label: "RSA-SHA384" },
+ { value: "RSA-SHA512", label: "RSA-SHA512" },
+ { value: "ECDSA-SHA256", label: "ECDSA-SHA256" },
+ { value: "ECDSA-SHA384", label: "ECDSA-SHA384" },
+ { value: "ECDSA-SHA512", label: "ECDSA-SHA512" }
+] as const;
diff --git a/frontend/src/hooks/api/certificates/enums.tsx b/frontend/src/hooks/api/certificates/enums.tsx
index 566da7506..ecda22afb 100644
--- a/frontend/src/hooks/api/certificates/enums.tsx
+++ b/frontend/src/hooks/api/certificates/enums.tsx
@@ -5,6 +5,7 @@ export enum CertStatus {
export enum CertKeyAlgorithm {
RSA_2048 = "RSA_2048",
+ RSA_3072 = "RSA_3072",
RSA_4096 = "RSA_4096",
ECDSA_P256 = "EC_prime256v1",
ECDSA_P384 = "EC_secp384r1"
diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts
index ede2f8cf1..5c0fa687b 100644
--- a/frontend/src/hooks/api/subscriptions/types.ts
+++ b/frontend/src/hooks/api/subscriptions/types.ts
@@ -47,6 +47,7 @@ export type SubscriptionPlan = {
gateway: boolean;
externalKms: boolean;
pkiEst: boolean;
+ pkiLegacyTemplates: boolean;
enforceMfa: boolean;
enforceGoogleSSO: boolean;
projectTemplates: boolean;
diff --git a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx
index 5a17c7592..1703a1126 100644
--- a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx
+++ b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx
@@ -19,6 +19,10 @@ import { motion } from "framer-motion";
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
import { useProject, useProjectPermission } from "@app/context";
+import {
+ useListWorkspaceCertificateTemplates,
+ useListWorkspacePkiSubscribers
+} from "@app/hooks/api";
import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner";
@@ -27,6 +31,16 @@ export const PkiManagerLayout = () => {
const { assumedPrivilegeDetails } = useProjectPermission();
const { t } = useTranslation();
+ const { data: subscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || "");
+ const { data: templatesData } = useListWorkspaceCertificateTemplates({
+ projectId: currentProject?.id || ""
+ });
+ const templates = templatesData?.certificateTemplates || [];
+
+ const hasExistingSubscribers = subscribers.length > 0;
+ const hasExistingTemplates = templates.length > 0;
+ const showLegacySection = hasExistingSubscribers || hasExistingTemplates;
+
return (
<>
@@ -48,24 +62,7 @@ export const PkiManagerLayout = () => {
)}
@@ -110,7 +107,7 @@ export const PkiManagerLayout = () => {
- Certificates Authority
+ Certificates Authorities
)}
@@ -167,6 +164,48 @@ export const PkiManagerLayout = () => {
)}
+ {showLegacySection && (
+
+ {hasExistingSubscribers && (
+
+ {({ isActive }) => (
+
+ )}
+
+ )}
+ {hasExistingTemplates && (
+
+ {({ isActive }) => (
+
+ )}
+
+ )}
+
+ )}
{
maxPathLength: ca.configuration.maxPathLength
? String(ca.configuration.maxPathLength)
: "",
- keyAlgorithm: ca.configuration.keyAlgorithm
+ keyAlgorithm:
+ ca.configuration.keyAlgorithm === CertKeyAlgorithm.RSA_2048 ||
+ ca.configuration.keyAlgorithm === CertKeyAlgorithm.RSA_4096 ||
+ ca.configuration.keyAlgorithm === CertKeyAlgorithm.ECDSA_P256 ||
+ ca.configuration.keyAlgorithm === CertKeyAlgorithm.ECDSA_P384
+ ? ca.configuration.keyAlgorithm
+ : CertKeyAlgorithm.RSA_2048
}
});
} else {
diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx
new file mode 100644
index 000000000..b17e394a3
--- /dev/null
+++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx
@@ -0,0 +1,1018 @@
+/* eslint-disable react/no-array-index-key */
+/* eslint-disable no-nested-ternary */
+import { useEffect, useState } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons";
+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 {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+ Button,
+ FormControl,
+ FormLabel,
+ IconButton,
+ Input,
+ Modal,
+ ModalContent,
+ Select,
+ SelectItem,
+ Tooltip
+} from "@app/components/v2";
+import { useProject } from "@app/context";
+import { useCreateCertificateV3, useGetCert, useListWorkspacePkiCollections } from "@app/hooks/api";
+import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
+import {
+ certKeyAlgorithms,
+ EXTENDED_KEY_USAGES_OPTIONS,
+ KEY_USAGES_OPTIONS,
+ SIGNATURE_ALGORITHMS_OPTIONS
+} from "@app/hooks/api/certificates/constants";
+import {
+ CertExtendedKeyUsage,
+ CertKeyAlgorithm,
+ CertKeyUsage
+} from "@app/hooks/api/certificates/enums";
+import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+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 (
+
+
+
+
+ );
+};
+
+const schema = z.object({
+ profileId: z.string().min(1, "Profile is required"),
+ collectionId: z.string().optional(),
+ friendlyName: z.string(),
+ subjectAttributes: z
+ .array(
+ z.object({
+ type: z.enum(["common_name"]),
+ value: z.string().min(1, "Value is required")
+ })
+ )
+ .min(1, "At least one subject attribute is required"),
+ altNames: z
+ .array(
+ z.object({
+ type: z.enum(["dns", "ip", "email", "uri"]),
+ value: z.string().min(1, "Value is required")
+ })
+ )
+ .default([]),
+ ttl: z.string().trim().min(1, "TTL is required"),
+ signatureAlgorithm: z.string().optional(),
+ keyAlgorithm: z.string().optional(),
+ keyUsages: z.object({
+ [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
+ [CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
+ [CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
+ [CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
+ [CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
+ [CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
+ [CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
+ [CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
+ [CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
+ }),
+ extendedKeyUsages: z.object({
+ [CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
+ [CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
+ [CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
+ [CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
+ [CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
+ [CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
+ })
+});
+
+export type FormData = z.infer;
+
+type Props = {
+ popUp: UsePopUpState<["certificateIssuance"]>;
+ handlePopUpToggle: (
+ popUpName: keyof UsePopUpState<["certificateIssuance"]>,
+ state?: boolean
+ ) => void;
+};
+
+type TCertificateDetails = {
+ serialNumber: string;
+ certificate: string;
+ certificateChain: string;
+ privateKey: string;
+};
+
+export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const [certificateDetails, setCertificateDetails] = useState(null);
+ const [allowedKeyUsages, setAllowedKeyUsages] = useState([]);
+ const [allowedExtendedKeyUsages, setAllowedExtendedKeyUsages] = useState([]);
+ const [allowedSignatureAlgorithms, setAllowedSignatureAlgorithms] = useState([]);
+ const [allowedKeyAlgorithms, setAllowedKeyAlgorithms] = useState([]);
+ const { currentProject } = useProject();
+ const { data: cert } = useGetCert(
+ (popUp?.certificateIssuance?.data as { serialNumber: string })?.serialNumber || ""
+ );
+
+ const { data: profilesData } = useListCertificateProfiles({
+ projectId: currentProject?.id || "",
+ includeMetrics: false
+ });
+
+ const { data: collectionsData } = useListWorkspacePkiCollections({
+ projectId: currentProject?.id || ""
+ });
+
+ const { mutateAsync: createCertificate } = useCreateCertificateV3();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ watch,
+ setValue,
+ formState: { isSubmitting }
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ profileId: "",
+ friendlyName: "",
+ subjectAttributes: [{ type: "common_name", value: "" }],
+ altNames: [],
+ ttl: "30d",
+ signatureAlgorithm: "",
+ keyAlgorithm: "",
+ keyUsages: {},
+ extendedKeyUsages: {}
+ }
+ });
+
+ const selectedProfileId = watch("profileId");
+ const selectedProfile = profilesData?.certificateProfiles?.find(
+ (p) => p.id === selectedProfileId
+ );
+
+ const { data: templateData } = useGetCertificateTemplateV2ById({
+ templateId: selectedProfile?.certificateTemplateId || ""
+ });
+
+ useEffect(() => {
+ if (templateData && selectedProfile) {
+ if (templateData.signatureAlgorithm?.defaultAlgorithm) {
+ let sigAlgValue = templateData.signatureAlgorithm.defaultAlgorithm;
+
+ const sigAlgMap: Record = {
+ "SHA256-RSA": "RSA-SHA256",
+ "SHA384-RSA": "RSA-SHA384",
+ "SHA512-RSA": "RSA-SHA512",
+ "SHA256-ECDSA": "ECDSA-SHA256",
+ "SHA384-ECDSA": "ECDSA-SHA384",
+ "SHA512-ECDSA": "ECDSA-SHA512"
+ };
+
+ if (sigAlgMap[sigAlgValue]) {
+ sigAlgValue = sigAlgMap[sigAlgValue];
+ }
+
+ setValue("signatureAlgorithm", sigAlgValue);
+ }
+ if (templateData.keyAlgorithm?.defaultKeyType) {
+ let keyAlgValue = templateData.keyAlgorithm.defaultKeyType;
+
+ const keyAlgMap: Record = {
+ "RSA-2048": CertKeyAlgorithm.RSA_2048,
+ "RSA-3072": CertKeyAlgorithm.RSA_3072,
+ "RSA-4096": CertKeyAlgorithm.RSA_4096,
+ "ECDSA-P256": CertKeyAlgorithm.ECDSA_P256,
+ "ECDSA-P384": CertKeyAlgorithm.ECDSA_P384,
+ [CertKeyAlgorithm.ECDSA_P256]: CertKeyAlgorithm.ECDSA_P256,
+ [CertKeyAlgorithm.ECDSA_P384]: CertKeyAlgorithm.ECDSA_P384
+ };
+
+ if (keyAlgMap[keyAlgValue]) {
+ keyAlgValue = keyAlgMap[keyAlgValue];
+ } else {
+ keyAlgValue = templateData.keyAlgorithm.defaultKeyType;
+ }
+
+ setValue("keyAlgorithm", keyAlgValue);
+ }
+
+ if (templateData.signatureAlgorithm?.allowedAlgorithms) {
+ const mappedSigAlgs = templateData.signatureAlgorithm.allowedAlgorithms.map(
+ (alg: string) => {
+ const sigAlgMap: Record = {
+ "SHA256-RSA": "RSA-SHA256",
+ "SHA384-RSA": "RSA-SHA384",
+ "SHA512-RSA": "RSA-SHA512",
+ "SHA256-ECDSA": "ECDSA-SHA256",
+ "SHA384-ECDSA": "ECDSA-SHA384",
+ "SHA512-ECDSA": "ECDSA-SHA512"
+ };
+ return sigAlgMap[alg] || alg;
+ }
+ );
+ setAllowedSignatureAlgorithms(mappedSigAlgs);
+ }
+
+ if (templateData.keyAlgorithm?.allowedKeyTypes) {
+ const mappedKeyAlgs = templateData.keyAlgorithm.allowedKeyTypes.map((alg: string) => {
+ const keyAlgMap: Record = {
+ "RSA-2048": CertKeyAlgorithm.RSA_2048,
+ "RSA-3072": CertKeyAlgorithm.RSA_3072,
+ "RSA-4096": CertKeyAlgorithm.RSA_4096,
+ "ECDSA-P256": CertKeyAlgorithm.ECDSA_P256,
+ "ECDSA-P384": CertKeyAlgorithm.ECDSA_P384
+ };
+ return keyAlgMap[alg] || alg;
+ });
+ setAllowedKeyAlgorithms(mappedKeyAlgs);
+ }
+
+ const allAllowedKeyUsages: string[] = [];
+ if (templateData.keyUsages?.requiredUsages?.all) {
+ allAllowedKeyUsages.push(...templateData.keyUsages.requiredUsages.all);
+ }
+ if (templateData.keyUsages?.optionalUsages?.all) {
+ allAllowedKeyUsages.push(...templateData.keyUsages.optionalUsages.all);
+ }
+ setAllowedKeyUsages([...new Set(allAllowedKeyUsages)]);
+
+ const allAllowedExtendedKeyUsages: string[] = [];
+ if (templateData.extendedKeyUsages?.requiredUsages?.all) {
+ allAllowedExtendedKeyUsages.push(...templateData.extendedKeyUsages.requiredUsages.all);
+ }
+ if (templateData.extendedKeyUsages?.optionalUsages?.all) {
+ allAllowedExtendedKeyUsages.push(...templateData.extendedKeyUsages.optionalUsages.all);
+ }
+ setAllowedExtendedKeyUsages([...new Set(allAllowedExtendedKeyUsages)]);
+
+ if (templateData.attributes && Array.isArray(templateData.attributes)) {
+ const subjectAttrs: Array<{
+ type: "common_name";
+ value: string;
+ }> = [];
+
+ templateData.attributes.forEach((attr) => {
+ if (
+ (attr.include === "mandatory" ||
+ attr.include === "optional" ||
+ attr.include === "prohibit") &&
+ attr.value &&
+ attr.value.length > 0
+ ) {
+ attr.value.forEach((val: string) => {
+ subjectAttrs.push({ type: attr.type as any, value: val });
+ });
+ }
+ });
+
+ if (subjectAttrs.length > 0) {
+ setValue("subjectAttributes", subjectAttrs);
+ }
+ }
+
+ if (
+ templateData.subjectAlternativeNames &&
+ Array.isArray(templateData.subjectAlternativeNames)
+ ) {
+ const templateSans: Array<{ type: "dns" | "ip" | "email" | "uri"; value: string }> = [];
+
+ templateData.subjectAlternativeNames.forEach((sanPolicy) => {
+ if (
+ (sanPolicy.include === "mandatory" ||
+ sanPolicy.include === "optional" ||
+ sanPolicy.include === "prohibit") &&
+ sanPolicy.value &&
+ sanPolicy.value.length > 0
+ ) {
+ const typeMapping: Record = {
+ dns_name: "dns",
+ ip_address: "ip",
+ email: "email",
+ uri: "uri"
+ };
+
+ const mappedType = typeMapping[sanPolicy.type];
+ if (mappedType) {
+ sanPolicy.value.forEach((val: string) => {
+ templateSans.push({ type: mappedType, value: val });
+ });
+ }
+ }
+ });
+
+ if (templateSans.length > 0) {
+ setValue("altNames", templateSans);
+ }
+ }
+
+ const resetKeyUsages = {
+ [CertKeyUsage.DIGITAL_SIGNATURE]: false,
+ [CertKeyUsage.KEY_ENCIPHERMENT]: false,
+ [CertKeyUsage.NON_REPUDIATION]: false,
+ [CertKeyUsage.DATA_ENCIPHERMENT]: false,
+ [CertKeyUsage.KEY_AGREEMENT]: false,
+ [CertKeyUsage.KEY_CERT_SIGN]: false,
+ [CertKeyUsage.CRL_SIGN]: false,
+ [CertKeyUsage.ENCIPHER_ONLY]: false,
+ [CertKeyUsage.DECIPHER_ONLY]: false
+ };
+
+ const resetExtendedKeyUsages = {
+ [CertExtendedKeyUsage.CLIENT_AUTH]: false,
+ [CertExtendedKeyUsage.CODE_SIGNING]: false,
+ [CertExtendedKeyUsage.EMAIL_PROTECTION]: false,
+ [CertExtendedKeyUsage.OCSP_SIGNING]: false,
+ [CertExtendedKeyUsage.SERVER_AUTH]: false,
+ [CertExtendedKeyUsage.TIMESTAMPING]: false
+ };
+
+ const templateToEnumMap = {
+ digital_signature: CertKeyUsage.DIGITAL_SIGNATURE,
+ digitalSignature: CertKeyUsage.DIGITAL_SIGNATURE,
+ key_encipherment: CertKeyUsage.KEY_ENCIPHERMENT,
+ keyEncipherment: CertKeyUsage.KEY_ENCIPHERMENT,
+ non_repudiation: CertKeyUsage.NON_REPUDIATION,
+ nonRepudiation: CertKeyUsage.NON_REPUDIATION,
+ data_encipherment: CertKeyUsage.DATA_ENCIPHERMENT,
+ dataEncipherment: CertKeyUsage.DATA_ENCIPHERMENT,
+ key_agreement: CertKeyUsage.KEY_AGREEMENT,
+ keyAgreement: CertKeyUsage.KEY_AGREEMENT,
+ key_cert_sign: CertKeyUsage.KEY_CERT_SIGN,
+ keyCertSign: CertKeyUsage.KEY_CERT_SIGN,
+ crl_sign: CertKeyUsage.CRL_SIGN,
+ cRLSign: CertKeyUsage.CRL_SIGN,
+ encipher_only: CertKeyUsage.ENCIPHER_ONLY,
+ encipherOnly: CertKeyUsage.ENCIPHER_ONLY,
+ decipher_only: CertKeyUsage.DECIPHER_ONLY,
+ decipherOnly: CertKeyUsage.DECIPHER_ONLY,
+ client_auth: CertExtendedKeyUsage.CLIENT_AUTH,
+ clientAuth: CertExtendedKeyUsage.CLIENT_AUTH,
+ server_auth: CertExtendedKeyUsage.SERVER_AUTH,
+ serverAuth: CertExtendedKeyUsage.SERVER_AUTH,
+ code_signing: CertExtendedKeyUsage.CODE_SIGNING,
+ codeSigning: CertExtendedKeyUsage.CODE_SIGNING,
+ email_protection: CertExtendedKeyUsage.EMAIL_PROTECTION,
+ emailProtection: CertExtendedKeyUsage.EMAIL_PROTECTION,
+ ocsp_signing: CertExtendedKeyUsage.OCSP_SIGNING,
+ ocspSigning: CertExtendedKeyUsage.OCSP_SIGNING,
+ time_stamping: CertExtendedKeyUsage.TIMESTAMPING,
+ timestamping: CertExtendedKeyUsage.TIMESTAMPING,
+ timeStamping: CertExtendedKeyUsage.TIMESTAMPING
+ };
+
+ const currentKeyUsages = { ...resetKeyUsages };
+ if (templateData.keyUsages?.requiredUsages?.all) {
+ templateData.keyUsages.requiredUsages.all.forEach((usage: string) => {
+ const enumValue = (templateToEnumMap as any)[usage];
+ if (enumValue && enumValue in currentKeyUsages) {
+ (currentKeyUsages as any)[enumValue] = true;
+ }
+ });
+ }
+
+ const currentExtendedKeyUsages = { ...resetExtendedKeyUsages };
+ if (templateData.extendedKeyUsages?.requiredUsages?.all) {
+ templateData.extendedKeyUsages.requiredUsages.all.forEach((usage: string) => {
+ const enumValue = (templateToEnumMap as any)[usage];
+ if (enumValue && enumValue in currentExtendedKeyUsages) {
+ (currentExtendedKeyUsages as any)[enumValue] = true;
+ }
+ });
+ }
+
+ setValue("keyUsages", currentKeyUsages);
+ setValue("extendedKeyUsages", currentExtendedKeyUsages);
+ }
+ }, [templateData, selectedProfile, setValue]);
+
+ useEffect(() => {
+ if (cert) {
+ const subjectAttrs: Array<{ type: string; value: string }> = [];
+ if (cert.commonName) subjectAttrs.push({ type: "common_name", value: cert.commonName });
+
+ reset({
+ profileId: "",
+ friendlyName: cert.friendlyName,
+ subjectAttributes:
+ subjectAttrs.length > 0
+ ? (subjectAttrs as any)
+ : [{ type: "common_name" as const, value: "" }],
+ altNames: cert.altNames
+ ? cert.altNames.split(",").map((name) => {
+ const trimmed = name.trim();
+ if (trimmed.includes("@")) return { type: "email" as const, value: trimmed };
+ if (trimmed.match(/^\d+\.\d+\.\d+\.\d+$/))
+ return { type: "ip" as const, value: trimmed };
+ if (trimmed.startsWith("http")) return { type: "uri" as const, value: trimmed };
+ return { type: "dns" as const, value: trimmed };
+ })
+ : [],
+ ttl: "",
+ keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])),
+ extendedKeyUsages: Object.fromEntries(
+ (cert.extendedKeyUsages || []).map((name) => [name, true])
+ )
+ });
+ }
+ }, [cert, reset]);
+
+ const onFormSubmit = async ({
+ profileId,
+ friendlyName,
+ collectionId,
+ subjectAttributes,
+ altNames,
+ ttl,
+ signatureAlgorithm,
+ keyAlgorithm,
+ keyUsages,
+ extendedKeyUsages
+ }: FormData) => {
+ try {
+ if (!currentProject?.slug) return;
+
+ const getAttributeValue = (type: string) => {
+ const foundAttr = subjectAttributes.find((attr) => attr.type === type);
+ return foundAttr?.value || "";
+ };
+
+ const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({
+ profileId,
+ projectSlug: currentProject.slug,
+ pkiCollectionId: collectionId,
+ friendlyName,
+ commonName: getAttributeValue("common_name"),
+ altNames: altNames
+ .filter((san) => san.value.trim())
+ .map((san) => san.value.trim())
+ .join(", "),
+ ttl,
+ signatureAlgorithm: (() => {
+ const frontendToBackendSigAlg: Record = {
+ "RSA-SHA256": "SHA256-RSA",
+ "RSA-SHA384": "SHA384-RSA",
+ "RSA-SHA512": "SHA512-RSA",
+ "ECDSA-SHA256": "SHA256-ECDSA",
+ "ECDSA-SHA384": "SHA384-ECDSA",
+ "ECDSA-SHA512": "SHA512-ECDSA"
+ };
+ return signatureAlgorithm
+ ? frontendToBackendSigAlg[signatureAlgorithm] || signatureAlgorithm
+ : undefined;
+ })(),
+ keyAlgorithm: (() => {
+ const frontendToBackendKeyAlg: Record = {
+ RSA_2048: "RSA-2048",
+ RSA_3072: "RSA-3072",
+ RSA_4096: "RSA-4096",
+ EC_prime256v1: "ECDSA-P256",
+ EC_secp384r1: "ECDSA-P384"
+ };
+ return keyAlgorithm ? frontendToBackendKeyAlg[keyAlgorithm] || keyAlgorithm : undefined;
+ })(),
+ keyUsages: Object.entries(keyUsages)
+ .filter(([, value]) => value)
+ .map(([key]) => key as CertKeyUsage),
+ extendedKeyUsages: Object.entries(extendedKeyUsages)
+ .filter(([, value]) => value)
+ .map(([key]) => key as CertExtendedKeyUsage)
+ });
+
+ reset();
+
+ setCertificateDetails({
+ serialNumber,
+ certificate,
+ certificateChain,
+ privateKey
+ });
+
+ createNotification({
+ text: "Successfully created certificate",
+ type: "success"
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to create certificate",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+ {
+ handlePopUpToggle("certificateIssuance", isOpen);
+ setCertificateDetails(null);
+ reset();
+ }}
+ >
+
+ {certificateDetails && (
+
+ )}
+ {cert && (
+
+
+
Certificate Details
+
Serial Number: {cert.serialNumber}
+
Common Name: {cert.commonName}
+
Status: {cert.status}
+
+
+ )}
+ {!cert && !certificateDetails && (
+
+ )}
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx
index 696d07762..8daad5335 100644
--- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx
+++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx
@@ -7,22 +7,28 @@ import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionCertificateActions,
ProjectPermissionSub,
- useProject
+ useProject,
+ useSubscription
} from "@app/context";
import { useDeleteCert } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateImportModal } from "./CertificateImportModal";
+import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
import { CertificateModal } from "./CertificateModal";
import { CertificateRevocationModal } from "./CertificateRevocationModal";
import { CertificatesTable } from "./CertificatesTable";
export const CertificatesSection = () => {
const { currentProject } = useProject();
+ const { subscription } = useSubscription();
const { mutateAsync: deleteCert } = useDeleteCert();
+ const useOldCertificateFlow = subscription.pkiLegacyTemplates;
+
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
+ "certificateIssuance",
"certificate",
"certificateImport",
"certificateCert",
@@ -73,7 +79,9 @@ export const CertificatesSection = () => {
colorSchema="primary"
type="submit"
leftIcon={}
- onClick={() => handlePopUpOpen("certificate")}
+ onClick={() =>
+ handlePopUpOpen(useOldCertificateFlow ? "certificate" : "certificateIssuance")
+ }
isDisabled={!isAllowed}
>
Issue
@@ -83,7 +91,11 @@ export const CertificatesSection = () => {
-
+ {useOldCertificateFlow ? (
+
+ ) : (
+
+ )}
diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx
index f9680af9b..24be8596d 100644
--- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx
+++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx
@@ -19,6 +19,8 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable";
export const PkiSubscriberSection = () => {
const { currentProject } = useProject();
const projectId = currentProject.id;
+
+ const allowNewSubscriberCreation = false;
const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber();
const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber();
@@ -100,23 +102,25 @@ export const PkiSubscriberSection = () => {
/>
-
- {(isAllowed) => (
- }
- onClick={() => handlePopUpOpen("pkiSubscriber")}
- isDisabled={!isAllowed}
- className="ml-4"
- >
- Add Subscriber
-
- )}
-
+ {allowNewSubscriberCreation && (
+
+ {(isAllowed) => (
+ }
+ onClick={() => handlePopUpOpen("pkiSubscriber")}
+ isDisabled={!isAllowed}
+ className="ml-4"
+ >
+ Add Subscriber
+
+ )}
+
+ )}
diff --git a/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx b/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx
index 2ffe68813..54033df40 100644
--- a/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx
+++ b/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx
@@ -58,6 +58,7 @@ export const PkiTemplateListPage = () => {
const { currentProject } = useProject();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
+
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"certificateTemplate",
"deleteTemplate",
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx
new file mode 100644
index 000000000..b6f28bede
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx
@@ -0,0 +1,55 @@
+import { useState } from "react";
+import { Helmet } from "react-helmet";
+import { useTranslation } from "react-i18next";
+
+import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
+import { useProject } from "@app/context";
+
+import { CertificateProfilesTab } from "./components/CertificateProfilesTab";
+import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab";
+
+enum TabSections {
+ CertificateTemplatesV2 = "templates-v2",
+ CertificateProfiles = "profiles"
+}
+
+export const PoliciesPage = () => {
+ const { t } = useTranslation();
+ const { currentProject } = useProject();
+ const [activeTab, setActiveTab] = useState(TabSections.CertificateProfiles);
+
+ if (!currentProject) {
+ return ;
+ }
+
+ return (
+
+
+ {t("common.head-title", { title: "Certificate Policies" })}
+
+
+
+
+
setActiveTab(value as TabSections)}>
+
+
+ Certificate Profiles
+ Certificate Templates
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx
new file mode 100644
index 000000000..54f5115df
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx
@@ -0,0 +1,115 @@
+import { useState } from "react";
+import { faPlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { Button, DeleteActionModal } from "@app/components/v2";
+import { useProjectPermission } from "@app/context";
+import {
+ ProjectPermissionActions,
+ ProjectPermissionSub
+} from "@app/context/ProjectPermissionContext/types";
+import {
+ TCertificateProfile,
+ useDeleteCertificateProfile
+} from "@app/hooks/api/certificateProfiles";
+
+import { CreateProfileModal } from "./CreateProfileModal";
+import { EditProfileModal } from "./EditProfileModal";
+import { ProfileList } from "./ProfileList";
+
+export const CertificateProfilesTab = () => {
+ const { permission } = useProjectPermission();
+
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [selectedProfile, setSelectedProfile] = useState(null);
+
+ const deleteProfile = useDeleteCertificateProfile();
+
+ const canCreateProfile = permission.can(
+ ProjectPermissionActions.Create,
+ ProjectPermissionSub.CertificateAuthorities
+ );
+
+ const handleCreateProfile = () => {
+ setIsCreateModalOpen(true);
+ };
+
+ const handleEditProfile = (profile: TCertificateProfile) => {
+ setSelectedProfile(profile);
+ setIsEditModalOpen(true);
+ };
+
+ const handleDeleteProfile = (profile: TCertificateProfile) => {
+ setSelectedProfile(profile);
+ setIsDeleteModalOpen(true);
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (!selectedProfile) return;
+
+ try {
+ await deleteProfile.mutateAsync({
+ profileId: selectedProfile.id
+ });
+ setIsDeleteModalOpen(false);
+ setSelectedProfile(null);
+ } catch (error) {
+ console.error("Failed to delete profile:", error);
+ }
+ };
+
+ return (
+
+
+
+
Certificate Profiles
+
+ Unified certificate issuance configurations combining CA, template, and enrollment
+ method
+
+
+
+ {canCreateProfile && (
+
}
+ onClick={handleCreateProfile}
+ >
+ Create Profile
+
+ )}
+
+
+
+
+
setIsCreateModalOpen(false)} />
+
+ {selectedProfile && (
+ <>
+ {
+ setIsEditModalOpen(false);
+ setSelectedProfile(null);
+ }}
+ profile={selectedProfile}
+ />
+
+ {
+ setIsDeleteModalOpen(isOpen);
+ if (!isOpen) setSelectedProfile(null);
+ }}
+ deleteKey={selectedProfile.name}
+ onDeleteApproved={handleDeleteConfirm}
+ />
+ >
+ )}
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx
new file mode 100644
index 000000000..5d28e8787
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx
@@ -0,0 +1,438 @@
+/* eslint-disable jsx-a11y/label-has-associated-control */
+import { useEffect } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+
+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 { useCreateCertificateProfile } from "@app/hooks/api/certificateProfiles";
+import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
+
+const schema = z
+ .object({
+ name: z.string().trim().min(1, "Profile name is required"),
+ slug: z.string().trim().min(1, "Profile slug is required"),
+ description: z.string().optional(),
+ enrollmentType: z.enum(["api", "est"]),
+ certificateAuthorityId: z.string().min(1, "Certificate Authority is required"),
+ certificateTemplateId: z.string().min(1, "Certificate Template is required"),
+ estConfig: z
+ .object({
+ disableBootstrapCaValidation: z.boolean().optional(),
+ passphrase: z.string().min(1, "EST passphrase is required"),
+ caChain: z.string().min(1, "EST CA chain is required")
+ })
+ .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;
+
+interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
+ const { currentProject } = useProject();
+
+ const { data: caData } = useListCasByProjectId(currentProject?.id || "");
+ const { data: templateData } = useListCertificateTemplatesV2({
+ projectId: currentProject?.id || "",
+ limit: 100,
+ offset: 0
+ });
+
+ const createProfile = useCreateCertificateProfile();
+
+ const certificateAuthorities = caData || [];
+ const certificateTemplates = templateData?.certificateTemplates || [];
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ watch,
+ setValue,
+ formState: { isSubmitting }
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ name: "",
+ slug: "",
+ description: "",
+ enrollmentType: "api",
+ certificateAuthorityId: "",
+ certificateTemplateId: "",
+ apiConfig: {
+ autoRenew: false,
+ autoRenewDays: 30
+ }
+ }
+ });
+
+ const watchedName = watch("name");
+ const watchedEnrollmentType = watch("enrollmentType");
+ const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation");
+ 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) => {
+ try {
+ if (!currentProject?.id) return;
+
+ const payload: any = {
+ projectId: currentProject.id,
+ name: data.name,
+ slug: data.slug,
+ description: data.description,
+ enrollmentType: data.enrollmentType,
+ caId: data.certificateAuthorityId,
+ certificateTemplateId: data.certificateTemplateId
+ };
+
+ if (data.enrollmentType === "est" && data.estConfig) {
+ payload.estConfig = data.estConfig;
+ } else if (data.enrollmentType === "api" && data.apiConfig) {
+ payload.apiConfig = data.apiConfig;
+ }
+ await createProfile.mutateAsync(payload);
+
+ createNotification({
+ text: "Certificate profile created successfully",
+ type: "success"
+ });
+
+ reset();
+ onClose();
+ } catch (error) {
+ console.error("Error creating profile:", error);
+ createNotification({
+ text: "Failed to create certificate profile",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+ {
+ if (!open) {
+ reset();
+ }
+ onClose();
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/EditProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/EditProfileModal.tsx
new file mode 100644
index 000000000..7ab072a86
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/EditProfileModal.tsx
@@ -0,0 +1,303 @@
+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 (
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx
new file mode 100644
index 000000000..cccec2a0e
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx
@@ -0,0 +1,71 @@
+import {
+ EmptyState,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Th,
+ THead,
+ Tr
+} from "@app/components/v2";
+import { useProject } from "@app/context";
+import {
+ TCertificateProfile,
+ useListCertificateProfiles
+} from "@app/hooks/api/certificateProfiles";
+
+import { ProfileRow } from "./ProfileRow";
+
+interface Props {
+ onEditProfile: (profile: TCertificateProfile) => void;
+ onDeleteProfile: (profile: TCertificateProfile) => void;
+}
+
+export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
+ const { currentProject } = useProject();
+
+ const { data, isLoading } = useListCertificateProfiles({
+ projectId: currentProject?.id || "",
+ limit: 100,
+ offset: 0,
+ includeMetrics: true
+ });
+
+ const profiles = data?.certificateProfiles || [];
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!profiles || profiles.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ | Name |
+ Enrollment Type |
+ Certificate Authority |
+ Template |
+ Certificates |
+ Created |
+ |
+
+
+
+ {profiles.map((profile) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx
new file mode 100644
index 000000000..d1b4cca4f
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx
@@ -0,0 +1,182 @@
+/* eslint-disable no-nested-ternary */
+import { faCircleInfo, faEdit, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import {
+ Badge,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Td,
+ Tooltip,
+ Tr
+} from "@app/components/v2";
+import { useProjectPermission } from "@app/context";
+import {
+ ProjectPermissionActions,
+ ProjectPermissionSub
+} from "@app/context/ProjectPermissionContext/types";
+import { useGetCaById } from "@app/hooks/api/ca/queries";
+import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
+import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
+
+interface Props {
+ profile: TCertificateProfile;
+ onEditProfile: (profile: TCertificateProfile) => void;
+ onDeleteProfile: (profile: TCertificateProfile) => void;
+}
+
+export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) => {
+ const { permission } = useProjectPermission();
+
+ const { data: caData } = useGetCaById(profile.caId);
+
+ const { data: templateData } = useGetCertificateTemplateV2ById({
+ templateId: profile.certificateTemplateId
+ });
+
+ const canEditProfile = permission.can(
+ ProjectPermissionActions.Edit,
+ ProjectPermissionSub.CertificateAuthorities
+ );
+
+ const canDeleteProfile = permission.can(
+ ProjectPermissionActions.Delete,
+ ProjectPermissionSub.CertificateAuthorities
+ );
+
+ const getEnrollmentTypeBadge = (enrollmentType: string) => {
+ const config = {
+ api: { variant: "success" as const, label: "API" },
+ est: { variant: "primary" as const, label: "EST" }
+ };
+
+ const { variant, label } = config[enrollmentType as keyof typeof config] || config.api;
+
+ return {label};
+ };
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString();
+ };
+
+ return (
+
+
+
+
+ {profile.name}
+ {profile.description && (
+
+
+
+ )}
+
+ {profile.slug}
+
+ |
+ {getEnrollmentTypeBadge(profile.enrollmentType)} |
+
+
+ {caData?.friendlyName || caData?.commonName || profile.caId}
+
+ |
+
+
+ {templateData?.name || profile.certificateTemplateId}
+
+ |
+
+
+ {profile.metrics ? (
+ profile.metrics.totalCertificates === 0 ? (
+ No certificates attached
+ ) : (
+ <>
+ {profile.metrics.activeCertificates > 0 && (
+
+ {profile.metrics.activeCertificates} active
+
+ )}
+ {profile.metrics.expiringCertificates > 0 && (
+ <>
+ {profile.metrics.activeCertificates > 0 && (
+ •
+ )}
+
+ {profile.metrics.expiringCertificates} expiring
+
+ >
+ )}
+ {profile.metrics.expiredCertificates > 0 && (
+ <>
+ {(profile.metrics.activeCertificates > 0 ||
+ profile.metrics.expiringCertificates > 0) && (
+ •
+ )}
+
+ {profile.metrics.expiredCertificates} expired
+
+ >
+ )}
+ {profile.metrics.revokedCertificates > 0 && (
+ <>
+ {(profile.metrics.activeCertificates > 0 ||
+ profile.metrics.expiringCertificates > 0 ||
+ profile.metrics.expiredCertificates > 0) && (
+ •
+ )}
+
+ {profile.metrics.revokedCertificates} revoked
+
+ >
+ )}
+ >
+ )
+ ) : (
+ No metrics available
+ )}
+
+ |
+
+ {formatDate(profile.createdAt)}
+ |
+
+
+
+
+
+
+
+
+
+
+ {canEditProfile && (
+ {
+ e.stopPropagation();
+ onEditProfile(profile);
+ }}
+ icon={}
+ >
+ Edit Profile
+
+ )}
+ {canDeleteProfile && (
+ {
+ e.stopPropagation();
+ onDeleteProfile(profile);
+ }}
+ icon={}
+ >
+ Delete Profile
+
+ )}
+
+
+ |
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/index.ts
new file mode 100644
index 000000000..f07d54d30
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/index.ts
@@ -0,0 +1,4 @@
+export { CertificateProfilesTab } from "./CertificateProfilesTab";
+export { CreateProfileModal } from "./CreateProfileModal";
+export { EditProfileModal } from "./EditProfileModal";
+export { ProfileList } from "./ProfileList";
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx
new file mode 100644
index 000000000..cc680b1da
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx
@@ -0,0 +1,113 @@
+import { useState } from "react";
+import { faPlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { Button, DeleteActionModal } from "@app/components/v2";
+import { useProjectPermission } from "@app/context";
+import {
+ ProjectPermissionActions,
+ ProjectPermissionSub
+} from "@app/context/ProjectPermissionContext/types";
+import { useDeleteCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/mutations";
+import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
+
+import { CreateTemplateModal } from "./CreateTemplateModal";
+import { EditTemplateModal } from "./EditTemplateModal";
+import { TemplateList } from "./TemplateList";
+
+export const CertificateTemplatesV2Tab = () => {
+ const { permission } = useProjectPermission();
+
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [selectedTemplate, setSelectedTemplate] = useState(null);
+
+ const deleteTemplateV2 = useDeleteCertificateTemplateV2New();
+
+ const canCreateTemplate = permission.can(
+ ProjectPermissionActions.Create,
+ ProjectPermissionSub.CertificateAuthorities
+ );
+
+ const handleCreateTemplate = () => {
+ setIsCreateModalOpen(true);
+ };
+
+ const handleEditTemplate = (template: TCertificateTemplateV2New) => {
+ setSelectedTemplate(template);
+ setIsEditModalOpen(true);
+ };
+
+ const handleDeleteTemplate = (template: TCertificateTemplateV2New) => {
+ setSelectedTemplate(template);
+ setIsDeleteModalOpen(true);
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (!selectedTemplate) return;
+
+ try {
+ await deleteTemplateV2.mutateAsync({
+ templateId: selectedTemplate.id
+ });
+ setIsDeleteModalOpen(false);
+ setSelectedTemplate(null);
+ } catch (error) {
+ console.error("Failed to delete template:", error);
+ }
+ };
+
+ return (
+
+
+
+
Certificate Templates
+
+ Define certificate policies, validation rules, and attribute constraints for certificate
+ issuance
+
+
+
+ {canCreateTemplate && (
+
}
+ onClick={handleCreateTemplate}
+ >
+ Create Template
+
+ )}
+
+
+
+
+
setIsCreateModalOpen(false)} />
+
+ {selectedTemplate && (
+ <>
+ {
+ setIsEditModalOpen(false);
+ setSelectedTemplate(null);
+ }}
+ template={selectedTemplate}
+ />
+
+ {
+ setIsDeleteModalOpen(isOpen);
+ if (!isOpen) setSelectedTemplate(null);
+ }}
+ deleteKey={selectedTemplate.name}
+ onDeleteApproved={handleDeleteConfirm}
+ />
+ >
+ )}
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx
new file mode 100644
index 000000000..1ad7e43ce
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx
@@ -0,0 +1,699 @@
+import { 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 { createNotification } from "@app/components/notifications";
+import {
+ Button,
+ FormControl,
+ Input,
+ Modal,
+ ModalContent,
+ Select,
+ SelectItem,
+ TextArea
+} from "@app/components/v2";
+import { useProject } from "@app/context";
+import { useCreateCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/mutations";
+
+import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared";
+
+export type FormData = TemplateFormData;
+
+interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+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 CreateTemplateModal = ({ isOpen, onClose }: Props) => {
+ const { currentProject } = useProject();
+ const createTemplate = useCreateCertificateTemplateV2New();
+ const [activeTab, setActiveTab] = useState("basic");
+
+ const { control, handleSubmit, reset, watch, setValue } = useForm({
+ resolver: zodResolver(templateSchema),
+ defaultValues: {
+ name: "",
+ description: "",
+ attributes: [],
+ keyUsages: { requiredUsages: [], optionalUsages: [] },
+ extendedKeyUsages: { requiredUsages: [], optionalUsages: [] },
+ subjectAlternativeNames: [],
+ validity: {
+ maxDuration: { value: 365, unit: "days" }
+ },
+ signatureAlgorithm: {
+ allowedAlgorithms: ["SHA256-RSA"],
+ defaultAlgorithm: "SHA256-RSA"
+ },
+ keyAlgorithm: {
+ allowedKeyTypes: ["RSA-2048"],
+ defaultKeyType: "RSA-2048"
+ }
+ }
+ });
+
+ const watchedAttributes = watch("attributes") || [];
+ const watchedSans = watch("subjectAlternativeNames") || [];
+ const watchedKeyUsages = watch("keyUsages");
+ const watchedExtendedKeyUsages = watch("extendedKeyUsages");
+
+ const onFormSubmit = async (data: FormData) => {
+ try {
+ if (!currentProject?.id) return;
+
+ const templateData = {
+ projectId: currentProject.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: {
+ 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(templateData);
+
+ createNotification({
+ text: "Certificate template created successfully",
+ type: "success"
+ });
+
+ reset();
+ onClose();
+ } catch (error) {
+ console.error("Error creating template:", error);
+ createNotification({
+ text: "Failed to create 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 (
+ {
+ if (!open) {
+ reset();
+ }
+ onClose();
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/EditTemplateModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/EditTemplateModal.tsx
new file mode 100644
index 000000000..b3ba1409c
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/EditTemplateModal.tsx
@@ -0,0 +1,778 @@
+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;
+
+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("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({
+ 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 (
+ {
+ if (!open) {
+ reset();
+ }
+ onClose();
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx
new file mode 100644
index 000000000..4d9bc1356
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx
@@ -0,0 +1,137 @@
+import { faCircleInfo, faEdit, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ EmptyState,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tooltip,
+ Tr
+} from "@app/components/v2";
+import { useProject, useProjectPermission } from "@app/context";
+import {
+ ProjectPermissionActions,
+ ProjectPermissionSub
+} from "@app/context/ProjectPermissionContext/types";
+import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
+import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
+
+interface Props {
+ onEditTemplate: (template: TCertificateTemplateV2New) => void;
+ onDeleteTemplate: (template: TCertificateTemplateV2New) => void;
+}
+
+export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
+ const { permission } = useProjectPermission();
+ const { currentProject } = useProject();
+
+ const { data, isLoading } = useListCertificateTemplatesV2({
+ projectId: currentProject?.id || "",
+ limit: 100,
+ offset: 0
+ });
+
+ const templates = data?.certificateTemplates || [];
+
+ const canEditTemplate = permission.can(
+ ProjectPermissionActions.Edit,
+ ProjectPermissionSub.CertificateAuthorities
+ );
+
+ const canDeleteTemplate = permission.can(
+ ProjectPermissionActions.Delete,
+ ProjectPermissionSub.CertificateAuthorities
+ );
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString();
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!templates || templates.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ | Name |
+ Created |
+ |
+
+
+
+ {templates.map((template) => (
+
+
+
+ {template.name}
+ {template.description && (
+
+
+
+ )}
+
+ |
+
+ {formatDate(template.createdAt)}
+ |
+
+
+
+
+
+
+
+
+
+
+ {canEditTemplate && (
+ {
+ e.stopPropagation();
+ onEditTemplate(template);
+ }}
+ icon={}
+ >
+ Edit Template
+
+ )}
+ {canDeleteTemplate && (
+ {
+ e.stopPropagation();
+ onDeleteTemplate(template);
+ }}
+ icon={}
+ >
+ Delete Template
+
+ )}
+
+
+ |
+
+ ))}
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/index.ts
new file mode 100644
index 000000000..f7372b94c
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/index.ts
@@ -0,0 +1,4 @@
+export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
+export { CreateTemplateModal } from "./CreateTemplateModal";
+export { EditTemplateModal } from "./EditTemplateModal";
+export { TemplateList } from "./TemplateList";
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/components.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/components.tsx
new file mode 100644
index 000000000..9cf6a14ed
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/components.tsx
@@ -0,0 +1,143 @@
+import { Button } from "@app/components/v2";
+
+import {
+ EXTENDED_KEY_USAGES,
+ formatUsageName,
+ getUsageState,
+ KEY_USAGES,
+ toggleUsageState
+} from "./utils";
+
+type UsageToggleProps = {
+ value: "required" | "optional" | undefined;
+ onChange: (value: "required" | "optional" | undefined) => void;
+};
+
+export const UsageToggle = ({ value, onChange }: UsageToggleProps) => {
+ return (
+
+
+
+
+ );
+};
+
+type KeyUsagesSectionProps = {
+ watchedKeyUsages?: {
+ requiredUsages?: string[];
+ optionalUsages?: string[];
+ };
+ watchedExtendedKeyUsages?: {
+ requiredUsages?: string[];
+ optionalUsages?: string[];
+ };
+ toggleKeyUsage: (usage: string, type: "required" | "optional") => void;
+ toggleExtendedKeyUsage: (usage: string, type: "required" | "optional") => void;
+};
+
+export const KeyUsagesSection = ({
+ watchedKeyUsages,
+ watchedExtendedKeyUsages,
+ toggleKeyUsage,
+ toggleExtendedKeyUsage
+}: KeyUsagesSectionProps) => {
+ return (
+
+
+
Key Usages
+
+ {KEY_USAGES.map((usage) => {
+ const requiredUsages = Array.isArray(watchedKeyUsages?.requiredUsages)
+ ? watchedKeyUsages.requiredUsages
+ : [];
+ const optionalUsages = Array.isArray(watchedKeyUsages?.optionalUsages)
+ ? watchedKeyUsages.optionalUsages
+ : [];
+
+ const currentState = getUsageState(usage, requiredUsages, optionalUsages);
+
+ return (
+
+
+ {formatUsageName(usage)}
+
+ {
+ toggleUsageState(
+ usage,
+ newValue,
+ requiredUsages,
+ optionalUsages,
+ (u) => toggleKeyUsage(u, "required"),
+ (u) => toggleKeyUsage(u, "optional")
+ );
+ }}
+ />
+
+ );
+ })}
+
+
+
+
+
Extended Key Usages
+
+ {EXTENDED_KEY_USAGES.map((usage) => {
+ const requiredUsages = Array.isArray(watchedExtendedKeyUsages?.requiredUsages)
+ ? watchedExtendedKeyUsages.requiredUsages
+ : [];
+ const optionalUsages = Array.isArray(watchedExtendedKeyUsages?.optionalUsages)
+ ? watchedExtendedKeyUsages.optionalUsages
+ : [];
+
+ const currentState = getUsageState(usage, requiredUsages, optionalUsages);
+
+ return (
+
+
+ {formatUsageName(usage)}
+
+ {
+ toggleUsageState(
+ usage,
+ newValue,
+ requiredUsages,
+ optionalUsages,
+ (u) => toggleExtendedKeyUsage(u, "required"),
+ (u) => toggleExtendedKeyUsage(u, "optional")
+ );
+ }}
+ />
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/index.ts
new file mode 100644
index 000000000..6567de724
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/index.ts
@@ -0,0 +1,3 @@
+export * from "./components";
+export * from "./schemas";
+export * from "./utils";
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts
new file mode 100644
index 000000000..ff106da99
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts
@@ -0,0 +1,64 @@
+import { z } from "zod";
+
+import { INCLUDE_OPTIONS, SAN_TYPES, SUBJECT_ATTRIBUTE_TYPES } from "./utils";
+
+export const attributeSchema = z.object({
+ type: z.enum(SUBJECT_ATTRIBUTE_TYPES),
+ include: z.enum(INCLUDE_OPTIONS),
+ value: z.array(z.string()).optional()
+});
+
+export const sanSchema = z.object({
+ type: z.enum(SAN_TYPES),
+ include: z.enum(INCLUDE_OPTIONS),
+ value: z.array(z.string()).optional()
+});
+
+export const templateSchema = 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()).optional(),
+ optionalUsages: z.array(z.string()).optional()
+ })
+ .optional(),
+ extendedKeyUsages: z
+ .object({
+ requiredUsages: z.array(z.string()).optional(),
+ optionalUsages: z.array(z.string()).optional()
+ })
+ .optional(),
+ subjectAlternativeNames: z.array(sanSchema).optional(),
+ validity: z
+ .object({
+ maxDuration: z
+ .object({
+ value: z.number().min(1, "Duration must be at least 1"),
+ unit: z.enum(["days", "months", "years"])
+ })
+ .optional(),
+ minDuration: z
+ .object({
+ value: z.number().min(1, "Duration must be at least 1"),
+ unit: z.enum(["days", "months", "years"])
+ })
+ .optional()
+ })
+ .optional(),
+ signatureAlgorithm: z
+ .object({
+ allowedAlgorithms: z.array(z.string()).optional(),
+ defaultAlgorithm: z.string().optional()
+ })
+ .optional(),
+ keyAlgorithm: z
+ .object({
+ allowedKeyTypes: z.array(z.string()).optional(),
+ defaultKeyType: z.string().optional()
+ })
+ .optional()
+});
+
+export type TemplateFormData = z.infer;
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/utils.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/utils.ts
new file mode 100644
index 000000000..2fecb2264
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/utils.ts
@@ -0,0 +1,72 @@
+export const KEY_USAGES = [
+ "digital_signature",
+ "key_encipherment",
+ "non_repudiation",
+ "data_encipherment",
+ "key_agreement",
+ "key_cert_sign",
+ "crl_sign",
+ "encipher_only",
+ "decipher_only"
+] as const;
+
+export const EXTENDED_KEY_USAGES = [
+ "client_auth",
+ "server_auth",
+ "code_signing",
+ "email_protection",
+ "ocsp_signing",
+ "time_stamping"
+] as const;
+
+export const SUBJECT_ATTRIBUTE_TYPES = [
+ "common_name",
+ "organization_name",
+ "organization_unit",
+ "locality",
+ "state",
+ "country",
+ "email",
+ "street_address",
+ "postal_code"
+] as const;
+
+export const SAN_TYPES = ["dns_name", "ip_address", "email", "uri"] as const;
+
+export const INCLUDE_OPTIONS = ["mandatory", "optional", "prohibit"] as const;
+
+export const formatUsageName = (usage: string): string => {
+ return usage.replace(/_/g, " ");
+};
+export const getUsageState = (
+ usage: string,
+ requiredUsages: string[],
+ optionalUsages: string[]
+): "required" | "optional" | undefined => {
+ if (requiredUsages.includes(usage)) return "required";
+ if (optionalUsages.includes(usage)) return "optional";
+ return undefined;
+};
+
+export const toggleUsageState = (
+ usage: string,
+ newState: "required" | "optional" | undefined,
+ currentRequiredUsages: string[],
+ currentOptionalUsages: string[],
+ toggleRequired: (usage: string) => void,
+ toggleOptional: (usage: string) => void
+) => {
+ const isRequired = currentRequiredUsages.includes(usage);
+ const isOptional = currentOptionalUsages.includes(usage);
+
+ if (newState === "required") {
+ if (isOptional) toggleOptional(usage);
+ if (!isRequired) toggleRequired(usage);
+ } else if (newState === "optional") {
+ if (isRequired) toggleRequired(usage);
+ if (!isOptional) toggleOptional(usage);
+ } else {
+ if (isRequired) toggleRequired(usage);
+ if (isOptional) toggleOptional(usage);
+ }
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts
new file mode 100644
index 000000000..62809f571
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts
@@ -0,0 +1,2 @@
+export { CertificateProfilesTab } from "./CertificateProfilesTab";
+export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/index.ts
new file mode 100644
index 000000000..e5a4decb2
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/index.ts
@@ -0,0 +1 @@
+export { PoliciesPage } from "./PoliciesPage";
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/route.tsx b/frontend/src/pages/cert-manager/PoliciesPage/route.tsx
new file mode 100644
index 000000000..1db3b90d9
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/route.tsx
@@ -0,0 +1,19 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+import { PoliciesPage } from "./PoliciesPage";
+
+export const Route = createFileRoute(
+ "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies"
+)({
+ component: PoliciesPage,
+ beforeLoad: ({ context }) => {
+ return {
+ breadcrumbs: [
+ ...context.breadcrumbs,
+ {
+ label: "Certificate Policies"
+ }
+ ]
+ };
+ }
+});
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
index 1562f302d..277baa912 100644
--- a/frontend/src/routeTree.gen.ts
+++ b/frontend/src/routeTree.gen.ts
@@ -114,6 +114,7 @@ import { Route as kmsSettingsPageRouteImport } from './pages/kms/SettingsPage/ro
import { Route as kmsOverviewPageRouteImport } from './pages/kms/OverviewPage/route'
import { Route as kmsKmipPageRouteImport } from './pages/kms/KmipPage/route'
import { Route as certManagerSettingsPageRouteImport } from './pages/cert-manager/SettingsPage/route'
+import { Route as certManagerPoliciesPageRouteImport } from './pages/cert-manager/PoliciesPage/route'
import { Route as certManagerCertificatesPageRouteImport } from './pages/cert-manager/CertificatesPage/route'
import { Route as certManagerCertificateAuthoritiesPageRouteImport } from './pages/cert-manager/CertificateAuthoritiesPage/route'
import { Route as certManagerAlertingPageRouteImport } from './pages/cert-manager/AlertingPage/route'
@@ -1216,6 +1217,13 @@ const certManagerSettingsPageRouteRoute =
getParentRoute: () => certManagerLayoutRoute,
} as any)
+const certManagerPoliciesPageRouteRoute =
+ certManagerPoliciesPageRouteImport.update({
+ id: '/policies',
+ path: '/policies',
+ getParentRoute: () => certManagerLayoutRoute,
+ } as any)
+
const certManagerCertificatesPageRouteRoute =
certManagerCertificatesPageRouteImport.update({
id: '/certificates',
@@ -2820,6 +2828,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof certManagerCertificatesPageRouteImport
parentRoute: typeof certManagerLayoutImport
}
+ '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies': {
+ id: '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies'
+ path: '/policies'
+ fullPath: '/projects/cert-management/$projectId/policies'
+ preLoaderRoute: typeof certManagerPoliciesPageRouteImport
+ parentRoute: typeof certManagerLayoutImport
+ }
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings': {
id: '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings'
path: '/settings'
@@ -4165,6 +4180,7 @@ interface certManagerLayoutRouteChildren {
certManagerAlertingPageRouteRoute: typeof certManagerAlertingPageRouteRoute
certManagerCertificateAuthoritiesPageRouteRoute: typeof certManagerCertificateAuthoritiesPageRouteRoute
certManagerCertificatesPageRouteRoute: typeof certManagerCertificatesPageRouteRoute
+ certManagerPoliciesPageRouteRoute: typeof certManagerPoliciesPageRouteRoute
certManagerSettingsPageRouteRoute: typeof certManagerSettingsPageRouteRoute
projectAccessControlPageRouteCertManagerRoute: typeof projectAccessControlPageRouteCertManagerRoute
projectAppConnectionsPageRouteCertManagerRoute: typeof projectAppConnectionsPageRouteCertManagerRoute
@@ -4185,6 +4201,7 @@ const certManagerLayoutRouteChildren: certManagerLayoutRouteChildren = {
certManagerCertificateAuthoritiesPageRouteRoute:
certManagerCertificateAuthoritiesPageRouteRoute,
certManagerCertificatesPageRouteRoute: certManagerCertificatesPageRouteRoute,
+ certManagerPoliciesPageRouteRoute: certManagerPoliciesPageRouteRoute,
certManagerSettingsPageRouteRoute: certManagerSettingsPageRouteRoute,
projectAccessControlPageRouteCertManagerRoute:
projectAccessControlPageRouteCertManagerRoute,
@@ -5100,6 +5117,7 @@ export interface FileRoutesByFullPath {
'/projects/cert-management/$projectId/alerting': typeof certManagerAlertingPageRouteRoute
'/projects/cert-management/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/projects/cert-management/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute
+ '/projects/cert-management/$projectId/policies': typeof certManagerPoliciesPageRouteRoute
'/projects/cert-management/$projectId/settings': typeof certManagerSettingsPageRouteRoute
'/projects/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute
'/projects/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute
@@ -5333,6 +5351,7 @@ export interface FileRoutesByTo {
'/projects/cert-management/$projectId/alerting': typeof certManagerAlertingPageRouteRoute
'/projects/cert-management/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/projects/cert-management/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute
+ '/projects/cert-management/$projectId/policies': typeof certManagerPoliciesPageRouteRoute
'/projects/cert-management/$projectId/settings': typeof certManagerSettingsPageRouteRoute
'/projects/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute
'/projects/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute
@@ -5578,6 +5597,7 @@ export interface FileRoutesById {
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/alerting': typeof certManagerAlertingPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates': typeof certManagerCertificatesPageRouteRoute
+ '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies': typeof certManagerPoliciesPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings': typeof certManagerSettingsPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/kmip': typeof kmsKmipPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/overview': typeof kmsOverviewPageRouteRoute
@@ -5821,6 +5841,7 @@ export interface FileRouteTypes {
| '/projects/cert-management/$projectId/alerting'
| '/projects/cert-management/$projectId/certificate-authorities'
| '/projects/cert-management/$projectId/certificates'
+ | '/projects/cert-management/$projectId/policies'
| '/projects/cert-management/$projectId/settings'
| '/projects/kms/$projectId/kmip'
| '/projects/kms/$projectId/overview'
@@ -6053,6 +6074,7 @@ export interface FileRouteTypes {
| '/projects/cert-management/$projectId/alerting'
| '/projects/cert-management/$projectId/certificate-authorities'
| '/projects/cert-management/$projectId/certificates'
+ | '/projects/cert-management/$projectId/policies'
| '/projects/cert-management/$projectId/settings'
| '/projects/kms/$projectId/kmip'
| '/projects/kms/$projectId/overview'
@@ -6296,6 +6318,7 @@ export interface FileRouteTypes {
| '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/alerting'
| '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities'
| '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates'
+ | '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies'
| '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings'
| '/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/kmip'
| '/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/overview'
@@ -6943,6 +6966,7 @@ export const routeTree = rootRoute
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/alerting",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates",
+ "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/access-management",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/app-connections",
@@ -7060,6 +7084,10 @@ export const routeTree = rootRoute
"filePath": "cert-manager/CertificatesPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout"
},
+ "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies": {
+ "filePath": "cert-manager/PoliciesPage/route.tsx",
+ "parent": "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout"
+ },
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings": {
"filePath": "cert-manager/SettingsPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout"
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts
index bebc50339..860c677c1 100644
--- a/frontend/src/routes.ts
+++ b/frontend/src/routes.ts
@@ -301,6 +301,7 @@ const secretManagerIntegrationsRedirect = route("/integrations", [
const certManagerRoutes = route("/projects/cert-management/$projectId", [
layout("cert-manager-layout", "cert-manager/layout.tsx", [
+ route("/policies", "cert-manager/PoliciesPage/route.tsx"),
route("/subscribers", [
index("cert-manager/PkiSubscribersPage/route.tsx"),
route("/$subscriberName", "cert-manager/PkiSubscriberDetailsByIDPage/route.tsx")