Compare commits

...

6 Commits

Author SHA1 Message Date
Jamie Curnow
ca3c9aa39a Show cert expiry date in yellow when < 30 days 2025-10-27 19:34:25 +10:00
Jamie Curnow
e4e5fb3b58 Update biome 2025-10-27 19:29:14 +10:00
Jamie Curnow
83a2c79e16 Custom certificate upload 2025-10-27 19:26:33 +10:00
Jamie Curnow
0de26f2950 Certificates react work
- renewal and download
- table columns rendering
- searching
- deleting
2025-10-27 18:08:37 +10:00
Jamie Curnow
7b5c70ed35 Fix cert renewal backend bug after refactor 2025-10-27 18:04:58 +10:00
Jamie Curnow
e4d9f48870 Fix creating wrong cert type when trying dns 2025-10-27 18:04:29 +10:00
28 changed files with 800 additions and 451 deletions

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", "$schema": "https://biomejs.dev/schemas/2.3.1/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -1,9 +1,9 @@
import fs from "node:fs"; import fs from "node:fs";
import https from "node:https"; import https from "node:https";
import path from "path";
import archiver from "archiver"; import archiver from "archiver";
import _ from "lodash"; import _ from "lodash";
import moment from "moment"; import moment from "moment";
import path from "path";
import tempWrite from "temp-write"; import tempWrite from "temp-write";
import dnsPlugins from "../certbot/dns-plugins.json" with { type: "json" }; import dnsPlugins from "../certbot/dns-plugins.json" with { type: "json" };
import { installPlugin } from "../lib/certbot.js"; import { installPlugin } from "../lib/certbot.js";
@@ -20,17 +20,15 @@ import internalNginx from "./nginx.js";
const letsencryptConfig = "/etc/letsencrypt.ini"; const letsencryptConfig = "/etc/letsencrypt.ini";
const certbotCommand = "certbot"; const certbotCommand = "certbot";
const certbotLogsDir = "/data/logs";
const certbotWorkDir = "/tmp/letsencrypt-lib";
const omissions = () => { const omissions = () => {
return ["is_deleted", "owner.is_deleted"]; return ["is_deleted", "owner.is_deleted"];
}; };
const internalCertificate = { const internalCertificate = {
allowedSslFiles: [ allowedSslFiles: ["certificate", "certificate_key", "intermediate_certificate"],
"certificate",
"certificate_key",
"intermediate_certificate",
],
intervalTimeout: 1000 * 60 * 60, // 1 hour intervalTimeout: 1000 * 60 * 60, // 1 hour
interval: null, interval: null,
intervalProcessing: false, intervalProcessing: false,
@@ -57,10 +55,7 @@ const internalCertificate = {
); );
const expirationThreshold = moment() const expirationThreshold = moment()
.add( .add(internalCertificate.renewBeforeExpirationBy[0], internalCertificate.renewBeforeExpirationBy[1])
internalCertificate.renewBeforeExpirationBy[0],
internalCertificate.renewBeforeExpirationBy[1],
)
.format("YYYY-MM-DD HH:mm:ss"); .format("YYYY-MM-DD HH:mm:ss");
// Fetch all the letsencrypt certs from the db that will expire within the configured threshold // Fetch all the letsencrypt certs from the db that will expire within the configured threshold
@@ -127,10 +122,7 @@ const internalCertificate = {
} }
// this command really should clean up and delete the cert if it can't fully succeed // this command really should clean up and delete the cert if it can't fully succeed
const certificate = await certificateModel const certificate = await certificateModel.query().insertAndFetch(data).then(utils.omitRow(omissions()));
.query()
.insertAndFetch(data)
.then(utils.omitRow(omissions()));
try { try {
if (certificate.provider === "letsencrypt") { if (certificate.provider === "letsencrypt") {
@@ -144,18 +136,12 @@ const internalCertificate = {
// 6. Re-instate previously disabled hosts // 6. Re-instate previously disabled hosts
// 1. Find out any hosts that are using any of the hostnames in this cert // 1. Find out any hosts that are using any of the hostnames in this cert
const inUseResult = await internalHost.getHostsWithDomains( const inUseResult = await internalHost.getHostsWithDomains(certificate.domain_names);
certificate.domain_names,
);
// 2. Disable them in nginx temporarily // 2. Disable them in nginx temporarily
await internalCertificate.disableInUseHosts(inUseResult); await internalCertificate.disableInUseHosts(inUseResult);
const user = await userModel const user = await userModel.query().where("is_deleted", 0).andWhere("id", data.owner_user_id).first();
.query()
.where("is_deleted", 0)
.andWhere("id", data.owner_user_id)
.first();
if (!user || !user.email) { if (!user || !user.email) {
throw new error.ValidationError( throw new error.ValidationError(
"A valid email address must be set on your user account to use Let's Encrypt", "A valid email address must be set on your user account to use Let's Encrypt",
@@ -167,10 +153,7 @@ const internalCertificate = {
try { try {
await internalNginx.reload(); await internalNginx.reload();
// 4. Request cert // 4. Request cert
await internalCertificate.requestLetsEncryptSslWithDnsChallenge( await internalCertificate.requestLetsEncryptSslWithDnsChallenge(certificate, user.email);
certificate,
user.email,
);
await internalNginx.reload(); await internalNginx.reload();
// 6. Re-instate previously disabled hosts // 6. Re-instate previously disabled hosts
await internalCertificate.enableInUseHosts(inUseResult); await internalCertificate.enableInUseHosts(inUseResult);
@@ -187,10 +170,7 @@ const internalCertificate = {
await internalNginx.reload(); await internalNginx.reload();
setTimeout(() => {}, 5000); setTimeout(() => {}, 5000);
// 4. Request cert // 4. Request cert
await internalCertificate.requestLetsEncryptSsl( await internalCertificate.requestLetsEncryptSsl(certificate, user.email);
certificate,
user.email,
);
// 5. Remove LE config // 5. Remove LE config
await internalNginx.deleteLetsEncryptRequestConfig(certificate); await internalNginx.deleteLetsEncryptRequestConfig(certificate);
await internalNginx.reload(); await internalNginx.reload();
@@ -214,9 +194,7 @@ const internalCertificate = {
const savedRow = await certificateModel const savedRow = await certificateModel
.query() .query()
.patchAndFetchById(certificate.id, { .patchAndFetchById(certificate.id, {
expires_on: moment(certInfo.dates.to, "X").format( expires_on: moment(certInfo.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
"YYYY-MM-DD HH:mm:ss",
),
}) })
.then(utils.omitRow(omissions())); .then(utils.omitRow(omissions()));
@@ -344,9 +322,7 @@ const internalCertificate = {
if (certificate.provider === "letsencrypt") { if (certificate.provider === "letsencrypt") {
const zipDirectory = internalCertificate.getLiveCertPath(data.id); const zipDirectory = internalCertificate.getLiveCertPath(data.id);
if (!fs.existsSync(zipDirectory)) { if (!fs.existsSync(zipDirectory)) {
throw new error.ItemNotFoundError( throw new error.ItemNotFoundError(`Certificate ${certificate.nice_name} does not exists`);
`Certificate ${certificate.nice_name} does not exists`,
);
} }
const certFiles = fs const certFiles = fs
@@ -363,9 +339,7 @@ const internalCertificate = {
fileName: opName, fileName: opName,
}; };
} }
throw new error.ValidationError( throw new error.ValidationError("Only Let'sEncrypt certificates can be downloaded");
"Only Let'sEncrypt certificates can be downloaded",
);
}, },
/** /**
@@ -470,10 +444,7 @@ const internalCertificate = {
* @returns {Promise} * @returns {Promise}
*/ */
getCount: async (userId, visibility) => { getCount: async (userId, visibility) => {
const query = certificateModel const query = certificateModel.query().count("id as count").where("is_deleted", 0);
.query()
.count("id as count")
.where("is_deleted", 0);
if (visibility !== "all") { if (visibility !== "all") {
query.andWhere("owner_user_id", userId); query.andWhere("owner_user_id", userId);
@@ -521,17 +492,13 @@ const internalCertificate = {
}); });
}).then(() => { }).then(() => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fs.writeFile( fs.writeFile(`${dir}/privkey.pem`, certificate.meta.certificate_key, (err) => {
`${dir}/privkey.pem`,
certificate.meta.certificate_key,
(err) => {
if (err) { if (err) {
reject(err); reject(err);
} else { } else {
resolve(); resolve();
} }
}, });
);
}); });
}); });
}, },
@@ -604,9 +571,7 @@ const internalCertificate = {
upload: async (access, data) => { upload: async (access, data) => {
const row = await internalCertificate.get(access, { id: data.id }); const row = await internalCertificate.get(access, { id: data.id });
if (row.provider !== "other") { if (row.provider !== "other") {
throw new error.ValidationError( throw new error.ValidationError("Cannot upload certificates for this type of provider");
"Cannot upload certificates for this type of provider",
);
} }
const validations = await internalCertificate.validate(data); const validations = await internalCertificate.validate(data);
@@ -622,9 +587,7 @@ const internalCertificate = {
const certificate = await internalCertificate.update(access, { const certificate = await internalCertificate.update(access, {
id: data.id, id: data.id,
expires_on: moment(validations.certificate.dates.to, "X").format( expires_on: moment(validations.certificate.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
"YYYY-MM-DD HH:mm:ss",
),
domain_names: [validations.certificate.cn], domain_names: [validations.certificate.cn],
meta: _.clone(row.meta), // Prevent the update method from changing this value that we'll use later meta: _.clone(row.meta), // Prevent the update method from changing this value that we'll use later
}); });
@@ -649,9 +612,7 @@ const internalCertificate = {
}, 10000); }, 10000);
try { try {
const result = await utils.exec( const result = await utils.exec(`openssl pkey -in ${filepath} -check -noout 2>&1 `);
`openssl pkey -in ${filepath} -check -noout 2>&1 `,
);
clearTimeout(failTimeout); clearTimeout(failTimeout);
if (!result.toLowerCase().includes("key is valid")) { if (!result.toLowerCase().includes("key is valid")) {
throw new error.ValidationError(`Result Validation Error: ${result}`); throw new error.ValidationError(`Result Validation Error: ${result}`);
@@ -661,10 +622,7 @@ const internalCertificate = {
} catch (err) { } catch (err) {
clearTimeout(failTimeout); clearTimeout(failTimeout);
fs.unlinkSync(filepath); fs.unlinkSync(filepath);
throw new error.ValidationError( throw new error.ValidationError(`Certificate Key is not valid (${err.message})`, err);
`Certificate Key is not valid (${err.message})`,
err,
);
} }
}, },
@@ -678,10 +636,7 @@ const internalCertificate = {
getCertificateInfo: async (certificate, throwExpired) => { getCertificateInfo: async (certificate, throwExpired) => {
try { try {
const filepath = await tempWrite(certificate, "/tmp"); const filepath = await tempWrite(certificate, "/tmp");
const certData = await internalCertificate.getCertificateInfoFromFile( const certData = await internalCertificate.getCertificateInfoFromFile(filepath, throwExpired);
filepath,
throwExpired,
);
fs.unlinkSync(filepath); fs.unlinkSync(filepath);
return certData; return certData;
} catch (err) { } catch (err) {
@@ -701,13 +656,7 @@ const internalCertificate = {
const certData = {}; const certData = {};
try { try {
const result = await utils.execFile("openssl", [ const result = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-subject", "-noout"]);
"x509",
"-in",
certificateFile,
"-subject",
"-noout",
]);
// Examples: // Examples:
// subject=CN = *.jc21.com // subject=CN = *.jc21.com
// subject=CN = something.example.com // subject=CN = something.example.com
@@ -717,13 +666,7 @@ const internalCertificate = {
certData.cn = match[1]; certData.cn = match[1];
} }
const result2 = await utils.execFile("openssl", [ const result2 = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-issuer", "-noout"]);
"x509",
"-in",
certificateFile,
"-issuer",
"-noout",
]);
// Examples: // Examples:
// issuer=C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3 // issuer=C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3
// issuer=C = US, O = Let's Encrypt, CN = E5 // issuer=C = US, O = Let's Encrypt, CN = E5
@@ -734,13 +677,7 @@ const internalCertificate = {
certData.issuer = match2[1]; certData.issuer = match2[1];
} }
const result3 = await utils.execFile("openssl", [ const result3 = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-dates", "-noout"]);
"x509",
"-in",
certificateFile,
"-dates",
"-noout",
]);
// notBefore=Jul 14 04:04:29 2018 GMT // notBefore=Jul 14 04:04:29 2018 GMT
// notAfter=Oct 12 04:04:29 2018 GMT // notAfter=Oct 12 04:04:29 2018 GMT
let validFrom = null; let validFrom = null;
@@ -752,10 +689,7 @@ const internalCertificate = {
const match = regex.exec(str.trim()); const match = regex.exec(str.trim());
if (match && typeof match[2] !== "undefined") { if (match && typeof match[2] !== "undefined") {
const date = Number.parseInt( const date = Number.parseInt(moment(match[2], "MMM DD HH:mm:ss YYYY z").format("X"), 10);
moment(match[2], "MMM DD HH:mm:ss YYYY z").format("X"),
10,
);
if (match[1].toLowerCase() === "notbefore") { if (match[1].toLowerCase() === "notbefore") {
validFrom = date; validFrom = date;
@@ -767,15 +701,10 @@ const internalCertificate = {
}); });
if (!validFrom || !validTo) { if (!validFrom || !validTo) {
throw new error.ValidationError( throw new error.ValidationError(`Could not determine dates from certificate: ${result}`);
`Could not determine dates from certificate: ${result}`,
);
} }
if ( if (throw_expired && validTo < Number.parseInt(moment().format("X"), 10)) {
throw_expired &&
validTo < Number.parseInt(moment().format("X"), 10)
) {
throw new error.ValidationError("Certificate has expired"); throw new error.ValidationError("Certificate has expired");
} }
@@ -786,10 +715,7 @@ const internalCertificate = {
return certData; return certData;
} catch (err) { } catch (err) {
throw new error.ValidationError( throw new error.ValidationError(`Certificate is not valid (${err.message})`, err);
`Certificate is not valid (${err.message})`,
err,
);
} }
}, },
@@ -830,18 +756,18 @@ const internalCertificate = {
"--config", "--config",
letsencryptConfig, letsencryptConfig,
"--work-dir", "--work-dir",
"/tmp/letsencrypt-lib", certbotWorkDir,
"--logs-dir", "--logs-dir",
"/tmp/letsencrypt-log", certbotLogsDir,
"--cert-name", "--cert-name",
`npm-${certificate.id}`, `npm-${certificate.id}`,
"--agree-tos", "--agree-tos",
"--authenticator", "--authenticator",
"webroot", "webroot",
"--email", "-m",
email, email,
"--preferred-challenges", "--preferred-challenges",
"dns,http", "http",
"--domains", "--domains",
certificate.domain_names.join(","), certificate.domain_names.join(","),
]; ];
@@ -870,11 +796,7 @@ const internalCertificate = {
const credentialsLocation = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; const credentialsLocation = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
fs.mkdirSync("/etc/letsencrypt/credentials", { recursive: true }); fs.mkdirSync("/etc/letsencrypt/credentials", { recursive: true });
fs.writeFileSync( fs.writeFileSync(credentialsLocation, certificate.meta.dns_provider_credentials, { mode: 0o600 });
credentialsLocation,
certificate.meta.dns_provider_credentials,
{ mode: 0o600 },
);
// Whether the plugin has a --<name>-credentials argument // Whether the plugin has a --<name>-credentials argument
const hasConfigArg = certificate.meta.dns_provider !== "route53"; const hasConfigArg = certificate.meta.dns_provider !== "route53";
@@ -884,14 +806,16 @@ const internalCertificate = {
"--config", "--config",
letsencryptConfig, letsencryptConfig,
"--work-dir", "--work-dir",
"/tmp/letsencrypt-lib", certbotWorkDir,
"--logs-dir", "--logs-dir",
"/tmp/letsencrypt-log", certbotLogsDir,
"--cert-name", "--cert-name",
`npm-${certificate.id}`, `npm-${certificate.id}`,
"--agree-tos", "--agree-tos",
"--email", "-m",
email, email,
"--preferred-challenges",
"dns",
"--domains", "--domains",
certificate.domain_names.join(","), certificate.domain_names.join(","),
"--authenticator", "--authenticator",
@@ -899,10 +823,7 @@ const internalCertificate = {
]; ];
if (hasConfigArg) { if (hasConfigArg) {
args.push( args.push(`--${dnsPlugin.full_plugin_name}-credentials`, credentialsLocation);
`--${dnsPlugin.full_plugin_name}-credentials`,
credentialsLocation,
);
} }
if (certificate.meta.propagation_seconds !== undefined) { if (certificate.meta.propagation_seconds !== undefined) {
args.push( args.push(
@@ -911,10 +832,7 @@ const internalCertificate = {
); );
} }
const adds = internalCertificate.getAdditionalCertbotArgs( const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
certificate.id,
certificate.meta.dns_provider,
);
args.push(...adds.args); args.push(...adds.args);
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
@@ -950,12 +868,8 @@ const internalCertificate = {
`${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`, `${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`,
); );
const updatedCertificate = await certificateModel const updatedCertificate = await certificateModel.query().patchAndFetchById(certificate.id, {
.query() expires_on: moment(certInfo.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
.patchAndFetchById(certificate.id, {
expires_on: moment(certInfo.dates.to, "X").format(
"YYYY-MM-DD HH:mm:ss",
),
}); });
// Add to audit log // Add to audit log
@@ -965,11 +879,11 @@ const internalCertificate = {
object_id: updatedCertificate.id, object_id: updatedCertificate.id,
meta: updatedCertificate, meta: updatedCertificate,
}); });
} else {
throw new error.ValidationError( return updatedCertificate;
"Only Let'sEncrypt certificates can be renewed",
);
} }
throw new error.ValidationError("Only Let'sEncrypt certificates can be renewed");
}, },
/** /**
@@ -987,21 +901,18 @@ const internalCertificate = {
"--config", "--config",
letsencryptConfig, letsencryptConfig,
"--work-dir", "--work-dir",
"/tmp/letsencrypt-lib", certbotWorkDir,
"--logs-dir", "--logs-dir",
"/tmp/letsencrypt-log", certbotLogsDir,
"--cert-name", "--cert-name",
`npm-${certificate.id}`, `npm-${certificate.id}`,
"--preferred-challenges", "--preferred-challenges",
"dns,http", "http",
"--no-random-sleep-on-renew", "--no-random-sleep-on-renew",
"--disable-hook-validation", "--disable-hook-validation",
]; ];
const adds = internalCertificate.getAdditionalCertbotArgs( const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
certificate.id,
certificate.meta.dns_provider,
);
args.push(...adds.args); args.push(...adds.args);
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
@@ -1031,19 +942,18 @@ const internalCertificate = {
"--config", "--config",
letsencryptConfig, letsencryptConfig,
"--work-dir", "--work-dir",
"/tmp/letsencrypt-lib", certbotWorkDir,
"--logs-dir", "--logs-dir",
"/tmp/letsencrypt-log", certbotLogsDir,
"--cert-name", "--cert-name",
`npm-${certificate.id}`, `npm-${certificate.id}`,
"--preferred-challenges",
"dns",
"--disable-hook-validation", "--disable-hook-validation",
"--no-random-sleep-on-renew", "--no-random-sleep-on-renew",
]; ];
const adds = internalCertificate.getAdditionalCertbotArgs( const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
certificate.id,
certificate.meta.dns_provider,
);
args.push(...adds.args); args.push(...adds.args);
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
@@ -1068,9 +978,9 @@ const internalCertificate = {
"--config", "--config",
letsencryptConfig, letsencryptConfig,
"--work-dir", "--work-dir",
"/tmp/letsencrypt-lib", certbotWorkDir,
"--logs-dir", "--logs-dir",
"/tmp/letsencrypt-log", certbotLogsDir,
"--cert-path", "--cert-path",
`${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`, `${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`,
"--delete-after-revoke", "--delete-after-revoke",
@@ -1083,9 +993,7 @@ const internalCertificate = {
try { try {
const result = await utils.execFile(certbotCommand, args, adds.opts); const result = await utils.execFile(certbotCommand, args, adds.opts);
await utils.exec( await utils.exec(`rm -f '/etc/letsencrypt/credentials/credentials-${certificate.id}' || true`);
`rm -f '/etc/letsencrypt/credentials/credentials-${certificate.id}' || true`,
);
logger.info(result); logger.info(result);
return result; return result;
} catch (err) { } catch (err) {
@@ -1102,10 +1010,7 @@ const internalCertificate = {
*/ */
hasLetsEncryptSslCerts: (certificate) => { hasLetsEncryptSslCerts: (certificate) => {
const letsencryptPath = internalCertificate.getLiveCertPath(certificate.id); const letsencryptPath = internalCertificate.getLiveCertPath(certificate.id);
return ( return fs.existsSync(`${letsencryptPath}/fullchain.pem`) && fs.existsSync(`${letsencryptPath}/privkey.pem`);
fs.existsSync(`${letsencryptPath}/fullchain.pem`) &&
fs.existsSync(`${letsencryptPath}/privkey.pem`)
);
}, },
/** /**
@@ -1119,24 +1024,15 @@ const internalCertificate = {
disableInUseHosts: async (inUseResult) => { disableInUseHosts: async (inUseResult) => {
if (inUseResult?.total_count) { if (inUseResult?.total_count) {
if (inUseResult?.proxy_hosts.length) { if (inUseResult?.proxy_hosts.length) {
await internalNginx.bulkDeleteConfigs( await internalNginx.bulkDeleteConfigs("proxy_host", inUseResult.proxy_hosts);
"proxy_host",
inUseResult.proxy_hosts,
);
} }
if (inUseResult?.redirection_hosts.length) { if (inUseResult?.redirection_hosts.length) {
await internalNginx.bulkDeleteConfigs( await internalNginx.bulkDeleteConfigs("redirection_host", inUseResult.redirection_hosts);
"redirection_host",
inUseResult.redirection_hosts,
);
} }
if (inUseResult?.dead_hosts.length) { if (inUseResult?.dead_hosts.length) {
await internalNginx.bulkDeleteConfigs( await internalNginx.bulkDeleteConfigs("dead_host", inUseResult.dead_hosts);
"dead_host",
inUseResult.dead_hosts,
);
} }
} }
}, },
@@ -1152,24 +1048,15 @@ const internalCertificate = {
enableInUseHosts: async (inUseResult) => { enableInUseHosts: async (inUseResult) => {
if (inUseResult.total_count) { if (inUseResult.total_count) {
if (inUseResult.proxy_hosts.length) { if (inUseResult.proxy_hosts.length) {
await internalNginx.bulkGenerateConfigs( await internalNginx.bulkGenerateConfigs("proxy_host", inUseResult.proxy_hosts);
"proxy_host",
inUseResult.proxy_hosts,
);
} }
if (inUseResult.redirection_hosts.length) { if (inUseResult.redirection_hosts.length) {
await internalNginx.bulkGenerateConfigs( await internalNginx.bulkGenerateConfigs("redirection_host", inUseResult.redirection_hosts);
"redirection_host",
inUseResult.redirection_hosts,
);
} }
if (inUseResult.dead_hosts.length) { if (inUseResult.dead_hosts.length) {
await internalNginx.bulkGenerateConfigs( await internalNginx.bulkGenerateConfigs("dead_host", inUseResult.dead_hosts);
"dead_host",
inUseResult.dead_hosts,
);
} }
} }
}, },
@@ -1184,8 +1071,7 @@ const internalCertificate = {
await access.can("certificates:list"); await access.can("certificates:list");
// Create a test challenge file // Create a test challenge file
const testChallengeDir = const testChallengeDir = "/data/letsencrypt-acme-challenge/.well-known/acme-challenge";
"/data/letsencrypt-acme-challenge/.well-known/acme-challenge";
const testChallengeFile = `${testChallengeDir}/test-challenge`; const testChallengeFile = `${testChallengeDir}/test-challenge`;
fs.mkdirSync(testChallengeDir, { recursive: true }); fs.mkdirSync(testChallengeDir, { recursive: true });
fs.writeFileSync(testChallengeFile, "Success", { encoding: "utf8" }); fs.writeFileSync(testChallengeFile, "Success", { encoding: "utf8" });
@@ -1215,10 +1101,7 @@ const internalCertificate = {
}; };
const result = await new Promise((resolve) => { const result = await new Promise((resolve) => {
const req = https.request( const req = https.request("https://www.site24x7.com/tools/restapi-tester", options, (res) => {
"https://www.site24x7.com/tools/restapi-tester",
options,
(res) => {
let responseBody = ""; let responseBody = "";
res.on("data", (chunk) => { res.on("data", (chunk) => {
@@ -1249,8 +1132,7 @@ const internalCertificate = {
resolve(undefined); resolve(undefined);
} }
}); });
}, });
);
// Make sure to write the request body. // Make sure to write the request body.
req.write(formBody); req.write(formBody);
@@ -1271,10 +1153,7 @@ const internalCertificate = {
); );
return `other:${result.error.msg}`; return `other:${result.error.msg}`;
} }
if ( if (`${result.responsecode}` === "200" && result.htmlresponse === "Success") {
`${result.responsecode}` === "200" &&
result.htmlresponse === "Success"
) {
// Server exists and has responded with the correct data // Server exists and has responded with the correct data
return "ok"; return "ok";
} }
@@ -1288,26 +1167,19 @@ const internalCertificate = {
} }
if (`${result.responsecode}` === "404") { if (`${result.responsecode}` === "404") {
// Server exists but responded with a 404 // Server exists but responded with a 404
logger.info( logger.info(`HTTP challenge test failed for domain ${domain} because code 404 was returned`);
`HTTP challenge test failed for domain ${domain} because code 404 was returned`,
);
return "404"; return "404";
} }
if ( if (
`${result.responsecode}` === "0" || `${result.responsecode}` === "0" ||
(typeof result.reason === "string" && (typeof result.reason === "string" && result.reason.toLowerCase() === "host unavailable")
result.reason.toLowerCase() === "host unavailable")
) { ) {
// Server does not exist at domain // Server does not exist at domain
logger.info( logger.info(`HTTP challenge test failed for domain ${domain} the host was not found`);
`HTTP challenge test failed for domain ${domain} the host was not found`,
);
return "no-host"; return "no-host";
} }
// Other errors // Other errors
logger.info( logger.info(`HTTP challenge test failed for domain ${domain} because code ${result.responsecode} was returned`);
`HTTP challenge test failed for domain ${domain} because code ${result.responsecode} was returned`,
);
return `other:${result.responsecode}`; return `other:${result.responsecode}`;
}, },

View File

@@ -38,7 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@apidevtools/swagger-parser": "^10.1.0", "@apidevtools/swagger-parser": "^10.1.0",
"@biomejs/biome": "^2.3.0", "@biomejs/biome": "^2.3.1",
"chalk": "4.1.2", "chalk": "4.1.2",
"nodemon": "^2.0.2" "nodemon": "^2.0.2"
}, },

View File

@@ -43,59 +43,59 @@
ajv-draft-04 "^1.0.0" ajv-draft-04 "^1.0.0"
call-me-maybe "^1.0.2" call-me-maybe "^1.0.2"
"@biomejs/biome@^2.3.0": "@biomejs/biome@^2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.3.0.tgz#80030b68d94bd0a0761ac2cd22cc4f2c0f23f4f9" resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.3.1.tgz#d1a9284f52986324f288cdaf450331a0f3fb1da7"
integrity sha512-shdUY5H3S3tJVUWoVWo5ua+GdPW5lRHf+b0IwZ4OC1o2zOKQECZ6l2KbU6t89FNhtd3Qx5eg5N7/UsQWGQbAFw== integrity sha512-A29evf1R72V5bo4o2EPxYMm5mtyGvzp2g+biZvRFx29nWebGyyeOSsDWGx3tuNNMFRepGwxmA9ZQ15mzfabK2w==
optionalDependencies: optionalDependencies:
"@biomejs/cli-darwin-arm64" "2.3.0" "@biomejs/cli-darwin-arm64" "2.3.1"
"@biomejs/cli-darwin-x64" "2.3.0" "@biomejs/cli-darwin-x64" "2.3.1"
"@biomejs/cli-linux-arm64" "2.3.0" "@biomejs/cli-linux-arm64" "2.3.1"
"@biomejs/cli-linux-arm64-musl" "2.3.0" "@biomejs/cli-linux-arm64-musl" "2.3.1"
"@biomejs/cli-linux-x64" "2.3.0" "@biomejs/cli-linux-x64" "2.3.1"
"@biomejs/cli-linux-x64-musl" "2.3.0" "@biomejs/cli-linux-x64-musl" "2.3.1"
"@biomejs/cli-win32-arm64" "2.3.0" "@biomejs/cli-win32-arm64" "2.3.1"
"@biomejs/cli-win32-x64" "2.3.0" "@biomejs/cli-win32-x64" "2.3.1"
"@biomejs/cli-darwin-arm64@2.3.0": "@biomejs/cli-darwin-arm64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.0.tgz#78cef4d7415adbf0718c7854e7160e181d916652" resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.1.tgz#607835f8ef043e1a80f9ad2a232c9e860941ab60"
integrity sha512-3cJVT0Z5pbTkoBmbjmDZTDFYxIkRcrs9sYVJbIBHU8E6qQxgXAaBfSVjjCreG56rfDuQBr43GzwzmaHPcu4vlw== integrity sha512-ombSf3MnTUueiYGN1SeI9tBCsDUhpWzOwS63Dove42osNh0PfE1cUtHFx6eZ1+MYCCLwXzlFlYFdrJ+U7h6LcA==
"@biomejs/cli-darwin-x64@2.3.0": "@biomejs/cli-darwin-x64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.0.tgz#068baf1f0f748c01658ba9bb511d8d18461d922b" resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.1.tgz#654fe4aaa8ea5d5bde5457db4961ad5d214713ac"
integrity sha512-6LIkhglh3UGjuDqJXsK42qCA0XkD1Ke4K/raFOii7QQPbM8Pia7Qj2Hji4XuF2/R78hRmEx7uKJH3t/Y9UahtQ== integrity sha512-pcOfwyoQkrkbGvXxRvZNe5qgD797IowpJPovPX5biPk2FwMEV+INZqfCaz4G5bVq9hYnjwhRMamg11U4QsRXrQ==
"@biomejs/cli-linux-arm64-musl@2.3.0": "@biomejs/cli-linux-arm64-musl@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.0.tgz#9a1350184abcea8092957a9519098cac7629705a" resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.1.tgz#5fe502082a575c31ef808cf080cbcd4485964167"
integrity sha512-nDksoFdwZ2YrE7NiYDhtMhL2UgFn8Kb7Y0bYvnTAakHnqEdb4lKindtBc1f+xg2Snz0JQhJUYO7r9CDBosRU5w== integrity sha512-+DZYv8l7FlUtTrWs1Tdt1KcNCAmRO87PyOnxKGunbWm5HKg1oZBSbIIPkjrCtDZaeqSG1DiGx7qF+CPsquQRcg==
"@biomejs/cli-linux-arm64@2.3.0": "@biomejs/cli-linux-arm64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.0.tgz#f322daebb32fe0b18f7981c8cdbe84a06852bfee" resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.1.tgz#81c02547905d379dbb312e6ff24b04908c2e320f"
integrity sha512-uhAsbXySX7xsXahegDg5h3CDgfMcRsJvWLFPG0pjkylgBb9lErbK2C0UINW52zhwg0cPISB09lxHPxCau4e2xA== integrity sha512-td5O8pFIgLs8H1sAZsD6v+5quODihyEw4nv2R8z7swUfIK1FKk+15e4eiYVLcAE4jUqngvh4j3JCNgg0Y4o4IQ==
"@biomejs/cli-linux-x64-musl@2.3.0": "@biomejs/cli-linux-x64-musl@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.0.tgz#ce441d5c00eda977b74e4116f9723f2edc579485" resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.1.tgz#c7c00beb5eda1ad25185544897e66eeec6be3b0b"
integrity sha512-+i9UcJwl99uAhtRQDz9jUAh+Xkb097eekxs/D9j4deWDg5/yB/jPWzISe1nBHvlzTXsdUSj0VvB4Go2DSpKIMw== integrity sha512-Y3Ob4nqgv38Mh+6EGHltuN+Cq8aj/gyMTJYzkFZV2AEj+9XzoXB9VNljz9pjfFNHUxvLEV4b55VWyxozQTBaUQ==
"@biomejs/cli-linux-x64@2.3.0": "@biomejs/cli-linux-x64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.0.tgz#da7ea904307b3211df62a4b42e5a022f8f583009" resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.1.tgz#7481d2e7be98d4de574df233766a5bdda037c897"
integrity sha512-uxa8reA2s1VgoH8MhbGlCmMOt3JuSE1vJBifkh1ulaPiuk0SPx8cCdpnm9NWnTe2x/LfWInWx4sZ7muaXTPGGw== integrity sha512-PYWgEO7up7XYwSAArOpzsVCiqxBCXy53gsReAb1kKYIyXaoAlhBaBMvxR/k2Rm9aTuZ662locXUmPk/Aj+Xu+Q==
"@biomejs/cli-win32-arm64@2.3.0": "@biomejs/cli-win32-arm64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.0.tgz#cdc0f8bbf025fb28c5b03b326128cce363ecffa5" resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.1.tgz#dac8c7c7223e97f86cd0eed7aa95584984761481"
integrity sha512-ynjmsJLIKrAjC3CCnKMMhzcnNy8dbQWjKfSU5YA0mIruTxBNMbkAJp+Pr2iV7/hFou+66ZSD/WV8hmLEmhUaXA== integrity sha512-RHIG/zgo+69idUqVvV3n8+j58dKYABRpMyDmfWu2TITC+jwGPiEaT0Q3RKD+kQHiS80mpBrST0iUGeEXT0bU9A==
"@biomejs/cli-win32-x64@2.3.0": "@biomejs/cli-win32-x64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.0.tgz#10e1de6222e272a1e3e395b3d845ee66cb6febd8" resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.1.tgz#f8818ab2c1e3a6e2ed8a656935173e5ce4c720be"
integrity sha512-zOCYmCRVkWXc9v8P7OLbLlGGMxQTKMvi+5IC4v7O8DkjLCOHRzRVK/Lno2pGZNo0lzKM60pcQOhH8HVkXMQdFg== integrity sha512-izl30JJ5Dp10mi90Eko47zhxE6pYyWPcnX1NQxKpL/yMhXxf95oLTzfpu4q+MDBh/gemNqyJEwjBpe0MT5iWPA==
"@gar/promisify@^1.0.1": "@gar/promisify@^1.0.1":
version "1.1.3" version "1.1.3"

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", "$schema": "https://biomejs.dev/schemas/2.3.1/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -39,7 +39,7 @@
"rooks": "^9.3.0" "rooks": "^9.3.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.0", "@biomejs/biome": "^2.3.1",
"@formatjs/cli": "^6.7.4", "@formatjs/cli": "^6.7.4",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",

View File

@@ -80,8 +80,16 @@ export async function get(args: GetArgs, abortController?: AbortController) {
return processResponse(await baseGet(args, abortController)); return processResponse(await baseGet(args, abortController));
} }
export async function download(args: GetArgs, abortController?: AbortController) { export async function download({ url, params }: GetArgs, filename = "download.file") {
return (await baseGet(args, abortController)).text(); const headers = buildAuthHeader();
const res = await fetch(buildUrl({ url, params }), { headers });
const bl = await res.blob();
const u = window.URL.createObjectURL(bl);
const a = document.createElement("a");
a.href = u;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
} }
interface PostArgs { interface PostArgs {

View File

@@ -1,8 +1,10 @@
import * as api from "./base"; import * as api from "./base";
import type { Binary } from "./responseTypes";
export async function downloadCertificate(id: number): Promise<Binary> { export async function downloadCertificate(id: number): Promise<void> {
return await api.get({ await api.download(
{
url: `/nginx/certificates/${id}/download`, url: `/nginx/certificates/${id}/download`,
}); },
`certificate-${id}.zip`,
);
} }

View File

@@ -15,5 +15,3 @@ export interface ValidatedCertificateResponse {
certificate: Record<string, any>; certificate: Record<string, any>;
certificateKey: boolean; certificateKey: boolean;
} }
export type Binary = number & { readonly __brand: unique symbol };

View File

@@ -1,14 +1,9 @@
import * as api from "./base"; import * as api from "./base";
import type { Certificate } from "./models"; import type { Certificate } from "./models";
export async function uploadCertificate( export async function uploadCertificate(id: number, data: FormData): Promise<Certificate> {
id: number,
certificate: string,
certificateKey: string,
intermediateCertificate?: string,
): Promise<Certificate> {
return await api.post({ return await api.post({
url: `/nginx/certificates/${id}/upload`, url: `/nginx/certificates/${id}/upload`,
data: { certificate, certificateKey, intermediateCertificate }, data,
}); });
} }

View File

@@ -1,13 +1,9 @@
import * as api from "./base"; import * as api from "./base";
import type { ValidatedCertificateResponse } from "./responseTypes"; import type { ValidatedCertificateResponse } from "./responseTypes";
export async function validateCertificate( export async function validateCertificate(data: FormData): Promise<ValidatedCertificateResponse> {
certificate: string,
certificateKey: string,
intermediateCertificate?: string,
): Promise<ValidatedCertificateResponse> {
return await api.post({ return await api.post({
url: "/nginx/certificates/validate", url: "/nginx/certificates/validate",
data: { certificate, certificateKey, intermediateCertificate }, data,
}); });
} }

View File

@@ -0,0 +1,62 @@
import OverlayTrigger from "react-bootstrap/OverlayTrigger";
import Popover from "react-bootstrap/Popover";
import type { DeadHost, ProxyHost, RedirectionHost } from "src/api/backend";
import { T } from "src/locale";
const getSection = (title: string, items: ProxyHost[] | RedirectionHost[] | DeadHost[]) => {
if (items.length === 0) {
return null;
}
return (
<>
<div>
<strong>
<T id={title} />
</strong>
</div>
{items.map((host) => (
<div key={host.id} className="ms-1">
{host.domainNames.join(", ")}
</div>
))}
</>
);
};
interface Props {
proxyHosts: ProxyHost[];
redirectionHosts: RedirectionHost[];
deadHosts: DeadHost[];
}
export function CertificateInUseFormatter({ proxyHosts, redirectionHosts, deadHosts }: Props) {
const totalCount = proxyHosts?.length + redirectionHosts?.length + deadHosts?.length;
if (totalCount === 0) {
return (
<span className="badge bg-red-lt">
<T id="certificate.not-in-use" />
</span>
);
}
proxyHosts.sort();
redirectionHosts.sort();
deadHosts.sort();
const popover = (
<Popover id="popover-basic">
<Popover.Body>
{getSection("proxy-hosts", proxyHosts)}
{getSection("redirection-hosts", redirectionHosts)}
{getSection("dead-hosts", deadHosts)}
</Popover.Body>
</Popover>
);
return (
<OverlayTrigger trigger="hover" placement="bottom" overlay={popover}>
<span className="badge bg-lime-lt">
<T id="certificate.in-use" />
</span>
</OverlayTrigger>
);
}

View File

@@ -0,0 +1,18 @@
import cn from "classnames";
import { differenceInDays, isPast, parseISO } from "date-fns";
import { DateTimeFormat } from "src/locale";
interface Props {
value: string;
highlightPast?: boolean;
highlistNearlyExpired?: boolean;
}
export function DateFormatter({ value, highlightPast, highlistNearlyExpired }: Props) {
const dateIsPast = isPast(parseISO(value));
const days = differenceInDays(parseISO(value), new Date());
const cl = cn({
"text-danger": highlightPast && dateIsPast,
"text-warning": highlistNearlyExpired && !dateIsPast && days <= 30 && days >= 0,
});
return <span className={cl}>{DateTimeFormat(value)}</span>;
}

View File

@@ -1,8 +1,10 @@
import type { ReactNode } from "react";
import { DateTimeFormat, T } from "src/locale"; import { DateTimeFormat, T } from "src/locale";
interface Props { interface Props {
domains: string[]; domains: string[];
createdOn?: string; createdOn?: string;
niceName?: string;
} }
const DomainLink = ({ domain }: { domain: string }) => { const DomainLink = ({ domain }: { domain: string }) => {
@@ -24,14 +26,28 @@ const DomainLink = ({ domain }: { domain: string }) => {
); );
}; };
export function DomainsFormatter({ domains, createdOn }: Props) { export function DomainsFormatter({ domains, createdOn, niceName }: Props) {
const elms: ReactNode[] = [];
if (domains.length === 0 && !niceName) {
elms.push(
<span key="nice-name" className="badge bg-danger-lt me-2">
Unknown
</span>,
);
}
if (niceName) {
elms.push(
<span key="nice-name" className="badge bg-info-lt me-2">
{niceName}
</span>,
);
}
domains.map((domain: string) => elms.push(<DomainLink key={domain} domain={domain} />));
return ( return (
<div className="flex-fill"> <div className="flex-fill">
<div className="font-weight-medium"> <div className="font-weight-medium">{...elms}</div>
{domains.map((domain: string) => (
<DomainLink key={domain} domain={domain} />
))}
</div>
{createdOn ? ( {createdOn ? (
<div className="text-secondary mt-1"> <div className="text-secondary mt-1">
<T id="created-on" data={{ date: DateTimeFormat(createdOn) }} /> <T id="created-on" data={{ date: DateTimeFormat(createdOn) }} />

View File

@@ -1,5 +1,7 @@
export * from "./AccessListformatter"; export * from "./AccessListformatter";
export * from "./CertificateFormatter"; export * from "./CertificateFormatter";
export * from "./CertificateInUseFormatter";
export * from "./DateFormatter";
export * from "./DomainsFormatter"; export * from "./DomainsFormatter";
export * from "./EmailFormatter"; export * from "./EmailFormatter";
export * from "./EnabledFormatter"; export * from "./EnabledFormatter";

View File

@@ -2,6 +2,7 @@ export * from "./useAccessList";
export * from "./useAccessLists"; export * from "./useAccessLists";
export * from "./useAuditLog"; export * from "./useAuditLog";
export * from "./useAuditLogs"; export * from "./useAuditLogs";
export * from "./useCertificate";
export * from "./useCertificates"; export * from "./useCertificates";
export * from "./useDeadHost"; export * from "./useDeadHost";
export * from "./useDeadHosts"; export * from "./useDeadHosts";

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { type Certificate, getCertificate } from "src/api/backend";
const fetchCertificate = (id: number) => {
return getCertificate(id, ["owner"]);
};
const useCertificate = (id: number, options = {}) => {
return useQuery<Certificate, Error>({
queryKey: ["certificate", id],
queryFn: () => fetchCertificate(id),
staleTime: 60 * 1000, // 1 minute
...options,
});
};
export { useCertificate };

View File

@@ -15,18 +15,26 @@
"action.close": "Close", "action.close": "Close",
"action.delete": "Delete", "action.delete": "Delete",
"action.disable": "Disable", "action.disable": "Disable",
"action.download": "Download",
"action.edit": "Edit", "action.edit": "Edit",
"action.enable": "Enable", "action.enable": "Enable",
"action.permissions": "Permissions", "action.permissions": "Permissions",
"action.renew": "Renew",
"action.view-details": "View Details", "action.view-details": "View Details",
"auditlogs": "Audit Logs", "auditlogs": "Audit Logs",
"cancel": "Cancel", "cancel": "Cancel",
"certificate": "Certificate", "certificate": "Certificate",
"certificate.custom-certificate": "Certificate",
"certificate.custom-certificate-key": "Certificate Key",
"certificate.custom-intermediate": "Intermediate Certificate",
"certificate.in-use": "In Use",
"certificate.none.subtitle": "No certificate assigned", "certificate.none.subtitle": "No certificate assigned",
"certificate.none.subtitle.for-http": "This host will not use HTTPS", "certificate.none.subtitle.for-http": "This host will not use HTTPS",
"certificate.none.title": "None", "certificate.none.title": "None",
"certificate.not-in-use": "Not Used",
"certificates": "Certificates", "certificates": "Certificates",
"certificates.custom": "Custom Certificate", "certificates.custom": "Custom Certificate",
"certificates.custom.warning": "Key files protected with a passphrase are not supported.",
"certificates.dns.credentials": "Credentials File Content", "certificates.dns.credentials": "Credentials File Content",
"certificates.dns.credentials-note": "This plugin requires a configuration file containing an API token or other credentials for your provider", "certificates.dns.credentials-note": "This plugin requires a configuration file containing an API token or other credentials for your provider",
"certificates.dns.credentials-warning": "This data will be stored as plaintext in the database and in a file!", "certificates.dns.credentials-warning": "This data will be stored as plaintext in the database and in a file!",
@@ -121,6 +129,7 @@
"notification.object-deleted": "{object} has been deleted", "notification.object-deleted": "{object} has been deleted",
"notification.object-disabled": "{object} has been disabled", "notification.object-disabled": "{object} has been disabled",
"notification.object-enabled": "{object} has been enabled", "notification.object-enabled": "{object} has been enabled",
"notification.object-renewed": "{object} has been renewed",
"notification.object-saved": "{object} has been saved", "notification.object-saved": "{object} has been saved",
"notification.success": "Success", "notification.success": "Success",
"object.actions-title": "{object} #{id}", "object.actions-title": "{object} #{id}",

View File

@@ -47,6 +47,9 @@
"action.disable": { "action.disable": {
"defaultMessage": "Disable" "defaultMessage": "Disable"
}, },
"action.download": {
"defaultMessage": "Download"
},
"action.edit": { "action.edit": {
"defaultMessage": "Edit" "defaultMessage": "Edit"
}, },
@@ -56,6 +59,9 @@
"action.permissions": { "action.permissions": {
"defaultMessage": "Permissions" "defaultMessage": "Permissions"
}, },
"action.renew": {
"defaultMessage": "Renew"
},
"action.view-details": { "action.view-details": {
"defaultMessage": "View Details" "defaultMessage": "View Details"
}, },
@@ -68,6 +74,18 @@
"certificate": { "certificate": {
"defaultMessage": "Certificate" "defaultMessage": "Certificate"
}, },
"certificate.custom-certificate": {
"defaultMessage": "Certificate"
},
"certificate.custom-certificate-key": {
"defaultMessage": "Certificate Key"
},
"certificate.custom-intermediate": {
"defaultMessage": "Intermediate Certificate"
},
"certificate.in-use": {
"defaultMessage": "In Use"
},
"certificate.none.subtitle": { "certificate.none.subtitle": {
"defaultMessage": "No certificate assigned" "defaultMessage": "No certificate assigned"
}, },
@@ -77,12 +95,18 @@
"certificate.none.title": { "certificate.none.title": {
"defaultMessage": "None" "defaultMessage": "None"
}, },
"certificate.not-in-use": {
"defaultMessage": "Not Used"
},
"certificates": { "certificates": {
"defaultMessage": "Certificates" "defaultMessage": "Certificates"
}, },
"certificates.custom": { "certificates.custom": {
"defaultMessage": "Custom Certificate" "defaultMessage": "Custom Certificate"
}, },
"certificates.custom.warning": {
"defaultMessage": "Key files protected with a passphrase are not supported."
},
"certificates.dns.credentials": { "certificates.dns.credentials": {
"defaultMessage": "Credentials File Content" "defaultMessage": "Credentials File Content"
}, },
@@ -365,6 +389,9 @@
"notification.object-enabled": { "notification.object-enabled": {
"defaultMessage": "{object} has been enabled" "defaultMessage": "{object} has been enabled"
}, },
"notification.object-renewed": {
"defaultMessage": "{object} has been renewed"
},
"notification.object-saved": { "notification.object-saved": {
"defaultMessage": "{object} has been saved" "defaultMessage": "{object} has been saved"
}, },

View File

@@ -1,11 +1,14 @@
import { IconAlertTriangle } from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import EasyModal, { type InnerModalProps } from "ez-modal-react"; import EasyModal, { type InnerModalProps } from "ez-modal-react";
import { Form, Formik } from "formik"; import { Field, Form, Formik } from "formik";
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap"; import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal"; import Modal from "react-bootstrap/Modal";
import { Button, DomainNamesField } from "src/components"; import { type Certificate, createCertificate, uploadCertificate, validateCertificate } from "src/api/backend";
import { useSetProxyHost } from "src/hooks"; import { Button } from "src/components";
import { T } from "src/locale"; import { T } from "src/locale";
import { validateString } from "src/modules/Validations";
import { showObjectSuccess } from "src/notifications"; import { showObjectSuccess } from "src/notifications";
const showCustomCertificateModal = () => { const showCustomCertificateModal = () => {
@@ -13,7 +16,7 @@ const showCustomCertificateModal = () => {
}; };
const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModalProps) => { const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModalProps) => {
const { mutate: setProxyHost } = useSetProxyHost(); const queryClient = useQueryClient();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null); const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -22,17 +25,35 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal
setIsSubmitting(true); setIsSubmitting(true);
setErrorMsg(null); setErrorMsg(null);
setProxyHost(values, { try {
onError: (err: any) => setErrorMsg(<T id={err.message} />), const { niceName, provider, certificate, certificateKey, intermediateCertificate } = values;
onSuccess: () => { const formData = new FormData();
formData.append("certificate", certificate);
formData.append("certificate_key", certificateKey);
if (intermediateCertificate !== null) {
formData.append("intermediate_certificate", intermediateCertificate);
}
// Validate
await validateCertificate(formData);
// Create certificate, as other without anything else
const cert = await createCertificate({ niceName, provider } as Certificate);
// Upload the certificates to the created certificate
await uploadCertificate(cert.id, formData);
// Success
showObjectSuccess("certificate", "saved"); showObjectSuccess("certificate", "saved");
remove(); remove();
}, } catch (err: any) {
onSettled: () => { setErrorMsg(<T id={err.message} />);
}
queryClient.invalidateQueries({ queryKey: ["certificates"] });
setIsSubmitting(false); setIsSubmitting(false);
setSubmitting(false); setSubmitting(false);
},
});
}; };
return ( return (
@@ -40,7 +61,11 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal
<Formik <Formik
initialValues={ initialValues={
{ {
domainNames: [], niceName: "",
provider: "other",
certificate: null,
certificateKey: null,
intermediateCertificate: null,
} as any } as any
} }
onSubmit={onSubmit} onSubmit={onSubmit}
@@ -49,7 +74,7 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal
<Form> <Form>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title> <Modal.Title>
<T id="object.add" tData={{ object: "certificate" }} /> <T id="object.add" tData={{ object: "lets-encrypt-via-dns" }} />
</Modal.Title> </Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body className="p-0"> <Modal.Body className="p-0">
@@ -57,9 +82,128 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal
{errorMsg} {errorMsg}
</Alert> </Alert>
<div className="card m-0 border-0"> <div className="card m-0 border-0">
<div className="card-header">asd</div>
<div className="card-body"> <div className="card-body">
<DomainNamesField /> <p className="text-warning">
<IconAlertTriangle size={16} className="me-1" />
<T id="certificates.custom.warning" />
</p>
<Field name="niceName" validate={validateString(1, 255)}>
{({ field, form }: any) => (
<div className="mb-3">
<label htmlFor="niceName" className="form-label">
<T id="column.name" />
</label>
<input
id="niceName"
type="text"
required
autoComplete="off"
className="form-control"
{...field}
/>
{form.errors.niceName ? (
<div className="invalid-feedback">
{form.errors.niceName && form.touched.niceName
? form.errors.niceName
: null}
</div>
) : null}
</div>
)}
</Field>
<Field name="certificateKey">
{({ field, form }: any) => (
<div className="mb-3">
<label htmlFor="certificateKey" className="form-label">
<T id="certificate.custom-certificate-key" />
</label>
<input
id="certificateKey"
type="file"
required
autoComplete="off"
className="form-control"
onChange={(event) => {
form.setFieldValue(
field.name,
event.currentTarget.files?.length
? event.currentTarget.files[0]
: null,
);
}}
/>
{form.errors.certificateKey ? (
<div className="invalid-feedback">
{form.errors.certificateKey && form.touched.certificateKey
? form.errors.certificateKey
: null}
</div>
) : null}
</div>
)}
</Field>
<Field name="certificate">
{({ field, form }: any) => (
<div className="mb-3">
<label htmlFor="certificate" className="form-label">
<T id="certificate.custom-certificate" />
</label>
<input
id="certificate"
type="file"
required
autoComplete="off"
className="form-control"
onChange={(event) => {
form.setFieldValue(
field.name,
event.currentTarget.files?.length
? event.currentTarget.files[0]
: null,
);
}}
/>
{form.errors.certificate ? (
<div className="invalid-feedback">
{form.errors.certificate && form.touched.certificate
? form.errors.certificate
: null}
</div>
) : null}
</div>
)}
</Field>
<Field name="intermediateCertificate">
{({ field, form }: any) => (
<div className="mb-3">
<label htmlFor="intermediateCertificate" className="form-label">
<T id="certificate.custom-intermediate" />
</label>
<input
id="intermediateCertificate"
type="file"
autoComplete="off"
className="form-control"
onChange={(event) => {
form.setFieldValue(
field.name,
event.currentTarget.files?.length
? event.currentTarget.files[0]
: null,
);
}}
/>
{form.errors.intermediateCertificate ? (
<div className="invalid-feedback">
{form.errors.intermediateCertificate &&
form.touched.intermediateCertificate
? form.errors.intermediateCertificate
: null}
</div>
) : null}
</div>
)}
</Field>
</div> </div>
</div> </div>
</Modal.Body> </Modal.Body>
@@ -70,7 +214,7 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal
<Button <Button
type="submit" type="submit"
actionType="primary" actionType="primary"
className="ms-auto bg-lime" className="ms-auto bg-pink"
data-bs-dismiss="modal" data-bs-dismiss="modal"
isLoading={isSubmitting} isLoading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}

View File

@@ -42,6 +42,9 @@ const DNSCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPro
{ {
domainNames: [], domainNames: [],
provider: "letsencrypt", provider: "letsencrypt",
meta: {
dnsChallenge: true,
},
} as any } as any
} }
onSubmit={onSubmit} onSubmit={onSubmit}

View File

@@ -1,4 +1,5 @@
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import EasyModal, { type InnerModalProps } from "ez-modal-react"; import EasyModal, { type InnerModalProps } from "ez-modal-react";
import { Form, Formik } from "formik"; import { Form, Formik } from "formik";
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
@@ -14,6 +15,7 @@ const showHTTPCertificateModal = () => {
}; };
const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalProps) => { const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalProps) => {
const queryClient = useQueryClient();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null); const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [domains, setDomains] = useState([] as string[]); const [domains, setDomains] = useState([] as string[]);
@@ -32,6 +34,7 @@ const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPr
} catch (err: any) { } catch (err: any) {
setErrorMsg(<T id={err.message} />); setErrorMsg(<T id={err.message} />);
} }
queryClient.invalidateQueries({ queryKey: ["certificates"] });
setIsSubmitting(false); setIsSubmitting(false);
setSubmitting(false); setSubmitting(false);
}; };

View File

@@ -0,0 +1,74 @@
import { useQueryClient } from "@tanstack/react-query";
import EasyModal, { type InnerModalProps } from "ez-modal-react";
import { type ReactNode, useEffect, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { renewCertificate } from "src/api/backend";
import { Button, Loading } from "src/components";
import { useCertificate } from "src/hooks";
import { T } from "src/locale";
import { showObjectSuccess } from "src/notifications";
interface Props extends InnerModalProps {
id: number;
}
const showRenewCertificateModal = (id: number) => {
EasyModal.show(RenewCertificateModal, { id });
};
const RenewCertificateModal = EasyModal.create(({ id, visible, remove }: Props) => {
const queryClient = useQueryClient();
const { data, isLoading, error } = useCertificate(id);
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isFresh, setIsFresh] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (!data || !isFresh || isSubmitting) return;
setIsFresh(false);
setIsSubmitting(true);
renewCertificate(id)
.then(() => {
showObjectSuccess("certificate", "renewed");
queryClient.invalidateQueries({ queryKey: ["certificates"] });
remove();
})
.catch((err: any) => {
setErrorMsg(<T id={err.message} />);
})
.finally(() => {
setIsSubmitting(false);
});
}, [id, data, isFresh, isSubmitting, remove, queryClient.invalidateQueries]);
return (
<Modal show={visible} onHide={isSubmitting ? undefined : remove}>
<Modal.Header closeButton={!isSubmitting}>
<Modal.Title>
<T id="renew-certificate" />
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="danger" show={!!errorMsg}>
{errorMsg}
</Alert>
{isLoading && <Loading noLogo />}
{!isLoading && error && (
<Alert variant="danger" className="m-3">
{error?.message || "Unknown error"}
</Alert>
)}
{data && isSubmitting && !errorMsg ? <p className="text-center mt-3">Please wait ...</p> : null}
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
<T id="action.close" />
</Button>
</Modal.Footer>
</Modal>
);
});
export { showRenewCertificateModal };

View File

@@ -9,6 +9,7 @@ export * from "./HTTPCertificateModal";
export * from "./PermissionsModal"; export * from "./PermissionsModal";
export * from "./ProxyHostModal"; export * from "./ProxyHostModal";
export * from "./RedirectionHostModal"; export * from "./RedirectionHostModal";
export * from "./RenewCertificateModal";
export * from "./SetPasswordModal"; export * from "./SetPasswordModal";
export * from "./StreamModal"; export * from "./StreamModal";
export * from "./UserModal"; export * from "./UserModal";

View File

@@ -1,17 +1,27 @@
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react"; import { IconDotsVertical, IconDownload, IconRefresh, IconTrash } from "@tabler/icons-react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo } from "react"; import { useMemo } from "react";
import type { Certificate } from "src/api/backend"; import type { Certificate } from "src/api/backend";
import { DomainsFormatter, EmptyData, GravatarFormatter } from "src/components"; import {
CertificateInUseFormatter,
DateFormatter,
DomainsFormatter,
EmptyData,
GravatarFormatter,
} from "src/components";
import { TableLayout } from "src/components/Table/TableLayout"; import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale"; import { intl, T } from "src/locale";
import { showCustomCertificateModal, showDNSCertificateModal, showHTTPCertificateModal } from "src/modals"; import { showCustomCertificateModal, showDNSCertificateModal, showHTTPCertificateModal } from "src/modals";
interface Props { interface Props {
data: Certificate[]; data: Certificate[];
isFiltered?: boolean;
isFetching?: boolean; isFetching?: boolean;
onDelete?: (id: number) => void;
onRenew?: (id: number) => void;
onDownload?: (id: number) => void;
} }
export default function Table({ data, isFetching }: Props) { export default function Table({ data, isFetching, onDelete, onRenew, onDownload, isFiltered }: Props) {
const columnHelper = createColumnHelper<Certificate>(); const columnHelper = createColumnHelper<Certificate>();
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -30,32 +40,51 @@ export default function Table({ data, isFetching }: Props) {
header: intl.formatMessage({ id: "column.name" }), header: intl.formatMessage({ id: "column.name" }),
cell: (info: any) => { cell: (info: any) => {
const value = info.getValue(); const value = info.getValue();
return <DomainsFormatter domains={value.domainNames} createdOn={value.createdOn} />; return (
<DomainsFormatter
domains={value.domainNames}
createdOn={value.createdOn}
niceName={value.niceName}
/>
);
}, },
}), }),
columnHelper.accessor((row: any) => row.provider, { columnHelper.accessor((row: any) => row.provider, {
id: "provider", id: "provider",
header: intl.formatMessage({ id: "column.provider" }), header: intl.formatMessage({ id: "column.provider" }),
cell: (info: any) => { cell: (info: any) => {
return info.getValue(); if (info.getValue() === "letsencrypt") {
return <T id="lets-encrypt" />;
}
if (info.getValue() === "other") {
return <T id="certificates.custom" />;
}
return <T id={info.getValue()} />;
}, },
}), }),
columnHelper.accessor((row: any) => row.expires_on, { columnHelper.accessor((row: any) => row.expiresOn, {
id: "expires_on", id: "expiresOn",
header: intl.formatMessage({ id: "column.expires" }), header: intl.formatMessage({ id: "column.expires" }),
cell: (info: any) => { cell: (info: any) => {
return info.getValue(); return <DateFormatter value={info.getValue()} highlightPast />;
}, },
}), }),
columnHelper.accessor((row: any) => row, { columnHelper.accessor((row: any) => row, {
id: "id", id: "proxyHosts",
header: intl.formatMessage({ id: "column.status" }), header: intl.formatMessage({ id: "column.status" }),
cell: (info: any) => { cell: (info: any) => {
return info.getValue(); const r = info.getValue();
return (
<CertificateInUseFormatter
proxyHosts={r.proxyHosts}
redirectionHosts={r.redirectionHosts}
deadHosts={r.deadHosts}
/>
);
}, },
}), }),
columnHelper.display({ columnHelper.display({
id: "id", // todo: not needed for a display? id: "id",
cell: (info: any) => { cell: (info: any) => {
return ( return (
<span className="dropdown"> <span className="dropdown">
@@ -75,16 +104,37 @@ export default function Table({ data, isFetching }: Props) {
data={{ id: info.row.original.id }} data={{ id: info.row.original.id }}
/> />
</span> </span>
<a className="dropdown-item" href="#"> <a
<IconEdit size={16} /> className="dropdown-item"
<T id="action.edit" /> href="#"
onClick={(e) => {
e.preventDefault();
onRenew?.(info.row.original.id);
}}
>
<IconRefresh size={16} />
<T id="action.renew" />
</a> </a>
<a className="dropdown-item" href="#"> <a
<IconPower size={16} /> className="dropdown-item"
<T id="action.disable" /> href="#"
onClick={(e) => {
e.preventDefault();
onDownload?.(info.row.original.id);
}}
>
<IconDownload size={16} />
<T id="action.download" />
</a> </a>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a className="dropdown-item" href="#"> <a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDelete?.(info.row.original.id);
}}
>
<IconTrash size={16} /> <IconTrash size={16} />
<T id="action.delete" /> <T id="action.delete" />
</a> </a>
@@ -97,7 +147,7 @@ export default function Table({ data, isFetching }: Props) {
}, },
}), }),
], ],
[columnHelper], [columnHelper, onDelete, onRenew, onDownload],
); );
const tableInstance = useReactTable<Certificate>({ const tableInstance = useReactTable<Certificate>({
@@ -160,8 +210,7 @@ export default function Table({ data, isFetching }: Props) {
object="certificate" object="certificate"
objects="certificates" objects="certificates"
tableInstance={tableInstance} tableInstance={tableInstance}
// onNew={onNew} isFiltered={isFiltered}
// isFiltered={isFiltered}
color="pink" color="pink"
customAddBtn={customAddBtn} customAddBtn={customAddBtn}
/> />

View File

@@ -1,12 +1,22 @@
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { deleteCertificate, downloadCertificate } from "src/api/backend";
import { LoadingPage } from "src/components"; import { LoadingPage } from "src/components";
import { useCertificates } from "src/hooks"; import { useCertificates } from "src/hooks";
import { T } from "src/locale"; import { T } from "src/locale";
import { showCustomCertificateModal, showDNSCertificateModal, showHTTPCertificateModal } from "src/modals"; import {
showCustomCertificateModal,
showDeleteConfirmModal,
showDNSCertificateModal,
showHTTPCertificateModal,
showRenewCertificateModal,
} from "src/modals";
import { showError, showObjectSuccess } from "src/notifications";
import Table from "./Table"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
const [search, setSearch] = useState("");
const { isFetching, isLoading, isError, error, data } = useCertificates([ const { isFetching, isLoading, isError, error, data } = useCertificates([
"owner", "owner",
"dead_hosts", "dead_hosts",
@@ -22,6 +32,31 @@ export default function TableWrapper() {
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>; return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
} }
const handleDelete = async (id: number) => {
await deleteCertificate(id);
showObjectSuccess("certificate", "deleted");
};
const handleDownload = async (id: number) => {
try {
await downloadCertificate(id);
} catch (err: any) {
showError(err.message);
}
};
let filtered = null;
if (search && data) {
filtered = data?.filter(
(item) =>
item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) ||
item.niceName.toLowerCase().includes(search),
);
} else if (search !== "") {
// this can happen if someone deletes the last item while searching
setSearch("");
}
return ( return (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-status-top bg-pink" /> <div className="card-status-top bg-pink" />
@@ -33,6 +68,7 @@ export default function TableWrapper() {
<T id="certificates" /> <T id="certificates" />
</h2> </h2>
</div> </div>
{data?.length ? (
<div className="col-md-auto col-sm-12"> <div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list"> <div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto"> <div className="input-group input-group-flat w-auto">
@@ -44,6 +80,7 @@ export default function TableWrapper() {
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
autoComplete="off" autoComplete="off"
onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
/> />
</div> </div>
<div className="dropdown"> <div className="dropdown">
@@ -90,9 +127,24 @@ export default function TableWrapper() {
</div> </div>
</div> </div>
</div> </div>
) : null}
</div> </div>
</div> </div>
<Table data={data ?? []} isFetching={isFetching} /> <Table
data={filtered ?? data ?? []}
isFiltered={!!search}
isFetching={isFetching}
onRenew={showRenewCertificateModal}
onDownload={handleDownload}
onDelete={(id: number) =>
showDeleteConfirmModal({
title: <T id="object.delete" tData={{ object: "certificate" }} />,
onConfirm: () => handleDelete(id),
invalidations: [["certificates"], ["certificate", id]],
children: <T id="object.delete.content" tData={{ object: "certificate" }} />,
})
}
/>
</div> </div>
</div> </div>
); );

View File

@@ -89,10 +89,10 @@ export default function TableWrapper() {
onEdit={(id: number) => showProxyHostModal(id)} onEdit={(id: number) => showProxyHostModal(id)}
onDelete={(id: number) => onDelete={(id: number) =>
showDeleteConfirmModal({ showDeleteConfirmModal({
title: "proxy-host.delete.title", title: <T id="object.delete" tData={{ object: "proxy-host" }} />,
onConfirm: () => handleDelete(id), onConfirm: () => handleDelete(id),
invalidations: [["proxy-hosts"], ["proxy-host", id]], invalidations: [["proxy-hosts"], ["proxy-host", id]],
children: <T id="proxy-host.delete.content" />, children: <T id="object.delete.content" tData={{ object: "proxy-host" }} />,
}) })
} }
onDisableToggle={handleDisableToggle} onDisableToggle={handleDisableToggle}

View File

@@ -203,59 +203,59 @@
"@babel/helper-string-parser" "^7.27.1" "@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1"
"@biomejs/biome@^2.3.0": "@biomejs/biome@^2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.3.0.tgz#80030b68d94bd0a0761ac2cd22cc4f2c0f23f4f9" resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.3.1.tgz#d1a9284f52986324f288cdaf450331a0f3fb1da7"
integrity sha512-shdUY5H3S3tJVUWoVWo5ua+GdPW5lRHf+b0IwZ4OC1o2zOKQECZ6l2KbU6t89FNhtd3Qx5eg5N7/UsQWGQbAFw== integrity sha512-A29evf1R72V5bo4o2EPxYMm5mtyGvzp2g+biZvRFx29nWebGyyeOSsDWGx3tuNNMFRepGwxmA9ZQ15mzfabK2w==
optionalDependencies: optionalDependencies:
"@biomejs/cli-darwin-arm64" "2.3.0" "@biomejs/cli-darwin-arm64" "2.3.1"
"@biomejs/cli-darwin-x64" "2.3.0" "@biomejs/cli-darwin-x64" "2.3.1"
"@biomejs/cli-linux-arm64" "2.3.0" "@biomejs/cli-linux-arm64" "2.3.1"
"@biomejs/cli-linux-arm64-musl" "2.3.0" "@biomejs/cli-linux-arm64-musl" "2.3.1"
"@biomejs/cli-linux-x64" "2.3.0" "@biomejs/cli-linux-x64" "2.3.1"
"@biomejs/cli-linux-x64-musl" "2.3.0" "@biomejs/cli-linux-x64-musl" "2.3.1"
"@biomejs/cli-win32-arm64" "2.3.0" "@biomejs/cli-win32-arm64" "2.3.1"
"@biomejs/cli-win32-x64" "2.3.0" "@biomejs/cli-win32-x64" "2.3.1"
"@biomejs/cli-darwin-arm64@2.3.0": "@biomejs/cli-darwin-arm64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.0.tgz#78cef4d7415adbf0718c7854e7160e181d916652" resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.1.tgz#607835f8ef043e1a80f9ad2a232c9e860941ab60"
integrity sha512-3cJVT0Z5pbTkoBmbjmDZTDFYxIkRcrs9sYVJbIBHU8E6qQxgXAaBfSVjjCreG56rfDuQBr43GzwzmaHPcu4vlw== integrity sha512-ombSf3MnTUueiYGN1SeI9tBCsDUhpWzOwS63Dove42osNh0PfE1cUtHFx6eZ1+MYCCLwXzlFlYFdrJ+U7h6LcA==
"@biomejs/cli-darwin-x64@2.3.0": "@biomejs/cli-darwin-x64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.0.tgz#068baf1f0f748c01658ba9bb511d8d18461d922b" resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.1.tgz#654fe4aaa8ea5d5bde5457db4961ad5d214713ac"
integrity sha512-6LIkhglh3UGjuDqJXsK42qCA0XkD1Ke4K/raFOii7QQPbM8Pia7Qj2Hji4XuF2/R78hRmEx7uKJH3t/Y9UahtQ== integrity sha512-pcOfwyoQkrkbGvXxRvZNe5qgD797IowpJPovPX5biPk2FwMEV+INZqfCaz4G5bVq9hYnjwhRMamg11U4QsRXrQ==
"@biomejs/cli-linux-arm64-musl@2.3.0": "@biomejs/cli-linux-arm64-musl@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.0.tgz#9a1350184abcea8092957a9519098cac7629705a" resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.1.tgz#5fe502082a575c31ef808cf080cbcd4485964167"
integrity sha512-nDksoFdwZ2YrE7NiYDhtMhL2UgFn8Kb7Y0bYvnTAakHnqEdb4lKindtBc1f+xg2Snz0JQhJUYO7r9CDBosRU5w== integrity sha512-+DZYv8l7FlUtTrWs1Tdt1KcNCAmRO87PyOnxKGunbWm5HKg1oZBSbIIPkjrCtDZaeqSG1DiGx7qF+CPsquQRcg==
"@biomejs/cli-linux-arm64@2.3.0": "@biomejs/cli-linux-arm64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.0.tgz#f322daebb32fe0b18f7981c8cdbe84a06852bfee" resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.1.tgz#81c02547905d379dbb312e6ff24b04908c2e320f"
integrity sha512-uhAsbXySX7xsXahegDg5h3CDgfMcRsJvWLFPG0pjkylgBb9lErbK2C0UINW52zhwg0cPISB09lxHPxCau4e2xA== integrity sha512-td5O8pFIgLs8H1sAZsD6v+5quODihyEw4nv2R8z7swUfIK1FKk+15e4eiYVLcAE4jUqngvh4j3JCNgg0Y4o4IQ==
"@biomejs/cli-linux-x64-musl@2.3.0": "@biomejs/cli-linux-x64-musl@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.0.tgz#ce441d5c00eda977b74e4116f9723f2edc579485" resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.1.tgz#c7c00beb5eda1ad25185544897e66eeec6be3b0b"
integrity sha512-+i9UcJwl99uAhtRQDz9jUAh+Xkb097eekxs/D9j4deWDg5/yB/jPWzISe1nBHvlzTXsdUSj0VvB4Go2DSpKIMw== integrity sha512-Y3Ob4nqgv38Mh+6EGHltuN+Cq8aj/gyMTJYzkFZV2AEj+9XzoXB9VNljz9pjfFNHUxvLEV4b55VWyxozQTBaUQ==
"@biomejs/cli-linux-x64@2.3.0": "@biomejs/cli-linux-x64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.0.tgz#da7ea904307b3211df62a4b42e5a022f8f583009" resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.1.tgz#7481d2e7be98d4de574df233766a5bdda037c897"
integrity sha512-uxa8reA2s1VgoH8MhbGlCmMOt3JuSE1vJBifkh1ulaPiuk0SPx8cCdpnm9NWnTe2x/LfWInWx4sZ7muaXTPGGw== integrity sha512-PYWgEO7up7XYwSAArOpzsVCiqxBCXy53gsReAb1kKYIyXaoAlhBaBMvxR/k2Rm9aTuZ662locXUmPk/Aj+Xu+Q==
"@biomejs/cli-win32-arm64@2.3.0": "@biomejs/cli-win32-arm64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.0.tgz#cdc0f8bbf025fb28c5b03b326128cce363ecffa5" resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.1.tgz#dac8c7c7223e97f86cd0eed7aa95584984761481"
integrity sha512-ynjmsJLIKrAjC3CCnKMMhzcnNy8dbQWjKfSU5YA0mIruTxBNMbkAJp+Pr2iV7/hFou+66ZSD/WV8hmLEmhUaXA== integrity sha512-RHIG/zgo+69idUqVvV3n8+j58dKYABRpMyDmfWu2TITC+jwGPiEaT0Q3RKD+kQHiS80mpBrST0iUGeEXT0bU9A==
"@biomejs/cli-win32-x64@2.3.0": "@biomejs/cli-win32-x64@2.3.1":
version "2.3.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.0.tgz#10e1de6222e272a1e3e395b3d845ee66cb6febd8" resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.1.tgz#f8818ab2c1e3a6e2ed8a656935173e5ce4c720be"
integrity sha512-zOCYmCRVkWXc9v8P7OLbLlGGMxQTKMvi+5IC4v7O8DkjLCOHRzRVK/Lno2pGZNo0lzKM60pcQOhH8HVkXMQdFg== integrity sha512-izl30JJ5Dp10mi90Eko47zhxE6pYyWPcnX1NQxKpL/yMhXxf95oLTzfpu4q+MDBh/gemNqyJEwjBpe0MT5iWPA==
"@emotion/babel-plugin@^11.13.5": "@emotion/babel-plugin@^11.13.5":
version "11.13.5" version "11.13.5"