diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 358a61b31b..0fb692c6ee 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -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) { diff --git a/backend/src/db/migrations/20251212150000_add-certificate-requests-partitioning.ts b/backend/src/db/migrations/20251212150000_add-certificate-requests-partitioning.ts new file mode 100644 index 0000000000..e1d810218f --- /dev/null +++ b/backend/src/db/migrations/20251212150000_add-certificate-requests-partitioning.ts @@ -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 { + // 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[] = []; + 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 { + // skip +} diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 64cd7c9360..88e4dc0776 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -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 diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 25beb710cd..377c171812 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -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", diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index e3838cddc2..6ae9ef27bd 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -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({ diff --git a/backend/src/services/certificate-request/certificate-request-dal.ts b/backend/src/services/certificate-request/certificate-request-dal.ts index df2a4b0c4b..79da7f9533 100644 --- a/backend/src/services/certificate-request/certificate-request-dal.ts +++ b/backend/src/services/certificate-request/certificate-request-dal.ts @@ -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; + export type TCertificateRequestDALFactory = ReturnType; export const certificateRequestDALFactory = (db: TDbClient) => { @@ -16,24 +27,45 @@ export const certificateRequestDALFactory = (db: TDbClient) => { const findByIdWithCertificate = async (id: string): Promise => { 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; + 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 => { + 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 => { + 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)?.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 => { + 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; + 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 }; }; diff --git a/backend/src/services/certificate-request/certificate-request-service.ts b/backend/src/services/certificate-request/certificate-request-service.ts index 78bde276b2..b3e28028f6 100644 --- a/backend/src/services/certificate-request/certificate-request-service.ts +++ b/backend/src/services/certificate-request/certificate-request-service.ts @@ -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[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 }; }; diff --git a/backend/src/services/certificate-request/certificate-request-types.ts b/backend/src/services/certificate-request/certificate-request-types.ts index 9ccf6fbaef..9b96515974 100644 --- a/backend/src/services/certificate-request/certificate-request-types.ts +++ b/backend/src/services/certificate-request/certificate-request-types.ts @@ -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"; +}; diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index 72cef90fad..ca4de7ec74 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -13,6 +13,11 @@ import { CertStatus } from "./certificate-types"; export type TCertificateDALFactory = ReturnType; +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, + 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; } diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index a48daefdc2..3c87bb61b2 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -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 diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 2d27c7c446..483f857c2f 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -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; export type TListProjectAlertsDTO = TProjectPermission; diff --git a/frontend/src/components/utilities/serialNumberUtils.tsx b/frontend/src/components/utilities/serialNumberUtils.tsx new file mode 100644 index 0000000000..9dc3d69d91 --- /dev/null +++ b/frontend/src/components/utilities/serialNumberUtils.tsx @@ -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}`; +}; diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index bc7f80d2cb..d4b271683f 100644 --- a/frontend/src/hooks/api/certificates/index.tsx +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -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"; diff --git a/frontend/src/hooks/api/certificates/queries.tsx b/frontend/src/hooks/api/certificates/queries.tsx index 13245894b5..fb2490658d 100644 --- a/frontend/src/hooks/api/certificates/queries.tsx +++ b/frontend/src/hooks/api/certificates/queries.tsx @@ -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( + `/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( + `/api/v1/cert-manager/certificates/certificate-requests/${requestId}?projectSlug=${projectSlug}` + ); + return data; + }, + enabled: Boolean(requestId) && Boolean(projectSlug) + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 4d34822b32..97c0725334 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -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"; +}; diff --git a/frontend/src/hooks/api/projects/queries.tsx b/frontend/src/hooks/api/projects/queries.tsx index b9748950b7..a8dd35bf23 100644 --- a/frontend/src/hooks/api/projects/queries.tsx +++ b/frontend/src/hooks/api/projects/queries.tsx @@ -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 } diff --git a/frontend/src/hooks/api/projects/query-keys.tsx b/frontend/src/hooks/api/projects/query-keys.tsx index 6f74bf6f3f..fdbece632b 100644 --- a/frontend/src/hooks/api/projects/query-keys.tsx +++ b/frontend/src/hooks/api/projects/query-keys.tsx @@ -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) => diff --git a/frontend/src/pages/cert-manager/CertificateRequestsPage/components/CertificateRequestRow.tsx b/frontend/src/pages/cert-manager/CertificateRequestsPage/components/CertificateRequestRow.tsx new file mode 100644 index 0000000000..5b5f85f5d7 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificateRequestsPage/components/CertificateRequestRow.tsx @@ -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 Pending; + case "issued": + return Issued; + case "failed": + return Failed; + default: + return {status.charAt(0).toUpperCase() + status.slice(1)}; + } + }; + + const { displayName } = getCertificateDisplayName( + { + altNames: request.altNames, + commonName: request.commonName + }, + 64, + "—" + ); + + return ( + + +
+ {displayName} +
+ + +
+ {truncateSerialNumber(request.certificate?.serialNumber)} +
+ + {getStatusBadge(request.status)} + +
{request.profileName || "N/A"}
+ + + + + + + + + + + + + + +
+ + + +
+
+ + request.certificateId && onViewCertificates?.(request.certificateId)} + disabled={!request.certificateId} + className="flex items-center gap-2" + > + View in Certificates + + +
+ + + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificateRequestsPage/components/CertificateRequestsSection.tsx b/frontend/src/pages/cert-manager/CertificateRequestsPage/components/CertificateRequestsSection.tsx new file mode 100644 index 0000000000..748009f2b0 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificateRequestsPage/components/CertificateRequestsSection.tsx @@ -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([]); + const [pendingFilters, setPendingFilters] = useState({}); + + const [appliedProfileIds, setAppliedProfileIds] = useState([]); + const [appliedFilters, setAppliedFilters] = useState({}); + + 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 ( +
+
+
+
+

Certificate Requests

+
+
+
+ + {(isAllowed) => ( + + )} + +
+
+ +
+ setPendingSearch(e.target.value)} + leftIcon={} + placeholder="Search by SAN, CN or Profile Name..." + className="flex-1" + /> + + + + + + + +
+
+

Filters

+ + {isTableFiltered && ( + + )} + +
+ +
+
+ + Certificate Profiles + + {pendingProfileIds.length > 0 && ( + + )} +
+ ({ + 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} + /> +
+ +
+
+ + {pendingFilters.status && ( + + )} +
+ +
+ +
+ +
+
+
+
+
+ +
+ + + + + + + + + + + + + + {isLoading && } + {isError && ( + + + + )} + {!isLoading && + !isError && + certificateRequestsData?.certificateRequests?.length === 0 && ( + + + + )} + {!isLoading && + !isError && + certificateRequestsData?.certificateRequests?.map((request) => ( + + ))} + +
SAN / CNSERIAL NUMBERSTATUSPROFILECREATED ATUPDATED AT +
+ Failed to load certificate requests. Please try again. +
+ {isTableFiltered + ? "No certificate requests found matching your filters" + : "No certificate requests found"} +
+
+ + {certificateRequestsData && certificateRequestsData.totalCount > 0 && ( +
+ setCurrentPage(page)} + onChangePerPage={() => { + setCurrentPage(1); + }} + /> +
+ )} +
+ + +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificateRequestsPage/components/index.ts b/frontend/src/pages/cert-manager/CertificateRequestsPage/components/index.ts new file mode 100644 index 0000000000..3e08f9a1dc --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificateRequestsPage/components/index.ts @@ -0,0 +1,2 @@ +export { CertificateRequestRow } from "./CertificateRequestRow"; +export { CertificateRequestsSection } from "./CertificateRequestsSection"; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index 2c3b00bc23..cf4448771d 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -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 = () => { )} - - {(isAllowed) => ( - - )} - - - + { const expiryDate = new Date(notAfter); const now = new Date(); @@ -90,20 +111,56 @@ type Props = { renewedByCertificateId?: string; } ) => void; + externalFilter?: { + certificateId?: string; + search?: string; + }; }; const PER_PAGE_INIT = 25; -export const CertificatesTable = ({ handlePopUpOpen }: Props) => { +export const CertificatesTable = ({ handlePopUpOpen, externalFilter }: Props) => { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(PER_PAGE_INIT); + const [pendingSearch, setPendingSearch] = useState(externalFilter?.search || ""); + const [pendingProfileIds, setPendingProfileIds] = useState([]); + const [pendingFilters, setPendingFilters] = useState({}); + + const [appliedSearch, setAppliedSearch] = useState(externalFilter?.search || ""); + const [appliedProfileIds, setAppliedProfileIds] = useState([]); + const [appliedFilters, setAppliedFilters] = useState({}); + + useEffect(() => { + const timeoutId = setTimeout(() => { + setAppliedSearch(pendingSearch); + setPage(1); + }, 500); + + return () => clearTimeout(timeoutId); + }, [pendingSearch]); const { currentProject } = useProject(); const { permission } = useProjectPermission(); + + const { data: profilesData } = useListCertificateProfiles({ + projectId: currentProject?.id ?? "", + limit: 100 + }); + + const backendStatus = appliedFilters.status ? [appliedFilters.status] : undefined; + + const profileIds = useMemo(() => { + if (!appliedProfileIds.length) return undefined; + return appliedProfileIds; + }, [appliedProfileIds]); + const { data, isPending } = useListWorkspaceCertificates({ projectId: currentProject?.id ?? "", offset: (page - 1) * perPage, - limit: perPage + limit: perPage, + search: appliedSearch.trim() || undefined, + status: backendStatus, + ...(profileIds && { profileIds }) }); const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig(); @@ -122,6 +179,8 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { return map; }, [caData]); + const certificates = data?.certificates || []; + const handleDisableAutoRenewal = async (certificateId: string, commonName: string) => { if (!currentProject?.slug) { createNotification({ @@ -143,178 +202,314 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { }); }; + const handleClearFilters = () => { + setPendingSearch(""); + setPendingFilters({}); + setPendingProfileIds([]); + setAppliedSearch(""); + setAppliedFilters({}); + setAppliedProfileIds([]); + setPage(1); + }; + + const handleClearStatus = () => { + setPendingFilters((prev) => ({ ...prev, status: undefined })); + }; + + const handleClearProfiles = () => { + setPendingProfileIds([]); + }; + + const isTableFiltered = Boolean( + appliedSearch || appliedFilters.status || appliedProfileIds.length + ); + return ( - - - - - - - - - - - - {isPending && } - {!isPending && - data?.certificates.map((certificate) => { - const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter); - - const isRevoked = certificate.status === CertStatus.REVOKED; - const isExpired = new Date(certificate.notAfter) < new Date(); - const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter); - const hasFailed = Boolean(certificate.renewalError); - 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 ( - - - - - - + + ); + })} + +
SAN / CNStatusNot BeforeNot After -
- - - {certificate.status === CertStatus.REVOKED ? ( - Revoked - ) : ( - {label} - )} - - {certificate.notBefore - ? format(new Date(certificate.notBefore), "yyyy-MM-dd") - : "-"} - - {certificate.notAfter - ? format(new Date(certificate.notAfter), "yyyy-MM-dd") - : "-"} - -
{ - if (!canShowAutoRenewalIcon) return ""; - if (isAutoRenewalEnabled) return "opacity-100"; - return "opacity-0 group-hover:opacity-100"; - })()}`} +
+
+ setPendingSearch(e.target.value)} + leftIcon={} + placeholder="Search by SAN, CN, ID or Serial Number..." + className="flex-1" + /> + + + + + + + +
+
+

Filters

+ + {isTableFiltered && ( + + )} + +
- return ( - { - 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"; - })()} - > - + )} +
+ ({ + 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} + /> +
+ +
+
+ Status + {pendingFilters.status && ( + + )} +
+ +
+ +
+ +
+
+ + +
+ + + + + + + + + + + + + {isPending && } + {!isPending && + certificates.map((certificate) => { + const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter); + + const isRevoked = certificate.status === CertStatus.REVOKED; + const isExpired = new Date(certificate.notAfter) < new Date(); + const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter); + const hasFailed = Boolean(certificate.renewalError); + const isAutoRenewalEnabled = Boolean( + certificate.renewBeforeDays && certificate.renewBeforeDays > 0 + ); + + const canShowAutoRenewalIcon = Boolean( + certificate.profileId && + certificate.hasPrivateKey !== false && + !certificate.renewedByCertificateId && + !isRevoked && + !isExpired && + !isExpiringWithinDay + ); + + const { originalDisplayName } = getCertificateDisplayName(certificate, 64, "—"); + + return ( + + + + + + + - - ); - })} - -
SAN / CNSerial NumberStatusIssued AtExpiring At +
+ + +
+ {truncateSerialNumber(certificate.serialNumber)} +
+
+ {certificate.status === CertStatus.REVOKED ? ( + Revoked + ) : ( + {label} + )} + + {certificate.notBefore + ? format(new Date(certificate.notBefore), "yyyy-MM-dd") + : "-"} + + {certificate.notAfter + ? format(new Date(certificate.notAfter), "yyyy-MM-dd") + : "-"} + +
{ + if (!canShowAutoRenewalIcon) return ""; + if (isAutoRenewalEnabled) return "opacity-100"; + return "opacity-0 group-hover:opacity-100"; + })()}`} + > + {canShowAutoRenewalIcon && + (() => { + const canEditCertificate = permission.can( + ProjectPermissionCertificateActions.Edit, + subject(ProjectPermissionSub.Certificates, { + commonName: certificate.commonName, + altNames: certificate.altNames, + serialNumber: certificate.serialNumber + }) + ); + + return ( + { + 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"; })()} - aria-label="Certificate auto-renewal" - onClick={(e) => { - e.stopPropagation(); - if (!canEditCertificate) return; - 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) - ), - notAfter: certificate.notAfter, - renewalError: certificate.renewalError, - renewedFromCertificateId: certificate.renewedFromCertificateId, - renewedByCertificateId: certificate.renewedByCertificateId - }); - }} > - - + + + ); + })()} +
+ + +
+ + - ); - })()} -
- - -
- - - -
-
- - - {(isAllowed) => ( - - handlePopUpOpen("certificateExport", { - certificateId: certificate.id, - serialNumber: certificate.serialNumber - }) - } - disabled={!isAllowed} - icon={} - > - Export Certificate - - )} - - {isLegacyTemplatesEnabled && ( + +
+ { !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" )} onClick={async () => - handlePopUpOpen("issueCertificate", { + handlePopUpOpen("certificateExport", { + certificateId: certificate.id, serialNumber: certificate.serialNumber }) } disabled={!isAllowed} - icon={} + icon={} > - View Details + Export Certificate )} - )} - {/* Manage auto renewal option - not shown for failed renewals */} - {(() => { - const canManageRenewal = - certificate.profileId && - certificate.hasPrivateKey !== false && - !certificate.renewedByCertificateId && - !isRevoked && - !isExpired && - !hasFailed && - !isExpiringWithinDay; - - if (!canManageRenewal) return null; - - return ( + {isLegacyTemplatesEnabled && ( { friendlyName: certificate.friendlyName })} > - {(isAllowed) => { - return ( + {(isAllowed) => ( + + handlePopUpOpen("issueCertificate", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + View Details + + )} + + )} + {(() => { + const canManageRenewal = + certificate.profileId && + certificate.hasPrivateKey !== false && + !certificate.renewedByCertificateId && + !isRevoked && + !isExpired && + !hasFailed && + !isExpiringWithinDay; + + if (!canManageRenewal) return null; + + return ( + + {(isAllowed) => { + return ( + { + const notAfterDate = new Date(certificate.notAfter); + const notBeforeDate = certificate.notBefore + ? new Date(certificate.notBefore) + : new Date( + notAfterDate.getTime() - 365 * 24 * 60 * 60 * 1000 + ); + const ttlDays = Math.max( + 1, + Math.ceil( + (notAfterDate.getTime() - notBeforeDate.getTime()) / + (24 * 60 * 60 * 1000) + ) + ); + handlePopUpOpen("manageRenewal", { + certificateId: certificate.id, + commonName: certificate.commonName, + profileId: certificate.profileId, + renewBeforeDays: certificate.renewBeforeDays, + ttlDays, + notAfter: certificate.notAfter, + renewalError: certificate.renewalError, + renewedFromCertificateId: + certificate.renewedFromCertificateId, + renewedByCertificateId: certificate.renewedByCertificateId + }); + }} + disabled={!isAllowed} + icon={} + > + {isAutoRenewalEnabled + ? "Manage auto renewal" + : "Enable auto renewal"} + + ); + }} + + ); + })()} + {(() => { + const canDisableRenewal = + certificate.profileId && + certificate.hasPrivateKey !== false && + !certificate.renewedByCertificateId && + !isRevoked && + !isExpired && + !isExpiringWithinDay && + isAutoRenewalEnabled; + + if (!canDisableRenewal) return null; + + return ( + + {(isAllowed) => ( { - const notAfterDate = new Date(certificate.notAfter); - const notBeforeDate = certificate.notBefore - ? new Date(certificate.notBefore) - : new Date( - notAfterDate.getTime() - 365 * 24 * 60 * 60 * 1000 - ); - const ttlDays = Math.max( - 1, - Math.ceil( - (notAfterDate.getTime() - notBeforeDate.getTime()) / - (24 * 60 * 60 * 1000) - ) + await handleDisableAutoRenewal( + certificate.id, + certificate.commonName ); - handlePopUpOpen("manageRenewal", { + }} + disabled={!isAllowed} + icon={} + > + Disable auto renewal + + )} + + ); + })()} + {(() => { + const canRenew = + (certificate.profileId || certificate.caId) && + certificate.hasPrivateKey !== false && + !certificate.renewedByCertificateId && + !isRevoked && + !isExpired; + + if (!canRenew) return null; + + return ( + + {(isAllowed) => ( + { + handlePopUpOpen("renewCertificate", { certificateId: certificate.id, - commonName: certificate.commonName, - profileId: certificate.profileId, - renewBeforeDays: certificate.renewBeforeDays, - ttlDays, - notAfter: certificate.notAfter, - renewalError: certificate.renewalError, - renewedFromCertificateId: - certificate.renewedFromCertificateId, - renewedByCertificateId: certificate.renewedByCertificateId + commonName: certificate.commonName }); }} disabled={!isAllowed} icon={} > - {isAutoRenewalEnabled - ? "Manage auto renewal" - : "Enable auto renewal"} + Renew Now - ); - }} - - ); - })()} - {/* Disable auto renewal option - only shown when auto renewal is active */} - {(() => { - const canDisableRenewal = - certificate.profileId && - certificate.hasPrivateKey !== false && - !certificate.renewedByCertificateId && - !isRevoked && - !isExpired && - !isExpiringWithinDay && - isAutoRenewalEnabled; + )} + + ); + })()} + {certificate.status === CertStatus.ACTIVE && + !certificate.renewedByCertificateId && ( + + {(isAllowed) => ( + + handlePopUpOpen("managePkiSyncs", { + certificateId: certificate.id, + commonName: certificate.commonName + }) + } + disabled={!isAllowed} + icon={} + > + Manage PKI Syncs + + )} + + )} + {(() => { + const caType = caCapabilityMap[certificate.caId]; + const supportsRevocation = + !caType || + caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES); - if (!canDisableRenewal) return null; + if (!supportsRevocation || isRevoked) { + return null; + } - return ( - - {(isAllowed) => ( - { - await handleDisableAutoRenewal( - certificate.id, - certificate.commonName - ); - }} - disabled={!isAllowed} - icon={} - > - Disable auto renewal - - )} - - ); - })()} - {/* Manual renewal action for profile-issued certificates that are not revoked/expired (including failed ones) */} - {(() => { - const canRenew = - (certificate.profileId || certificate.caId) && - certificate.hasPrivateKey !== false && - !certificate.renewedByCertificateId && - !isRevoked && - !isExpired; - - if (!canRenew) return null; - - return ( - - {(isAllowed) => ( - { - handlePopUpOpen("renewCertificate", { - certificateId: certificate.id, - commonName: certificate.commonName - }); - }} - disabled={!isAllowed} - icon={} - > - Renew Now - - )} - - ); - })()} - {/* PKI Sync management - only for active certificates that are not renewed */} - {certificate.status === CertStatus.ACTIVE && - !certificate.renewedByCertificateId && ( - - {(isAllowed) => ( - - handlePopUpOpen("managePkiSyncs", { - certificateId: certificate.id, - commonName: certificate.commonName - }) - } - disabled={!isAllowed} - icon={} - > - Manage PKI Syncs - - )} - - )} - {/* Only show revoke button if CA supports revocation and certificate is not already revoked */} - {(() => { - const caType = caCapabilityMap[certificate.caId]; - const supportsRevocation = - !caType || - caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES); - - if (!supportsRevocation || isRevoked) { - return null; - } - - return ( - - {(isAllowed) => ( - - handlePopUpOpen("revokeCertificate", { - certificateId: certificate.id - }) - } - disabled={!isAllowed} - icon={} - > - Revoke Certificate - - )} - - ); - })()} - - {(isAllowed) => ( - - handlePopUpOpen("deleteCertificate", { - certificateId: certificate.id, - commonName: certificate.commonName - }) - } - disabled={!isAllowed} - icon={} - > - Delete Certificate - - )} - - -
-
- {!isPending && data?.totalCount !== undefined && data.totalCount >= PER_PAGE_INIT && ( - setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} - /> - )} - {!isPending && !data?.certificates?.length && ( - - )} -
+ return ( + + {(isAllowed) => ( + + handlePopUpOpen("revokeCertificate", { + certificateId: certificate.id + }) + } + disabled={!isAllowed} + icon={} + > + Revoke Certificate + + )} + + ); + })()} + + {(isAllowed) => ( + + handlePopUpOpen("deleteCertificate", { + certificateId: certificate.id, + commonName: certificate.commonName + }) + } + disabled={!isAllowed} + icon={} + > + Delete Certificate + + )} + + + +
+ {!isPending && (data?.totalCount || 0) >= PER_PAGE_INIT && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && !certificates.length && ( + + )} +
+ ); }; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx index 31265b5fb2..2529b69ca9 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx @@ -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)} > + + Certificates + + + Certificate Requests + Certificate Profiles Certificate Templates - - Certificates - + + {canReadCertificates ? ( + + ) : ( + + )} + + + + {canReadCertificates ? ( + + ) : ( + + )} + + {canReadCertificateProfiles ? : } @@ -87,10 +116,6 @@ export const PoliciesPage = () => { )} - - - {canReadCertificates ? : } - diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateRequestsTab/CertificateRequestsTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateRequestsTab/CertificateRequestsTab.tsx new file mode 100644 index 0000000000..102ce8d08e --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateRequestsTab/CertificateRequestsTab.tsx @@ -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 ; +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateRequestsTab/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateRequestsTab/index.ts new file mode 100644 index 0000000000..8249d740e0 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateRequestsTab/index.ts @@ -0,0 +1 @@ +export { CertificateRequestsTab } from "./CertificateRequestsTab"; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx index caf9cdb2f0..24cc69c8a6 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx @@ -1,5 +1,12 @@ import { CertificatesSection } from "../../../CertificatesPage/components/CertificatesSection"; -export const CertificatesTab = () => { - return ; +type Props = { + externalFilter?: { + certificateId?: string; + search?: string; + }; +}; + +export const CertificatesTab = ({ externalFilter }: Props) => { + return ; };