Certificate request DB partition, UI certificate request tab and filters on certificate views

This commit is contained in:
Carlos Monastyrski
2025-12-16 23:45:06 -03:00
parent fd207db786
commit 9d2530ddf6
26 changed files with 2254 additions and 487 deletions

View File

@@ -98,9 +98,11 @@ const main = async () => {
(el) =>
!el.tableName.includes("_migrations") &&
!el.tableName.includes("audit_logs_") &&
!el.tableName.includes("certificate_requests_") &&
!el.tableName.includes("user_notifications_") &&
!el.tableName.includes("active_locks") &&
el.tableName !== "intermediate_audit_logs"
el.tableName !== "intermediate_audit_logs" &&
el.tableName !== "intermediate_certificate_requests"
);
for (let i = 0; i < tables.length; i += 1) {

View File

@@ -0,0 +1,168 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger } from "../utils";
const INTERMEDIATE_CERTIFICATE_REQUESTS_TABLE = "intermediate_certificate_requests";
const formatPartitionDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const createCertificateRequestPartition = async (knex: Knex, startDate: Date, endDate: Date) => {
const startDateStr = formatPartitionDate(startDate);
const endDateStr = formatPartitionDate(endDate);
const partitionName = `${TableName.CertificateRequests}_${startDateStr.replace(/-/g, "")}_${endDateStr.replace(/-/g, "")}`;
await knex.schema.raw(
`CREATE TABLE ${partitionName} PARTITION OF ${TableName.CertificateRequests} FOR VALUES FROM ('${startDateStr}') TO ('${endDateStr}')`
);
};
export async function up(knex: Knex): Promise<void> {
// Check if table is already partitioned by looking for partition information
const partitionInfo: { rows: { schemaname: string; tablename: string }[] } = await knex.raw(
`
SELECT schemaname, tablename
FROM pg_tables
WHERE tablename LIKE '${TableName.CertificateRequests}_%'
AND schemaname = 'public'
`
);
if (partitionInfo.rows.length > 0) {
console.info("Certificate requests table is already partitioned, skipping migration...");
return;
}
if (await knex.schema.hasTable(TableName.CertificateRequests)) {
console.info("Converting existing certificate_requests table to partitioned table...");
// Drop primary key constraint
console.info("Dropping primary key of certificate_requests table...");
await knex.schema.alterTable(TableName.CertificateRequests, (t) => {
t.dropPrimary();
});
// Get all indices of the certificate_requests table and drop them
const indexNames: { rows: { indexname: string }[] } = await knex.raw(
`
SELECT indexname
FROM pg_indexes
WHERE tablename = '${TableName.CertificateRequests}'
`
);
console.log(
"Deleting existing certificate_requests indices:",
indexNames.rows.map((e) => e.indexname)
);
for await (const row of indexNames.rows) {
await knex.raw(`DROP INDEX IF EXISTS ??`, [row.indexname]);
}
// Rename existing table to intermediate name
console.log("Renaming certificate_requests table to intermediate name");
await knex.schema.renameTable(TableName.CertificateRequests, INTERMEDIATE_CERTIFICATE_REQUESTS_TABLE);
// Create new partitioned table with same schema - MUST MATCH EXACTLY the original table
const createTableSql = knex.schema
.createTable(TableName.CertificateRequests, (t) => {
t.uuid("id").defaultTo(knex.fn.uuid());
t.timestamps(true, true, true);
t.string("status").notNullable();
t.string("projectId").notNullable();
t.uuid("profileId").nullable();
t.uuid("caId").nullable();
t.uuid("certificateId").nullable();
t.text("csr").nullable();
t.string("commonName").nullable();
t.text("altNames").nullable();
t.specificType("keyUsages", "text[]").nullable();
t.specificType("extendedKeyUsages", "text[]").nullable();
t.datetime("notBefore").nullable();
t.datetime("notAfter").nullable();
t.string("keyAlgorithm").nullable();
t.string("signatureAlgorithm").nullable();
t.text("errorMessage").nullable();
t.text("metadata").nullable();
t.primary(["id", "createdAt"]);
})
.toString();
console.info("Creating partitioned certificate_requests table...");
await knex.schema.raw(`${createTableSql} PARTITION BY RANGE ("createdAt")`);
console.log("Adding indices...");
await knex.schema.alterTable(TableName.CertificateRequests, (t) => {
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("SET NULL");
t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("SET NULL");
t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL");
t.index("status");
t.index(["projectId", "status"]);
t.index(["projectId", "createdAt"]);
});
// Create default partition
console.log("Creating default partition...");
await knex.schema.raw(
`CREATE TABLE ${TableName.CertificateRequests}_default PARTITION OF ${TableName.CertificateRequests} DEFAULT`
);
const nextDate = new Date();
nextDate.setDate(nextDate.getDate() + 1);
const nextDateStr = formatPartitionDate(nextDate);
console.log("Attaching existing certificate_requests table as a partition...");
await knex.schema.raw(
`
ALTER TABLE ${INTERMEDIATE_CERTIFICATE_REQUESTS_TABLE} ADD CONSTRAINT certificate_requests_old
CHECK ( "createdAt" < DATE '${nextDateStr}' );
ALTER TABLE ${TableName.CertificateRequests} ATTACH PARTITION ${INTERMEDIATE_CERTIFICATE_REQUESTS_TABLE}
FOR VALUES FROM (MINVALUE) TO ('${nextDateStr}' );
`
);
// Create partition from next day until end of month
console.log("Creating certificate_requests partitions ahead of time... next date:", nextDateStr);
await createCertificateRequestPartition(
knex,
nextDate,
new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 1)
);
// Create partitions 20 years ahead for certificate requests
const partitionMonths = 20 * 12;
const partitionPromises: Promise<void>[] = [];
for (let x = 1; x <= partitionMonths; x += 1) {
partitionPromises.push(
createCertificateRequestPartition(
knex,
new Date(nextDate.getFullYear(), nextDate.getMonth() + x, 1),
new Date(nextDate.getFullYear(), nextDate.getMonth() + (x + 1), 1)
)
);
}
await Promise.all(partitionPromises);
await createOnUpdateTrigger(knex, TableName.CertificateRequests);
console.log("Certificate requests partition migration complete");
} else {
console.log("Certificate requests table does not exist, skipping partitioning migration");
}
}
export async function down(): Promise<void> {
// skip
}

View File

