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

View File

@@ -121,9 +121,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(),
caId: z.string().uuid().optional(),
includeMetrics: z.coerce.boolean().optional().default(false),
expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
caId: z.string().uuid().optional()
}),
response: {
200: z.object({
@@ -195,10 +193,6 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
params: z.object({
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: {
200: z.object({
certificateProfile: PkiCertificateProfilesSchema.extend({
@@ -232,16 +226,6 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean(),
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()
})
})
@@ -257,20 +241,6 @@ 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,
@@ -283,7 +253,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
}
});
return { certificateProfile: result };
return { certificateProfile };
}
});

View File

@@ -89,6 +89,7 @@ const PkiSyncCertificateSchema = z.object({
updatedAt: z.date(),
certificateSerialNumber: z.string().optional(),
certificateCommonName: z.string().optional(),
certificateAltNames: z.string().optional(),
certificateStatus: z.string().optional(),
certificateNotBefore: z.date().optional(),
certificateNotAfter: z.date().optional(),

View File

@@ -10,10 +10,8 @@ import {
TCertificateProfile,
TCertificateProfileCertificate,
TCertificateProfileInsert,
TCertificateProfileMetrics,
TCertificateProfileUpdate,
TCertificateProfileWithConfigs,
TCertificateProfileWithRawMetrics
TCertificateProfileWithConfigs
} from "./certificate-profile-types";
export type TCertificateProfileDALFactory = ReturnType<typeof certificateProfileDALFactory>;
@@ -203,21 +201,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
search?: string;
enrollmentType?: EnrollmentType;
caId?: string;
includeMetrics?: boolean;
expiringDays?: number;
} = {},
tx?: Knex
): Promise<TCertificateProfile[] | TCertificateProfileWithRawMetrics[] | TCertificateProfileWithConfigs[]> => {
): Promise<TCertificateProfile[] | TCertificateProfileWithConfigs[]> => {
try {
const {
offset = 0,
limit = 20,
search,
enrollmentType,
caId,
includeMetrics = false,
expiringDays = 7
} = options;
const { offset = 0, limit = 20, search, enrollmentType, caId } = options;
let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where(
`${TableName.PkiCertificateProfile}.projectId`,
@@ -242,7 +230,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId);
}
let query = baseQuery
const query = baseQuery
.leftJoin(
TableName.PkiEstEnrollmentConfig,
`${TableName.PkiCertificateProfile}.estConfigId`,
@@ -267,52 +255,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
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
.orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc")
.offset(offset)
@@ -353,17 +295,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
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;
});
} 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) => {
try {
const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first();
@@ -546,7 +438,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
countByProjectId,
findByNameAndProjectId,
getCertificatesByProfile,
getProfileMetrics,
isProfileInUse
};
};

View File

@@ -127,8 +127,3 @@ export const listCertificatesByProfileSchema = z.object({
status: z.enum(["active", "expired", "revoked"]).optional(),
search: z.string().optional()
});
export const getCertificateProfileMetricsSchema = z.object({
profileId: z.string().uuid(),
expiringDays: z.coerce.number().min(1).max(365).default(30)
});

View File

