PKI UI improvements

This commit is contained in:
Carlos Monastyrski
2025-10-30 00:46:48 -03:00
parent c0654872b9
commit 2eb56d8770
37 changed files with 759 additions and 967 deletions
@@ -121,9 +121,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
limit: z.coerce.number().min(1).max(100).default(20), limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(), search: z.string().optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(),
caId: z.string().uuid().optional(), caId: z.string().uuid().optional()
includeMetrics: z.coerce.boolean().optional().default(false),
expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -195,10 +193,6 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
params: z.object({ params: z.object({
id: z.string().uuid() id: z.string().uuid()
}), }),
querystring: z.object({
includeMetrics: z.coerce.boolean().optional().default(false),
expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
}),
response: { response: {
200: z.object({ 200: z.object({
certificateProfile: PkiCertificateProfilesSchema.extend({ certificateProfile: PkiCertificateProfilesSchema.extend({
@@ -232,16 +226,6 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean(), autoRenew: z.boolean(),
renewBeforeDays: z.number().optional() renewBeforeDays: 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() .optional()
}) })
}) })
@@ -257,20 +241,6 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
profileId: req.params.id 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({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: certificateProfile.projectId, projectId: certificateProfile.projectId,
@@ -283,7 +253,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
} }
}); });
return { certificateProfile: result }; return { certificateProfile };
} }
}); });
@@ -89,6 +89,7 @@ const PkiSyncCertificateSchema = z.object({
updatedAt: z.date(), updatedAt: z.date(),
certificateSerialNumber: z.string().optional(), certificateSerialNumber: z.string().optional(),
certificateCommonName: z.string().optional(), certificateCommonName: z.string().optional(),
certificateAltNames: z.string().optional(),
certificateStatus: z.string().optional(), certificateStatus: z.string().optional(),
certificateNotBefore: z.date().optional(), certificateNotBefore: z.date().optional(),
certificateNotAfter: z.date().optional(), certificateNotAfter: z.date().optional(),
@@ -10,10 +10,8 @@ import {
TCertificateProfile, TCertificateProfile,
TCertificateProfileCertificate, TCertificateProfileCertificate,
TCertificateProfileInsert, TCertificateProfileInsert,
TCertificateProfileMetrics,
TCertificateProfileUpdate, TCertificateProfileUpdate,
TCertificateProfileWithConfigs, TCertificateProfileWithConfigs
TCertificateProfileWithRawMetrics
} from "./certificate-profile-types"; } from "./certificate-profile-types";
export type TCertificateProfileDALFactory = ReturnType<typeof certificateProfileDALFactory>; export type TCertificateProfileDALFactory = ReturnType<typeof certificateProfileDALFactory>;
@@ -203,21 +201,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
search?: string; search?: string;
enrollmentType?: EnrollmentType; enrollmentType?: EnrollmentType;
caId?: string; caId?: string;
includeMetrics?: boolean;
expiringDays?: number;
} = {}, } = {},
tx?: Knex tx?: Knex
): Promise<TCertificateProfile[] | TCertificateProfileWithRawMetrics[] | TCertificateProfileWithConfigs[]> => { ): Promise<TCertificateProfile[] | TCertificateProfileWithConfigs[]> => {
try { try {
const { const { offset = 0, limit = 20, search, enrollmentType, caId } = options;
offset = 0,
limit = 20,
search,
enrollmentType,
caId,
includeMetrics = false,
expiringDays = 7
} = options;
let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where( let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where(
`${TableName.PkiCertificateProfile}.projectId`, `${TableName.PkiCertificateProfile}.projectId`,
@@ -242,7 +230,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId); baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId);
} }
let query = baseQuery const query = baseQuery
.leftJoin( .leftJoin(
TableName.PkiEstEnrollmentConfig, TableName.PkiEstEnrollmentConfig,
`${TableName.PkiCertificateProfile}.estConfigId`, `${TableName.PkiCertificateProfile}.estConfigId`,
@@ -267,52 +255,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays") db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays")
); );
if (includeMetrics) {
query = query.leftJoin(
TableName.Certificate,
`${TableName.PkiCertificateProfile}.id`,
`${TableName.Certificate}.profileId`
);
const now = new Date();
const expiringDate = new Date();
expiringDate.setDate(now.getDate() + expiringDays);
query = query
.select(
selectAllTableCols(TableName.PkiCertificateProfile),
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
db
.ref("disableBootstrapCaValidation")
.withSchema(TableName.PkiEstEnrollmentConfig)
.as("estDisableBootstrapCaValidation"),
db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"),
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"),
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.PkiCertificateProfile}.id`,
`${TableName.PkiEstEnrollmentConfig}.id`,
`${TableName.PkiApiEnrollmentConfig}.id`
);
}
const results = (await query const results = (await query
.orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") .orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc")
.offset(offset) .offset(offset)
@@ -353,17 +295,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
apiConfig apiConfig
}; };
if (includeMetrics) {
return {
...baseProfile,
total_certificates: result.total_certificates,
active_certificates: result.active_certificates,
expired_certificates: result.expired_certificates,
expiring_certificates: result.expiring_certificates,
revoked_certificates: result.revoked_certificates
} as TCertificateProfileWithRawMetrics & TCertificateProfileWithConfigs;
}
return baseProfile as TCertificateProfileWithConfigs; return baseProfile as TCertificateProfileWithConfigs;
}); });
} catch (error) { } catch (error) {
@@ -485,45 +416,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
} }
}; };
const getProfileMetrics = async (
profileId: string,
expiringDays: number = 7,
tx?: Knex
): Promise<TCertificateProfileMetrics> => {
try {
const now = new Date();
const expiringDate = new Date();
expiringDate.setDate(now.getDate() + expiringDays);
const metrics = await (tx || db)(TableName.Certificate)
.where("profileId", profileId)
.select(
db.raw("COUNT(*) as total_certificates"),
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 "revokedAt" IS NULL AND "notAfter" > ? AND "notAfter" <= ? THEN 1 END) as expiring_certificates',
[now, expiringDate]
),
db.raw('COUNT(CASE WHEN "revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates')
)
.first();
return {
profileId,
totalCertificates: parseInt(String((metrics as Record<string, unknown>)?.total_certificates || 0), 10),
activeCertificates: parseInt(String((metrics as Record<string, unknown>)?.active_certificates || 0), 10),
expiredCertificates: parseInt(String((metrics as Record<string, unknown>)?.expired_certificates || 0), 10),
expiringCertificates: parseInt(String((metrics as Record<string, unknown>)?.expiring_certificates || 0), 10),
revokedCertificates: parseInt(String((metrics as Record<string, unknown>)?.revoked_certificates || 0), 10)
};
} catch (error) {
throw new DatabaseError({ error, name: "Get certificate profile metrics" });
}
};
const isProfileInUse = async (profileId: string, tx?: Knex) => { const isProfileInUse = async (profileId: string, tx?: Knex) => {
try { try {
const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first(); const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first();
@@ -546,7 +438,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
countByProjectId, countByProjectId,
findByNameAndProjectId, findByNameAndProjectId,
getCertificatesByProfile, getCertificatesByProfile,
getProfileMetrics,
isProfileInUse isProfileInUse
}; };
}; };
@@ -127,8 +127,3 @@ export const listCertificatesByProfileSchema = z.object({
status: z.enum(["active", "expired", "revoked"]).optional(), status: z.enum(["active", "expired", "revoked"]).optional(),
search: z.string().optional() search: z.string().optional()
}); });
export const getCertificateProfileMetricsSchema = z.object({
profileId: z.string().uuid(),
expiringDays: z.coerce.number().min(1).max(365).default(30)
});
@@ -47,7 +47,6 @@ describe("CertificateProfileService", () => {
findByNameAndProjectId: vi.fn(), findByNameAndProjectId: vi.fn(),
findByIdWithConfigs: vi.fn(), findByIdWithConfigs: vi.fn(),
getCertificatesByProfile: vi.fn(), getCertificatesByProfile: vi.fn(),
getProfileMetrics: vi.fn(),
isProfileInUse: vi.fn(), isProfileInUse: vi.fn(),
transaction: vi.fn(), transaction: vi.fn(),
find: vi.fn(), find: vi.fn(),
@@ -493,9 +492,7 @@ describe("CertificateProfileService", () => {
limit: 20, limit: 20,
search: undefined, search: undefined,
enrollmentType: undefined, enrollmentType: undefined,
caId: undefined, caId: undefined
includeMetrics: false,
expiringDays: 30
}); });
}); });
@@ -515,51 +512,7 @@ describe("CertificateProfileService", () => {
limit: 5, limit: 5,
search: "test", search: "test",
enrollmentType: EnrollmentType.API, 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
}); });
}); });
}); });
@@ -659,54 +612,6 @@ describe("CertificateProfileService", () => {
}); });
}); });
describe("getProfileMetrics", () => {
const mockMetrics = {
profileId: "profile-123",
totalCertificates: 10,
activeCertificates: 8,
expiredCertificates: 1,
expiringCertificates: 2,
revokedCertificates: 1
};
beforeEach(() => {
(mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
(mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(mockMetrics);
});
it("should get profile metrics successfully", async () => {
const result = await service.getProfileMetrics({
...mockActor,
profileId: "profile-123"
});
expect(result).toEqual(mockMetrics);
expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123");
expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 30);
});
it("should get profile metrics with custom expiring days", async () => {
await service.getProfileMetrics({
...mockActor,
profileId: "profile-123",
expiringDays: 60
});
expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 60);
});
it("should throw NotFoundError when profile not found", async () => {
(mockCertificateProfileDAL.findById as any).mockResolvedValue(null);
await expect(
service.getProfileMetrics({
...mockActor,
profileId: "profile-123"
})
).rejects.toThrow(NotFoundError);
});
});
describe("comprehensive certificate profile scenarios", () => { describe("comprehensive certificate profile scenarios", () => {
describe("profile configuration validation", () => { describe("profile configuration validation", () => {
it("should validate EST enrollment configuration", async () => { it("should validate EST enrollment configuration", async () => {
@@ -929,53 +834,6 @@ describe("CertificateProfileService", () => {
}); });
}); });
describe("metrics and monitoring", () => {
it("should calculate profile metrics correctly", async () => {
const detailedMetrics = {
profileId: "profile-123",
totalCertificates: 50,
activeCertificates: 40,
expiredCertificates: 5,
expiringCertificates: 3,
revokedCertificates: 2
};
(mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
(mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(detailedMetrics);
const result = await service.getProfileMetrics({
...mockActor,
profileId: "profile-123",
expiringDays: 14
});
expect(result).toEqual(detailedMetrics);
expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 14);
});
it("should handle zero certificate metrics", async () => {
const emptyMetrics = {
profileId: "profile-123",
totalCertificates: 0,
activeCertificates: 0,
expiredCertificates: 0,
expiringCertificates: 0,
revokedCertificates: 0
};
(mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
(mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(emptyMetrics);
const result = await service.getProfileMetrics({
...mockActor,
profileId: "profile-123"
});
expect(result.totalCertificates).toBe(0);
expect(result.activeCertificates).toBe(0);
});
});
describe("error scenarios", () => { describe("error scenarios", () => {
it("should handle database connection errors gracefully", async () => { it("should handle database connection errors gracefully", async () => {
(mockCertificateProfileDAL.findById as any).mockRejectedValue(new Error("Database connection failed")); (mockCertificateProfileDAL.findById as any).mockRejectedValue(new Error("Database connection failed"));
@@ -27,10 +27,8 @@ import {
TCertificateProfile, TCertificateProfile,
TCertificateProfileCertificate, TCertificateProfileCertificate,
TCertificateProfileInsert, TCertificateProfileInsert,
TCertificateProfileMetrics,
TCertificateProfileUpdate, TCertificateProfileUpdate,
TCertificateProfileWithConfigs, TCertificateProfileWithConfigs
TCertificateProfileWithRawMetrics
} from "./certificate-profile-types"; } from "./certificate-profile-types";
const validateAndEncryptPemCaChain = async ( const validateAndEncryptPemCaChain = async (
@@ -361,18 +359,14 @@ export const certificateProfileServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
profileId, profileId
includeMetrics = false,
expiringDays = 30
}: { }: {
actor: ActorType; actor: ActorType;
actorId: string; actorId: string;
actorAuthMethod: ActorAuthMethod; actorAuthMethod: ActorAuthMethod;
actorOrgId: string; actorOrgId: string;
profileId: string; profileId: string;
includeMetrics?: boolean; }): Promise<TCertificateProfile> => {
expiringDays?: number;
}): Promise<TCertificateProfile & { metrics?: TCertificateProfileMetrics }> => {
const profile = await certificateProfileDAL.findById(profileId); const profile = await certificateProfileDAL.findById(profileId);
if (!profile) { if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" }); throw new NotFoundError({ message: "Certificate profile not found" });
@@ -393,14 +387,6 @@ export const certificateProfileServiceFactory = ({
const converted = convertDalToService(profile); const converted = convertDalToService(profile);
if (includeMetrics) {
const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays);
return {
...converted,
metrics
};
}
return converted; return converted;
}; };
@@ -506,9 +492,7 @@ export const certificateProfileServiceFactory = ({
limit = 20, limit = 20,
search, search,
enrollmentType, enrollmentType,
caId, caId
includeMetrics = false,
expiringDays = 30
}: { }: {
actor: ActorType; actor: ActorType;
actorId: string; actorId: string;
@@ -520,10 +504,8 @@ export const certificateProfileServiceFactory = ({
search?: string; search?: string;
enrollmentType?: EnrollmentType; enrollmentType?: EnrollmentType;
caId?: string; caId?: string;
includeMetrics?: boolean;
expiringDays?: number;
}): Promise<{ }): Promise<{
profiles: (TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics })[]; profiles: TCertificateProfileWithConfigs[];
totalCount: number; totalCount: number;
}> => { }> => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
@@ -544,9 +526,7 @@ export const certificateProfileServiceFactory = ({
limit, limit,
search, search,
enrollmentType, enrollmentType,
caId, caId
includeMetrics,
expiringDays
}); });
const totalCount = await certificateProfileDAL.countByProjectId(projectId, { const totalCount = await certificateProfileDAL.countByProjectId(projectId, {
@@ -591,27 +571,12 @@ export const certificateProfileServiceFactory = ({
} }
const converted = convertDalToService(profileWithConfigs); const converted = convertDalToService(profileWithConfigs);
let result: TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics } = { const result: TCertificateProfileWithConfigs = {
...converted, ...converted,
estConfig: decryptedEstConfig, estConfig: decryptedEstConfig,
apiConfig: profileWithConfigs.apiConfig apiConfig: profileWithConfigs.apiConfig
}; };
if (includeMetrics) {
const profileWithMetrics = profile as TCertificateProfileWithRawMetrics;
result = {
...result,
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 result; return result;
}) })
); );
@@ -709,43 +674,6 @@ export const certificateProfileServiceFactory = ({
return certificates; return certificates;
}; };
const getProfileMetrics = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
profileId,
expiringDays = 30
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
profileId: string;
expiringDays?: number;
}): Promise<TCertificateProfileMetrics> => {
const profile = await certificateProfileDAL.findById(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: profile.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Read,
ProjectPermissionSub.CertificateProfiles
);
const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays);
return metrics;
};
const getEstConfigurationByProfile = async ( const getEstConfigurationByProfile = async (
params: params:
| { | {
@@ -818,7 +746,6 @@ export const certificateProfileServiceFactory = ({
listProfiles, listProfiles,
deleteProfile, deleteProfile,
getProfileCertificates, getProfileCertificates,
getProfileMetrics,
getEstConfigurationByProfile getEstConfigurationByProfile
}; };
}; };
@@ -54,18 +54,8 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
autoRenew: boolean; autoRenew: boolean;
renewBeforeDays?: number; renewBeforeDays?: number;
}; };
metrics?: TCertificateProfileMetrics;
}; };
export interface TCertificateProfileMetrics {
profileId: string;
totalCertificates: number;
activeCertificates: number;
expiredCertificates: number;
expiringCertificates: number;
revokedCertificates: number;
}
export interface TCertificateProfileCertificate { export interface TCertificateProfileCertificate {
id: string; id: string;
serialNumber: string; serialNumber: string;
@@ -76,11 +66,3 @@ export interface TCertificateProfileCertificate {
revokedAt: Date | null; revokedAt: Date | null;
createdAt: Date; createdAt: Date;
} }
export type TCertificateProfileWithRawMetrics = TCertificateProfile & {
total_certificates?: string;
active_certificates?: string;
expired_certificates?: string;
expiring_certificates?: string;
revoked_certificates?: string;
};
@@ -180,6 +180,7 @@ export const certificateSyncDALFactory = (db: TDbClient) => {
certificateDetails: (TCertificateSyncs & { certificateDetails: (TCertificateSyncs & {
certificateSerialNumber?: string; certificateSerialNumber?: string;
certificateCommonName?: string; certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string; certificateStatus?: string;
certificateNotBefore?: Date; certificateNotBefore?: Date;
certificateNotAfter?: Date; certificateNotAfter?: Date;
@@ -211,6 +212,7 @@ export const certificateSyncDALFactory = (db: TDbClient) => {
.select( .select(
db.ref("serialNumber").withSchema(TableName.Certificate).as("certificateSerialNumber"), db.ref("serialNumber").withSchema(TableName.Certificate).as("certificateSerialNumber"),
db.ref("commonName").withSchema(TableName.Certificate).as("certificateCommonName"), db.ref("commonName").withSchema(TableName.Certificate).as("certificateCommonName"),
db.ref("altNames").withSchema(TableName.Certificate).as("certificateAltNames"),
db.ref("status").withSchema(TableName.Certificate).as("certificateStatus"), db.ref("status").withSchema(TableName.Certificate).as("certificateStatus"),
db.ref("notBefore").withSchema(TableName.Certificate).as("certificateNotBefore"), db.ref("notBefore").withSchema(TableName.Certificate).as("certificateNotBefore"),
db.ref("notAfter").withSchema(TableName.Certificate).as("certificateNotAfter"), db.ref("notAfter").withSchema(TableName.Certificate).as("certificateNotAfter"),
@@ -229,6 +231,7 @@ export const certificateSyncDALFactory = (db: TDbClient) => {
const certificateDetails = (await query) as (TCertificateSyncs & { const certificateDetails = (await query) as (TCertificateSyncs & {
certificateSerialNumber?: string; certificateSerialNumber?: string;
certificateCommonName?: string; certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string; certificateStatus?: string;
certificateNotBefore?: Date; certificateNotBefore?: Date;
certificateNotAfter?: Date; certificateNotAfter?: Date;
@@ -520,7 +520,7 @@ export const awsCertificateManagerPkiSyncFactory = ({
try { try {
// Small delay to ensure AWS ACM has processed the certificate import // Small delay to ensure AWS ACM has processed the certificate import
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 100); setTimeout(() => resolve(), 500);
}); });
await withRateLimitRetry( await withRateLimitRetry(
@@ -606,6 +606,7 @@ export const pkiSyncServiceFactory = ({
updatedAt: detail.updatedAt, updatedAt: detail.updatedAt,
certificateSerialNumber: detail.certificateSerialNumber || undefined, certificateSerialNumber: detail.certificateSerialNumber || undefined,
certificateCommonName: detail.certificateCommonName || undefined, certificateCommonName: detail.certificateCommonName || undefined,
certificateAltNames: detail.certificateAltNames || undefined,
certificateStatus: detail.certificateStatus || undefined, certificateStatus: detail.certificateStatus || undefined,
certificateNotBefore: detail.certificateNotBefore || undefined, certificateNotBefore: detail.certificateNotBefore || undefined,
certificateNotAfter: detail.certificateNotAfter || undefined, certificateNotAfter: detail.certificateNotAfter || undefined,
@@ -170,13 +170,14 @@ export type TPkiSyncCertificate = {
lastSyncedAt?: Date; lastSyncedAt?: Date;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
certificate?: { certificateSerialNumber?: string;
serialNumber: string; certificateCommonName?: string;
commonName: string; certificateAltNames?: string;
status: string; certificateStatus?: string;
notBefore: Date; certificateNotBefore?: Date;
notAfter: Date; certificateNotAfter?: Date;
}; pkiSyncName?: string;
pkiSyncDestination?: string;
}; };
export type TPkiSyncRaw = NonNullable<Awaited<ReturnType<TPkiSyncDALFactory["findById"]>>>; export type TPkiSyncRaw = NonNullable<Awaited<ReturnType<TPkiSyncDALFactory["findById"]>>>;
@@ -17,9 +17,9 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { import {
CertStatus, CertStatus,
@@ -299,119 +299,129 @@ export const CertificateManagementModal = ({
</div> </div>
</div> </div>
{allCertificates.length === 0 ? ( <TableContainer>
<EmptyState title="No certificates found"> <Table>
{searchTerm <THead>
? "No certificates match your search criteria." <Tr>
: "No certificates available for sync."} <Th className="w-12">
</EmptyState> <Checkbox
) : ( id="select-all-certificates"
<> isChecked={
<TableContainer> allCertificates.length > 0 &&
<Table> allCertificates.every((cert) => selectedIds.includes(cert.id))
<THead> }
<Tr> onCheckedChange={handleSelectAll}
<Th className="w-12"> />
<Checkbox </Th>
id="select-all-certificates" <Th className="w-1/3">SAN / CN</Th>
isChecked={ <Th className="w-1/4">Serial Number</Th>
allCertificates.length > 0 && <Th className="w-1/6">Issued At</Th>
allCertificates.every((cert) => selectedIds.includes(cert.id)) <Th className="w-1/6">Expires At</Th>
} </Tr>
onCheckedChange={handleSelectAll} </THead>
/> <TBody>
</Th> {allCertificates.map((cert) => {
<Th className="w-1/3">Common Name</Th> const isExpired = new Date(cert.notAfter) < new Date();
<Th className="w-1/3">Serial Number</Th> const isRevoked = cert.status === CertStatus.REVOKED;
<Th className="w-1/6">Status</Th> const cannotBeAdded = isExpired || isRevoked;
<Th className="w-2/6">Expires</Th> const isAlreadySynced = syncedCertificateIds.includes(cert.id);
</Tr>
</THead>
<TBody>
{allCertificates.map((cert) => {
const isExpired = new Date(cert.notAfter) < new Date();
const isRevoked = cert.status === CertStatus.REVOKED;
const cannotBeAdded = isExpired || isRevoked;
const isAlreadySynced = syncedCertificateIds.includes(cert.id);
return ( let originalDisplayName = "—";
<Tr if (cert.altNames && cert.altNames.trim()) {
key={cert.id} originalDisplayName = cert.altNames.trim();
className={`cursor-pointer hover:bg-mineshaft-700 ${ } else if (cert.commonName && cert.commonName.trim()) {
cannotBeAdded && !isAlreadySynced ? "opacity-50" : "" originalDisplayName = cert.commonName.trim();
}`} }
onClick={() => {
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > 34) {
displayName = `${originalDisplayName.substring(0, 34)}...`;
isTruncated = true;
}
const truncatedSerial =
cert.serialNumber.length > 8
? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}`
: cert.serialNumber;
return (
<Tr
key={cert.id}
className={`cursor-pointer hover:bg-mineshaft-700 ${
cannotBeAdded && !isAlreadySynced ? "opacity-50" : ""
}`}
onClick={() => {
if (!cannotBeAdded || isAlreadySynced) {
handleToggleSelection(cert.id);
}
}}
>
<Td className="max-w-0" onClick={(e) => e.stopPropagation()}>
<Checkbox
id={cert.id}
isChecked={selectedIds.includes(cert.id)}
onCheckedChange={() => {
if (!cannotBeAdded || isAlreadySynced) { if (!cannotBeAdded || isAlreadySynced) {
handleToggleSelection(cert.id); handleToggleSelection(cert.id);
} }
}} }}
isDisabled={cannotBeAdded && !isAlreadySynced}
/>
</Td>
<Td className="max-w-0">
{isTruncated ? (
<Tooltip content={originalDisplayName} className="max-w-lg">
<div className="truncate">{displayName}</div>
</Tooltip>
) : (
<div className="truncate">{displayName}</div>
)}
</Td>
<Td className="max-w-0">
<div
className="font-mono text-xs text-bunker-300"
title={cert.serialNumber}
> >
<Td className="max-w-0"> {truncatedSerial}
<Checkbox </div>
id={cert.id} </Td>
isChecked={selectedIds.includes(cert.id)} <Td className="max-w-0">
onCheckedChange={() => { <span className="text-sm text-bunker-300">
if (!cannotBeAdded || isAlreadySynced) { {new Date(cert.notBefore).toLocaleDateString()}
handleToggleSelection(cert.id); </span>
} </Td>
}} <Td className="max-w-0">
isDisabled={cannotBeAdded && !isAlreadySynced} <span
/> className={`text-sm ${isExpired ? "text-red-400" : "text-bunker-300"}`}
</Td> >
<Td className="max-w-0"> {new Date(cert.notAfter).toLocaleDateString()}
<div className="truncate" title={cert.commonName}> </span>
{cert.commonName} </Td>
</div> </Tr>
</Td> );
<Td className="max-w-0"> })}
<div </TBody>
className="truncate font-mono text-xs text-bunker-300" </Table>
title={cert.serialNumber} {allCertificates.length === 0 && (
> <EmptyState title="No certificates found">
{cert.serialNumber} {searchTerm
</div> ? "No certificates match your search criteria."
</Td> : "No certificates available for sync."}
<Td className="max-w-0"> </EmptyState>
<Badge )}
variant={ </TableContainer>
cert.status === CertStatus.ACTIVE && !isExpired
? "success"
: "danger"
}
>
{(() => {
if (isRevoked) return "Revoked";
if (isExpired) return "Expired";
return cert.status === CertStatus.ACTIVE ? "Active" : cert.status;
})()}
</Badge>
</Td>
<Td className="max-w-0">
<span
className={`text-sm ${isExpired ? "text-red-400" : "text-bunker-300"}`}
>
{new Date(cert.notAfter).toLocaleDateString()}
</span>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</TableContainer>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">
<Pagination <Pagination
count={totalCount} count={totalCount}
page={currentPage} page={currentPage}
perPage={pageSize} perPage={pageSize}
onChangePage={(page: number) => setCurrentPage(page)} onChangePage={(page: number) => setCurrentPage(page)}
onChangePerPage={() => {}} onChangePerPage={() => {}}
/> />
</div> </div>
)}
</>
)} )}
</div> </div>
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { faEdit, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faCertificate, faEdit, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
@@ -13,6 +13,7 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
@@ -76,25 +77,71 @@ export const PkiSyncCertificatesFields = () => {
> >
Add Certificates Add Certificates
</Button> </Button>
{selectedCertificates.length === 0 ? ( <div className="max-h-64 overflow-y-auto">
<EmptyState title="No certificates selected" icon={faPlus} /> <TableContainer>
) : ( <Table>
<div className="max-h-64 overflow-y-auto"> <THead>
<TableContainer> <Tr>
<Table> <Th className="w-1/3">SAN / CN</Th>
<THead> <Th className="w-1/4">Serial Number</Th>
<Tr> <Th className="w-1/6">Issued At</Th>
<Th className="w-2/5">Common Name</Th> <Th className="w-1/6">Expires At</Th>
<Th className="w-2/5">Serial Number</Th> <Th className="w-12">Remove</Th>
<Th className="w-1/5">Remove</Th> </Tr>
</Tr> </THead>
</THead> <TBody>
<TBody> {selectedCertificates.map((cert) => {
{selectedCertificates.map((cert) => ( let originalDisplayName = "—";
if (cert.altNames && cert.altNames.trim()) {
originalDisplayName = cert.altNames.trim();
} else if (cert.commonName && cert.commonName.trim()) {
originalDisplayName = cert.commonName.trim();
}
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > 34) {
displayName = `${originalDisplayName.substring(0, 34)}...`;
isTruncated = true;
}
const truncatedSerial =
cert.serialNumber.length > 8
? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}`
: cert.serialNumber;
const isExpired = new Date(cert.notAfter) < new Date();
return (
<Tr key={cert.id}> <Tr key={cert.id}>
<Td className="max-w-xs truncate">{cert.commonName}</Td> <Td className="max-w-0">
<Td className="font-mono text-xs text-bunker-300"> {isTruncated ? (
{cert.serialNumber} <Tooltip content={originalDisplayName} className="max-w-lg">
<div className="truncate">{displayName}</div>
</Tooltip>
) : (
<div className="truncate">{displayName}</div>
)}
</Td>
<Td className="max-w-0">
<div
className="font-mono text-xs text-bunker-300"
title={cert.serialNumber}
>
{truncatedSerial}
</div>
</Td>
<Td className="max-w-0">
<span className="text-sm text-bunker-300">
{new Date(cert.notBefore).toLocaleDateString()}
</span>
</Td>
<Td className="max-w-0">
<span
className={`text-sm ${isExpired ? "text-red-400" : "text-bunker-300"}`}
>
{new Date(cert.notAfter).toLocaleDateString()}
</span>
</Td> </Td>
<Td> <Td>
<Button <Button
@@ -112,12 +159,15 @@ export const PkiSyncCertificatesFields = () => {
</Button> </Button>
</Td> </Td>
</Tr> </Tr>
))} );
</TBody> })}
</Table> </TBody>
</TableContainer> </Table>
</div> {selectedCertificates.length === 0 && (
)} <EmptyState title="No certificates selected" icon={faCertificate} />
)}
</TableContainer>
</div>
</div> </div>
</FormControl> </FormControl>
)} )}
@@ -1,6 +1,16 @@
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { GenericFieldLabel } from "@app/components/v2"; import {
GenericFieldLabel,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v3"; import { Badge } from "@app/components/v3";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
@@ -45,20 +55,71 @@ export const PkiSyncReviewFields = () => {
<div className="w-full border-b border-mineshaft-600"> <div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Certificates</span> <span className="text-sm text-mineshaft-300">Certificates</span>
</div> </div>
<div className="flex flex-wrap gap-x-8 gap-y-2"> <div className="w-full">
<div> {selectedCertificates.length === 0 ? (
{selectedCertificates.length === 0 ? ( <span className="text-bunker-400">No certificates selected</span>
<span className="text-bunker-400">No certificates selected</span> ) : (
) : ( <TableContainer>
<div className="space-y-1"> <Table>
{selectedCertificates.map((cert) => ( <THead>
<div key={cert.id} className="text-sm"> <Tr>
{cert.commonName} <Th className="w-1/2">SAN / CN</Th>
</div> <Th className="w-1/4">Serial Number</Th>
))} <Th className="w-1/4">Expires At</Th>
</div> </Tr>
)} </THead>
</div> <TBody>
{selectedCertificates.map((cert) => {
let originalDisplayName = "—";
if (cert.altNames && cert.altNames.trim()) {
originalDisplayName = cert.altNames.trim();
} else if (cert.commonName && cert.commonName.trim()) {
originalDisplayName = cert.commonName.trim();
}
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > 34) {
displayName = `${originalDisplayName.substring(0, 34)}...`;
isTruncated = true;
}
const truncatedSerial =
cert.serialNumber.length > 8
? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}`
: cert.serialNumber;
return (
<Tr key={cert.id}>
<Td className="max-w-0">
{isTruncated ? (
<Tooltip content={originalDisplayName} className="max-w-lg">
<div className="truncate">{displayName}</div>
</Tooltip>
) : (
<div className="truncate">{displayName}</div>
)}
</Td>
<Td className="max-w-0">
<div
className="font-mono text-xs text-bunker-300"
title={cert.serialNumber}
>
{truncatedSerial}
</div>
</Td>
<Td className="max-w-0">
<span className="text-sm text-bunker-300">
{new Date(cert.notAfter).toLocaleDateString()}
</span>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</TableContainer>
)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -79,11 +140,13 @@ export const PkiSyncReviewFields = () => {
<div className="w-full border-b border-mineshaft-600"> <div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Sync Options</span> <span className="text-sm text-mineshaft-300">Sync Options</span>
</div> </div>
<div className="flex flex-wrap gap-x-8 gap-y-2"> <div className="flex flex-wrap gap-x-8 gap-y-3">
<GenericFieldLabel label="Auto-Sync"> <GenericFieldLabel label="Auto-Sync">
<Badge variant={isAutoSyncEnabled ? "success" : "danger"}> <div className="mt-1">
{isAutoSyncEnabled ? "Enabled" : "Disabled"} <Badge variant={isAutoSyncEnabled ? "success" : "danger"}>
</Badge> {isAutoSyncEnabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</GenericFieldLabel> </GenericFieldLabel>
{/* Hidden for now - Import certificates functionality disabled {/* Hidden for now - Import certificates functionality disabled
{syncOptions?.canImportCertificates !== undefined && ( {syncOptions?.canImportCertificates !== undefined && (
@@ -96,9 +159,11 @@ export const PkiSyncReviewFields = () => {
*/} */}
{syncOptions?.canRemoveCertificates !== undefined && ( {syncOptions?.canRemoveCertificates !== undefined && (
<GenericFieldLabel label="Remove Certificates"> <GenericFieldLabel label="Remove Certificates">
<Badge variant={syncOptions.canRemoveCertificates ? "success" : "danger"}> <div className="mt-1">
{syncOptions.canRemoveCertificates ? "Enabled" : "Disabled"} <Badge variant={syncOptions.canRemoveCertificates ? "success" : "danger"}>
</Badge> {syncOptions.canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge>
</div>
</GenericFieldLabel> </GenericFieldLabel>
)} )}
</div> </div>
@@ -0,0 +1,100 @@
import { ReactNode } from "react";
import { Tooltip } from "@app/components/v2";
interface CertificateNameData {
altNames?: string | null;
commonName?: string | null;
certificateAltNames?: string | null;
certificateCommonName?: string | null;
}
interface DisplayNameResult {
originalDisplayName: string;
displayName: string;
isTruncated: boolean;
}
/**
* Extracts and formats the display name for a certificate from SAN/CN data
* @param cert - Certificate object with potential altNames/commonName fields
* @param maxLength - Maximum length before truncating (default: 64)
* @param fallback - Fallback text when no name is found (default: "—")
* @returns Object with original name, truncated name, and truncation flag
*/
export const getCertificateDisplayName = (
cert: CertificateNameData,
maxLength: number = 64,
fallback: string = "—"
): DisplayNameResult => {
// Extract original display name - prioritize SAN over CN
let originalDisplayName = fallback;
// Handle different property name variations
const altNames = cert.altNames || cert.certificateAltNames;
const commonName = cert.commonName || cert.certificateCommonName;
if (altNames && altNames.trim()) {
originalDisplayName = altNames.trim();
} else if (commonName && commonName.trim()) {
originalDisplayName = commonName.trim();
}
// Handle truncation
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > maxLength) {
displayName = `${originalDisplayName.substring(0, maxLength)}...`;
isTruncated = true;
}
return {
originalDisplayName,
displayName,
isTruncated
};
};
/**
* Renders a certificate display name with optional tooltip for truncated names
* @param cert - Certificate object with potential altNames/commonName fields
* @param maxLength - Maximum length before truncating (default: 64)
* @param fallback - Fallback text when no name is found (default: "—")
* @param className - Optional CSS class for the display element
* @param tooltipClassName - Optional CSS class for the tooltip (default: "max-w-lg")
* @returns JSX element with certificate name and optional tooltip
*/
export const CertificateDisplayName = ({
cert,
maxLength = 64,
fallback = "—",
className = "truncate",
tooltipClassName = "max-w-lg"
}: {
cert: CertificateNameData;
maxLength?: number;
fallback?: string;
className?: string;
tooltipClassName?: string;
}): ReactNode => {
const { originalDisplayName, displayName, isTruncated } = getCertificateDisplayName(
cert,
maxLength,
fallback
);
if (isTruncated) {
return (
<Tooltip content={originalDisplayName} className={tooltipClassName}>
<div className={className}>{displayName}</div>
</Tooltip>
);
}
return (
<div className={className} title={originalDisplayName}>
{displayName}
</div>
);
};
+7 -1
View File
@@ -152,7 +152,7 @@ export const useCreateCertificate = () => {
}); });
}; };
export const useCreateCertificateV3 = () => { export const useCreateCertificateV3 = (options?: { projectId?: string }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<TCreateCertificateV3Response, object, TCreateCertificateV3DTO>({ return useMutation<TCreateCertificateV3Response, object, TCreateCertificateV3DTO>({
mutationFn: async (body) => { mutationFn: async (body) => {
@@ -167,6 +167,12 @@ export const useCreateCertificateV3 = () => {
queryKey: projectKeys.forProjectCertificates(projectSlug) queryKey: projectKeys.forProjectCertificates(projectSlug)
}); });
if (options?.projectId) {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(options.projectId)
});
}
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["certificate-profiles"] queryKey: ["certificate-profiles"]
}); });
@@ -8,7 +8,6 @@ export {
useGetCertificateProfileById, useGetCertificateProfileById,
useGetCertificateProfileBySlug, useGetCertificateProfileBySlug,
useGetProfileCertificates, useGetProfileCertificates,
useGetProfileMetrics,
useListCertificateProfiles useListCertificateProfiles
} from "./queries"; } from "./queries";
export type * from "./types"; export type * from "./types";
@@ -4,7 +4,6 @@ import { apiRequest } from "@app/config/request";
import { import {
TCertificateProfile, TCertificateProfile,
TCertificateProfileMetrics,
TCertificateProfileWithDetails, TCertificateProfileWithDetails,
TGetCertificateProfileByIdDTO, TGetCertificateProfileByIdDTO,
TGetCertificateProfileBySlugDTO, TGetCertificateProfileBySlugDTO,
@@ -20,7 +19,6 @@ export const certificateProfileKeys = {
limit?: number; limit?: number;
offset?: number; offset?: number;
search?: string; search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean; includeConfigs?: boolean;
enrollmentType?: string; enrollmentType?: string;
expiringDays?: number; expiringDays?: number;
@@ -51,10 +49,8 @@ export const useListCertificateProfiles = ({
limit = 20, limit = 20,
offset = 0, offset = 0,
search, search,
includeMetrics = false,
includeConfigs = false, includeConfigs = false,
enrollmentType, enrollmentType
expiringDays = 7
}: TListCertificateProfilesDTO) => { }: TListCertificateProfilesDTO) => {
return useQuery({ return useQuery({
queryKey: certificateProfileKeys.list({ queryKey: certificateProfileKeys.list({
@@ -62,10 +58,8 @@ export const useListCertificateProfiles = ({
limit, limit,
offset, offset,
search, search,
includeMetrics,
includeConfigs, includeConfigs,
enrollmentType, enrollmentType
expiringDays
}), }),
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.get<{ const { data } = await apiRequest.get<{
@@ -77,10 +71,8 @@ export const useListCertificateProfiles = ({
limit, limit,
offset, offset,
search, search,
includeMetrics,
includeConfigs, includeConfigs,
enrollmentType, enrollmentType
expiringDays
} }
}); });
return data; return data;
@@ -145,18 +137,3 @@ export const useGetProfileCertificates = ({
enabled: Boolean(profileId) 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)
});
};
@@ -10,7 +10,6 @@ export type TCertificateProfile = {
apiConfigId?: string; apiConfigId?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
metrics?: TCertificateProfileMetrics;
}; };
export type TCertificateProfileWithDetails = TCertificateProfile & { export type TCertificateProfileWithDetails = TCertificateProfile & {
@@ -81,10 +80,8 @@ export type TListCertificateProfilesDTO = {
limit?: number; limit?: number;
offset?: number; offset?: number;
search?: string; search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean; includeConfigs?: boolean;
enrollmentType?: "api" | "est"; enrollmentType?: "api" | "est";
expiringDays?: number;
}; };
export type TGetCertificateProfileByIdDTO = { export type TGetCertificateProfileByIdDTO = {
@@ -96,15 +93,6 @@ export type TGetCertificateProfileBySlugDTO = {
slug: string; slug: string;
}; };
export type TCertificateProfileMetrics = {
profileId: string;
totalCertificates: number;
activeCertificates: number;
expiredCertificates: number;
expiringCertificates: number;
revokedCertificates: number;
};
export type TProfileCertificate = { export type TProfileCertificate = {
id: string; id: string;
serialNumber: string; serialNumber: string;
@@ -126,5 +114,4 @@ export type TGetProfileCertificatesDTO = {
export type TGetProfileMetricsDTO = { export type TGetProfileMetricsDTO = {
profileId: string; profileId: string;
expiringDays?: number;
}; };
@@ -9,6 +9,7 @@ export type TCertificate = {
friendlyName: string; friendlyName: string;
commonName: string; commonName: string;
subjectAltNames: string; subjectAltNames: string;
altNames?: string;
serialNumber: string; serialNumber: string;
notBefore: string; notBefore: string;
notAfter: string; notAfter: string;
@@ -57,6 +57,7 @@ export type TPkiSyncCertificate = {
updatedAt: string; updatedAt: string;
certificateSerialNumber?: string; certificateSerialNumber?: string;
certificateCommonName?: string; certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string; certificateStatus?: string;
certificateNotBefore?: Date; certificateNotBefore?: Date;
certificateNotAfter?: Date; certificateNotAfter?: Date;
@@ -52,9 +52,7 @@ export const PkiManagerLayout = () => {
projectId: currentProject.id projectId: currentProject.id
}} }}
> >
{({ isActive }) => ( {({ isActive }) => <Tab value={isActive ? "selected" : ""}>Certificates</Tab>}
<Tab value={isActive ? "selected" : ""}>Certificate Management</Tab>
)}
</Link> </Link>
<Link <Link
to="/projects/cert-management/$projectId/certificate-authorities" to="/projects/cert-management/$projectId/certificate-authorities"
@@ -122,11 +122,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const { data: profilesData } = useListCertificateProfiles({ const { data: profilesData } = useListCertificateProfiles({
projectId: currentProject?.id || "", projectId: currentProject?.id || "",
includeMetrics: false,
enrollmentType: "api" enrollmentType: "api"
}); });
const { mutateAsync: createCertificate } = useCreateCertificateV3(); const { mutateAsync: createCertificate } = useCreateCertificateV3({
projectId: currentProject?.id
});
const formResolver = useMemo(() => { const formResolver = useMemo(() => {
return zodResolver(createSchema(shouldShowSubjectSection)); return zodResolver(createSchema(shouldShowSubjectSection));
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { import {
faBan, faBan,
faCertificate, faCertificate,
faClockRotateLeft,
faEllipsis, faEllipsis,
faEye, faEye,
faFileExport, faFileExport,
@@ -11,11 +12,14 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
import { CircleQuestionMarkIcon } from "lucide-react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import {
CertificateDisplayName,
getCertificateDisplayName
} from "@app/components/utilities/certificateDisplayUtils";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -45,7 +49,6 @@ import { caSupportsCapability } from "@app/hooks/api/ca/constants";
import { CaCapability, CaType } from "@app/hooks/api/ca/enums"; import { CaCapability, CaType } from "@app/hooks/api/ca/enums";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import { CertStatus } from "@app/hooks/api/certificates/enums"; import { CertStatus } from "@app/hooks/api/certificates/enums";
import { TCertificate } from "@app/hooks/api/certificates/types";
import { useListWorkspaceCertificates } from "@app/hooks/api/projects"; import { useListWorkspaceCertificates } from "@app/hooks/api/projects";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -58,93 +61,6 @@ const isExpiringWithinOneDay = (notAfter: string): boolean => {
return expiryDate <= oneDayFromNow; return expiryDate <= oneDayFromNow;
}; };
const getAutoRenewalInfo = (certificate: TCertificate) => {
if (certificate.renewedByCertificateId) {
return { text: "Renewed", variant: "neutral" as const };
}
const isRevoked = certificate.status === CertStatus.REVOKED;
const isExpired = new Date(certificate.notAfter) < new Date();
const hasNoProfile = !certificate.profileId;
const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter);
if (isRevoked) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Renewal is not available for revoked certificates"
};
}
if (isExpired) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Renewal is not available for expired certificates"
};
}
if (hasNoProfile) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Renewal requires a certificate profile"
};
}
if (certificate.hasPrivateKey === false) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Renewal is not available for certificates with externally generated private keys"
};
}
if (isExpiringWithinDay) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Auto-renewal is not available for certificates expiring within 24 hours"
};
}
if (certificate.renewalError) {
return {
text: "Failed",
variant: "danger" as const,
tooltip: certificate.renewalError
};
}
if (!certificate.renewBeforeDays) {
return { text: "Auto-Renewal Disabled", variant: "warning" as const };
}
const notAfterDate = new Date(certificate.notAfter);
const renewalDate = new Date(
notAfterDate.getTime() - certificate.renewBeforeDays * 24 * 60 * 60 * 1000
);
const now = new Date();
if (renewalDate <= now) {
return { text: "Due Now", variant: "danger" as const };
}
const daysUntilRenewal = Math.floor(
(renewalDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
);
if (daysUntilRenewal === 0) {
return { text: "Renews today", variant: "warning" as const };
}
if (daysUntilRenewal <= 7) {
return { text: `Renews in ${daysUntilRenewal}d`, variant: "warning" as const };
}
return { text: `Renews in ${daysUntilRenewal}d`, variant: "success" as const };
};
type Props = { type Props = {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState< popUpName: keyof UsePopUpState<
@@ -236,20 +152,18 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Common Name</Th> <Th className="w-1/2">SAN / CN</Th>
<Th>Status</Th> <Th className="w-1/6">Status</Th>
<Th>Not Before</Th> <Th className="w-1/6">Not Before</Th>
<Th>Not After</Th> <Th className="w-1/6">Not After</Th>
<Th>Renewal Status</Th> <Th className="w-12" />
<Th />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isPending && <TableSkeleton columns={5} innerKey="project-cas" />} {isPending && <TableSkeleton columns={4} innerKey="project-cas" />}
{!isPending && {!isPending &&
data?.certificates.map((certificate) => { data?.certificates.map((certificate) => {
const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter); const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter);
const autoRenewalInfo = getAutoRenewalInfo(certificate);
const isRevoked = certificate.status === CertStatus.REVOKED; const isRevoked = certificate.status === CertStatus.REVOKED;
const isExpired = new Date(certificate.notAfter) < new Date(); const isExpired = new Date(certificate.notAfter) < new Date();
@@ -258,9 +172,24 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
const isAutoRenewalEnabled = Boolean( const isAutoRenewalEnabled = Boolean(
certificate.renewBeforeDays && certificate.renewBeforeDays > 0 certificate.renewBeforeDays && certificate.renewBeforeDays > 0
); );
const canShowAutoRenewalIcon = Boolean(
certificate.profileId &&
certificate.hasPrivateKey !== false &&
!certificate.renewedByCertificateId &&
!isRevoked &&
!isExpired &&
!isExpiringWithinDay
);
// Still need originalDisplayName for other uses in the component
const { originalDisplayName } = getCertificateDisplayName(certificate, 64, "—");
return ( return (
<Tr className="h-10" key={`certificate-${certificate.id}`}> <Tr className="group h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.commonName}</Td> <Td className="max-w-0">
<CertificateDisplayName cert={certificate} maxLength={64} fallback="—" />
</Td>
<Td> <Td>
{certificate.status === CertStatus.REVOKED ? ( {certificate.status === CertStatus.REVOKED ? (
<Badge variant="danger">Revoked</Badge> <Badge variant="danger">Revoked</Badge>
@@ -278,22 +207,64 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
? format(new Date(certificate.notAfter), "yyyy-MM-dd") ? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"} : "-"}
</Td> </Td>
<Td> <Td className="flex items-center justify-end gap-2">
{autoRenewalInfo && <div
(autoRenewalInfo.tooltip ? ( className={`transition-opacity ${(() => {
<div className="flex items-center gap-2"> if (!canShowAutoRenewalIcon) return "";
<Tooltip content={autoRenewalInfo.tooltip}> if (isAutoRenewalEnabled) return "opacity-100";
<Badge variant={autoRenewalInfo.variant}> return "opacity-0 group-hover:opacity-100";
{autoRenewalInfo.text} })()}`}
<CircleQuestionMarkIcon /> >
</Badge> {canShowAutoRenewalIcon && (
</Tooltip> <Tooltip
</div> content={(() => {
) : ( if (hasFailed && certificate.renewalError) {
<Badge variant={autoRenewalInfo.variant}>{autoRenewalInfo.text}</Badge> return `Auto-renewal failed: ${certificate.renewalError}`;
))} }
</Td> if (isAutoRenewalEnabled) {
<Td className="flex justify-end"> const expiryDate = new Date(certificate.notAfter);
const now = new Date();
const daysUntilExpiry = Math.ceil(
(expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
);
const daysUntilRenewal = Math.max(
0,
daysUntilExpiry - (certificate.renewBeforeDays || 0)
);
return `Auto-renews in ${daysUntilRenewal}d`;
}
return "Set auto renewal";
})()}
>
<button
type="button"
className={(() => {
if (hasFailed) return "pr-1 text-red-500 hover:text-red-400";
return "pr-1 text-primary-500 hover:text-primary-400";
})()}
aria-label="Certificate auto-renewal"
onClick={(e) => {
e.stopPropagation();
if (hasFailed) return;
handlePopUpOpen("manageRenewal", {
certificateId: certificate.id,
commonName: originalDisplayName,
profileId: certificate.profileId || "",
renewBeforeDays: certificate.renewBeforeDays || 7,
ttlDays: Math.ceil(
(new Date(certificate.notAfter).getTime() -
new Date(certificate.notBefore).getTime()) /
(24 * 60 * 60 * 1000)
)
});
}}
>
<FontAwesomeIcon icon={faClockRotateLeft} />
</button>
</Tooltip>
)}
</div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg"> <DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400"> <div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -370,20 +341,20 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<DropdownMenuItem <DropdownMenuItem
className={twMerge( className={twMerge(
!isAllowed && !isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50" "pointer-events-none cursor-not-allowed opacity-50"
)} )}
onClick={async () => { onClick={async () => {
const notAfterDate = new Date(certificate.notAfter); const notAfterDate = new Date(certificate.notAfter);
const notBeforeDate = certificate.notBefore const notBeforeDate = certificate.notBefore
? new Date(certificate.notBefore) ? new Date(certificate.notBefore)
: new Date( : new Date(
notAfterDate.getTime() - 365 * 24 * 60 * 60 * 1000 notAfterDate.getTime() - 365 * 24 * 60 * 60 * 1000
); );
const ttlDays = Math.max( const ttlDays = Math.max(
1, 1,
Math.ceil( Math.ceil(
(notAfterDate.getTime() - notBeforeDate.getTime()) / (notAfterDate.getTime() - notBeforeDate.getTime()) /
(24 * 60 * 60 * 1000) (24 * 60 * 60 * 1000)
) )
); );
handlePopUpOpen("manageRenewal", { handlePopUpOpen("manageRenewal", {
@@ -433,7 +404,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<DropdownMenuItem <DropdownMenuItem
className={twMerge( className={twMerge(
!isAllowed && !isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50" "pointer-events-none cursor-not-allowed opacity-50"
)} )}
onClick={async () => { onClick={async () => {
await handleDisableAutoRenewal( await handleDisableAutoRenewal(
@@ -470,7 +441,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<DropdownMenuItem <DropdownMenuItem
className={twMerge( className={twMerge(
!isAllowed && !isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50" "pointer-events-none cursor-not-allowed opacity-50"
)} )}
onClick={async () => { onClick={async () => {
handlePopUpOpen("renewCertificate", { handlePopUpOpen("renewCertificate", {
@@ -532,7 +503,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<DropdownMenuItem <DropdownMenuItem
className={twMerge( className={twMerge(
!isAllowed && !isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50" "pointer-events-none cursor-not-allowed opacity-50"
)} )}
onClick={async () => onClick={async () =>
handlePopUpOpen("revokeCertificate", { handlePopUpOpen("revokeCertificate", {
@@ -12,7 +12,6 @@ import {
faToggleOff, faToggleOff,
faToggleOn, faToggleOn,
faTrash, faTrash,
faTriangleExclamation,
faXmark faXmark
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -47,7 +46,6 @@ import { useToggle } from "@app/hooks";
import { PkiSyncStatus, TPkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs"; import { PkiSyncStatus, TPkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs";
import { PkiSyncDestinationCol } from "./PkiSyncDestinationCol"; import { PkiSyncDestinationCol } from "./PkiSyncDestinationCol";
import { PkiSyncTableCell } from "./PkiSyncTableCell";
type Props = { type Props = {
pkiSync: TPkiSync; pkiSync: TPkiSync;
@@ -163,23 +161,6 @@ export const PkiSyncRow = ({
<p className="truncate text-xs leading-4 text-bunker-300">{destinationDetails.name}</p> <p className="truncate text-xs leading-4 text-bunker-300">{destinationDetails.name}</p>
</div> </div>
</Td> </Td>
{subscriberId ? (
<PkiSyncTableCell
primaryText={pkiSync.subscriber?.name || subscriberId}
secondaryText="PKI Subscriber"
/>
) : (
<Td>
<Tooltip content="The PKI subscriber for this sync has been deleted. Configure a new source or remove this sync.">
<div className="w-min">
<Badge variant="warning">
<FontAwesomeIcon icon={faTriangleExclamation} />
<span>Source Deleted</span>
</Badge>
</div>
</Tooltip>
</Td>
)}
<PkiSyncDestinationCol pkiSync={pkiSync} /> <PkiSyncDestinationCol pkiSync={pkiSync} />
<Td> <Td>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -57,7 +57,6 @@ import { PkiSyncRow } from "./PkiSyncRow";
enum PkiSyncsOrderBy { enum PkiSyncsOrderBy {
Destination = "destination", Destination = "destination",
Source = "source",
Name = "name", Name = "name",
Status = "status" Status = "status"
} }
@@ -160,14 +159,6 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
const [syncOne, syncTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; const [syncOne, syncTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) { switch (orderBy) {
case PkiSyncsOrderBy.Source:
return (syncOne.subscriber?.name ?? syncOne.subscriberId ?? "")
.toLowerCase()
.localeCompare(
syncTwo.subscriber?.name?.toLowerCase() ??
syncTwo.subscriberId?.toLowerCase() ??
""
);
case PkiSyncsOrderBy.Destination: case PkiSyncsOrderBy.Destination:
return getPkiSyncDestinationColValues(syncOne) return getPkiSyncDestinationColValues(syncOne)
.primaryText.toLowerCase() .primaryText.toLowerCase()
@@ -370,7 +361,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
<THead> <THead>
<Tr> <Tr>
<Th className="w-2" /> <Th className="w-2" />
<Th className="w-1/4"> <Th className="w-1/2">
<div className="flex items-center"> <div className="flex items-center">
Name Name
<IconButton <IconButton
@@ -383,20 +374,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
</IconButton> </IconButton>
</div> </div>
</Th> </Th>
<Th className="w-1/3"> <Th className="w-1/4">
<div className="flex items-center">
Source
<IconButton
variant="plain"
className={getClassName(PkiSyncsOrderBy.Source)}
ariaLabel="sort"
onClick={() => handleSort(PkiSyncsOrderBy.Source)}
>
<FontAwesomeIcon icon={getColSortIcon(PkiSyncsOrderBy.Source)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center"> <div className="flex items-center">
Destination Destination
<IconButton <IconButton
@@ -409,7 +387,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
</IconButton> </IconButton>
</div> </div>
</Th> </Th>
<Th className="min-w-42"> <Th className="w-1/4 min-w-42">
<div className="flex items-center"> <div className="flex items-center">
Status Status
<IconButton <IconButton
@@ -26,7 +26,7 @@ export const PkiSyncAuditLogsSection = ({ pkiSync }: Props) => {
return ( return (
<div className="flex max-h-full w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> <div className="flex max-h-full w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Sync Logs</h3> <h3 className="text-lg font-medium text-mineshaft-100">Sync Logs</h3>
{subscription.auditLogs && ( {subscription.auditLogs && (
<p className="text-xs text-bunker-300"> <p className="text-xs text-bunker-300">
Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days
@@ -1,12 +1,17 @@
import { useState } from "react"; import { useState } from "react";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { faEdit, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faCertificate, faEdit, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { CertificateManagementModal } from "@app/components/pki-syncs/CertificateManagementModal"; import { CertificateManagementModal } from "@app/components/pki-syncs/CertificateManagementModal";
import { import {
CertificateDisplayName,
getCertificateDisplayName
} from "@app/components/utilities/certificateDisplayUtils";
import {
DeleteActionModal,
EmptyState, EmptyState,
IconButton, IconButton,
Pagination, Pagination,
@@ -46,6 +51,11 @@ const getSyncStatusText = (status?: CertificateSyncStatus | null) => {
export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => { export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
const [isManageModalOpen, setIsManageModalOpen] = useState(false); const [isManageModalOpen, setIsManageModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [certificateToDelete, setCertificateToDelete] = useState<{
id: string;
displayName: string;
} | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10; const pageSize = 10;
@@ -74,6 +84,9 @@ export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
text: "Certificate removed from sync", text: "Certificate removed from sync",
type: "success" type: "success"
}); });
setIsDeleteModalOpen(false);
setCertificateToDelete(null);
} catch { } catch {
createNotification({ createNotification({
text: "Failed to remove certificate from sync", text: "Failed to remove certificate from sync",
@@ -82,13 +95,18 @@ export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
} }
}; };
const handleDeleteClick = (certificateId: string, displayName: string) => {
setCertificateToDelete({ id: certificateId, displayName });
setIsDeleteModalOpen(true);
};
const totalPages = Math.ceil(totalCount / pageSize); const totalPages = Math.ceil(totalCount / pageSize);
return ( return (
<div> <div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> <div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Certificates ({totalCount})</h3> <h3 className="text-lg font-medium text-mineshaft-100">Certificates</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}> <ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => ( {(isAllowed) => (
<IconButton <IconButton
@@ -105,108 +123,120 @@ export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
</div> </div>
<div> <div>
{syncCertificates.length === 0 ? ( <div className="space-y-4">
<EmptyState title="No certificates" icon={faPlus}> <TableContainer>
No certificates are currently synced with this PKI destination. <Table>
</EmptyState> <THead>
) : ( <Tr>
<div className="space-y-4"> <Th className="w-1/3">SAN / CN</Th>
<TableContainer> <Th className="w-1/3">Serial Number</Th>
<Table> <Th className="w-1/9">Status</Th>
<THead> <Th className="w-1/9">Expires</Th>
<Tr> <Th className="w-1/9">Actions</Th>
<Th className="w-1/3">Common Name</Th> </Tr>
<Th className="w-1/3">Serial Number</Th> </THead>
<Th className="w-1/9">Status</Th> <TBody>
<Th className="w-1/9">Expires</Th> {syncCertificates.map((syncCert) => {
<Th className="w-1/9">Actions</Th> const isExpired = syncCert.certificateNotAfter
</Tr> ? new Date(syncCert.certificateNotAfter) < new Date()
</THead> : false;
<TBody>
{syncCertificates.map((syncCert) => {
const isExpired = syncCert.certificateNotAfter
? new Date(syncCert.certificateNotAfter) < new Date()
: false;
return ( const { originalDisplayName } = getCertificateDisplayName(
<Tr key={syncCert.id}> {
<Td className="max-w-0"> altNames: syncCert.certificateAltNames,
<div commonName: syncCert.certificateCommonName
className="truncate" },
title={syncCert.certificateCommonName || "Unknown"} 34,
> "Unknown"
{syncCert.certificateCommonName || "Unknown"} );
</div>
</Td> return (
<Td className="max-w-0"> <Tr key={syncCert.id}>
<div <Td className="max-w-0">
className="truncate text-xs" <CertificateDisplayName
title={syncCert.certificateSerialNumber || "Unknown"} cert={{
> altNames: syncCert.certificateAltNames,
{syncCert.certificateSerialNumber || "Unknown"} commonName: syncCert.certificateCommonName
</div> }}
</Td> maxLength={34}
<Td> fallback="Unknown"
{syncCert.lastSyncMessage && />
syncCert.syncStatus === CertificateSyncStatus.Failed ? ( </Td>
<Tooltip content={syncCert.lastSyncMessage}> <Td className="max-w-0">
<Badge variant="danger">Failed</Badge> <div
</Tooltip> className="truncate text-xs"
) : ( title={syncCert.certificateSerialNumber || "Unknown"}
<Badge variant={getSyncStatusVariant(syncCert.syncStatus)}> >
{getSyncStatusText(syncCert.syncStatus)} {syncCert.certificateSerialNumber || "Unknown"}
</Badge> </div>
</Td>
<Td>
{syncCert.lastSyncMessage &&
syncCert.syncStatus === CertificateSyncStatus.Failed ? (
<Tooltip content={syncCert.lastSyncMessage}>
<Badge variant="danger">Failed</Badge>
</Tooltip>
) : (
<Badge variant={getSyncStatusVariant(syncCert.syncStatus)}>
{getSyncStatusText(syncCert.syncStatus)}
</Badge>
)}
</Td>
<Td>
<span
className={`text-sm ${isExpired ? "text-red-400" : "text-bunker-300"}`}
>
{syncCert.certificateNotAfter
? new Date(syncCert.certificateNotAfter).toLocaleDateString()
: "Unknown"}
</span>
</Td>
<Td className="flex items-center">
<ProjectPermissionCan
I={ProjectPermissionPkiSyncActions.Edit}
a={permissionSubject}
>
{(isAllowed) => (
<IconButton
size="xs"
variant="plain"
colorSchema="danger"
ariaLabel="Remove certificate"
isDisabled={!isAllowed}
onClick={() =>
handleDeleteClick(syncCert.certificateId, originalDisplayName)
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)} )}
</Td> </ProjectPermissionCan>
<Td> </Td>
<span </Tr>
className={`text-sm ${isExpired ? "text-red-400" : "text-bunker-300"}`} );
> })}
{syncCert.certificateNotAfter </TBody>
? new Date(syncCert.certificateNotAfter).toLocaleDateString() </Table>
: "Unknown"} {syncCertificates.length === 0 && (
</span> <EmptyState
</Td> title="No certificates are part of this certificate sync"
<Td className="flex items-center"> icon={faCertificate}
<ProjectPermissionCan />
I={ProjectPermissionPkiSyncActions.Edit}
a={permissionSubject}
>
{(isAllowed) => (
<IconButton
size="xs"
variant="plain"
colorSchema="danger"
ariaLabel="Remove certificate"
isDisabled={!isAllowed}
onClick={() => handleRemoveCertificate(syncCert.certificateId)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</TableContainer>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center">
<Pagination
count={totalCount}
page={currentPage}
perPage={pageSize}
onChangePage={(page: number) => setCurrentPage(page)}
onChangePerPage={() => {}}
/>
</div>
)} )}
</div> </TableContainer>
)} {/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center">
<Pagination
count={totalCount}
page={currentPage}
perPage={pageSize}
onChangePage={(page: number) => setCurrentPage(page)}
onChangePerPage={() => {}}
/>
</div>
)}
</div>
</div> </div>
</div> </div>
@@ -218,6 +248,23 @@ export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
refetchSyncCertificates(); refetchSyncCertificates();
}} }}
/> />
<DeleteActionModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
setCertificateToDelete(null);
}}
title="Remove Certificate from Sync"
subTitle={`Are you sure you want to remove "${certificateToDelete?.displayName}" from this PKI sync?`}
deleteKey="confirm"
onDeleteApproved={async () => {
if (certificateToDelete) {
await handleRemoveCertificate(certificateToDelete.id);
}
}}
buttonText="Remove Certificate"
/>
</div> </div>
); );
}; };
@@ -11,12 +11,15 @@ import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionC
import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs"; import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs";
import { AzureKeyVaultPkiSyncDestinationSection } from "./PkiSyncDestinationSection/index"; import {
AwsCertificateManagerPkiSyncDestinationSection,
AzureKeyVaultPkiSyncDestinationSection
} from "./PkiSyncDestinationSection/index";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => ( const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div> <div className="mb-4">
<label className="text-sm text-bunker-300">{label}</label> <p className="text-sm font-medium text-mineshaft-300">{label}</p>
<div className="mt-1">{children}</div> <div className="text-sm text-mineshaft-300">{children}</div>
</div> </div>
); );
@@ -32,6 +35,9 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
let DestinationComponents: ReactNode; let DestinationComponents: ReactNode;
switch (destination) { switch (destination) {
case PkiSync.AwsCertificateManager:
DestinationComponents = <AwsCertificateManagerPkiSyncDestinationSection pkiSync={pkiSync} />;
break;
case PkiSync.AzureKeyVault: case PkiSync.AzureKeyVault:
DestinationComponents = <AzureKeyVaultPkiSyncDestinationSection pkiSync={pkiSync} />; DestinationComponents = <AzureKeyVaultPkiSyncDestinationSection pkiSync={pkiSync} />;
break; break;
@@ -47,7 +53,7 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
return ( return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> <div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Destination Configuration</h3> <h3 className="text-lg font-medium text-mineshaft-100">Destination Configuration</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}> <ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => ( {(isAllowed) => (
<IconButton <IconButton
@@ -62,7 +68,7 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
<div className="flex w-full flex-wrap gap-8"> <div className="flex w-full flex-wrap gap-8 pt-2">
<GenericFieldLabel label={`${destinationDetails.name} Connection`}> <GenericFieldLabel label={`${destinationDetails.name} Connection`}>
{pkiSync.appConnectionName || "Default Connection"} {pkiSync.appConnectionName || "Default Connection"}
</GenericFieldLabel> </GenericFieldLabel>
@@ -0,0 +1,21 @@
import { TPkiSync } from "@app/hooks/api/pkiSyncs";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">{label}</p>
<div className="text-sm text-mineshaft-300">{children}</div>
</div>
);
type Props = {
pkiSync: TPkiSync;
};
export const AwsCertificateManagerPkiSyncDestinationSection = ({ pkiSync }: Props) => {
const region =
pkiSync.destinationConfig && "region" in pkiSync.destinationConfig
? pkiSync.destinationConfig.region
: undefined;
return <GenericFieldLabel label="AWS Region">{region || "Not specified"}</GenericFieldLabel>;
};
@@ -2,9 +2,9 @@
import { TAzureKeyVaultPkiSync } from "@app/hooks/api/pkiSyncs/types/azure-key-vault-sync"; import { TAzureKeyVaultPkiSync } from "@app/hooks/api/pkiSyncs/types/azure-key-vault-sync";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => ( const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div> <div className="mb-4">
<label className="text-sm text-bunker-300">{label}</label> <p className="text-sm font-medium text-mineshaft-300">{label}</p>
<div className="mt-1">{children}</div> <div className="text-sm text-mineshaft-300">{children}</div>
</div> </div>
); );
@@ -1 +1,2 @@
export { AwsCertificateManagerPkiSyncDestinationSection } from "./AwsCertificateManagerPkiSyncDestinationSection";
export { AzureKeyVaultPkiSyncDestinationSection } from "./AzureKeyVaultPkiSyncDestinationSection"; export { AzureKeyVaultPkiSyncDestinationSection } from "./AzureKeyVaultPkiSyncDestinationSection";
@@ -21,9 +21,9 @@ const GenericFieldLabel = ({
children: React.ReactNode; children: React.ReactNode;
labelClassName?: string; labelClassName?: string;
}) => ( }) => (
<div> <div className="mb-4">
<label className={`text-sm text-bunker-300 ${labelClassName || ""}`}>{label}</label> <p className={`text-sm font-medium text-mineshaft-300 ${labelClassName || ""}`}>{label}</p>
<div className="mt-1">{children}</div> <div className="text-sm text-mineshaft-300">{children}</div>
</div> </div>
); );
@@ -57,7 +57,7 @@ export const PkiSyncDetailsSection = ({ pkiSync, onEditDetails }: Props) => {
return ( return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> <div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Details</h3> <h3 className="text-lg font-medium text-mineshaft-100">Details</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}> <ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => ( {(isAllowed) => (
<IconButton <IconButton
@@ -72,31 +72,27 @@ export const PkiSyncDetailsSection = ({ pkiSync, onEditDetails }: Props) => {
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
<div> <div className="pt-2">
<div className="space-y-3"> <GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Name">{name}</GenericFieldLabel> <GenericFieldLabel label="Description">{description || "None"}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description || "None"}</GenericFieldLabel> {subscriber && (
<GenericFieldLabel label="Source Subscriber"> <GenericFieldLabel label="Source Subscriber">{subscriber.name}</GenericFieldLabel>
{subscriber ? subscriber.name : "Subscriber deleted"} )}
{syncStatus && (
<GenericFieldLabel label="Status">
<PkiSyncStatusBadge status={syncStatus} />
</GenericFieldLabel> </GenericFieldLabel>
{syncStatus && ( )}
<GenericFieldLabel label="Status"> {lastSyncedAt && (
<PkiSyncStatusBadge status={syncStatus} /> <GenericFieldLabel label="Last Synced">
</GenericFieldLabel> {format(new Date(lastSyncedAt), "yyyy-MM-dd, h:mm aaa")}
)} </GenericFieldLabel>
{lastSyncedAt && ( )}
<GenericFieldLabel label="Last Synced"> {syncStatus === PkiSyncStatus.Failed && failureMessage && (
{format(new Date(lastSyncedAt), "yyyy-MM-dd, h:mm aaa")} <GenericFieldLabel labelClassName="text-red" label="Last Sync Error">
</GenericFieldLabel> <p className="rounded-sm bg-mineshaft-600 p-2 text-xs break-words">{failureMessage}</p>
)} </GenericFieldLabel>
{syncStatus === PkiSyncStatus.Failed && failureMessage && ( )}
<GenericFieldLabel labelClassName="text-red" label="Last Sync Error">
<p className="rounded-sm bg-mineshaft-600 p-2 text-xs break-words">
{failureMessage}
</p>
</GenericFieldLabel>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -3,13 +3,27 @@ import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { IconButton } from "@app/components/v2"; import { IconButton } from "@app/components/v2";
import { Badge } from "@app/components/v3"; import { Badge } from "@app/components/v3";
import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
import { TPkiSync } from "@app/hooks/api/pkiSyncs"; import { TPkiSync } from "@app/hooks/api/pkiSyncs";
const GenericFieldLabel = ({
label,
children,
labelClassName
}: {
label: string;
children: React.ReactNode;
labelClassName?: string;
}) => (
<div className="mb-4">
<p className={`text-sm font-medium text-mineshaft-300 ${labelClassName || ""}`}>{label}</p>
<div className="text-sm text-mineshaft-300">{children}</div>
</div>
);
type Props = { type Props = {
pkiSync: TPkiSync; pkiSync: TPkiSync;
onEditOptions: VoidFunction; onEditOptions: VoidFunction;
@@ -28,7 +42,7 @@ export const PkiSyncOptionsSection = ({ pkiSync, onEditOptions }: Props) => {
<div> <div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> <div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Sync Options</h3> <h3 className="text-lg font-medium text-mineshaft-100">Sync Options</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}> <ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => ( {(isAllowed) => (
<IconButton <IconButton
@@ -43,21 +57,19 @@ export const PkiSyncOptionsSection = ({ pkiSync, onEditOptions }: Props) => {
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
<div> <div className="pt-1">
<div className="space-y-3"> {/* Hidden for now - Import certificates functionality disabled
{/* Hidden for now - Import certificates functionality disabled
<GenericFieldLabel label="Certificate Import"> <GenericFieldLabel label="Certificate Import">
<Badge variant={canImportCertificates ? "success" : "danger"}> <Badge variant={canImportCertificates ? "success" : "danger"}>
{canImportCertificates ? "Enabled" : "Disabled"} {canImportCertificates ? "Enabled" : "Disabled"}
</Badge> </Badge>
</GenericFieldLabel> </GenericFieldLabel>
*/} */}
<GenericFieldLabel label="Certificate Removal"> <GenericFieldLabel label="Certificate Removal" labelClassName="mb-1">
<Badge variant={canRemoveCertificates ? "success" : "danger"}> <Badge variant={canRemoveCertificates ? "success" : "danger"}>
{canRemoveCertificates ? "Enabled" : "Disabled"} {canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge> </Badge>
</GenericFieldLabel> </GenericFieldLabel>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -36,7 +36,7 @@ export const PoliciesPage = () => {
<PageHeader <PageHeader
scope={ProjectType.CertificateManager} scope={ProjectType.CertificateManager}
title="Certificate Management" title="Certificate Management"
description="Manage certificate templates, profiles, certificates, and PKI collections for unified certificate issuance" description="Streamline certificate management by creating and maintaining templates, profiles, and certificates in one place"
/> />
<Tabs <Tabs
@@ -29,8 +29,7 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
projectId: currentProject?.id || "", projectId: currentProject?.id || "",
limit: 100, limit: 100,
offset: 0, offset: 0,
includeConfigs: true, includeConfigs: true
includeMetrics: true
}); });
const profiles = data?.certificateProfiles || []; const profiles = data?.certificateProfiles || [];
@@ -42,10 +41,9 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th> <Th>Name</Th>
<Th>Enrollment Type</Th> <Th>Enrollment Method</Th>
<Th>Issuing CA</Th> <Th>Issuing CA</Th>
<Th>Certificate Template</Th> <Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
@@ -67,10 +65,9 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th> <Th>Name</Th>
<Th>Enrollment Type</Th> <Th>Enrollment Method</Th>
<Th>Issuing CA</Th> <Th>Issuing CA</Th>
<Th>Certificate Template</Th> <Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
@@ -33,43 +33,6 @@ import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal"; import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal";
const MetricsBadges = ({
metrics
}: {
metrics?: {
totalCertificates: number;
activeCertificates: number;
expiringCertificates: number;
expiredCertificates: number;
revokedCertificates: number;
};
}) => {
if (!metrics) {
return <Badge variant="warning">No metrics</Badge>;
}
if (metrics.totalCertificates === 0) {
return <Badge variant="warning">No certificates</Badge>;
}
return (
<>
{metrics.activeCertificates > 0 && (
<Badge variant="success">{metrics.activeCertificates} active</Badge>
)}
{metrics.expiringCertificates > 0 && (
<Badge variant="warning">{metrics.expiringCertificates} expiring</Badge>
)}
{metrics.expiredCertificates > 0 && (
<Badge variant="danger">{metrics.expiredCertificates} expired</Badge>
)}
{metrics.revokedCertificates > 0 && (
<Badge variant="danger">{metrics.revokedCertificates} revoked</Badge>
)}
</>
);
};
interface Props { interface Props {
profile: TCertificateProfile; profile: TCertificateProfile;
onEditProfile: (profile: TCertificateProfile) => void; onEditProfile: (profile: TCertificateProfile) => void;
@@ -118,8 +81,8 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
const getEnrollmentTypeBadge = (enrollmentType: string) => { const getEnrollmentTypeBadge = (enrollmentType: string) => {
const config = { const config = {
api: { variant: "success" as const, label: "API" }, api: { variant: "ghost" as const, label: "API" },
est: { variant: "warning" as const, label: "EST" } est: { variant: "ghost" as const, label: "EST" }
} as const; } as const;
const configKey = Object.keys(config).includes(enrollmentType) const configKey = Object.keys(config).includes(enrollmentType)
@@ -153,11 +116,6 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
{templateData?.name || profile.certificateTemplateId} {templateData?.name || profile.certificateTemplateId}
</span> </span>
</Td> </Td>
<Td>
<div className="flex flex-wrap gap-1">
<MetricsBadges metrics={profile.metrics} />
</div>
</Td>
<Td className="text-right"> <Td className="text-right">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg"> <DropdownMenuTrigger asChild className="rounded-lg">