mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: added subscriber cert auto-renewal
This commit is contained in:
@@ -94,6 +94,11 @@ export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.PkiSubscriber)) {
|
||||
await knex.schema.alterTable(TableName.PkiSubscriber, (t) => {
|
||||
t.string("ttl").nullable().alter();
|
||||
|
||||
t.boolean("enableAutoRenewal").notNullable().defaultTo(false);
|
||||
t.integer("autoRenewalPeriodInDays");
|
||||
t.datetime("lastAutoRenewAt");
|
||||
|
||||
t.string("lastOperationStatus");
|
||||
t.text("lastOperationMessage");
|
||||
t.dateTime("lastOperationAt");
|
||||
@@ -188,6 +193,10 @@ export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
if (await knex.schema.hasTable(TableName.PkiSubscriber)) {
|
||||
await knex.schema.alterTable(TableName.PkiSubscriber, (t) => {
|
||||
t.dropColumn("enableAutoRenewal");
|
||||
t.dropColumn("autoRenewalPeriodInDays");
|
||||
t.dropColumn("lastAutoRenewAt");
|
||||
|
||||
t.dropColumn("lastOperationStatus");
|
||||
t.dropColumn("lastOperationMessage");
|
||||
t.dropColumn("lastOperationAt");
|
||||
|
||||
@@ -20,6 +20,9 @@ export const PkiSubscribersSchema = z.object({
|
||||
keyUsages: z.string().array(),
|
||||
extendedKeyUsages: z.string().array(),
|
||||
status: z.string(),
|
||||
enableAutoRenewal: z.boolean().default(false),
|
||||
autoRenewalPeriodInDays: z.number().nullable().optional(),
|
||||
lastAutoRenewAt: z.date().nullable().optional(),
|
||||
lastOperationStatus: z.string().nullable().optional(),
|
||||
lastOperationMessage: z.string().nullable().optional(),
|
||||
lastOperationAt: z.date().nullable().optional()
|
||||
|
||||
@@ -268,6 +268,7 @@ export enum EventType {
|
||||
GET_PKI_SUBSCRIBER = "get-pki-subscriber",
|
||||
ISSUE_PKI_SUBSCRIBER_CERT = "issue-pki-subscriber-cert",
|
||||
SIGN_PKI_SUBSCRIBER_CERT = "sign-pki-subscriber-cert",
|
||||
AUTOMATED_RENEW_SUBSCRIBER_CERT = "automated-renew-subscriber-cert",
|
||||
LIST_PKI_SUBSCRIBER_CERTS = "list-pki-subscriber-certs",
|
||||
GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE = "get-subscriber-active-cert-bundle",
|
||||
CREATE_KMS = "create-kms",
|
||||
@@ -2099,6 +2100,14 @@ interface IssuePkiSubscriberCert {
|
||||
};
|
||||
}
|
||||
|
||||
interface AutomatedRenewPkiSubscriberCert {
|
||||
type: EventType.AUTOMATED_RENEW_SUBSCRIBER_CERT;
|
||||
metadata: {
|
||||
subscriberId: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SignPkiSubscriberCert {
|
||||
type: EventType.SIGN_PKI_SUBSCRIBER_CERT;
|
||||
metadata: {
|
||||
@@ -3103,6 +3112,7 @@ export type Event =
|
||||
| GetPkiSubscriber
|
||||
| IssuePkiSubscriberCert
|
||||
| SignPkiSubscriberCert
|
||||
| AutomatedRenewPkiSubscriberCert
|
||||
| ListPkiSubscriberCerts
|
||||
| GetSubscriberActiveCertBundle
|
||||
| CreateKmsEvent
|
||||
|
||||
@@ -1811,7 +1811,9 @@ export const PKI_SUBSCRIBERS = {
|
||||
subjectAlternativeNames:
|
||||
"A list of Subject Alternative Names (SANs) to be used on certificates issued for this subscriber; these can be host names or email addresses.",
|
||||
keyUsages: "The key usage extension to be used on certificates issued for this subscriber.",
|
||||
extendedKeyUsages: "The extended key usage extension to be used on certificates issued for this subscriber."
|
||||
extendedKeyUsages: "The extended key usage extension to be used on certificates issued for this subscriber.",
|
||||
enableAutoRenewal: "Whether or not to enable auto renewal for the PKI subscriber.",
|
||||
autoRenewalPeriodInDays: "The period in days to auto renew the PKI subscriber's certificates."
|
||||
},
|
||||
UPDATE: {
|
||||
projectId: "The ID of the project to update the PKI subscriber in.",
|
||||
@@ -1825,7 +1827,9 @@ export const PKI_SUBSCRIBERS = {
|
||||
"A comma-delimited list of Subject Alternative Names (SANs) to be used on certificates issued for this subscriber; these can be host names or email addresses.",
|
||||
keyUsages: "The key usage extension to be used on certificates issued for this subscriber to update to.",
|
||||
extendedKeyUsages:
|
||||
"The extended key usage extension to be used on certificates issued for this subscriber to update to."
|
||||
"The extended key usage extension to be used on certificates issued for this subscriber to update to.",
|
||||
enableAutoRenewal: "Whether or not to enable auto renewal for the PKI subscriber.",
|
||||
autoRenewalPeriodInDays: "The period in days to auto renew the PKI subscriber's certificates."
|
||||
},
|
||||
DELETE: {
|
||||
subscriberName: "The name of the PKI subscriber to delete.",
|
||||
|
||||
@@ -37,6 +37,7 @@ export enum QueueName {
|
||||
AuditLogPrune = "audit-log-prune",
|
||||
DailyResourceCleanUp = "daily-resource-cleanup",
|
||||
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
|
||||
PkiSubscriber = "pki-subscriber",
|
||||
TelemetryInstanceStats = "telemtry-self-hosted-stats",
|
||||
IntegrationSync = "sync-integrations",
|
||||
SecretWebhook = "secret-webhook",
|
||||
@@ -87,7 +88,8 @@ export enum QueueJobs {
|
||||
SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets",
|
||||
SecretRotationV2SendNotification = "secret-rotation-v2-send-notification",
|
||||
InvalidateCache = "invalidate-cache",
|
||||
CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber"
|
||||
CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber",
|
||||
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal"
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@@ -255,6 +257,10 @@ export type TQueueJobTypes = {
|
||||
caType: CaType;
|
||||
};
|
||||
};
|
||||
[QueueName.PkiSubscriber]: {
|
||||
name: QueueJobs.PkiSubscriberDailyAutoRenewal;
|
||||
payload: undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
|
||||
|
||||
@@ -134,6 +134,7 @@ import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-
|
||||
import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service";
|
||||
import { externalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal";
|
||||
import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal";
|
||||
import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns";
|
||||
import { internalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
|
||||
import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
|
||||
import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal";
|
||||
@@ -202,6 +203,7 @@ import { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collec
|
||||
import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal";
|
||||
import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
|
||||
import { pkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { pkiSubscriberQueueServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-queue";
|
||||
import { pkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
|
||||
import { projectDALFactory } from "@app/services/project/project-dal";
|
||||
import { projectQueueFactory } from "@app/services/project/project-queue";
|
||||
@@ -1700,6 +1702,28 @@ export const registerRoutes = async (
|
||||
pkiSubscriberDAL
|
||||
});
|
||||
|
||||
const internalCaFns = InternalCertificateAuthorityFns({
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const pkiSubscriberQueue = pkiSubscriberQueueServiceFactory({
|
||||
queueService,
|
||||
pkiSubscriberDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityQueue,
|
||||
certificateDAL,
|
||||
auditLogService,
|
||||
internalCaFns
|
||||
});
|
||||
|
||||
const pkiSubscriberService = pkiSubscriberServiceFactory({
|
||||
pkiSubscriberDAL,
|
||||
certificateAuthorityDAL,
|
||||
@@ -1712,7 +1736,8 @@ export const registerRoutes = async (
|
||||
projectDAL,
|
||||
kmsService,
|
||||
permissionService,
|
||||
certificateAuthorityQueue
|
||||
certificateAuthorityQueue,
|
||||
internalCaFns
|
||||
});
|
||||
|
||||
await secretRotationV2QueueServiceFactory({
|
||||
@@ -1735,6 +1760,7 @@ export const registerRoutes = async (
|
||||
await telemetryQueue.startTelemetryCheck();
|
||||
await dailyResourceCleanUp.startCleanUp();
|
||||
await dailyExpiringPkiItemAlert.startSendingAlerts();
|
||||
await pkiSubscriberQueue.startDailyAutoRenewalJob();
|
||||
await kmsService.startService();
|
||||
await microsoftTeamsService.start();
|
||||
|
||||
|
||||
@@ -110,7 +110,9 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) =>
|
||||
.array()
|
||||
.default([])
|
||||
.transform((arr) => Array.from(new Set(arr)))
|
||||
.describe(PKI_SUBSCRIBERS.CREATE.extendedKeyUsages)
|
||||
.describe(PKI_SUBSCRIBERS.CREATE.extendedKeyUsages),
|
||||
enableAutoRenewal: z.boolean().optional().describe(PKI_SUBSCRIBERS.CREATE.enableAutoRenewal),
|
||||
autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.CREATE.autoRenewalPeriodInDays)
|
||||
}),
|
||||
response: {
|
||||
200: sanitizedPkiSubscriber
|
||||
@@ -195,7 +197,9 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) =>
|
||||
.array()
|
||||
.transform((arr) => Array.from(new Set(arr)))
|
||||
.optional()
|
||||
.describe(PKI_SUBSCRIBERS.UPDATE.extendedKeyUsages)
|
||||
.describe(PKI_SUBSCRIBERS.UPDATE.extendedKeyUsages),
|
||||
enableAutoRenewal: z.boolean().optional().describe(PKI_SUBSCRIBERS.UPDATE.enableAutoRenewal),
|
||||
autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.UPDATE.autoRenewalPeriodInDays)
|
||||
}),
|
||||
response: {
|
||||
200: sanitizedPkiSubscriber
|
||||
|
||||
186
backend/src/services/pki-subscriber/pki-subscriber-queue.ts
Normal file
186
backend/src/services/pki-subscriber/pki-subscriber-queue.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
import { TCertificateDALFactory } from "../certificate/certificate-dal";
|
||||
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
|
||||
import { CaStatus, CaType } from "../certificate-authority/certificate-authority-enums";
|
||||
import { TCertificateAuthorityQueueFactory } from "../certificate-authority/certificate-authority-queue";
|
||||
import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns";
|
||||
import { TPkiSubscriberDALFactory } from "./pki-subscriber-dal";
|
||||
import { PkiSubscriberStatus, SubscriberOperationStatus } from "./pki-subscriber-types";
|
||||
|
||||
type TPkiSubscriberQueueServiceFactoryDep = {
|
||||
queueService: TQueueServiceFactory;
|
||||
pkiSubscriberDAL: TPkiSubscriberDALFactory;
|
||||
certificateAuthorityDAL: TCertificateAuthorityDALFactory;
|
||||
certificateAuthorityQueue: TCertificateAuthorityQueueFactory;
|
||||
internalCaFns: ReturnType<typeof InternalCertificateAuthorityFns>;
|
||||
certificateDAL: TCertificateDALFactory;
|
||||
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
|
||||
};
|
||||
|
||||
export const pkiSubscriberQueueServiceFactory = ({
|
||||
queueService,
|
||||
pkiSubscriberDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityQueue,
|
||||
internalCaFns,
|
||||
certificateDAL,
|
||||
auditLogService
|
||||
}: TPkiSubscriberQueueServiceFactoryDep) => {
|
||||
queueService.start(QueueName.PkiSubscriber, async (job) => {
|
||||
if (job.name === QueueJobs.PkiSubscriberDailyAutoRenewal) {
|
||||
logger.info(`${QueueJobs.PkiSubscriberDailyAutoRenewal}: queue task started`);
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
// fetch PKI subscribers with auto renewal enabled in batches
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const pkiSubscribers = await pkiSubscriberDAL.find(
|
||||
{
|
||||
enableAutoRenewal: true,
|
||||
$notNull: ["autoRenewalPeriodInDays"],
|
||||
status: PkiSubscriberStatus.ACTIVE
|
||||
},
|
||||
{
|
||||
limit: BATCH_SIZE,
|
||||
offset
|
||||
}
|
||||
);
|
||||
|
||||
if (pkiSubscribers.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Process each subscriber in the batch concurrently
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await Promise.all(
|
||||
pkiSubscribers.map(async (subscriber) => {
|
||||
try {
|
||||
const cert = await certificateDAL.findLatestActiveCertForSubscriber({ subscriberId: subscriber.id });
|
||||
let shouldRenew = false;
|
||||
if (!cert || !cert.notAfter) {
|
||||
shouldRenew = true;
|
||||
} else {
|
||||
const now = new Date();
|
||||
const expiry = new Date(cert.notAfter);
|
||||
const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||
shouldRenew = daysUntilExpiry <= subscriber.autoRenewalPeriodInDays!;
|
||||
}
|
||||
|
||||
if (shouldRenew) {
|
||||
// Get the CA for the subscriber
|
||||
if (!subscriber.caId) {
|
||||
await pkiSubscriberDAL.updateById(subscriber.id, {
|
||||
lastOperationStatus: SubscriberOperationStatus.FAILED,
|
||||
lastOperationMessage: "No CA assigned to subscriber",
|
||||
lastOperationAt: new Date()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
|
||||
if (!ca) {
|
||||
await pkiSubscriberDAL.updateById(subscriber.id, {
|
||||
lastOperationStatus: SubscriberOperationStatus.FAILED,
|
||||
lastOperationMessage: "CA not found",
|
||||
lastOperationAt: new Date()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if CA is active
|
||||
if (ca.status !== CaStatus.ACTIVE) {
|
||||
await pkiSubscriberDAL.updateById(subscriber.id, {
|
||||
lastOperationStatus: SubscriberOperationStatus.FAILED,
|
||||
lastOperationMessage: "CA is not active",
|
||||
lastOperationAt: new Date()
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Order new certificate based on CA type
|
||||
if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) {
|
||||
await certificateAuthorityQueue.orderCertificateForSubscriber({
|
||||
subscriberId: subscriber.id,
|
||||
caType: ca.externalCa.type
|
||||
});
|
||||
} else if (ca.internalCa?.id) {
|
||||
// For internal CAs, we can issue certificates directly
|
||||
await internalCaFns.issueCertificate(subscriber, ca);
|
||||
}
|
||||
|
||||
// Update last auto-renew timestamp
|
||||
await pkiSubscriberDAL.updateById(subscriber.id, {
|
||||
lastAutoRenewAt: new Date(),
|
||||
lastOperationStatus: SubscriberOperationStatus.SUCCESS,
|
||||
lastOperationMessage: "Triggered certificate auto-renewal",
|
||||
lastOperationAt: new Date()
|
||||
});
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId: subscriber.projectId,
|
||||
actor: {
|
||||
type: ActorType.PLATFORM,
|
||||
metadata: {}
|
||||
},
|
||||
event: {
|
||||
type: EventType.AUTOMATED_RENEW_SUBSCRIBER_CERT,
|
||||
metadata: {
|
||||
subscriberId: subscriber.id,
|
||||
name: subscriber.name
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error and update subscriber status
|
||||
logger.error(error, `Failed to auto-renew certificate for subscriber ${subscriber.id}`);
|
||||
await pkiSubscriberDAL.updateById(subscriber.id, {
|
||||
lastOperationStatus: SubscriberOperationStatus.FAILED,
|
||||
lastOperationMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
lastOperationAt: new Date()
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
offset += BATCH_SIZE;
|
||||
}
|
||||
|
||||
logger.info(`${QueueJobs.PkiSubscriberDailyAutoRenewal}: queue task completed`);
|
||||
}
|
||||
});
|
||||
|
||||
// we do a repeat cron job in utc timezone at 12 Midnight each day
|
||||
const startDailyAutoRenewalJob = async () => {
|
||||
// clear previous job
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.PkiSubscriber,
|
||||
QueueJobs.PkiSubscriberDailyAutoRenewal,
|
||||
// { pattern: "0 0 * * *", utc: true },
|
||||
{ pattern: "*/30 * * * * *", utc: true },
|
||||
QueueName.PkiSubscriber // just a job id
|
||||
);
|
||||
|
||||
await queueService.queue(QueueName.PkiSubscriber, QueueJobs.PkiSubscriberDailyAutoRenewal, undefined, {
|
||||
delay: 5000,
|
||||
jobId: QueueName.PkiSubscriber,
|
||||
repeat: { pattern: "*/30 * * * * *", utc: true }
|
||||
});
|
||||
};
|
||||
|
||||
queueService.listen(QueueName.PkiSubscriber, "failed", (_, err) => {
|
||||
logger.error(err, `${QueueName.PkiSubscriber}: failed`);
|
||||
});
|
||||
|
||||
return {
|
||||
startDailyAutoRenewalJob
|
||||
};
|
||||
};
|
||||
@@ -15,7 +15,10 @@ export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({
|
||||
extendedKeyUsages: true,
|
||||
lastOperationStatus: true,
|
||||
lastOperationMessage: true,
|
||||
lastOperationAt: true
|
||||
lastOperationAt: true,
|
||||
enableAutoRenewal: true,
|
||||
autoRenewalPeriodInDays: true,
|
||||
lastAutoRenewAt: true
|
||||
}).extend({
|
||||
supportsImmediateCertIssuance: z.boolean().optional()
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ type TPkiSubscriberServiceFactoryDep = {
|
||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction" | "findById" | "find">;
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "decryptWithKmsKey" | "encryptWithKmsKey">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
internalCaFns: ReturnType<typeof InternalCertificateAuthorityFns>;
|
||||
};
|
||||
|
||||
export type TPkiSubscriberServiceFactory = ReturnType<typeof pkiSubscriberServiceFactory>;
|
||||
@@ -94,20 +95,9 @@ export const pkiSubscriberServiceFactory = ({
|
||||
projectDAL,
|
||||
kmsService,
|
||||
permissionService,
|
||||
certificateAuthorityQueue
|
||||
certificateAuthorityQueue,
|
||||
internalCaFns
|
||||
}: TPkiSubscriberServiceFactoryDep) => {
|
||||
const internalCaFns = InternalCertificateAuthorityFns({
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const createSubscriber = async ({
|
||||
name,
|
||||
commonName,
|
||||
@@ -117,6 +107,8 @@ export const pkiSubscriberServiceFactory = ({
|
||||
subjectAlternativeNames,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
enableAutoRenewal,
|
||||
autoRenewalPeriodInDays,
|
||||
projectId,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
@@ -139,6 +131,12 @@ export const pkiSubscriberServiceFactory = ({
|
||||
})
|
||||
);
|
||||
|
||||
if (enableAutoRenewal) {
|
||||
if (!autoRenewalPeriodInDays) {
|
||||
throw new BadRequestError({ message: "autoRenewalPeriodInDays is required when enableAutoRenewal is true" });
|
||||
}
|
||||
}
|
||||
|
||||
const newSubscriber = await pkiSubscriberDAL.create({
|
||||
caId,
|
||||
projectId,
|
||||
@@ -148,7 +146,9 @@ export const pkiSubscriberServiceFactory = ({
|
||||
ttl,
|
||||
subjectAlternativeNames,
|
||||
keyUsages,
|
||||
extendedKeyUsages
|
||||
extendedKeyUsages,
|
||||
enableAutoRenewal,
|
||||
autoRenewalPeriodInDays
|
||||
});
|
||||
|
||||
return newSubscriber;
|
||||
@@ -210,6 +210,8 @@ export const pkiSubscriberServiceFactory = ({
|
||||
subjectAlternativeNames,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
enableAutoRenewal,
|
||||
autoRenewalPeriodInDays,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
@@ -237,6 +239,12 @@ export const pkiSubscriberServiceFactory = ({
|
||||
})
|
||||
);
|
||||
|
||||
if (enableAutoRenewal) {
|
||||
if (!autoRenewalPeriodInDays && !subscriber.autoRenewalPeriodInDays) {
|
||||
throw new BadRequestError({ message: "autoRenewalPeriodInDays is required when enableAutoRenewal is true" });
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSubscriber = await pkiSubscriberDAL.updateById(subscriber.id, {
|
||||
caId,
|
||||
name,
|
||||
@@ -245,7 +253,9 @@ export const pkiSubscriberServiceFactory = ({
|
||||
ttl,
|
||||
subjectAlternativeNames,
|
||||
keyUsages,
|
||||
extendedKeyUsages
|
||||
extendedKeyUsages,
|
||||
enableAutoRenewal,
|
||||
autoRenewalPeriodInDays
|
||||
});
|
||||
|
||||
return updatedSubscriber;
|
||||
|
||||
@@ -16,6 +16,8 @@ export type TCreatePkiSubscriberDTO = {
|
||||
subjectAlternativeNames: string[];
|
||||
keyUsages: CertKeyUsage[];
|
||||
extendedKeyUsages: CertExtendedKeyUsage[];
|
||||
enableAutoRenewal?: boolean;
|
||||
autoRenewalPeriodInDays?: number;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetPkiSubscriberDTO = {
|
||||
@@ -32,6 +34,8 @@ export type TUpdatePkiSubscriberDTO = {
|
||||
subjectAlternativeNames?: string[];
|
||||
keyUsages?: CertKeyUsage[];
|
||||
extendedKeyUsages?: CertExtendedKeyUsage[];
|
||||
enableAutoRenewal?: boolean;
|
||||
autoRenewalPeriodInDays?: number;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TDeletePkiSubscriberDTO = {
|
||||
|
||||
@@ -22,6 +22,8 @@ export type TPkiSubscriber = {
|
||||
keyUsages: CertKeyUsage[];
|
||||
extendedKeyUsages: CertExtendedKeyUsage[];
|
||||
supportsImmediateCertIssuance?: boolean;
|
||||
enableAutoRenewal?: boolean;
|
||||
autoRenewalPeriodInDays?: number;
|
||||
lastOperationStatus?: SubscriberOperationStatus;
|
||||
lastOperationMessage?: string;
|
||||
lastOperationAt?: string;
|
||||
@@ -36,6 +38,8 @@ export type TCreatePkiSubscriberDTO = {
|
||||
subjectAlternativeNames: string[];
|
||||
keyUsages: CertKeyUsage[];
|
||||
extendedKeyUsages: CertExtendedKeyUsage[];
|
||||
enableAutoRenewal?: boolean;
|
||||
autoRenewalPeriodInDays?: number;
|
||||
};
|
||||
|
||||
export type TUpdatePkiSubscriberDTO = {
|
||||
@@ -49,6 +53,8 @@ export type TUpdatePkiSubscriberDTO = {
|
||||
subjectAlternativeNames?: string[];
|
||||
keyUsages?: CertKeyUsage[];
|
||||
extendedKeyUsages?: CertExtendedKeyUsage[];
|
||||
enableAutoRenewal?: boolean;
|
||||
autoRenewalPeriodInDays?: number;
|
||||
};
|
||||
|
||||
export type TDeletePkiSubscriberDTO = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
SelectItem,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
Tabs
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import {
|
||||
@@ -39,6 +43,11 @@ type Props = {
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["pkiSubscriber"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
enum FormTab {
|
||||
Configuration = "configuration",
|
||||
Advanced = "advanced"
|
||||
}
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, "Name is required"),
|
||||
@@ -64,7 +73,9 @@ const schema = z
|
||||
[CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
|
||||
[CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
|
||||
[CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
|
||||
})
|
||||
}),
|
||||
enableAutoRenewal: z.boolean().optional().default(false),
|
||||
autoRenewalPeriodInDays: z.number().min(1).optional()
|
||||
})
|
||||
.required();
|
||||
|
||||
@@ -75,6 +86,7 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const projectId = currentWorkspace.id;
|
||||
const { data: subscribers } = useListWorkspacePkiSubscribers(projectId);
|
||||
const { data: cas } = useListCasByProjectId(projectId);
|
||||
const [tabValue, setTabValue] = useState<FormTab>(FormTab.Configuration);
|
||||
|
||||
const { data: pkiSubscriber } = useGetPkiSubscriber({
|
||||
subscriberName:
|
||||
@@ -104,12 +116,15 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
|
||||
[CertKeyUsage.KEY_ENCIPHERMENT]: true
|
||||
},
|
||||
extendedKeyUsages: {}
|
||||
extendedKeyUsages: {},
|
||||
enableAutoRenewal: false,
|
||||
autoRenewalPeriodInDays: 7
|
||||
}
|
||||
});
|
||||
|
||||
const selectedCaId = watch("caId");
|
||||
const selectedCa = cas?.find((ca) => ca.id === selectedCaId);
|
||||
const selectedAutoRenewalState = watch("enableAutoRenewal");
|
||||
|
||||
useEffect(() => {
|
||||
if (pkiSubscriber) {
|
||||
@@ -122,7 +137,9 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
keyUsages: Object.fromEntries((pkiSubscriber.keyUsages || []).map((name) => [name, true])),
|
||||
extendedKeyUsages: Object.fromEntries(
|
||||
(pkiSubscriber.extendedKeyUsages || []).map((name) => [name, true])
|
||||
)
|
||||
),
|
||||
enableAutoRenewal: pkiSubscriber.enableAutoRenewal || false,
|
||||
autoRenewalPeriodInDays: pkiSubscriber.autoRenewalPeriodInDays || 7
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
@@ -135,7 +152,9 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
|
||||
[CertKeyUsage.KEY_ENCIPHERMENT]: true
|
||||
},
|
||||
extendedKeyUsages: {}
|
||||
extendedKeyUsages: {},
|
||||
enableAutoRenewal: false,
|
||||
autoRenewalPeriodInDays: 7
|
||||
});
|
||||
}
|
||||
}, [pkiSubscriber, reset]);
|
||||
@@ -153,7 +172,9 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
subjectAlternativeNames,
|
||||
ttl,
|
||||
keyUsages,
|
||||
extendedKeyUsages
|
||||
extendedKeyUsages,
|
||||
enableAutoRenewal,
|
||||
autoRenewalPeriodInDays
|
||||
}: FormData) => {
|
||||
try {
|
||||
if (!projectId) return;
|
||||
@@ -201,7 +222,9 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
subjectAlternativeNames: subjectAlternativeNamesList,
|
||||
ttl,
|
||||
keyUsages: keyUsagesList,
|
||||
extendedKeyUsages: extendedKeyUsagesList
|
||||
extendedKeyUsages: extendedKeyUsagesList,
|
||||
enableAutoRenewal,
|
||||
autoRenewalPeriodInDays
|
||||
});
|
||||
} else {
|
||||
await createMutateAsync({
|
||||
@@ -212,7 +235,9 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
subjectAlternativeNames: subjectAlternativeNamesList,
|
||||
ttl,
|
||||
keyUsages: keyUsagesList,
|
||||
extendedKeyUsages: extendedKeyUsagesList
|
||||
extendedKeyUsages: extendedKeyUsagesList,
|
||||
enableAutoRenewal,
|
||||
autoRenewalPeriodInDays
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,178 +266,245 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`${pkiSubscriber ? "Update" : "Add"} PKI Subscriber`}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
{pkiSubscriber && (
|
||||
<FormControl label="Subscriber ID">
|
||||
<Input value={pkiSubscriber.id} isDisabled className="bg-white/[0.07]" />
|
||||
</FormControl>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Subscriber Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input {...field} placeholder="web-service" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="caId"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Issuing CA"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
isRequired
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(cas || []).map(({ id, name, type, configuration }) => {
|
||||
const displayName =
|
||||
type === CaType.INTERNAL ? `${name} (${configuration.dn})` : name;
|
||||
|
||||
return (
|
||||
<SelectItem value={id} key={`ca-${id}`}>
|
||||
{displayName}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="commonName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Common Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input {...field} placeholder="web.example.com" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="subjectAlternativeNames"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Subject Alternative Names (SANs)"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="app1.example.com, app2.example.com, ..." />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{selectedCa?.type !== CaType.ACME && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="ttl"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="TTL"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
|
||||
<form
|
||||
onSubmit={handleSubmit(onFormSubmit, (fields) => {
|
||||
setTabValue(
|
||||
["name", "caId", "commonName", "subjectAlternativeNames", "ttl"].includes(
|
||||
Object.keys(fields)[0]
|
||||
)
|
||||
? FormTab.Configuration
|
||||
: FormTab.Advanced
|
||||
);
|
||||
})}
|
||||
>
|
||||
<Tabs value={tabValue} onValueChange={(value) => setTabValue(value as FormTab)}>
|
||||
<TabList>
|
||||
<Tab value={FormTab.Configuration}>Configuration</Tab>
|
||||
<Tab value={FormTab.Advanced}>Advanced</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={FormTab.Configuration}>
|
||||
{pkiSubscriber && (
|
||||
<FormControl label="Subscriber ID">
|
||||
<Input value={pkiSubscriber.id} isDisabled className="bg-white/[0.07]" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{selectedCa?.type !== CaType.ACME && (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="key-usages" className="data-[state=open]:border-none">
|
||||
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
|
||||
<div className="order-1 ml-3">Key Usage</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="keyUsages"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
label="Key Usage"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
|
||||
{KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
id={optionValue}
|
||||
key={optionValue}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
isChecked={value[optionValue]}
|
||||
onCheckedChange={(state) => {
|
||||
onChange({
|
||||
...value,
|
||||
[optionValue]: state
|
||||
});
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="extendedKeyUsages"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
label="Extended Key Usage"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
|
||||
{EXTENDED_KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
id={optionValue}
|
||||
key={optionValue}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
isChecked={value[optionValue]}
|
||||
onCheckedChange={(state) => {
|
||||
onChange({
|
||||
...value,
|
||||
[optionValue]: state
|
||||
});
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Subscriber Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input {...field} placeholder="web-service" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="caId"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Issuing CA"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
isRequired
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(cas || []).map(({ id, name, type, configuration }) => {
|
||||
const displayName =
|
||||
type === CaType.INTERNAL ? `${name} (${configuration.dn})` : name;
|
||||
|
||||
return (
|
||||
<SelectItem value={id} key={`ca-${id}`}>
|
||||
{displayName}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="commonName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Common Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input {...field} placeholder="web.example.com" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="subjectAlternativeNames"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Subject Alternative Names (SANs)"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="app1.example.com, app2.example.com, ..." />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{selectedCa?.type !== CaType.ACME && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="ttl"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="TTL"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{selectedCa?.type !== CaType.ACME && (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="key-usages" className="data-[state=open]:border-none">
|
||||
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
|
||||
<div className="order-1 ml-3">Key Usage</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="keyUsages"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
label="Key Usage"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
|
||||
{KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
id={optionValue}
|
||||
key={optionValue}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
isChecked={value[optionValue]}
|
||||
onCheckedChange={(state) => {
|
||||
onChange({
|
||||
...value,
|
||||
[optionValue]: state
|
||||
});
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="extendedKeyUsages"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
label="Extended Key Usage"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
|
||||
{EXTENDED_KEY_USAGES_OPTIONS.map(
|
||||
({ label, value: optionValue }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
id={optionValue}
|
||||
key={optionValue}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
isChecked={value[optionValue]}
|
||||
onCheckedChange={(state) => {
|
||||
onChange({
|
||||
...value,
|
||||
[optionValue]: state
|
||||
});
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabPanel value={FormTab.Advanced}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enableAutoRenewal"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText="If enabled, a new certificate will be issued automatically X days before the current certificate expires."
|
||||
>
|
||||
<Checkbox
|
||||
id="enableAutoRenewal"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
>
|
||||
Enable Auto Renewal
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{selectedAutoRenewalState && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="autoRenewalPeriodInDays"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
label="Renew X days before expiry"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText="A new certificate will be issued this many days before the current certificate expires."
|
||||
>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="7"
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
<div className="mt-4 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
Reference in New Issue
Block a user