@@ -395,6 +395,7 @@ export enum EventType {
CREATE_CERTIFICATE_REQUEST = "create-certificate-request",
GET_CERTIFICATE_REQUEST = "get-certificate-request",
GET_CERTIFICATE_FROM_REQUEST = "get-certificate-from-request",
LIST_CERTIFICATE_REQUESTS = "list-certificate-requests",
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration",
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config",
@@ -4289,6 +4290,16 @@ interface GetCertificateFromRequestEvent {
};
}
interface ListCertificateRequestsEvent {
type: EventType.LIST_CERTIFICATE_REQUESTS;
metadata: {
offset: number;
limit: number;
search?: string;
status?: string;
};
}
interface ApprovalPolicyCreateEvent {
type: EventType.APPROVAL_POLICY_CREATE;
metadata: {
@@ -4869,6 +4880,7 @@ export type Event =
| CreateCertificateRequestEvent
| GetCertificateRequestEvent
| GetCertificateFromRequestEvent
| ListCertificateRequestsEvent
| AutomatedRenewCertificate
| AutomatedRenewCertificateFailed
| UserLoginEvent

View File

@@ -25,6 +25,7 @@ import { EnrollmentType } from "@app/services/certificate-profile/certificate-pr
import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
import { TCertificateFromProfileResponse } from "@app/services/certificate-v3/certificate-v3-types";
import { ProjectFilterType } from "@app/services/project/project-types";
import { booleanSchema } from "../sanitizedSchemas";
@@ -353,6 +354,120 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "GET",
url: "/certificate-requests",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
querystring: z.object({
projectSlug: z.string().min(1).trim(),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().trim().optional(),
status: z.nativeEnum(CertificateRequestStatus).optional(),
fromDate: z.coerce.date().optional(),
toDate: z.coerce.date().optional(),
profileIds: z
.union([z.string().uuid(), z.array(z.string().uuid())])
.transform((val) => (Array.isArray(val) ? val : [val]))
.optional()
.describe("Filter by profile IDs"),
sortBy: z.string().trim().optional(),
sortOrder: z.enum(["asc", "desc"]).optional()
}),
response: {
200: z.object({
certificateRequests: z.array(
z.object({
id: z.string(),
status: z.nativeEnum(CertificateRequestStatus),
commonName: z.string().nullable(),
altNames: z.string().nullable(),
profileId: z.string().nullable(),
profileName: z.string().nullable(),
caId: z.string().nullable(),
certificateId: z.string().nullable(),
errorMessage: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
certificate: z
.object({
id: z.string(),
serialNumber: z.string(),
status: z.string()
})
.nullable()
})
),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const project = await server.services.project.getAProject({
actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
filter: {
type: ProjectFilterType.SLUG,
slug: req.query.projectSlug,
orgId: req.permission.orgId
}
});
const { certificateRequests, totalCount } = await server.services.certificateRequest.listCertificateRequests({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: project.id,
offset: req.query.offset,
limit: req.query.limit,
search: req.query.search,
status: req.query.status,
fromDate: req.query.fromDate,
toDate: req.query.toDate,
profileIds: req.query.profileIds,
sortBy: req.query.sortBy,
sortOrder: req.query.sortOrder
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: project.id,
event: {
type: EventType.LIST_CERTIFICATE_REQUESTS,
metadata: {
offset: req.query.offset,
limit: req.query.limit,
search: req.query.search,
status: req.query.status
}
}
});
return {
certificateRequests: certificateRequests.map((certReq) => ({
...certReq,
profileId: certReq.profileId ?? null,
caId: certReq.caId ?? null,
certificateId: certReq.certificateId ?? null,
commonName: certReq.commonName ?? null,
altNames: certReq.altNames ?? null,
errorMessage: certReq.errorMessage ?? null,
profileName: certReq.profileName ?? null
})),
totalCount
};
}
});
server.route({
method: "POST",
url: "/issue-certificate",

View File

@@ -1209,7 +1209,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
.boolean()
.default(false)
.optional()
.describe("Retrieve only certificates available for PKI sync")
.describe("Retrieve only certificates available for PKI sync"),
search: z.string().trim().optional().describe("Search by SAN, CN, certificate ID, or serial number"),
status: z.string().optional().describe("Filter by certificate status"),
profileIds: z
.union([z.string().uuid(), z.array(z.string().uuid())])
.transform((val) => (Array.isArray(val) ? val : [val]))
.optional()
.describe("Filter by profile IDs"),
fromDate: z.coerce.date().optional().describe("Filter certificates created from this date"),
toDate: z.coerce.date().optional().describe("Filter certificates created until this date")
}),
response: {
200: z.object({

View File

@@ -1,14 +1,25 @@
import { Knex } from "knex";
import RE2 from "re2";
import { TDbClient } from "@app/db";
import { TableName, TCertificateRequests, TCertificates } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import {
applyProcessedPermissionRulesToQuery,
type ProcessedPermissionRules
} from "@app/lib/knex/permission-filter-utils";
type TCertificateRequestWithCertificate = TCertificateRequests & {
certificate: TCertificates | null;
profileName: string | null;
};
type TCertificateRequestQueryResult = {
certificate: string | null;
profileName: string | null;
} & Omit<TCertificateRequests, "certificate">;
export type TCertificateRequestDALFactory = ReturnType<typeof certificateRequestDALFactory>;
export const certificateRequestDALFactory = (db: TDbClient) => {
@@ -16,24 +27,45 @@ export const certificateRequestDALFactory = (db: TDbClient) => {
const findByIdWithCertificate = async (id: string): Promise<TCertificateRequestWithCertificate | null> => {
try {
const certificateRequest = await certificateRequestOrm.findById(id);
if (!certificateRequest) return null;
const result = (await db(TableName.CertificateRequests)
.leftJoin(
TableName.Certificate,
`${TableName.CertificateRequests}.certificateId`,
`${TableName.Certificate}.id`
)
.leftJoin(
TableName.PkiCertificateProfile,
`${TableName.CertificateRequests}.profileId`,
`${TableName.PkiCertificateProfile}.id`
)
.where(`${TableName.CertificateRequests}.id`, id)
.select(selectAllTableCols(TableName.CertificateRequests))
.select(db.ref("slug").withSchema(TableName.PkiCertificateProfile).as("profileName"))
.select(db.raw(`row_to_json(${TableName.Certificate}.*) as certificate`))
.first()) as TCertificateRequestQueryResult | undefined;
if (!certificateRequest.certificateId) {
return {
...certificateRequest,
certificate: null
};
if (!result) return null;
const { certificate: certificateJson, profileName, ...certificateRequestData } = result;
let parsedCertificate: TCertificates | null = null;
if (certificateJson && typeof certificateJson === "string") {
try {
const parsed = JSON.parse(certificateJson) as Record<string, unknown>;
if (parsed && typeof parsed === "object" && "id" in parsed) {
parsedCertificate = parsed as TCertificates;
}
} catch {
// Ignore parsing errors
}
} else if (certificateJson && typeof certificateJson === "object" && "id" in certificateJson) {
parsedCertificate = certificateJson as TCertificates;
}
const certificate = await db(TableName.Certificate)
.where("id", certificateRequest.certificateId)
.select(selectAllTableCols(TableName.Certificate))
.first();
return {
...certificateRequest,
certificate: certificate || null
...certificateRequestData,
profileName: profileName || null,
certificate: parsedCertificate
};
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate request by ID with certificate" });
@@ -82,11 +114,263 @@ export const certificateRequestDALFactory = (db: TDbClient) => {
}
};
const findByProjectId = async (
projectId: string,
options: {
offset?: number;
limit?: number;
search?: string;
status?: string;
fromDate?: Date;
toDate?: Date;
profileIds?: string[];
} = {},
processedRules?: ProcessedPermissionRules,
tx?: Knex
): Promise<TCertificateRequests[]> => {
try {
const { offset = 0, limit = 20, search, status, fromDate, toDate, profileIds } = options;
let query = (tx || db)(TableName.CertificateRequests)
.leftJoin(
TableName.PkiCertificateProfile,
`${TableName.CertificateRequests}.profileId`,
`${TableName.PkiCertificateProfile}.id`
)
.where(`${TableName.CertificateRequests}.projectId`, projectId);
if (profileIds && profileIds.length > 0) {
query = query.whereIn(`${TableName.CertificateRequests}.profileId`, profileIds);
}
if (search) {
const sanitizedSearch = String(search).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.where((builder) => {
void builder
.whereILike(`${TableName.CertificateRequests}.commonName`, `%${sanitizedSearch}%`)
.orWhereILike(`${TableName.CertificateRequests}.altNames`, `%${sanitizedSearch}%`)
.orWhereILike(`${TableName.CertificateRequests}.status`, `%${sanitizedSearch}%`);
});
}
if (status) {
query = query.where(`${TableName.CertificateRequests}.status`, status);
}
if (fromDate) {
query = query.where(`${TableName.CertificateRequests}.createdAt`, ">=", fromDate);
}
if (toDate) {
query = query.where(`${TableName.CertificateRequests}.createdAt`, "<=", toDate);
}
query = query
.select(selectAllTableCols(TableName.CertificateRequests))
.select(db.ref("slug").withSchema(TableName.PkiCertificateProfile).as("profileName"));
if (processedRules) {
query = applyProcessedPermissionRulesToQuery(
query,
TableName.CertificateRequests,
processedRules
) as typeof query;
}
const certificateRequests = await query.orderBy("createdAt", "desc").offset(offset).limit(limit);
return certificateRequests;
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate requests by project ID" });
}
};
const countByProjectId = async (
projectId: string,
options: {
search?: string;
status?: string;
fromDate?: Date;
toDate?: Date;
profileIds?: string[];
} = {},
processedRules?: ProcessedPermissionRules,
tx?: Knex
): Promise<number> => {
try {
const { search, status, fromDate, toDate, profileIds } = options;
let query = (tx || db)(TableName.CertificateRequests)
.leftJoin(
TableName.PkiCertificateProfile,
`${TableName.CertificateRequests}.profileId`,
`${TableName.PkiCertificateProfile}.id`
)
.where(`${TableName.CertificateRequests}.projectId`, projectId);
if (profileIds && profileIds.length > 0) {
query = query.whereIn(`${TableName.CertificateRequests}.profileId`, profileIds);
}
if (search) {
const sanitizedSearch = String(search).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.where((builder) => {
void builder
.whereILike(`${TableName.CertificateRequests}.commonName`, `%${sanitizedSearch}%`)
.orWhereILike(`${TableName.CertificateRequests}.altNames`, `%${sanitizedSearch}%`)
.orWhereILike(`${TableName.CertificateRequests}.status`, `%${sanitizedSearch}%`);
});
}
if (status) {
query = query.where(`${TableName.CertificateRequests}.status`, status);
}
if (fromDate) {
query = query.where(`${TableName.CertificateRequests}.createdAt`, ">=", fromDate);
}
if (toDate) {
query = query.where(`${TableName.CertificateRequests}.createdAt`, "<=", toDate);
}
if (processedRules) {
query = applyProcessedPermissionRulesToQuery(
query,
TableName.CertificateRequests,
processedRules
) as typeof query;
}
const result = await query.count("*").first();
const count = (result as unknown as Record<string, unknown>)?.count;
return parseInt(String(count || "0"), 10);
} catch (error) {
throw new DatabaseError({ error, name: "Count certificate requests by project ID" });
}
};
const findByProjectIdWithCertificate = async (
projectId: string,
options: {
offset?: number;
limit?: number;
search?: string;
status?: string;
fromDate?: Date;
toDate?: Date;
profileIds?: string[];
sortBy?: string;
sortOrder?: "asc" | "desc";
} = {},
processedRules?: ProcessedPermissionRules,
tx?: Knex
): Promise<TCertificateRequestWithCertificate[]> => {
try {
const {
offset = 0,
limit = 20,
search,
status,
fromDate,
toDate,
profileIds,
sortBy = "createdAt",
sortOrder = "desc"
} = options;
let query: Knex.QueryBuilder = (tx || db)(TableName.CertificateRequests)
.leftJoin(
TableName.Certificate,
`${TableName.CertificateRequests}.certificateId`,
`${TableName.Certificate}.id`
)
.leftJoin(
TableName.PkiCertificateProfile,
`${TableName.CertificateRequests}.profileId`,
`${TableName.PkiCertificateProfile}.id`
);
if (profileIds && profileIds.length > 0) {
query = query.whereIn(`${TableName.CertificateRequests}.profileId`, profileIds);
}
query = query
.select(selectAllTableCols(TableName.CertificateRequests))
.select(db.ref("slug").withSchema(TableName.PkiCertificateProfile).as("profileName"))
.select(db.raw(`row_to_json(${TableName.Certificate}.*) as certificate`))
.where(`${TableName.CertificateRequests}.projectId`, projectId);
if (search) {
const sanitizedSearch = String(search).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.where((builder) => {
void builder
.whereILike(`${TableName.CertificateRequests}.commonName`, `%${sanitizedSearch}%`)
.orWhereILike(`${TableName.CertificateRequests}.altNames`, `%${sanitizedSearch}%`)
.orWhereILike(`${TableName.CertificateRequests}.status`, `%${sanitizedSearch}%`);
});
}
if (status) {
query = query.where(`${TableName.CertificateRequests}.status`, status);
}
if (fromDate) {
query = query.where(`${TableName.CertificateRequests}.createdAt`, ">=", fromDate);
}
if (toDate) {
query = query.where(`${TableName.CertificateRequests}.createdAt`, "<=", toDate);
}
if (processedRules) {
query = applyProcessedPermissionRulesToQuery(query, TableName.CertificateRequests, processedRules);
}
const allowedSortColumns = ["createdAt", "updatedAt", "status", "commonName"];
const safeSortBy = allowedSortColumns.includes(sortBy) ? sortBy : "createdAt";
const safeSortOrder = sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc";
const results = (await query
.orderBy(`${TableName.CertificateRequests}.${safeSortBy}`, safeSortOrder)
.offset(offset)
.limit(limit)) as TCertificateRequestQueryResult[];
return results.map((row): TCertificateRequestWithCertificate => {
const { certificate: certificateJson, profileName: rowProfileName, ...certificateRequestData } = row;
let parsedCertificate: TCertificates | null = null;
if (certificateJson && typeof certificateJson === "string") {
try {
const parsed = JSON.parse(certificateJson) as Record<string, unknown>;
if (parsed && typeof parsed === "object" && "id" in parsed) {
parsedCertificate = parsed as TCertificates;
}
} catch {
// Ignore parsing errors
}
} else if (certificateJson && typeof certificateJson === "object" && "id" in certificateJson) {
parsedCertificate = certificateJson as TCertificates;
}
return {
...certificateRequestData,
profileName: rowProfileName || null,
certificate: parsedCertificate
};
});
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate requests by project ID with certificates" });
}
};
return {
...certificateRequestOrm,
findByIdWithCertificate,
findPendingByProjectId,
updateStatus,
attachCertificate
attachCertificate,
findByProjectId,
countByProjectId,
findByProjectIdWithCertificate
};
};

View File

@@ -9,6 +9,7 @@ import {
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { getProcessedPermissionRules } from "@app/lib/casl/permission-filter-utils";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service";
@@ -21,6 +22,7 @@ import {
TCreateCertificateRequestDTO,
TGetCertificateFromRequestDTO,
TGetCertificateRequestDTO,
TListCertificateRequestsDTO,
TUpdateCertificateRequestStatusDTO
} from "./certificate-request-types";
@@ -285,11 +287,76 @@ export const certificateRequestServiceFactory = ({
return certificateRequestDAL.attachCertificate(certificateRequestId, certificateId);
};
const listCertificateRequests = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
offset = 0,
limit = 20,
search,
status,
fromDate,
toDate,
profileIds,
sortBy,
sortOrder
}: TListCertificateRequestsDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Read,
ProjectPermissionSub.Certificates
);
const processedRules = getProcessedPermissionRules(
permission,
ProjectPermissionCertificateActions.Read,
ProjectPermissionSub.Certificates
);
const options: Parameters<typeof certificateRequestDAL.findByProjectIdWithCertificate>[1] = {
offset,
limit,
search,
status,
fromDate,
toDate,
profileIds,
sortBy,
sortOrder
};
const [certificateRequests, totalCount] = await Promise.all([
certificateRequestDAL.findByProjectIdWithCertificate(projectId, options, processedRules),
certificateRequestDAL.countByProjectId(projectId, options, processedRules)
]);
const mappedCertificateRequests = certificateRequests.map((request) => ({
...request,
status: request.status as CertificateRequestStatus
}));
return {
certificateRequests: mappedCertificateRequests,
totalCount
};
};
return {
createCertificateRequest,
getCertificateRequest,
getCertificateFromRequest,
updateCertificateRequestStatus,
attachCertificateToRequest
attachCertificateToRequest,
listCertificateRequests
};
};

View File

@@ -42,3 +42,15 @@ export type TAttachCertificateToRequestDTO = {
certificateRequestId: string;
certificateId: string;
};
export type TListCertificateRequestsDTO = TProjectPermission & {
offset?: number;
limit?: number;
search?: string;
status?: CertificateRequestStatus;
fromDate?: Date;
toDate?: Date;
profileIds?: string[];
sortBy?: string;
sortOrder?: "asc" | "desc";
};

View File

@@ -13,6 +13,11 @@ import { CertStatus } from "./certificate-types";
export type TCertificateDALFactory = ReturnType<typeof certificateDALFactory>;
const isValidUUID = (str: string): boolean => {
const uuidRegex = new RE2("^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", "i");
return uuidRegex.test(str);
};
export const certificateDALFactory = (db: TDbClient) => {
const certificateOrm = ormify(db, TableName.Certificate);
@@ -48,11 +53,21 @@ export const certificateDALFactory = (db: TDbClient) => {
const countCertificatesInProject = async ({
projectId,
friendlyName,
commonName
commonName,
search,
status,
profileIds,
fromDate,
toDate
}: {
projectId: string;
friendlyName?: string;
commonName?: string;
search?: string;
status?: string | string[];
profileIds?: string[];
fromDate?: Date;
toDate?: Date;
}) => {
try {
interface CountResult {
@@ -75,6 +90,63 @@ export const certificateDALFactory = (db: TDbClient) => {
query = query.andWhere(`${TableName.Certificate}.commonName`, "like", `%${sanitizedValue}%`);
}
if (search) {
const sanitizedValue = String(search).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.andWhere(function () {
void this.where(`${TableName.Certificate}.commonName`, "like", `%${sanitizedValue}%`)
.orWhere(`${TableName.Certificate}.altNames`, "like", `%${sanitizedValue}%`)
.orWhere(`${TableName.Certificate}.serialNumber`, "like", `%${sanitizedValue}%`)
.orWhere(`${TableName.Certificate}.friendlyName`, "like", `%${sanitizedValue}%`);
if (isValidUUID(sanitizedValue)) {
void this.orWhere(`${TableName.Certificate}.id`, sanitizedValue);
}
});
}
if (status) {
const now = new Date();
const statuses = Array.isArray(status) ? status : [status];
query = query.andWhere(function () {
statuses.forEach((statusValue, index) => {
const whereMethod = index === 0 ? "where" : "orWhere";
if (statusValue === "active") {
void this[whereMethod](function () {
void this.where(`${TableName.Certificate}.notAfter`, ">", now).andWhere(
`${TableName.Certificate}.status`,
"!=",
"revoked"
);
});
} else if (statusValue === "expired") {
void this[whereMethod](function () {
void this.where(`${TableName.Certificate}.notAfter`, "<=", now).andWhere(
`${TableName.Certificate}.status`,
"!=",
"revoked"
);
});
} else {
void this[whereMethod](`${TableName.Certificate}.status`, statusValue);
}
});
});
}
if (fromDate) {
query = query.andWhere(`${TableName.Certificate}.createdAt`, ">=", fromDate);
}
if (toDate) {
query = query.andWhere(`${TableName.Certificate}.createdAt`, "<=", toDate);
}
if (profileIds) {
query = query.whereIn(`${TableName.Certificate}.profileId`, profileIds);
}
const count = await query.count("*").first();
return parseInt((count as unknown as CountResult).count || "0", 10);
@@ -275,7 +347,17 @@ export const certificateDALFactory = (db: TDbClient) => {
};
const findWithPrivateKeyInfo = async (
filter: Partial<TCertificates & { friendlyName?: string; commonName?: string }>,
filter: Partial<
TCertificates & {
friendlyName?: string;
commonName?: string;
search?: string;
status?: string | string[];
profileIds?: string[];
fromDate?: Date;
toDate?: Date;
}
>,
options?: { offset?: number; limit?: number; sort?: [string, "asc" | "desc"][] },
permissionFilters?: ProcessedPermissionRules
): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => {
@@ -286,17 +368,81 @@ export const certificateDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.Certificate))
.select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef"));
Object.entries(filter).forEach(([key, value]) => {
const { friendlyName, commonName, search, status, profileIds, fromDate, toDate, ...regularFilters } = filter;
Object.entries(regularFilters).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (key === "friendlyName" || key === "commonName") {
const sanitizedValue = String(value).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.andWhere(`${TableName.Certificate}.${key}`, "like", `%${sanitizedValue}%`);
} else {
query = query.andWhere(`${TableName.Certificate}.${key}`, value);
}
query = query.andWhere(`${TableName.Certificate}.${key}`, value);
}
});
if (friendlyName) {
const sanitizedValue = String(friendlyName).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.andWhere(`${TableName.Certificate}.friendlyName`, "like", `%${sanitizedValue}%`);
}
if (commonName) {
const sanitizedValue = String(commonName).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.andWhere(`${TableName.Certificate}.commonName`, "like", `%${sanitizedValue}%`);
}
if (search) {
const sanitizedValue = String(search).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.andWhere(function () {
void this.where(`${TableName.Certificate}.commonName`, "like", `%${sanitizedValue}%`)
.orWhere(`${TableName.Certificate}.altNames`, "like", `%${sanitizedValue}%`)
.orWhere(`${TableName.Certificate}.serialNumber`, "like", `%${sanitizedValue}%`)
.orWhere(`${TableName.Certificate}.friendlyName`, "like", `%${sanitizedValue}%`);
if (isValidUUID(sanitizedValue)) {
void this.orWhere(`${TableName.Certificate}.id`, sanitizedValue);
}
});
}
if (status) {
const now = new Date();
const statuses = Array.isArray(status) ? status : [status];
query = query.andWhere(function () {
statuses.forEach((statusValue, index) => {
const whereMethod = index === 0 ? "where" : "orWhere";
if (statusValue === "active") {
void this[whereMethod](function () {
void this.where(`${TableName.Certificate}.notAfter`, ">", now).andWhere(
`${TableName.Certificate}.status`,
"!=",
"revoked"
);
});
} else if (statusValue === "expired") {
void this[whereMethod](function () {
void this.where(`${TableName.Certificate}.notAfter`, "<=", now).andWhere(
`${TableName.Certificate}.status`,
"!=",
"revoked"
);
});
} else {
void this[whereMethod](`${TableName.Certificate}.status`, statusValue);
}
});
});
}
if (fromDate) {
query = query.andWhere(`${TableName.Certificate}.createdAt`, ">=", fromDate);
}
if (toDate) {
query = query.andWhere(`${TableName.Certificate}.createdAt`, "<=", toDate);
}
if (profileIds) {
query = query.whereIn(`${TableName.Certificate}.profileId`, profileIds);
}
if (permissionFilters) {
query = applyProcessedPermissionRulesToQuery(query, TableName.Certificate, permissionFilters) as typeof query;
}

View File

@@ -942,6 +942,11 @@ export const projectServiceFactory = ({
friendlyName,
commonName,
forPkiSync = false,
search,
status,
profileIds,
fromDate,
toDate,
actorId,
actorOrgId,
actorAuthMethod,
@@ -968,7 +973,12 @@ export const projectServiceFactory = ({
const regularFilters = {
projectId,
...(friendlyName && { friendlyName }),
...(commonName && { commonName })
...(commonName && { commonName }),
...(search && { search }),
...(status && { status: Array.isArray(status) ? status[0] : status }),
...(profileIds && { profileIds }),
...(fromDate && { fromDate }),
...(toDate && { toDate })
};
const permissionFilters = getProcessedPermissionRules(
permission,
@@ -991,7 +1001,12 @@ export const projectServiceFactory = ({
const countFilter = {
projectId,
...(regularFilters.friendlyName && { friendlyName: String(regularFilters.friendlyName) }),
...(regularFilters.commonName && { commonName: String(regularFilters.commonName) })
...(regularFilters.commonName && { commonName: String(regularFilters.commonName) }),
...(regularFilters.search && { search: String(regularFilters.search) }),
...(regularFilters.status && { status: String(regularFilters.status) }),
...(regularFilters.profileIds && { profileIds: regularFilters.profileIds }),
...(regularFilters.fromDate && { fromDate: regularFilters.fromDate }),
...(regularFilters.toDate && { toDate: regularFilters.toDate })
};
const count = forPkiSync

View File

@@ -144,6 +144,11 @@ export type TListProjectCertsDTO = {
friendlyName?: string;
commonName?: string;
forPkiSync?: boolean;
search?: string;
status?: string | string[];
profileIds?: string[];
fromDate?: Date;
toDate?: Date;
} & Omit<TProjectPermission, "projectId">;
export type TListProjectAlertsDTO = TProjectPermission;

View File

@@ -0,0 +1,15 @@
export const truncateSerialNumber = (serialNumber: string | null | undefined): string => {
if (!serialNumber || typeof serialNumber !== "string") {
return "—";
}
// If serial number is 8 characters or less, show it in full
if (serialNumber.length <= 8) {
return serialNumber;
}
// Show first 4 + "..." + last 4
const first4 = serialNumber.substring(0, 4);
const last4 = serialNumber.substring(serialNumber.length - 4);
return `${first4}...${last4}`;
};

View File

@@ -7,4 +7,16 @@ export {
useRevokeCert,
useUpdateRenewalConfig
} from "./mutations";
export { useGetCert, useGetCertBody } from "./queries";
export {
useGetCert,
useGetCertBody,
useGetCertBundle,
useGetCertificateRequest,
useListCertificateRequests
} from "./queries";
export type {
TCertificateRequestDetails,
TCertificateRequestListItem,
TListCertificateRequestsParams,
TListCertificateRequestsResponse
} from "./types";

View File

@@ -2,7 +2,12 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TCertificate } from "./types";
import {
TCertificate,
TCertificateRequestDetails,
TListCertificateRequestsParams,
TListCertificateRequestsResponse
} from "./types";
export const certKeys = {
getCertById: (serialNumber: string) => [{ serialNumber }, "cert"],
@@ -11,6 +16,20 @@ export const certKeys = {
getCertificateRequest: (requestId: string, projectSlug: string) => [
{ requestId, projectSlug },
"certificateRequest"
],
listCertificateRequests: (params: TListCertificateRequestsParams) => [
"certificateRequests",
"list",
params.projectSlug,
params.offset,
params.limit,
params.search,
params.status,
params.fromDate,
params.toDate,
params.profileIds,
params.sortBy,
params.sortOrder
]
};
@@ -59,3 +78,50 @@ export const useGetCertBundle = (serialNumber: string) => {
enabled: Boolean(serialNumber)
});
};
export const useListCertificateRequests = (params: TListCertificateRequestsParams) => {
return useQuery({
queryKey: certKeys.listCertificateRequests(params),
queryFn: async () => {
const searchParams = new URLSearchParams();
searchParams.append("projectSlug", params.projectSlug);
if (params.offset !== undefined) searchParams.append("offset", params.offset.toString());
if (params.limit !== undefined) searchParams.append("limit", params.limit.toString());
if (params.search) searchParams.append("search", params.search);
if (params.status) searchParams.append("status", params.status);
if (params.fromDate) searchParams.append("fromDate", params.fromDate.toISOString());
if (params.toDate) searchParams.append("toDate", params.toDate.toISOString());
if (params.profileIds && params.profileIds.length > 0) {
params.profileIds.forEach((id) => {
searchParams.append("profileIds", id);
});
}
if (params.sortBy) searchParams.append("sortBy", params.sortBy);
if (params.sortOrder) searchParams.append("sortOrder", params.sortOrder);
const { data } = await apiRequest.get<TListCertificateRequestsResponse>(
`/api/v1/cert-manager/certificates/certificate-requests?${searchParams.toString()}`
);
return data;
},
enabled: Boolean(params.projectSlug),
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchInterval: false
});
};
export const useGetCertificateRequest = (requestId: string, projectSlug: string) => {
return useQuery({
queryKey: certKeys.getCertificateRequest(requestId, projectSlug),
queryFn: async () => {
const { data } = await apiRequest.get<TCertificateRequestDetails>(
`/api/v1/cert-manager/certificates/certificate-requests/${requestId}?projectSlug=${projectSlug}`
);
return data;
},
enabled: Boolean(requestId) && Boolean(projectSlug)
});
};

View File

@@ -136,3 +136,40 @@ export type TCertificateRequestDetails = {
createdAt: string;
updatedAt: string;
};
export type TCertificateRequestListItem = {
id: string;
status: "pending" | "issued" | "failed";
commonName: string | null;
altNames: string | null;
profileId: string | null;
profileName: string | null;
caId: string | null;
certificateId: string | null;
errorMessage: string | null;
createdAt: string;
updatedAt: string;
certificate: {
id: string;
serialNumber: string;
status: string;
} | null;
};
export type TListCertificateRequestsResponse = {
certificateRequests: TCertificateRequestListItem[];
totalCount: number;
};
export type TListCertificateRequestsParams = {
projectSlug: string;
offset?: number;
limit?: number;
search?: string;
status?: "pending" | "issued" | "failed";
fromDate?: Date;
toDate?: Date;
profileIds?: string[];
sortBy?: string;
sortOrder?: "asc" | "desc";
};

View File

@@ -516,7 +516,12 @@ export const useListWorkspaceCertificates = ({
limit,
friendlyName,
commonName,
forPkiSync
forPkiSync,
search,
status,
profileIds,
fromDate,
toDate
}: {
projectId: string;
offset: number;
@@ -524,6 +529,11 @@ export const useListWorkspaceCertificates = ({
friendlyName?: string;
commonName?: string;
forPkiSync?: boolean;
search?: string;
status?: string | string[];
profileIds?: string[];
fromDate?: Date;
toDate?: Date;
}) => {
return useQuery({
queryKey: projectKeys.specificProjectCertificates({
@@ -532,7 +542,12 @@ export const useListWorkspaceCertificates = ({
limit,
friendlyName,
commonName,
forPkiSync
forPkiSync,
search,
status,
profileIds,
fromDate,
toDate
}),
queryFn: async () => {
const params = new URLSearchParams({
@@ -549,6 +564,29 @@ export const useListWorkspaceCertificates = ({
if (forPkiSync) {
params.append("forPkiSync", "true");
}
if (search) {
params.append("search", search);
}
if (status) {
if (Array.isArray(status)) {
status.forEach((statusValue) => {
params.append("status", statusValue);
});
} else {
params.append("status", status);
}
}
if (fromDate) {
params.append("fromDate", fromDate.toISOString());
}
if (toDate) {
params.append("toDate", toDate.toISOString());
}
if (profileIds && profileIds.length > 0) {
profileIds.forEach((id) => {
params.append("profileIds", id);
});
}
const {
data: { certificates, totalCount }

View File

@@ -47,7 +47,12 @@ export const projectKeys = {
limit,
friendlyName,
commonName,
forPkiSync
forPkiSync,
search,
status,
profileIds,
fromDate,
toDate
}: {
projectId: string;
offset: number;
@@ -55,10 +60,26 @@ export const projectKeys = {
friendlyName?: string;
commonName?: string;
forPkiSync?: boolean;
search?: string;
status?: string | string[];
profileIds?: string[];
fromDate?: Date;
toDate?: Date;
}) =>
[
...projectKeys.forProjectCertificates(projectId),
{ offset, limit, friendlyName, commonName, forPkiSync }
{
offset,
limit,
friendlyName,
commonName,
forPkiSync,
search,
status,
profileIds,
fromDate,
toDate
}
] as const,
getProjectPkiAlerts: (projectId: string) => [{ projectId }, "project-pki-alerts"] as const,
getProjectPkiSubscribers: (projectId: string) =>

View File

@@ -0,0 +1,99 @@
import { faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { getCertificateDisplayName } from "@app/components/utilities/certificateDisplayUtils";
import { truncateSerialNumber } from "@app/components/utilities/serialNumberUtils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { TCertificateRequestListItem } from "@app/hooks/api/certificates";
type Props = {
request: TCertificateRequestListItem;
onViewCertificates?: (certificateId: string) => void;
};
export const CertificateRequestRow = ({ request, onViewCertificates }: Props) => {
const getStatusBadge = (status: string) => {
switch (status.toLowerCase()) {
case "pending":
return <Badge variant="warning">Pending</Badge>;
case "issued":
return <Badge variant="success">Issued</Badge>;
case "failed":
return <Badge variant="danger">Failed</Badge>;
default:
return <Badge variant="outline">{status.charAt(0).toUpperCase() + status.slice(1)}</Badge>;
}
};
const { displayName } = getCertificateDisplayName(
{
altNames: request.altNames,
commonName: request.commonName
},
64,
"—"
);
return (
<Tr className="h-10 hover:bg-mineshaft-700">
<Td>
<div className="max-w-xs truncate" title={displayName}>
{displayName}
</div>
</Td>
<Td>
<div className="max-w-xs truncate" title={request.certificate?.serialNumber || "N/A"}>
{truncateSerialNumber(request.certificate?.serialNumber)}
</div>
</Td>
<Td>{getStatusBadge(request.status)}</Td>
<Td>
<div className="max-w-xs truncate">{request.profileName || "N/A"}</div>
</Td>
<Td>
<Tooltip content={format(new Date(request.createdAt), "MMM dd, yyyy HH:mm:ss")}>
<time dateTime={request.createdAt}>
{format(new Date(request.createdAt), "yyyy-MM-dd")}
</time>
</Tooltip>
</Td>
<Td>
<Tooltip content={format(new Date(request.updatedAt), "MMM dd, yyyy HH:mm:ss")}>
<time dateTime={request.updatedAt}>
{format(new Date(request.updatedAt), "yyyy-MM-dd")}
</time>
</Tooltip>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => request.certificateId && onViewCertificates?.(request.certificateId)}
disabled={!request.certificateId}
className="flex items-center gap-2"
>
View in Certificates
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
};

View File

@@ -0,0 +1,385 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import { useEffect, useMemo, useState } from "react";
import { faFilter, faMagnifyingGlass, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
FilterableSelect,
IconButton,
Input,
Pagination,
Select,
SelectItem,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import {
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub,
useProject
} from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks";
import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
import {
TListCertificateRequestsParams,
useListCertificateRequests
} from "@app/hooks/api/certificates";
import { CertificateIssuanceModal } from "../../CertificatesPage/components/CertificateIssuanceModal";
import { CertificateRequestRow } from "./CertificateRequestRow";
const PAGE_SIZE = 20;
const DATE_RANGE_DAYS = 90;
const SEARCH_DEBOUNCE_DELAY = 500;
type CertificateRequestStatus = "pending" | "issued" | "failed";
type CertificateRequestFilters = {
status?: CertificateRequestStatus;
};
type Props = {
onViewCertificateFromRequest?: (certificateId: string) => void;
};
export const CertificateRequestsSection = ({ onViewCertificateFromRequest }: Props) => {
const { currentProject } = useProject();
const [pendingSearch, setPendingSearch] = useState("");
const [pendingProfileIds, setPendingProfileIds] = useState<string[]>([]);
const [pendingFilters, setPendingFilters] = useState<CertificateRequestFilters>({});
const [appliedProfileIds, setAppliedProfileIds] = useState<string[]>([]);
const [appliedFilters, setAppliedFilters] = useState<CertificateRequestFilters>({});
const [currentPage, setCurrentPage] = useState(1);
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["issueCertificate"] as const);
const [debouncedSearch, setDebouncedSearch] = useDebounce(pendingSearch, SEARCH_DEBOUNCE_DELAY);
useEffect(() => {
setCurrentPage(1);
}, [debouncedSearch]);
const { data: profilesData } = useListCertificateProfiles({
projectId: currentProject?.id ?? "",
limit: 100
});
const { fromDate, toDate } = useMemo(() => {
const now = Date.now();
const daysInMs = DATE_RANGE_DAYS * 24 * 60 * 60 * 1000;
return {
fromDate: new Date(now - daysInMs),
toDate: new Date(now)
};
}, []);
const profileIds = useMemo(() => {
return appliedProfileIds.length > 0 ? appliedProfileIds : undefined;
}, [appliedProfileIds]);
const queryParams: TListCertificateRequestsParams = useMemo(
() => ({
projectSlug: currentProject?.slug || "",
offset: (currentPage - 1) * PAGE_SIZE,
limit: PAGE_SIZE,
sortBy: "createdAt",
sortOrder: "desc",
...(debouncedSearch && { search: debouncedSearch }),
...(appliedFilters.status && { status: appliedFilters.status }),
...(profileIds && { profileIds }),
fromDate,
toDate
}),
[
currentProject?.slug,
currentPage,
debouncedSearch,
appliedFilters.status,
profileIds,
fromDate,
toDate
]
);
const {
data: certificateRequestsData,
isLoading,
isError
} = useListCertificateRequests(queryParams);
const handleApplyFilters = () => {
setAppliedFilters(pendingFilters);
setAppliedProfileIds(pendingProfileIds);
setCurrentPage(1);
};
const handleClearFilters = () => {
setPendingSearch("");
setPendingFilters({});
setPendingProfileIds([]);
setAppliedFilters({});
setAppliedProfileIds([]);
setDebouncedSearch("");
setCurrentPage(1);
};
const handleClearStatus = () => {
setPendingFilters((prev) => ({ ...prev, status: undefined }));
};
const handleClearProfiles = () => {
setPendingProfileIds([]);
};
const handleViewCertificates = (certificateId: string) => {
onViewCertificateFromRequest?.(certificateId);
};
const isTableFiltered = useMemo(
() => Boolean(debouncedSearch || appliedFilters.status || appliedProfileIds.length),
[debouncedSearch, appliedFilters.status, appliedProfileIds.length]
);
const hasPendingChanges = useMemo(() => {
if (pendingFilters.status !== appliedFilters.status) return true;
if (pendingProfileIds.length !== appliedProfileIds.length) return true;
return pendingProfileIds.some((id, index) => id !== appliedProfileIds[index]);
}, [pendingFilters.status, appliedFilters.status, pendingProfileIds, appliedProfileIds]);
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-y-2">
<div>
<div className="flex items-center gap-x-2 whitespace-nowrap">
<p className="text-xl font-medium text-mineshaft-100">Certificate Requests</p>
</div>
</div>
<div className="flex gap-2">
<ProjectPermissionCan
I={ProjectPermissionCertificateProfileActions.IssueCert}
a={ProjectPermissionSub.CertificateProfiles}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("issueCertificate")}
isDisabled={!isAllowed}
>
Request
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<div className="mb-4 flex gap-2">
<Input
value={pendingSearch}
onChange={(e) => setPendingSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search by SAN, CN or Profile Name..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Certificate Requests"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={2}
className="max-h-[70vh] thin-scrollbar w-80 overflow-y-auto p-4"
align="end"
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-mineshaft-100">Filters</h3>
<span className="text-xs text-bunker-300">
{isTableFiltered && (
<button
type="button"
onClick={handleClearFilters}
className="text-primary hover:text-primary-600"
>
Clear filters
</button>
)}
</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-bunker-300 uppercase">
Certificate Profiles
</span>
{pendingProfileIds.length > 0 && (
<button
type="button"
onClick={handleClearProfiles}
className="text-xs text-primary hover:text-primary-600"
>
Clear
</button>
)}
</div>
<FilterableSelect
value={pendingProfileIds.map((id) => ({
value: id,
label: profilesData?.certificateProfiles?.find((p) => p.id === id)?.slug || id
}))}
onChange={(selectedOptions) => {
const ids = Array.isArray(selectedOptions)
? selectedOptions.map((opt) => opt.value)
: [];
setPendingProfileIds(ids);
}}
options={
profilesData?.certificateProfiles?.map((profile) => ({
value: profile.id,
label: profile.slug
})) || []
}
placeholder="Select certificate profiles..."
className="w-full border-mineshaft-600 bg-mineshaft-700 text-bunker-200"
isMulti
isLoading={!profilesData}
maxMenuHeight={120}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-bunker-300 uppercase">Events</label>
{pendingFilters.status && (
<button
type="button"
onClick={handleClearStatus}
className="text-xs text-primary hover:text-primary-600"
>
Clear
</button>
)}
</div>
<Select
value={pendingFilters.status || "all"}
onValueChange={(value: string) => {
setPendingFilters((prev) => ({
...prev,
status: value === "all" ? undefined : (value as CertificateRequestStatus)
}));
}}
placeholder="All events"
className="w-full border-mineshaft-600 bg-mineshaft-700 text-bunker-200"
>
<SelectItem value="all">All events</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="issued">Issued</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
</Select>
</div>
<div className="pt-2">
<Button
onClick={handleApplyFilters}
className="w-full bg-primary font-medium text-black hover:bg-primary-600"
size="sm"
disabled={!hasPendingChanges}
>
Apply
</Button>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="text-xs font-medium text-bunker-300 uppercase">SAN / CN</Th>
<Th className="text-xs font-medium text-bunker-300 uppercase">SERIAL NUMBER</Th>
<Th className="text-xs font-medium text-bunker-300 uppercase">STATUS</Th>
<Th className="text-xs font-medium text-bunker-300 uppercase">PROFILE</Th>
<Th className="text-xs font-medium text-bunker-300 uppercase">CREATED AT</Th>
<Th className="text-xs font-medium text-bunker-300 uppercase">UPDATED AT</Th>
<Th className="text-xs font-medium text-bunker-300 uppercase" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={7} innerKey="certificate-requests-loading" />}
{isError && (
<Tr>
<td colSpan={7} className="py-8 text-center text-red-400">
Failed to load certificate requests. Please try again.
</td>
</Tr>
)}
{!isLoading &&
!isError &&
certificateRequestsData?.certificateRequests?.length === 0 && (
<Tr>
<td colSpan={7} className="py-8 text-center text-bunker-300">
{isTableFiltered
? "No certificate requests found matching your filters"
: "No certificate requests found"}
</td>
</Tr>
)}
{!isLoading &&
!isError &&
certificateRequestsData?.certificateRequests?.map((request) => (
<CertificateRequestRow
key={request.id}
request={request}
onViewCertificates={handleViewCertificates}
/>
))}
</TBody>
</Table>
</TableContainer>
{certificateRequestsData && certificateRequestsData.totalCount > 0 && (
<div className="flex items-center justify-between">
<Pagination
count={certificateRequestsData.totalCount}
page={currentPage}
perPage={PAGE_SIZE}
onChangePage={(page) => setCurrentPage(page)}
onChangePerPage={() => {
setCurrentPage(1);
}}
/>
</div>
)}
</div>
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { CertificateRequestRow } from "./CertificateRequestRow";
export { CertificateRequestsSection } from "./CertificateRequestsSection";

View File

@@ -1,4 +1,4 @@
import { faArrowRight, faPlus } from "@fortawesome/free-solid-svg-icons";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
@@ -6,7 +6,6 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub,
useProject
} from "@app/context";
@@ -16,14 +15,20 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateExportModal, ExportOptions } from "./CertificateExportModal";
import { CertificateImportModal } from "./CertificateImportModal";
import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
import { CertificateManagePkiSyncsModal } from "./CertificateManagePkiSyncsModal";
import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal";
import { CertificateRenewalModal } from "./CertificateRenewalModal";
import { CertificateRevocationModal } from "./CertificateRevocationModal";
import { CertificatesTable } from "./CertificatesTable";
export const CertificatesSection = () => {
type CertificatesSectionProps = {
externalFilter?: {
certificateId?: string;
search?: string;
};
};
export const CertificatesSection = ({ externalFilter }: CertificatesSectionProps) => {
const { currentProject } = useProject();
const { mutateAsync: deleteCert } = useDeleteCert();
const { mutateAsync: downloadCertPkcs12 } = useDownloadCertPkcs12();
@@ -121,26 +126,9 @@ export const CertificatesSection = () => {
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionCertificateProfileActions.IssueCert}
a={ProjectPermissionSub.CertificateProfiles}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("issueCertificate")}
isDisabled={!isAllowed}
>
Request
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificatesTable handlePopUpOpen={handlePopUpOpen} externalFilter={externalFilter} />
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateExportModal

View File

@@ -14,6 +14,7 @@ import {
import { ProjectType } from "@app/hooks/api/projects/types";
import { CertificateProfilesTab } from "./components/CertificateProfilesTab";
import { CertificateRequestsTab } from "./components/CertificateRequestsTab";
import { CertificatesTab } from "./components/CertificatesTab";
import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab";
@@ -21,6 +22,7 @@ enum TabSections {
CertificateProfiles = "profiles",
CertificateTemplatesV2 = "templates-v2",
Certificates = "certificates",
CertificateRequests = "certificate-requests",
PkiCollections = "pki-collections"
}
@@ -28,7 +30,13 @@ export const PoliciesPage = () => {
const { t } = useTranslation();
const { currentProject } = useProject();
const { permission } = useProjectPermission();
const [activeTab, setActiveTab] = useState(TabSections.CertificateProfiles);
const [activeTab, setActiveTab] = useState(TabSections.Certificates);
const [certificateFilter, setCertificateFilter] = useState<{ search?: string }>({});
const handleViewCertificateFromRequest = (certificateId: string) => {
setActiveTab(TabSections.Certificates);
setCertificateFilter({ search: certificateId });
};
const canReadCertificateProfiles = permission.can(
ProjectPermissionCertificateProfileActions.Read,
@@ -65,17 +73,38 @@ export const PoliciesPage = () => {
onValueChange={(value) => setActiveTab(value as TabSections)}
>
<TabList>
<Tab variant="project" value={TabSections.Certificates}>
Certificates
</Tab>
<Tab variant="project" value={TabSections.CertificateRequests}>
Certificate Requests
</Tab>
<Tab variant="project" value={TabSections.CertificateProfiles}>
Certificate Profiles
</Tab>
<Tab variant="project" value={TabSections.CertificateTemplatesV2}>
Certificate Templates
</Tab>
<Tab variant="project" value={TabSections.Certificates}>
Certificates
</Tab>
</TabList>
<TabPanel value={TabSections.Certificates}>
{canReadCertificates ? (
<CertificatesTab externalFilter={certificateFilter} />
) : (
<PermissionDeniedBanner />
)}
</TabPanel>
<TabPanel value={TabSections.CertificateRequests}>
{canReadCertificates ? (
<CertificateRequestsTab
onViewCertificateFromRequest={handleViewCertificateFromRequest}
/>
) : (
<PermissionDeniedBanner />
)}
</TabPanel>
<TabPanel value={TabSections.CertificateProfiles}>
{canReadCertificateProfiles ? <CertificateProfilesTab /> : <PermissionDeniedBanner />}
</TabPanel>
@@ -87,10 +116,6 @@ export const PoliciesPage = () => {
<PermissionDeniedBanner />
)}
</TabPanel>
<TabPanel value={TabSections.Certificates}>
{canReadCertificates ? <CertificatesTab /> : <PermissionDeniedBanner />}
</TabPanel>
</Tabs>
</div>
</div>

View File

@@ -0,0 +1,9 @@
import { CertificateRequestsSection } from "@app/pages/cert-manager/CertificateRequestsPage/components";
type Props = {
onViewCertificateFromRequest?: (certificateId: string) => void;
};
export const CertificateRequestsTab = ({ onViewCertificateFromRequest }: Props) => {
return <CertificateRequestsSection onViewCertificateFromRequest={onViewCertificateFromRequest} />;
};

View File

@@ -0,0 +1 @@
export { CertificateRequestsTab } from "./CertificateRequestsTab";

View File

@@ -1,5 +1,12 @@
import { CertificatesSection } from "../../../CertificatesPage/components/CertificatesSection";
export const CertificatesTab = () => {
return <CertificatesSection />;
type Props = {
externalFilter?: {
certificateId?: string;
search?: string;
};
};
export const CertificatesTab = ({ externalFilter }: Props) => {
return <CertificatesSection externalFilter={externalFilter} />;
};