mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-06 22:23:53 -05:00
Certificate request DB partition, UI certificate request tab and filters on certificate views
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
15
frontend/src/components/utilities/serialNumberUtils.tsx
Normal file
15
frontend/src/components/utilities/serialNumberUtils.tsx
Normal 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}`;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CertificateRequestRow } from "./CertificateRequestRow";
|
||||
export { CertificateRequestsSection } from "./CertificateRequestsSection";
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CertificateRequestsTab } from "./CertificateRequestsTab";
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user