@@ -47,7 +47,6 @@ describe("CertificateProfileService", () => {
findByNameAndProjectId: vi.fn(),
findByIdWithConfigs: vi.fn(),
getCertificatesByProfile: vi.fn(),
getProfileMetrics: vi.fn(),
isProfileInUse: vi.fn(),
transaction: vi.fn(),
find: vi.fn(),
@@ -493,9 +492,7 @@ describe("CertificateProfileService", () => {
limit: 20,
search: undefined,
enrollmentType: undefined,
caId: undefined,
includeMetrics: false,
expiringDays: 30
caId: undefined
});
});
@@ -515,51 +512,7 @@ describe("CertificateProfileService", () => {
limit: 5,
search: "test",
enrollmentType: EnrollmentType.API,
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
caId: "ca-123"
});
});
});
@@ -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("profile configuration validation", () => {
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", () => {
it("should handle database connection errors gracefully", async () => {
(mockCertificateProfileDAL.findById as any).mockRejectedValue(new Error("Database connection failed"));

View File

@@ -27,10 +27,8 @@ import {
TCertificateProfile,
TCertificateProfileCertificate,
TCertificateProfileInsert,
TCertificateProfileMetrics,
TCertificateProfileUpdate,
TCertificateProfileWithConfigs,
TCertificateProfileWithRawMetrics
TCertificateProfileWithConfigs
} from "./certificate-profile-types";
const validateAndEncryptPemCaChain = async (
@@ -361,18 +359,14 @@ export const certificateProfileServiceFactory = ({
actorId,
actorAuthMethod,
actorOrgId,
profileId,
includeMetrics = false,
expiringDays = 30
profileId
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
profileId: string;
includeMetrics?: boolean;
expiringDays?: number;
}): Promise<TCertificateProfile & { metrics?: TCertificateProfileMetrics }> => {
}): Promise<TCertificateProfile> => {
const profile = await certificateProfileDAL.findById(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
@@ -393,14 +387,6 @@ export const certificateProfileServiceFactory = ({
const converted = convertDalToService(profile);
if (includeMetrics) {
const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays);
return {
...converted,
metrics
};
}
return converted;
};
@@ -506,9 +492,7 @@ export const certificateProfileServiceFactory = ({
limit = 20,
search,
enrollmentType,
caId,
includeMetrics = false,
expiringDays = 30
caId
}: {
actor: ActorType;
actorId: string;
@@ -520,10 +504,8 @@ export const certificateProfileServiceFactory = ({
search?: string;
enrollmentType?: EnrollmentType;
caId?: string;
includeMetrics?: boolean;
expiringDays?: number;
}): Promise<{
profiles: (TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics })[];
profiles: TCertificateProfileWithConfigs[];
totalCount: number;
}> => {
const { permission } = await permissionService.getProjectPermission({
@@ -544,9 +526,7 @@ export const certificateProfileServiceFactory = ({
limit,
search,
enrollmentType,
caId,
includeMetrics,
expiringDays
caId
});
const totalCount = await certificateProfileDAL.countByProjectId(projectId, {
@@ -591,27 +571,12 @@ export const certificateProfileServiceFactory = ({
}
const converted = convertDalToService(profileWithConfigs);
let result: TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics } = {
const result: TCertificateProfileWithConfigs = {
...converted,
estConfig: decryptedEstConfig,
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;
})
);
@@ -709,43 +674,6 @@ export const certificateProfileServiceFactory = ({
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 (
params:
| {
@@ -818,7 +746,6 @@ export const certificateProfileServiceFactory = ({
listProfiles,
deleteProfile,
getProfileCertificates,
getProfileMetrics,
getEstConfigurationByProfile
};
};

View File

@@ -54,18 +54,8 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
autoRenew: boolean;
renewBeforeDays?: number;
};
metrics?: TCertificateProfileMetrics;
};
export interface TCertificateProfileMetrics {
profileId: string;
totalCertificates: number;
activeCertificates: number;
expiredCertificates: number;
expiringCertificates: number;
revokedCertificates: number;
}
export interface TCertificateProfileCertificate {
id: string;
serialNumber: string;
@@ -76,11 +66,3 @@ export interface TCertificateProfileCertificate {
revokedAt: Date | null;
createdAt: Date;
}
export type TCertificateProfileWithRawMetrics = TCertificateProfile & {
total_certificates?: string;
active_certificates?: string;
expired_certificates?: string;
expiring_certificates?: string;
revoked_certificates?: string;
};

View File

@@ -180,6 +180,7 @@ export const certificateSyncDALFactory = (db: TDbClient) => {
certificateDetails: (TCertificateSyncs & {
certificateSerialNumber?: string;
certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string;
certificateNotBefore?: Date;
certificateNotAfter?: Date;
@@ -211,6 +212,7 @@ export const certificateSyncDALFactory = (db: TDbClient) => {
.select(
db.ref("serialNumber").withSchema(TableName.Certificate).as("certificateSerialNumber"),
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("notBefore").withSchema(TableName.Certificate).as("certificateNotBefore"),
db.ref("notAfter").withSchema(TableName.Certificate).as("certificateNotAfter"),
@@ -229,6 +231,7 @@ export const certificateSyncDALFactory = (db: TDbClient) => {
const certificateDetails = (await query) as (TCertificateSyncs & {
certificateSerialNumber?: string;
certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string;
certificateNotBefore?: Date;
certificateNotAfter?: Date;

View File

@@ -520,7 +520,7 @@ export const awsCertificateManagerPkiSyncFactory = ({
try {
// Small delay to ensure AWS ACM has processed the certificate import
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 100);
setTimeout(() => resolve(), 500);
});
await withRateLimitRetry(

View File

@@ -606,6 +606,7 @@ export const pkiSyncServiceFactory = ({
updatedAt: detail.updatedAt,
certificateSerialNumber: detail.certificateSerialNumber || undefined,
certificateCommonName: detail.certificateCommonName || undefined,
certificateAltNames: detail.certificateAltNames || undefined,
certificateStatus: detail.certificateStatus || undefined,
certificateNotBefore: detail.certificateNotBefore || undefined,
certificateNotAfter: detail.certificateNotAfter || undefined,

View File

@@ -170,13 +170,14 @@ export type TPkiSyncCertificate = {
lastSyncedAt?: Date;
createdAt: Date;
updatedAt: Date;
certificate?: {
serialNumber: string;
commonName: string;
status: string;
notBefore: Date;
notAfter: Date;
};
certificateSerialNumber?: string;
certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string;
certificateNotBefore?: Date;
certificateNotAfter?: Date;
pkiSyncName?: string;
pkiSyncDestination?: string;
};
export type TPkiSyncRaw = NonNullable<Awaited<ReturnType<TPkiSyncDALFactory["findById"]>>>;

View File

@@ -17,9 +17,9 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { useProject } from "@app/context";
import {
CertStatus,
@@ -299,119 +299,129 @@ export const CertificateManagementModal = ({
</div>
</div>
{allCertificates.length === 0 ? (
<EmptyState title="No certificates found">
{searchTerm
? "No certificates match your search criteria."
: "No certificates available for sync."}
</EmptyState>
) : (
<>
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-12">
<Checkbox
id="select-all-certificates"
isChecked={
allCertificates.length > 0 &&
allCertificates.every((cert) => selectedIds.includes(cert.id))
}
onCheckedChange={handleSelectAll}
/>
</Th>
<Th className="w-1/3">Common Name</Th>
<Th className="w-1/3">Serial Number</Th>
<Th className="w-1/6">Status</Th>
<Th className="w-2/6">Expires</Th>
</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);
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-12">
<Checkbox
id="select-all-certificates"
isChecked={
allCertificates.length > 0 &&
allCertificates.every((cert) => selectedIds.includes(cert.id))
}
onCheckedChange={handleSelectAll}
/>
</Th>
<Th className="w-1/3">SAN / CN</Th>
<Th className="w-1/4">Serial Number</Th>
<Th className="w-1/6">Issued At</Th>
<Th className="w-1/6">Expires At</Th>
</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 (
<Tr
key={cert.id}
className={`cursor-pointer hover:bg-mineshaft-700 ${
cannotBeAdded && !isAlreadySynced ? "opacity-50" : ""
}`}
onClick={() => {
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}
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) {
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">
<Checkbox
id={cert.id}
isChecked={selectedIds.includes(cert.id)}
onCheckedChange={() => {
if (!cannotBeAdded || isAlreadySynced) {
handleToggleSelection(cert.id);
}
}}
isDisabled={cannotBeAdded && !isAlreadySynced}
/>
</Td>
<Td className="max-w-0">
<div className="truncate" title={cert.commonName}>
{cert.commonName}
</div>
</Td>
<Td className="max-w-0">
<div
className="truncate font-mono text-xs text-bunker-300"
title={cert.serialNumber}
>
{cert.serialNumber}
</div>
</Td>
<Td className="max-w-0">
<Badge
variant={
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>
{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>
</Tr>
);
})}
</TBody>
</Table>
{allCertificates.length === 0 && (
<EmptyState title="No certificates found">
{searchTerm
? "No certificates match your search criteria."
: "No certificates available for sync."}
</EmptyState>
)}
</TableContainer>
{totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
count={totalCount}
page={currentPage}
perPage={pageSize}
onChangePage={(page: number) => setCurrentPage(page)}
onChangePerPage={() => {}}
/>
</div>
)}
</>
{totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
count={totalCount}
page={currentPage}
perPage={pageSize}
onChangePage={(page: number) => setCurrentPage(page)}
onChangePerPage={() => {}}
/>
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
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 {
@@ -13,6 +13,7 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useProject } from "@app/context";
@@ -76,25 +77,71 @@ export const PkiSyncCertificatesFields = () => {
>
Add Certificates
</Button>
{selectedCertificates.length === 0 ? (
<EmptyState title="No certificates selected" icon={faPlus} />
) : (
<div className="max-h-64 overflow-y-auto">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-2/5">Common Name</Th>
<Th className="w-2/5">Serial Number</Th>
<Th className="w-1/5">Remove</Th>
</Tr>
</THead>
<TBody>
{selectedCertificates.map((cert) => (
<div className="max-h-64 overflow-y-auto">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-1/3">SAN / CN</Th>
<Th className="w-1/4">Serial Number</Th>
<Th className="w-1/6">Issued At</Th>
<Th className="w-1/6">Expires At</Th>
<Th className="w-12">Remove</Th>
</Tr>
</THead>
<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;
const isExpired = new Date(cert.notAfter) < new Date();
return (
<Tr key={cert.id}>
<Td className="max-w-xs truncate">{cert.commonName}</Td>
<Td className="font-mono text-xs text-bunker-300">
{cert.serialNumber}
<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.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>
<Button
@@ -112,12 +159,15 @@ export const PkiSyncCertificatesFields = () => {
</Button>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</div>
)}
);
})}
</TBody>
</Table>
{selectedCertificates.length === 0 && (
<EmptyState title="No certificates selected" icon={faCertificate} />
)}
</TableContainer>
</div>
</div>
</FormControl>
)}

View File

@@ -1,6 +1,16 @@
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 { useProject } from "@app/context";
import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
@@ -45,20 +55,71 @@ export const PkiSyncReviewFields = () => {
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Certificates</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<div>
{selectedCertificates.length === 0 ? (
<span className="text-bunker-400">No certificates selected</span>
) : (
<div className="space-y-1">
{selectedCertificates.map((cert) => (
<div key={cert.id} className="text-sm">
{cert.commonName}
</div>
))}
</div>
)}
</div>
<div className="w-full">
{selectedCertificates.length === 0 ? (
<span className="text-bunker-400">No certificates selected</span>
) : (
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-1/2">SAN / CN</Th>
<Th className="w-1/4">Serial Number</Th>
<Th className="w-1/4">Expires At</Th>
</Tr>
</THead>
<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 className="flex flex-col gap-3">
@@ -79,11 +140,13 @@ export const PkiSyncReviewFields = () => {
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Sync Options</span>
</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">
<Badge variant={isAutoSyncEnabled ? "success" : "danger"}>
{isAutoSyncEnabled ? "Enabled" : "Disabled"}
</Badge>
<div className="mt-1">
<Badge variant={isAutoSyncEnabled ? "success" : "danger"}>
{isAutoSyncEnabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</GenericFieldLabel>
{/* Hidden for now - Import certificates functionality disabled
{syncOptions?.canImportCertificates !== undefined && (
@@ -96,9 +159,11 @@ export const PkiSyncReviewFields = () => {
*/}
{syncOptions?.canRemoveCertificates !== undefined && (
<GenericFieldLabel label="Remove Certificates">
<Badge variant={syncOptions.canRemoveCertificates ? "success" : "danger"}>
{syncOptions.canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge>
<div className="mt-1">
<Badge variant={syncOptions.canRemoveCertificates ? "success" : "danger"}>
{syncOptions.canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge>
</div>
</GenericFieldLabel>
)}
</div>

View File

@@ -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>
);
};

View File

@@ -152,7 +152,7 @@ export const useCreateCertificate = () => {
});
};
export const useCreateCertificateV3 = () => {
export const useCreateCertificateV3 = (options?: { projectId?: string }) => {
const queryClient = useQueryClient();
return useMutation<TCreateCertificateV3Response, object, TCreateCertificateV3DTO>({
mutationFn: async (body) => {
@@ -167,6 +167,12 @@ export const useCreateCertificateV3 = () => {
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
if (options?.projectId) {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(options.projectId)
});
}
queryClient.invalidateQueries({
queryKey: ["certificate-profiles"]
});

View File

@@ -8,7 +8,6 @@ export {
useGetCertificateProfileById,
useGetCertificateProfileBySlug,
useGetProfileCertificates,
useGetProfileMetrics,
useListCertificateProfiles
} from "./queries";
export type * from "./types";

View File

@@ -4,7 +4,6 @@ import { apiRequest } from "@app/config/request";
import {
TCertificateProfile,
TCertificateProfileMetrics,
TCertificateProfileWithDetails,
TGetCertificateProfileByIdDTO,
TGetCertificateProfileBySlugDTO,
@@ -20,7 +19,6 @@ export const certificateProfileKeys = {
limit?: number;
offset?: number;
search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean;
enrollmentType?: string;
expiringDays?: number;
@@ -51,10 +49,8 @@ export const useListCertificateProfiles = ({
limit = 20,
offset = 0,
search,
includeMetrics = false,
includeConfigs = false,
enrollmentType,
expiringDays = 7
enrollmentType
}: TListCertificateProfilesDTO) => {
return useQuery({
queryKey: certificateProfileKeys.list({
@@ -62,10 +58,8 @@ export const useListCertificateProfiles = ({
limit,
offset,
search,
includeMetrics,
includeConfigs,
enrollmentType,
expiringDays
enrollmentType
}),
queryFn: async () => {
const { data } = await apiRequest.get<{
@@ -77,10 +71,8 @@ export const useListCertificateProfiles = ({
limit,
offset,
search,
includeMetrics,
includeConfigs,
enrollmentType,
expiringDays
enrollmentType
}
});
return data;
@@ -145,18 +137,3 @@ export const useGetProfileCertificates = ({
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)
});
};

View File

@@ -10,7 +10,6 @@ export type TCertificateProfile = {
apiConfigId?: string;
createdAt: string;
updatedAt: string;
metrics?: TCertificateProfileMetrics;
};
export type TCertificateProfileWithDetails = TCertificateProfile & {
@@ -81,10 +80,8 @@ export type TListCertificateProfilesDTO = {
limit?: number;
offset?: number;
search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean;
enrollmentType?: "api" | "est";
expiringDays?: number;
};
export type TGetCertificateProfileByIdDTO = {
@@ -96,15 +93,6 @@ export type TGetCertificateProfileBySlugDTO = {
slug: string;
};
export type TCertificateProfileMetrics = {
profileId: string;
totalCertificates: number;
activeCertificates: number;
expiredCertificates: number;
expiringCertificates: number;
revokedCertificates: number;
};
export type TProfileCertificate = {
id: string;
serialNumber: string;
@@ -126,5 +114,4 @@ export type TGetProfileCertificatesDTO = {
export type TGetProfileMetricsDTO = {
profileId: string;
expiringDays?: number;
};

View File

@@ -9,6 +9,7 @@ export type TCertificate = {
friendlyName: string;
commonName: string;
subjectAltNames: string;
altNames?: string;
serialNumber: string;
notBefore: string;
notAfter: string;

View File

@@ -57,6 +57,7 @@ export type TPkiSyncCertificate = {
updatedAt: string;
certificateSerialNumber?: string;
certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string;
certificateNotBefore?: Date;
certificateNotAfter?: Date;

View File

@@ -52,9 +52,7 @@ export const PkiManagerLayout = () => {
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>Certificate Management</Tab>
)}
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Certificates</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/certificate-authorities"

View File

@@ -122,11 +122,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const { data: profilesData } = useListCertificateProfiles({
projectId: currentProject?.id || "",
includeMetrics: false,
enrollmentType: "api"
});
const { mutateAsync: createCertificate } = useCreateCertificateV3();
const { mutateAsync: createCertificate } = useCreateCertificateV3({
projectId: currentProject?.id
});
const formResolver = useMemo(() => {
return zodResolver(createSchema(shouldShowSubjectSection));

View File

@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import {
faBan,
faCertificate,
faClockRotateLeft,
faEllipsis,
faEye,
faFileExport,
@@ -11,11 +12,14 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { CircleQuestionMarkIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
CertificateDisplayName,
getCertificateDisplayName
} from "@app/components/utilities/certificateDisplayUtils";
import {
DropdownMenu,
DropdownMenuContent,
@@ -45,7 +49,6 @@ import { caSupportsCapability } from "@app/hooks/api/ca/constants";
import { CaCapability, CaType } from "@app/hooks/api/ca/enums";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import { CertStatus } from "@app/hooks/api/certificates/enums";
import { TCertificate } from "@app/hooks/api/certificates/types";
import { useListWorkspaceCertificates } from "@app/hooks/api/projects";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -58,93 +61,6 @@ const isExpiringWithinOneDay = (notAfter: string): boolean => {
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 = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
@@ -236,20 +152,18 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<Table>
<THead>
<Tr>
<Th>Common Name</Th>
<Th>Status</Th>
<Th>Not Before</Th>
<Th>Not After</Th>
<Th>Renewal Status</Th>
<Th />
<Th className="w-1/2">SAN / CN</Th>
<Th className="w-1/6">Status</Th>
<Th className="w-1/6">Not Before</Th>
<Th className="w-1/6">Not After</Th>
<Th className="w-12" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={5} innerKey="project-cas" />}
{isPending && <TableSkeleton columns={4} innerKey="project-cas" />}
{!isPending &&
data?.certificates.map((certificate) => {
const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter);
const autoRenewalInfo = getAutoRenewalInfo(certificate);
const isRevoked = certificate.status === CertStatus.REVOKED;
const isExpired = new Date(certificate.notAfter) < new Date();
@@ -258,9 +172,24 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
const isAutoRenewalEnabled = Boolean(
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 (
<Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.commonName}</Td>
<Tr className="group h-10" key={`certificate-${certificate.id}`}>
<Td className="max-w-0">
<CertificateDisplayName cert={certificate} maxLength={64} fallback="—" />
</Td>
<Td>
{certificate.status === CertStatus.REVOKED ? (
<Badge variant="danger">Revoked</Badge>
@@ -278,22 +207,64 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"}
</Td>
<Td>
{autoRenewalInfo &&
(autoRenewalInfo.tooltip ? (
<div className="flex items-center gap-2">
<Tooltip content={autoRenewalInfo.tooltip}>
<Badge variant={autoRenewalInfo.variant}>
{autoRenewalInfo.text}
<CircleQuestionMarkIcon />
</Badge>
</Tooltip>
</div>
) : (
<Badge variant={autoRenewalInfo.variant}>{autoRenewalInfo.text}</Badge>
))}
</Td>
<Td className="flex justify-end">
<Td className="flex items-center justify-end gap-2">
<div
className={`transition-opacity ${(() => {
if (!canShowAutoRenewalIcon) return "";
if (isAutoRenewalEnabled) return "opacity-100";
return "opacity-0 group-hover:opacity-100";
})()}`}
>
{canShowAutoRenewalIcon && (
<Tooltip
content={(() => {
if (hasFailed && certificate.renewalError) {
return `Auto-renewal failed: ${certificate.renewalError}`;
}
if (isAutoRenewalEnabled) {
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>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -370,20 +341,20 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
const notAfterDate = new Date(certificate.notAfter);
const notBeforeDate = certificate.notBefore
? new Date(certificate.notBefore)
: new Date(
notAfterDate.getTime() - 365 * 24 * 60 * 60 * 1000
);
notAfterDate.getTime() - 365 * 24 * 60 * 60 * 1000
);
const ttlDays = Math.max(
1,
Math.ceil(
(notAfterDate.getTime() - notBeforeDate.getTime()) /
(24 * 60 * 60 * 1000)
(24 * 60 * 60 * 1000)
)
);
handlePopUpOpen("manageRenewal", {
@@ -433,7 +404,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
await handleDisableAutoRenewal(
@@ -470,7 +441,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
handlePopUpOpen("renewCertificate", {
@@ -532,7 +503,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("revokeCertificate", {

View File

@@ -12,7 +12,6 @@ import {
faToggleOff,
faToggleOn,
faTrash,
faTriangleExclamation,
faXmark
} from "@fortawesome/free-solid-svg-icons";
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 { PkiSyncDestinationCol } from "./PkiSyncDestinationCol";
import { PkiSyncTableCell } from "./PkiSyncTableCell";
type Props = {
pkiSync: TPkiSync;
@@ -163,23 +161,6 @@ export const PkiSyncRow = ({
<p className="truncate text-xs leading-4 text-bunker-300">{destinationDetails.name}</p>
</div>
</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} />
<Td>
<div className="flex items-center gap-1">

View File

@@ -57,7 +57,6 @@ import { PkiSyncRow } from "./PkiSyncRow";
enum PkiSyncsOrderBy {
Destination = "destination",
Source = "source",
Name = "name",
Status = "status"
}
@@ -160,14 +159,6 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
const [syncOne, syncTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case PkiSyncsOrderBy.Source:
return (syncOne.subscriber?.name ?? syncOne.subscriberId ?? "")
.toLowerCase()
.localeCompare(
syncTwo.subscriber?.name?.toLowerCase() ??
syncTwo.subscriberId?.toLowerCase() ??
""
);
case PkiSyncsOrderBy.Destination:
return getPkiSyncDestinationColValues(syncOne)
.primaryText.toLowerCase()
@@ -370,7 +361,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
<THead>
<Tr>
<Th className="w-2" />
<Th className="w-1/4">
<Th className="w-1/2">
<div className="flex items-center">
Name
<IconButton
@@ -383,20 +374,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<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">
<Th className="w-1/4">
<div className="flex items-center">
Destination
<IconButton
@@ -409,7 +387,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
</IconButton>
</div>
</Th>
<Th className="min-w-42">
<Th className="w-1/4 min-w-42">
<div className="flex items-center">
Status
<IconButton

View File

@@ -26,7 +26,7 @@ export const PkiSyncAuditLogsSection = ({ pkiSync }: Props) => {
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 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 && (
<p className="text-xs text-bunker-300">
Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days

View File

@@ -1,12 +1,17 @@
import { useState } from "react";
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 { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { CertificateManagementModal } from "@app/components/pki-syncs/CertificateManagementModal";
import {
CertificateDisplayName,
getCertificateDisplayName
} from "@app/components/utilities/certificateDisplayUtils";
import {
DeleteActionModal,
EmptyState,
IconButton,
Pagination,
@@ -46,6 +51,11 @@ const getSyncStatusText = (status?: CertificateSyncStatus | null) => {
export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
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 pageSize = 10;
@@ -74,6 +84,9 @@ export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
text: "Certificate removed from sync",
type: "success"
});
setIsDeleteModalOpen(false);
setCertificateToDelete(null);
} catch {
createNotification({
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);
return (
<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 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}>
{(isAllowed) => (
<IconButton
@@ -105,108 +123,120 @@ export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
</div>
<div>
{syncCertificates.length === 0 ? (
<EmptyState title="No certificates" icon={faPlus}>
No certificates are currently synced with this PKI destination.
</EmptyState>
) : (
<div className="space-y-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-1/3">Common Name</Th>
<Th className="w-1/3">Serial Number</Th>
<Th className="w-1/9">Status</Th>
<Th className="w-1/9">Expires</Th>
<Th className="w-1/9">Actions</Th>
</Tr>
</THead>
<TBody>
{syncCertificates.map((syncCert) => {
const isExpired = syncCert.certificateNotAfter
? new Date(syncCert.certificateNotAfter) < new Date()
: false;
<div className="space-y-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-1/3">SAN / CN</Th>
<Th className="w-1/3">Serial Number</Th>
<Th className="w-1/9">Status</Th>
<Th className="w-1/9">Expires</Th>
<Th className="w-1/9">Actions</Th>
</Tr>
</THead>
<TBody>
{syncCertificates.map((syncCert) => {
const isExpired = syncCert.certificateNotAfter
? new Date(syncCert.certificateNotAfter) < new Date()
: false;
return (
<Tr key={syncCert.id}>
<Td className="max-w-0">
<div
className="truncate"
title={syncCert.certificateCommonName || "Unknown"}
>
{syncCert.certificateCommonName || "Unknown"}
</div>
</Td>
<Td className="max-w-0">
<div
className="truncate text-xs"
title={syncCert.certificateSerialNumber || "Unknown"}
>
{syncCert.certificateSerialNumber || "Unknown"}
</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>
const { originalDisplayName } = getCertificateDisplayName(
{
altNames: syncCert.certificateAltNames,
commonName: syncCert.certificateCommonName
},
34,
"Unknown"
);
return (
<Tr key={syncCert.id}>
<Td className="max-w-0">
<CertificateDisplayName
cert={{
altNames: syncCert.certificateAltNames,
commonName: syncCert.certificateCommonName
}}
maxLength={34}
fallback="Unknown"
/>
</Td>
<Td className="max-w-0">
<div
className="truncate text-xs"
title={syncCert.certificateSerialNumber || "Unknown"}
>
{syncCert.certificateSerialNumber || "Unknown"}
</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>
<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={() => 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>
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{syncCertificates.length === 0 && (
<EmptyState
title="No certificates are part of this certificate sync"
icon={faCertificate}
/>
)}
</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>
@@ -218,6 +248,23 @@ export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
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>
);
};

View File

@@ -11,12 +11,15 @@ import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionC
import { PKI_SYNC_MAP } from "@app/helpers/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 }) => (
<div>
<label className="text-sm text-bunker-300">{label}</label>
<div className="mt-1">{children}</div>
<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>
);
@@ -32,6 +35,9 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
let DestinationComponents: ReactNode;
switch (destination) {
case PkiSync.AwsCertificateManager:
DestinationComponents = <AwsCertificateManagerPkiSyncDestinationSection pkiSync={pkiSync} />;
break;
case PkiSync.AzureKeyVault:
DestinationComponents = <AzureKeyVaultPkiSyncDestinationSection pkiSync={pkiSync} />;
break;
@@ -47,7 +53,7 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
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 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}>
{(isAllowed) => (
<IconButton
@@ -62,7 +68,7 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
)}
</ProjectPermissionCan>
</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`}>
{pkiSync.appConnectionName || "Default Connection"}
</GenericFieldLabel>

View File

@@ -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>;
};

View File

@@ -2,9 +2,9 @@
import { TAzureKeyVaultPkiSync } from "@app/hooks/api/pkiSyncs/types/azure-key-vault-sync";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div>
<label className="text-sm text-bunker-300">{label}</label>
<div className="mt-1">{children}</div>
<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>
);

View File

@@ -1 +1,2 @@
export { AwsCertificateManagerPkiSyncDestinationSection } from "./AwsCertificateManagerPkiSyncDestinationSection";
export { AzureKeyVaultPkiSyncDestinationSection } from "./AzureKeyVaultPkiSyncDestinationSection";

View File

@@ -21,9 +21,9 @@ const GenericFieldLabel = ({
children: React.ReactNode;
labelClassName?: string;
}) => (
<div>
<label className={`text-sm text-bunker-300 ${labelClassName || ""}`}>{label}</label>
<div className="mt-1">{children}</div>
<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>
);
@@ -57,7 +57,7 @@ export const PkiSyncDetailsSection = ({ pkiSync, onEditDetails }: Props) => {
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 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}>
{(isAllowed) => (
<IconButton
@@ -72,31 +72,27 @@ export const PkiSyncDetailsSection = ({ pkiSync, onEditDetails }: Props) => {
)}
</ProjectPermissionCan>
</div>
<div>
<div className="space-y-3">
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description || "None"}</GenericFieldLabel>
<GenericFieldLabel label="Source Subscriber">
{subscriber ? subscriber.name : "Subscriber deleted"}
<div className="pt-2">
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description || "None"}</GenericFieldLabel>
{subscriber && (
<GenericFieldLabel label="Source Subscriber">{subscriber.name}</GenericFieldLabel>
)}
{syncStatus && (
<GenericFieldLabel label="Status">
<PkiSyncStatusBadge status={syncStatus} />
</GenericFieldLabel>
{syncStatus && (
<GenericFieldLabel label="Status">
<PkiSyncStatusBadge status={syncStatus} />
</GenericFieldLabel>
)}
{lastSyncedAt && (
<GenericFieldLabel label="Last Synced">
{format(new Date(lastSyncedAt), "yyyy-MM-dd, h:mm aaa")}
</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>
)}
{lastSyncedAt && (
<GenericFieldLabel label="Last Synced">
{format(new Date(lastSyncedAt), "yyyy-MM-dd, h:mm aaa")}
</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>
);

View File

@@ -3,13 +3,27 @@ import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { IconButton } from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
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 = {
pkiSync: TPkiSync;
onEditOptions: VoidFunction;
@@ -28,7 +42,7 @@ export const PkiSyncOptionsSection = ({ pkiSync, onEditOptions }: Props) => {
<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 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}>
{(isAllowed) => (
<IconButton
@@ -43,21 +57,19 @@ export const PkiSyncOptionsSection = ({ pkiSync, onEditOptions }: Props) => {
)}
</ProjectPermissionCan>
</div>
<div>
<div className="space-y-3">
{/* Hidden for now - Import certificates functionality disabled
<div className="pt-1">
{/* Hidden for now - Import certificates functionality disabled
<GenericFieldLabel label="Certificate Import">
<Badge variant={canImportCertificates ? "success" : "danger"}>
{canImportCertificates ? "Enabled" : "Disabled"}
</Badge>
</GenericFieldLabel>
*/}
<GenericFieldLabel label="Certificate Removal">
<Badge variant={canRemoveCertificates ? "success" : "danger"}>
{canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge>
</GenericFieldLabel>
</div>
<GenericFieldLabel label="Certificate Removal" labelClassName="mb-1">
<Badge variant={canRemoveCertificates ? "success" : "danger"}>
{canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge>
</GenericFieldLabel>
</div>
</div>
</div>

View File

@@ -36,7 +36,7 @@ export const PoliciesPage = () => {
<PageHeader
scope={ProjectType.CertificateManager}
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

View File

@@ -29,8 +29,7 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
projectId: currentProject?.id || "",
limit: 100,
offset: 0,
includeConfigs: true,
includeMetrics: true
includeConfigs: true
});
const profiles = data?.certificateProfiles || [];
@@ -42,10 +41,9 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
<THead>
<Tr>
<Th>Name</Th>
<Th>Enrollment Type</Th>
<Th>Enrollment Method</Th>
<Th>Issuing CA</Th>
<Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" />
</Tr>
</THead>
@@ -67,10 +65,9 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
<THead>
<Tr>
<Th>Name</Th>
<Th>Enrollment Type</Th>
<Th>Enrollment Method</Th>
<Th>Issuing CA</Th>
<Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" />
</Tr>
</THead>

View File

@@ -33,43 +33,6 @@ import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
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 {
profile: TCertificateProfile;
onEditProfile: (profile: TCertificateProfile) => void;
@@ -118,8 +81,8 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
const getEnrollmentTypeBadge = (enrollmentType: string) => {
const config = {
api: { variant: "success" as const, label: "API" },
est: { variant: "warning" as const, label: "EST" }
api: { variant: "ghost" as const, label: "API" },
est: { variant: "ghost" as const, label: "EST" }
} as const;
const configKey = Object.keys(config).includes(enrollmentType)
@@ -153,11 +116,6 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
{templateData?.name || profile.certificateTemplateId}
</span>
</Td>
<Td>
<div className="flex flex-wrap gap-1">
<MetricsBadges metrics={profile.metrics} />
</div>
</Td>
<Td className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">