mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2025-10-04 03:40:10 +00:00
404 hosts add update complete, fix certbot renewals
and remove the need for email and agreement on cert requests
This commit is contained in:
@@ -13,6 +13,7 @@ import utils from "../lib/utils.js";
|
||||
import { ssl as logger } from "../logger.js";
|
||||
import certificateModel from "../models/certificate.js";
|
||||
import tokenModel from "../models/token.js";
|
||||
import userModel from "../models/user.js";
|
||||
import internalAuditLog from "./audit-log.js";
|
||||
import internalHost from "./host.js";
|
||||
import internalNginx from "./nginx.js";
|
||||
@@ -81,7 +82,7 @@ const internalCertificate = {
|
||||
Promise.resolve({
|
||||
permission_visibility: "all",
|
||||
}),
|
||||
token: new tokenModel(),
|
||||
token: tokenModel(),
|
||||
},
|
||||
{ id: certificate.id },
|
||||
)
|
||||
@@ -118,10 +119,7 @@ const internalCertificate = {
|
||||
data.nice_name = data.domain_names.join(", ");
|
||||
}
|
||||
|
||||
const certificate = await certificateModel
|
||||
.query()
|
||||
.insertAndFetch(data)
|
||||
.then(utils.omitRow(omissions()));
|
||||
const certificate = await certificateModel.query().insertAndFetch(data).then(utils.omitRow(omissions()));
|
||||
|
||||
if (certificate.provider === "letsencrypt") {
|
||||
// Request a new Cert from LE. Let the fun begin.
|
||||
@@ -139,12 +137,19 @@ const internalCertificate = {
|
||||
// 2. Disable them in nginx temporarily
|
||||
await internalCertificate.disableInUseHosts(inUseResult);
|
||||
|
||||
const user = await userModel.query().where("is_deleted", 0).andWhere("id", data.owner_user_id).first();
|
||||
if (!user || !user.email) {
|
||||
throw new error.ValidationError(
|
||||
"A valid email address must be set on your user account to use Let's Encrypt",
|
||||
);
|
||||
}
|
||||
|
||||
// With DNS challenge no config is needed, so skip 3 and 5.
|
||||
if (certificate.meta?.dns_challenge) {
|
||||
try {
|
||||
await internalNginx.reload();
|
||||
// 4. Request cert
|
||||
await internalCertificate.requestLetsEncryptSslWithDnsChallenge(certificate);
|
||||
await internalCertificate.requestLetsEncryptSslWithDnsChallenge(certificate, user.email);
|
||||
await internalNginx.reload();
|
||||
// 6. Re-instate previously disabled hosts
|
||||
await internalCertificate.enableInUseHosts(inUseResult);
|
||||
@@ -159,9 +164,9 @@ const internalCertificate = {
|
||||
try {
|
||||
await internalNginx.generateLetsEncryptRequestConfig(certificate);
|
||||
await internalNginx.reload();
|
||||
setTimeout(() => {}, 5000)
|
||||
setTimeout(() => {}, 5000);
|
||||
// 4. Request cert
|
||||
await internalCertificate.requestLetsEncryptSsl(certificate);
|
||||
await internalCertificate.requestLetsEncryptSsl(certificate, user.email);
|
||||
// 5. Remove LE config
|
||||
await internalNginx.deleteLetsEncryptRequestConfig(certificate);
|
||||
await internalNginx.reload();
|
||||
@@ -204,8 +209,7 @@ const internalCertificate = {
|
||||
data.meta = _.assign({}, data.meta || {}, certificate.meta);
|
||||
|
||||
// Add to audit log
|
||||
await internalAuditLog
|
||||
.add(access, {
|
||||
await internalAuditLog.add(access, {
|
||||
action: "created",
|
||||
object_type: "certificate",
|
||||
object_id: certificate.id,
|
||||
@@ -248,8 +252,7 @@ const internalCertificate = {
|
||||
}
|
||||
|
||||
// Add to audit log
|
||||
await internalAuditLog
|
||||
.add(access, {
|
||||
await internalAuditLog.add(access, {
|
||||
action: "updated",
|
||||
object_type: "certificate",
|
||||
object_id: row.id,
|
||||
@@ -268,7 +271,7 @@ const internalCertificate = {
|
||||
* @return {Promise}
|
||||
*/
|
||||
get: async (access, data) => {
|
||||
const accessData = await access.can("certificates:get", data.id)
|
||||
const accessData = await access.can("certificates:get", data.id);
|
||||
const query = certificateModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
@@ -367,10 +370,7 @@ const internalCertificate = {
|
||||
throw new error.ItemNotFoundError(data.id);
|
||||
}
|
||||
|
||||
await certificateModel
|
||||
.query()
|
||||
.where("id", row.id)
|
||||
.patch({
|
||||
await certificateModel.query().where("id", row.id).patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
|
||||
@@ -435,10 +435,7 @@ const internalCertificate = {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getCount: async (userId, visibility) => {
|
||||
const query = certificateModel
|
||||
.query()
|
||||
.count("id as count")
|
||||
.where("is_deleted", 0);
|
||||
const query = certificateModel.query().count("id as count").where("is_deleted", 0);
|
||||
|
||||
if (visibility !== "all") {
|
||||
query.andWhere("owner_user_id", userId);
|
||||
@@ -501,12 +498,10 @@ const internalCertificate = {
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
* @param {Array} data.domain_names
|
||||
* @param {String} data.meta.letsencrypt_email
|
||||
* @param {Boolean} data.meta.letsencrypt_agree
|
||||
* @returns {Promise}
|
||||
*/
|
||||
createQuickCertificate: async (access, data) => {
|
||||
return internalCertificate.create(access, {
|
||||
return await internalCertificate.create(access, {
|
||||
provider: "letsencrypt",
|
||||
domain_names: data.domain_names,
|
||||
meta: data.meta,
|
||||
@@ -652,7 +647,7 @@ const internalCertificate = {
|
||||
const certData = {};
|
||||
|
||||
try {
|
||||
const result = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-subject", "-noout"])
|
||||
const result = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-subject", "-noout"]);
|
||||
// Examples:
|
||||
// subject=CN = *.jc21.com
|
||||
// subject=CN = something.example.com
|
||||
@@ -739,9 +734,10 @@ const internalCertificate = {
|
||||
/**
|
||||
* Request a certificate using the http challenge
|
||||
* @param {Object} certificate the certificate row
|
||||
* @param {String} email the email address to use for registration
|
||||
* @returns {Promise}
|
||||
*/
|
||||
requestLetsEncryptSsl: async (certificate) => {
|
||||
requestLetsEncryptSsl: async (certificate, email) => {
|
||||
logger.info(
|
||||
`Requesting LetsEncrypt certificates for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
|
||||
);
|
||||
@@ -760,7 +756,7 @@ const internalCertificate = {
|
||||
"--authenticator",
|
||||
"webroot",
|
||||
"--email",
|
||||
certificate.meta.letsencrypt_email,
|
||||
email,
|
||||
"--preferred-challenges",
|
||||
"dns,http",
|
||||
"--domains",
|
||||
@@ -779,9 +775,10 @@ const internalCertificate = {
|
||||
|
||||
/**
|
||||
* @param {Object} certificate the certificate row
|
||||
* @param {String} email the email address to use for registration
|
||||
* @returns {Promise}
|
||||
*/
|
||||
requestLetsEncryptSslWithDnsChallenge: async (certificate) => {
|
||||
requestLetsEncryptSslWithDnsChallenge: async (certificate, email) => {
|
||||
await installPlugin(certificate.meta.dns_provider);
|
||||
const dnsPlugin = dnsPlugins[certificate.meta.dns_provider];
|
||||
logger.info(
|
||||
@@ -807,7 +804,7 @@ const internalCertificate = {
|
||||
`npm-${certificate.id}`,
|
||||
"--agree-tos",
|
||||
"--email",
|
||||
certificate.meta.letsencrypt_email,
|
||||
email,
|
||||
"--domains",
|
||||
certificate.domain_names.join(","),
|
||||
"--authenticator",
|
||||
@@ -847,7 +844,7 @@ const internalCertificate = {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
renew: async (access, data) => {
|
||||
await access.can("certificates:update", data)
|
||||
await access.can("certificates:update", data);
|
||||
const certificate = await internalCertificate.get(access, data);
|
||||
|
||||
if (certificate.provider === "letsencrypt") {
|
||||
@@ -860,9 +857,7 @@ const internalCertificate = {
|
||||
`${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`,
|
||||
);
|
||||
|
||||
const updatedCertificate = await certificateModel
|
||||
.query()
|
||||
.patchAndFetchById(certificate.id, {
|
||||
const updatedCertificate = await certificateModel.query().patchAndFetchById(certificate.id, {
|
||||
expires_on: moment(certInfo.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
|
||||
});
|
||||
|
||||
@@ -1159,7 +1154,9 @@ const internalCertificate = {
|
||||
return "no-host";
|
||||
}
|
||||
// Other errors
|
||||
logger.info(`HTTP challenge test failed for domain ${domain} because code ${result.responsecode} was returned`);
|
||||
logger.info(
|
||||
`HTTP challenge test failed for domain ${domain} because code ${result.responsecode} was returned`,
|
||||
);
|
||||
return `other:${result.responsecode}`;
|
||||
}
|
||||
|
||||
@@ -1201,7 +1198,7 @@ const internalCertificate = {
|
||||
|
||||
getLiveCertPath: (certificateId) => {
|
||||
return `/etc/letsencrypt/live/npm-${certificateId}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default internalCertificate;
|
||||
|
@@ -54,10 +54,21 @@ const internalDeadHost = {
|
||||
thisData.advanced_config = "";
|
||||
}
|
||||
|
||||
const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
|
||||
const row = await deadHostModel.query()
|
||||
.insertAndFetch(thisData)
|
||||
.then(utils.omitRow(omissions()));
|
||||
|
||||
// Add to audit log
|
||||
await internalAuditLog.add(access, {
|
||||
action: "created",
|
||||
object_type: "dead-host",
|
||||
object_id: row.id,
|
||||
meta: _.assign({}, data.meta || {}, row.meta),
|
||||
});
|
||||
|
||||
if (createCertificate) {
|
||||
const cert = await internalCertificate.createQuickCertificate(access, data);
|
||||
|
||||
// update host with cert id
|
||||
await internalDeadHost.update(access, {
|
||||
id: row.id,
|
||||
@@ -71,17 +82,13 @@ const internalDeadHost = {
|
||||
expand: ["certificate", "owner"],
|
||||
});
|
||||
|
||||
// Sanity check
|
||||
if (createCertificate && !freshRow.certificate_id) {
|
||||
throw new errs.InternalValidationError("The host was created but the Certificate creation failed.");
|
||||
}
|
||||
|
||||
// Configure nginx
|
||||
await internalNginx.configure(deadHostModel, "dead_host", freshRow);
|
||||
data.meta = _.assign({}, data.meta || {}, freshRow.meta);
|
||||
|
||||
// Add to audit log
|
||||
await internalAuditLog.add(access, {
|
||||
action: "created",
|
||||
object_type: "dead-host",
|
||||
object_id: freshRow.id,
|
||||
meta: data,
|
||||
});
|
||||
|
||||
return freshRow;
|
||||
},
|
||||
@@ -94,7 +101,6 @@ const internalDeadHost = {
|
||||
*/
|
||||
update: async (access, data) => {
|
||||
const createCertificate = data.certificate_id === "new";
|
||||
|
||||
if (createCertificate) {
|
||||
delete data.certificate_id;
|
||||
}
|
||||
@@ -147,6 +153,13 @@ const internalDeadHost = {
|
||||
|
||||
thisData = internalHost.cleanSslHstsData(thisData, row);
|
||||
|
||||
|
||||
// do the row update
|
||||
await deadHostModel
|
||||
.query()
|
||||
.where({id: data.id})
|
||||
.patch(data);
|
||||
|
||||
// Add to audit log
|
||||
await internalAuditLog.add(access, {
|
||||
action: "updated",
|
||||
|
@@ -6,46 +6,6 @@ import utils from "./utils.js";
|
||||
|
||||
const CERTBOT_VERSION_REPLACEMENT = "$(certbot --version | grep -Eo '[0-9](\\.[0-9]+)+')";
|
||||
|
||||
/**
|
||||
* @param {array} pluginKeys
|
||||
*/
|
||||
const installPlugins = async (pluginKeys) => {
|
||||
let hasErrors = false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (pluginKeys.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
batchflow(pluginKeys)
|
||||
.sequential()
|
||||
.each((_i, pluginKey, next) => {
|
||||
certbot
|
||||
.installPlugin(pluginKey)
|
||||
.then(() => {
|
||||
next();
|
||||
})
|
||||
.catch((err) => {
|
||||
hasErrors = true;
|
||||
next(err);
|
||||
});
|
||||
})
|
||||
.error((err) => {
|
||||
logger.error(err.message);
|
||||
})
|
||||
.end(() => {
|
||||
if (hasErrors) {
|
||||
reject(
|
||||
new errs.CommandError("Some plugins failed to install. Please check the logs above", 1),
|
||||
);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs a cerbot plugin given the key for the object from
|
||||
* ../global/certbot-dns-plugins.json
|
||||
@@ -84,4 +44,43 @@ const installPlugin = async (pluginKey) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {array} pluginKeys
|
||||
*/
|
||||
const installPlugins = async (pluginKeys) => {
|
||||
let hasErrors = false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (pluginKeys.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
batchflow(pluginKeys)
|
||||
.sequential()
|
||||
.each((_i, pluginKey, next) => {
|
||||
installPlugin(pluginKey)
|
||||
.then(() => {
|
||||
next();
|
||||
})
|
||||
.catch((err) => {
|
||||
hasErrors = true;
|
||||
next(err);
|
||||
});
|
||||
})
|
||||
.error((err) => {
|
||||
logger.error(err.message);
|
||||
})
|
||||
.end(() => {
|
||||
if (hasErrors) {
|
||||
reject(
|
||||
new errs.CommandError("Some plugins failed to install. Please check the logs above", 1),
|
||||
);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export { installPlugins, installPlugin };
|
||||
|
@@ -98,6 +98,8 @@ router
|
||||
name: dnsPlugins[key].name,
|
||||
credentials: dnsPlugins[key].credentials,
|
||||
}));
|
||||
|
||||
clean.sort((a, b) => a.name.localeCompare(b.name));
|
||||
res.status(200).send(clean);
|
||||
} catch (err) {
|
||||
logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||
|
@@ -62,15 +62,9 @@
|
||||
"dns_provider_credentials": {
|
||||
"type": "string"
|
||||
},
|
||||
"letsencrypt_agree": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"letsencrypt_certificate": {
|
||||
"type": "object"
|
||||
},
|
||||
"letsencrypt_email": {
|
||||
"$ref": "../common.json#/properties/email"
|
||||
},
|
||||
"propagation_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
|
@@ -36,8 +36,6 @@
|
||||
"domain_names": ["test.example.com"],
|
||||
"expires_on": "2025-01-07T04:34:18.000Z",
|
||||
"meta": {
|
||||
"letsencrypt_email": "jc@jc21.com",
|
||||
"letsencrypt_agree": true,
|
||||
"dns_challenge": false
|
||||
}
|
||||
}
|
||||
|
@@ -37,8 +37,6 @@
|
||||
"nice_name": "My Test Cert",
|
||||
"domain_names": ["test.jc21.supernerd.pro"],
|
||||
"meta": {
|
||||
"letsencrypt_email": "jc@jc21.com",
|
||||
"letsencrypt_agree": true,
|
||||
"dns_challenge": false
|
||||
}
|
||||
}
|
||||
|
@@ -36,8 +36,6 @@
|
||||
"domain_names": ["test.example.com"],
|
||||
"expires_on": "2025-01-07T04:34:18.000Z",
|
||||
"meta": {
|
||||
"letsencrypt_email": "jc@jc21.com",
|
||||
"letsencrypt_agree": true,
|
||||
"dns_challenge": false
|
||||
}
|
||||
}
|
||||
|
@@ -52,8 +52,6 @@
|
||||
"nice_name": "test.example.com",
|
||||
"domain_names": ["test.example.com"],
|
||||
"meta": {
|
||||
"letsencrypt_email": "jc@jc21.com",
|
||||
"letsencrypt_agree": true,
|
||||
"dns_challenge": false,
|
||||
"letsencrypt_certificate": {
|
||||
"cn": "test.example.com",
|
||||
|
@@ -121,12 +121,14 @@ const setupCertbotPlugins = async () => {
|
||||
// Make sure credentials file exists
|
||||
const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
|
||||
// Escape single quotes and backslashes
|
||||
if (typeof certificate.meta.dns_provider_credentials === "string") {
|
||||
const escapedCredentials = certificate.meta.dns_provider_credentials
|
||||
.replaceAll("'", "\\'")
|
||||
.replaceAll("\\", "\\\\");
|
||||
const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`;
|
||||
promises.push(utils.exec(credentials_cmd));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
|
@@ -65,3 +65,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.textareaMono {
|
||||
font-family: 'Courier New', Courier, monospace !important;
|
||||
resize: vertical;
|
||||
}
|
||||
|
@@ -1,16 +1,8 @@
|
||||
.dnsChallengeWarning {
|
||||
border: 1px solid #fecaca; /* Tailwind's red-300 */
|
||||
border: 1px solid var(--tblr-orange-lt);
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem; /* Tailwind's rounded-md */
|
||||
border-radius: 0.375rem;
|
||||
margin-top: 1rem;
|
||||
background-color: var(--tblr-cyan-lt);
|
||||
}
|
||||
|
||||
.textareaMono {
|
||||
font-family: 'Courier New', Courier, monospace !important;
|
||||
/* background-color: #f9fafb;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
width: 100%; */
|
||||
resize: vertical;
|
||||
}
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import cn from "classnames";
|
||||
import { Field, useFormikContext } from "formik";
|
||||
import { useState } from "react";
|
||||
import Select, { type ActionMeta } from "react-select";
|
||||
@@ -20,8 +19,8 @@ export function DNSProviderFields() {
|
||||
const v: any = values || {};
|
||||
|
||||
const handleChange = (newValue: any, _actionMeta: ActionMeta<DNSProviderOption>) => {
|
||||
setFieldValue("dnsProvider", newValue?.value);
|
||||
setFieldValue("dnsProviderCredentials", newValue?.credentials);
|
||||
setFieldValue("meta.dnsProvider", newValue?.value);
|
||||
setFieldValue("meta.dnsProviderCredentials", newValue?.credentials);
|
||||
setDnsProviderId(newValue?.value);
|
||||
};
|
||||
|
||||
@@ -34,12 +33,12 @@ export function DNSProviderFields() {
|
||||
|
||||
return (
|
||||
<div className={styles.dnsChallengeWarning}>
|
||||
<p className="text-danger">
|
||||
This section requires some knowledge about Certbot and its DNS plugins. Please consult the respective
|
||||
<p className="text-info">
|
||||
This section requires some knowledge about Certbot and DNS plugins. Please consult the respective
|
||||
plugins documentation.
|
||||
</p>
|
||||
|
||||
<Field name="dnsProvider">
|
||||
<Field name="meta.dnsProvider">
|
||||
{({ field }: any) => (
|
||||
<div className="row">
|
||||
<label htmlFor="dnsProvider" className="form-label">
|
||||
@@ -64,33 +63,37 @@ export function DNSProviderFields() {
|
||||
|
||||
{dnsProviderId ? (
|
||||
<>
|
||||
<Field name="dnsProviderCredentials">
|
||||
<Field name="meta.dnsProviderCredentials">
|
||||
{({ field }: any) => (
|
||||
<div className="row mt-3">
|
||||
<div className="mt-3">
|
||||
<label htmlFor="dnsProviderCredentials" className="form-label">
|
||||
Credentials File Content
|
||||
</label>
|
||||
<textarea
|
||||
id="dnsProviderCredentials"
|
||||
className={cn("form-control", styles.textareaMono)}
|
||||
className="form-control textareaMono"
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
value={v.dnsProviderCredentials || ""}
|
||||
value={v.meta.dnsProviderCredentials || ""}
|
||||
{...field}
|
||||
/>
|
||||
<div>
|
||||
<small className="text-muted">
|
||||
This plugin requires a configuration file containing an API token or other
|
||||
credentials to your provider
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<small className="text-danger">
|
||||
This data will be stored as plaintext in the database and in a file!
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="propagationSeconds">
|
||||
<Field name="meta.propagationSeconds">
|
||||
{({ field }: any) => (
|
||||
<div className="row mt-3">
|
||||
<div className="mt-3">
|
||||
<label htmlFor="propagationSeconds" className="form-label">
|
||||
Propagation Seconds
|
||||
</label>
|
||||
|
@@ -2,6 +2,7 @@ import { Field, useFormikContext } from "formik";
|
||||
import type { ActionMeta, MultiValue } from "react-select";
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
import { intl } from "src/locale";
|
||||
import { validateDomain, validateDomains } from "src/modules/Validations";
|
||||
|
||||
export type SelectOption = {
|
||||
label: string;
|
||||
@@ -22,17 +23,10 @@ export function DomainNamesField({
|
||||
label = "domain-names",
|
||||
id = "domainNames",
|
||||
maxDomains,
|
||||
isWildcardPermitted,
|
||||
dnsProviderWildcardSupported,
|
||||
isWildcardPermitted = true,
|
||||
dnsProviderWildcardSupported = true,
|
||||
}: Props) {
|
||||
const { values, setFieldValue } = useFormikContext();
|
||||
|
||||
const getDomainCount = (v: string[] | undefined): number => {
|
||||
if (v?.length) {
|
||||
return v.length;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
const { setFieldValue } = useFormikContext();
|
||||
|
||||
const handleChange = (v: MultiValue<SelectOption>, _actionMeta: ActionMeta<SelectOption>) => {
|
||||
const doms = v?.map((i: SelectOption) => {
|
||||
@@ -41,50 +35,18 @@ export function DomainNamesField({
|
||||
setFieldValue(name, doms);
|
||||
};
|
||||
|
||||
const isDomainValid = (d: string): boolean => {
|
||||
const dom = d.trim().toLowerCase();
|
||||
const v: any = values;
|
||||
|
||||
// Deny if the list of domains is hit
|
||||
if (maxDomains && getDomainCount(v?.[name]) >= maxDomains) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dom.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent wildcards
|
||||
if ((!isWildcardPermitted || !dnsProviderWildcardSupported) && dom.indexOf("*") !== -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent duplicate * in domain
|
||||
if ((dom.match(/\*/g) || []).length > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent some invalid characters
|
||||
if ((dom.match(/(@|,|!|&|\$|#|%|\^|\(|\))/g) || []).length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// This will match *.com type domains,
|
||||
return dom.match(/\*\.[^.]+$/m) === null;
|
||||
};
|
||||
|
||||
const helperTexts: string[] = [];
|
||||
if (maxDomains) {
|
||||
helperTexts.push(intl.formatMessage({ id: "domain_names.max" }, { count: maxDomains }));
|
||||
helperTexts.push(intl.formatMessage({ id: "domain-names.max" }, { count: maxDomains }));
|
||||
}
|
||||
if (!isWildcardPermitted) {
|
||||
helperTexts.push(intl.formatMessage({ id: "wildcards-not-permitted" }));
|
||||
helperTexts.push(intl.formatMessage({ id: "domain-names.wildcards-not-permitted" }));
|
||||
} else if (!dnsProviderWildcardSupported) {
|
||||
helperTexts.push(intl.formatMessage({ id: "wildcards-not-supported" }));
|
||||
helperTexts.push(intl.formatMessage({ id: "domain-names.wildcards-not-supported" }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Field name={name}>
|
||||
<Field name={name} validate={validateDomains(isWildcardPermitted && dnsProviderWildcardSupported, maxDomains)}>
|
||||
{({ field, form }: any) => (
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor={id}>
|
||||
@@ -97,21 +59,19 @@ export function DomainNamesField({
|
||||
id={id}
|
||||
closeMenuOnSelect={true}
|
||||
isClearable={false}
|
||||
isValidNewOption={isDomainValid}
|
||||
isValidNewOption={validateDomain(isWildcardPermitted && dnsProviderWildcardSupported)}
|
||||
isMulti
|
||||
placeholder="Start typing to add domain..."
|
||||
placeholder={intl.formatMessage({ id: "domain-names.placeholder" })}
|
||||
onChange={handleChange}
|
||||
value={field.value?.map((d: string) => ({ label: d, value: d }))}
|
||||
/>
|
||||
{form.errors[field.name] ? (
|
||||
<div className="invalid-feedback">
|
||||
{form.errors[field.name] && form.touched[field.name] ? form.errors[field.name] : null}
|
||||
</div>
|
||||
{form.errors[field.name] && form.touched[field.name] ? (
|
||||
<small className="text-danger">{form.errors[field.name]}</small>
|
||||
) : helperTexts.length ? (
|
||||
helperTexts.map((i) => (
|
||||
<div key={i} className="invalid-feedback text-info">
|
||||
<small key={i} className="text-info">
|
||||
{i}
|
||||
</div>
|
||||
</small>
|
||||
))
|
||||
) : null}
|
||||
</div>
|
||||
|
40
frontend/src/components/Form/NginxConfigField.tsx
Normal file
40
frontend/src/components/Form/NginxConfigField.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import CodeEditor from "@uiw/react-textarea-code-editor";
|
||||
import { Field } from "formik";
|
||||
import { intl } from "src/locale";
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
}
|
||||
export function NginxConfigField({
|
||||
name = "advancedConfig",
|
||||
label = "nginx-config.label",
|
||||
id = "advancedConfig",
|
||||
}: Props) {
|
||||
return (
|
||||
<Field name={name}>
|
||||
{({ field }: any) => (
|
||||
<div className="mt-3">
|
||||
<label htmlFor={id} className="form-label">
|
||||
{intl.formatMessage({ id: label })}
|
||||
</label>
|
||||
<CodeEditor
|
||||
language="nginx"
|
||||
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
|
||||
padding={15}
|
||||
data-color-mode="dark"
|
||||
minHeight={200}
|
||||
indentWidth={2}
|
||||
style={{
|
||||
fontFamily: "ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
|
||||
borderRadius: "0.3rem",
|
||||
minHeight: "200px",
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
@@ -2,7 +2,7 @@ import { IconShield } from "@tabler/icons-react";
|
||||
import { Field, useFormikContext } from "formik";
|
||||
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
|
||||
import type { Certificate } from "src/api/backend";
|
||||
import { useCertificates, useUser } from "src/hooks";
|
||||
import { useCertificates } from "src/hooks";
|
||||
import { DateTimeFormat, intl } from "src/locale";
|
||||
|
||||
interface CertOption {
|
||||
@@ -39,26 +39,33 @@ export function SSLCertificateField({
|
||||
required,
|
||||
allowNew,
|
||||
}: Props) {
|
||||
const { data: currentUser } = useUser("me");
|
||||
const { isLoading, isError, error, data } = useCertificates();
|
||||
const { values, setFieldValue } = useFormikContext();
|
||||
const v: any = values || {};
|
||||
|
||||
const handleChange = (newValue: any, _actionMeta: ActionMeta<CertOption>) => {
|
||||
setFieldValue(name, newValue?.value);
|
||||
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, dnsChallenge, letsencryptEmail } = v;
|
||||
const {
|
||||
sslForced,
|
||||
http2Support,
|
||||
hstsEnabled,
|
||||
hstsSubdomains,
|
||||
dnsChallenge,
|
||||
dnsProvider,
|
||||
dnsProviderCredentials,
|
||||
propagationSeconds,
|
||||
} = v;
|
||||
if (!newValue?.value) {
|
||||
sslForced && setFieldValue("sslForced", false);
|
||||
http2Support && setFieldValue("http2Support", false);
|
||||
hstsEnabled && setFieldValue("hstsEnabled", false);
|
||||
hstsSubdomains && setFieldValue("hstsSubdomains", false);
|
||||
}
|
||||
if (newValue?.value === "new") {
|
||||
if (!letsencryptEmail) {
|
||||
setFieldValue("letsencryptEmail", currentUser?.email);
|
||||
}
|
||||
} else {
|
||||
dnsChallenge && setFieldValue("dnsChallenge", false);
|
||||
if (newValue?.value !== "new") {
|
||||
dnsChallenge && setFieldValue("dnsChallenge", undefined);
|
||||
dnsProvider && setFieldValue("dnsProvider", undefined);
|
||||
dnsProviderCredentials && setFieldValue("dnsProviderCredentials", undefined);
|
||||
propagationSeconds && setFieldValue("propagationSeconds", undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,7 +112,7 @@ export function SSLCertificateField({
|
||||
<Select
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
defaultValue={options[0]}
|
||||
defaultValue={options.find((o) => o.value === field.value) || options[0]}
|
||||
options={options}
|
||||
components={{ Option }}
|
||||
styles={{
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import cn from "classnames";
|
||||
import { Field, useFormikContext } from "formik";
|
||||
import { DNSProviderFields } from "src/components";
|
||||
import { intl } from "src/locale";
|
||||
|
||||
export function SSLOptionsFields() {
|
||||
const { values, setFieldValue } = useFormikContext();
|
||||
@@ -8,10 +9,16 @@ export function SSLOptionsFields() {
|
||||
|
||||
const newCertificate = v?.certificateId === "new";
|
||||
const hasCertificate = newCertificate || (v?.certificateId && v?.certificateId > 0);
|
||||
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, dnsChallenge } = v;
|
||||
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, meta } = v;
|
||||
const { dnsChallenge } = meta || {};
|
||||
|
||||
const handleToggleChange = (e: any, fieldName: string) => {
|
||||
setFieldValue(fieldName, e.target.checked);
|
||||
if (fieldName === "meta.dnsChallenge" && !e.target.checked) {
|
||||
setFieldValue("meta.dnsProvider", undefined);
|
||||
setFieldValue("meta.dnsProviderCredentials", undefined);
|
||||
setFieldValue("meta.propagationSeconds", undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleClasses = "form-check-input";
|
||||
@@ -31,7 +38,9 @@ export function SSLOptionsFields() {
|
||||
onChange={(e) => handleToggleChange(e, field.name)}
|
||||
disabled={!hasCertificate}
|
||||
/>
|
||||
<span className="form-check-label">Force SSL</span>
|
||||
<span className="form-check-label">
|
||||
{intl.formatMessage({ id: "domains.force-ssl" })}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
@@ -47,7 +56,9 @@ export function SSLOptionsFields() {
|
||||
onChange={(e) => handleToggleChange(e, field.name)}
|
||||
disabled={!hasCertificate}
|
||||
/>
|
||||
<span className="form-check-label">HTTP/2 Support</span>
|
||||
<span className="form-check-label">
|
||||
{intl.formatMessage({ id: "domains.http2-support" })}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
@@ -65,7 +76,9 @@ export function SSLOptionsFields() {
|
||||
onChange={(e) => handleToggleChange(e, field.name)}
|
||||
disabled={!hasCertificate || !sslForced}
|
||||
/>
|
||||
<span className="form-check-label">HSTS Enabled</span>
|
||||
<span className="form-check-label">
|
||||
{intl.formatMessage({ id: "domains.hsts-enabled" })}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
@@ -81,7 +94,9 @@ export function SSLOptionsFields() {
|
||||
onChange={(e) => handleToggleChange(e, field.name)}
|
||||
disabled={!hasCertificate || !hstsEnabled}
|
||||
/>
|
||||
<span className="form-check-label">HSTS Enabled</span>
|
||||
<span className="form-check-label">
|
||||
{intl.formatMessage({ id: "domains.hsts-subdomains" })}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
@@ -89,7 +104,7 @@ export function SSLOptionsFields() {
|
||||
</div>
|
||||
{newCertificate ? (
|
||||
<>
|
||||
<Field name="dnsChallenge">
|
||||
<Field name="meta.dnsChallenge">
|
||||
{({ field }: any) => (
|
||||
<label className="form-check form-switch mt-1">
|
||||
<input
|
||||
@@ -98,29 +113,14 @@ export function SSLOptionsFields() {
|
||||
checked={!!dnsChallenge}
|
||||
onChange={(e) => handleToggleChange(e, field.name)}
|
||||
/>
|
||||
<span className="form-check-label">Use a DNS Challenge</span>
|
||||
<span className="form-check-label">
|
||||
{intl.formatMessage({ id: "domains.use-dns" })}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{dnsChallenge ? <DNSProviderFields /> : null}
|
||||
|
||||
<Field name="letsencryptEmail">
|
||||
{({ field }: any) => (
|
||||
<div className="mt-5">
|
||||
<label htmlFor="letsencryptEmail" className="form-label">
|
||||
Email Address for Let's Encrypt
|
||||
</label>
|
||||
<input
|
||||
id="letsencryptEmail"
|
||||
type="email"
|
||||
className="form-control"
|
||||
required
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
|
@@ -1,4 +1,5 @@
|
||||
export * from "./DNSProviderFields";
|
||||
export * from "./DomainNamesField";
|
||||
export * from "./NginxConfigField";
|
||||
export * from "./SSLCertificateField";
|
||||
export * from "./SSLOptionsFields";
|
||||
|
@@ -50,10 +50,23 @@
|
||||
"dead-hosts.title": "404 Hosts",
|
||||
"disabled": "Disabled",
|
||||
"domain-names": "Domain Names",
|
||||
"domain-names.max": "{count} domain names maximum",
|
||||
"domain-names.placeholder": "Start typing to add domain...",
|
||||
"domain-names.wildcards-not-permitted": "Wildcards not permitted for this type",
|
||||
"domain-names.wildcards-not-supported": "Wildcards not supported for this CA",
|
||||
"domains.force-ssl": "Force SSL",
|
||||
"domains.hsts-enabled": "HSTS Enabled",
|
||||
"domains.hsts-subdomains": "HSTS Sub-domains",
|
||||
"domains.http2-support": "HTTP/2 Support",
|
||||
"domains.use-dns": "Use DNS Challenge",
|
||||
"email-address": "Email address",
|
||||
"empty-subtitle": "Why don't you create one?",
|
||||
"error.invalid-auth": "Invalid email or password",
|
||||
"error.invalid-domain": "Invalid domain: {domain}",
|
||||
"error.invalid-email": "Invalid email address",
|
||||
"error.max-domains": "Too many domains, max is {max}",
|
||||
"error.passwords-must-match": "Passwords must match",
|
||||
"error.required": "This is required",
|
||||
"event.created-user": "Created User",
|
||||
"event.deleted-user": "Deleted User",
|
||||
"event.updated-user": "Updated User",
|
||||
@@ -63,10 +76,13 @@
|
||||
"lets-encrypt": "Let's Encrypt",
|
||||
"loading": "Loading…",
|
||||
"login.title": "Login to your account",
|
||||
"nginx-config.label": "Custom Nginx Configuration",
|
||||
"nginx-config.placeholder": "# Enter your custom Nginx configuration here at your own risk!",
|
||||
"no-permission-error": "You do not have access to view this.",
|
||||
"notfound.action": "Take me home",
|
||||
"notfound.text": "We are sorry but the page you are looking for was not found",
|
||||
"notfound.title": "Oops… You just found an error page",
|
||||
"notification.dead-host-saved": "404 Host has been saved",
|
||||
"notification.error": "Error",
|
||||
"notification.success": "Success",
|
||||
"notification.user-deleted": "User has been deleted",
|
||||
@@ -127,7 +143,5 @@
|
||||
"user.switch-light": "Switch to Light mode",
|
||||
"users.actions-title": "User #{id}",
|
||||
"users.add": "Add User",
|
||||
"users.title": "Users",
|
||||
"wildcards-not-permitted": "Wildcards not permitted for this type",
|
||||
"wildcards-not-supported": "Wildcards not supported for this CA"
|
||||
"users.title": "Users"
|
||||
}
|
@@ -152,6 +152,33 @@
|
||||
"domain-names": {
|
||||
"defaultMessage": "Domain Names"
|
||||
},
|
||||
"domain-names.max": {
|
||||
"defaultMessage": "{count} domain names maximum"
|
||||
},
|
||||
"domain-names.placeholder": {
|
||||
"defaultMessage": "Start typing to add domain..."
|
||||
},
|
||||
"domain-names.wildcards-not-permitted": {
|
||||
"defaultMessage": "Wildcards not permitted for this type"
|
||||
},
|
||||
"domain-names.wildcards-not-supported": {
|
||||
"defaultMessage": "Wildcards not supported for this CA"
|
||||
},
|
||||
"domains.force-ssl": {
|
||||
"defaultMessage": "Force SSL"
|
||||
},
|
||||
"domains.hsts-enabled": {
|
||||
"defaultMessage": "HSTS Enabled"
|
||||
},
|
||||
"domains.hsts-subdomains": {
|
||||
"defaultMessage": "HSTS Sub-domains"
|
||||
},
|
||||
"domains.http2-support": {
|
||||
"defaultMessage": "HTTP/2 Support"
|
||||
},
|
||||
"domains.use-dns": {
|
||||
"defaultMessage": "Use DNS Challenge"
|
||||
},
|
||||
"email-address": {
|
||||
"defaultMessage": "Email address"
|
||||
},
|
||||
@@ -161,6 +188,18 @@
|
||||
"error.invalid-auth": {
|
||||
"defaultMessage": "Invalid email or password"
|
||||
},
|
||||
"error.invalid-domain": {
|
||||
"defaultMessage": "Invalid domain: {domain}"
|
||||
},
|
||||
"error.invalid-email": {
|
||||
"defaultMessage": "Invalid email address"
|
||||
},
|
||||
"error.max-domains": {
|
||||
"defaultMessage": "Too many domains, max is {max}"
|
||||
},
|
||||
"error.required": {
|
||||
"defaultMessage": "This is required"
|
||||
},
|
||||
"event.created-user": {
|
||||
"defaultMessage": "Created User"
|
||||
},
|
||||
@@ -191,6 +230,12 @@
|
||||
"login.title": {
|
||||
"defaultMessage": "Login to your account"
|
||||
},
|
||||
"nginx-config.label": {
|
||||
"defaultMessage": "Custom Nginx Configuration"
|
||||
},
|
||||
"nginx-config.placeholder": {
|
||||
"defaultMessage": "# Enter your custom Nginx configuration here at your own risk!"
|
||||
},
|
||||
"no-permission-error": {
|
||||
"defaultMessage": "You do not have access to view this."
|
||||
},
|
||||
@@ -203,6 +248,9 @@
|
||||
"notfound.title": {
|
||||
"defaultMessage": "Oops… You just found an error page"
|
||||
},
|
||||
"notification.dead-host-saved": {
|
||||
"defaultMessage": "404 Host has been saved"
|
||||
},
|
||||
"notification.error": {
|
||||
"defaultMessage": "Error"
|
||||
},
|
||||
@@ -385,11 +433,5 @@
|
||||
},
|
||||
"users.title": {
|
||||
"defaultMessage": "Users"
|
||||
},
|
||||
"wildcards-not-permitted": {
|
||||
"defaultMessage": "Wildcards not permitted for this type"
|
||||
},
|
||||
"wildcards-not-supported": {
|
||||
"defaultMessage": "Wildcards not supported for this CA"
|
||||
}
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ interface Props {
|
||||
}
|
||||
export function ChangePasswordModal({ userId, onClose }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
if (values.new !== values.confirm) {
|
||||
@@ -20,13 +21,18 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await updateAuth(userId, values.new, values.current);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(intl.formatMessage({ id: err.message }));
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
@@ -42,7 +48,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
{() => (
|
||||
<Form>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{intl.formatMessage({ id: "user.change-password" })}</Modal.Title>
|
||||
|
@@ -3,9 +3,17 @@ import { Form, Formik } from "formik";
|
||||
import { useState } from "react";
|
||||
import { Alert } from "react-bootstrap";
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
import { Button, DomainNamesField, Loading, SSLCertificateField, SSLOptionsFields } from "src/components";
|
||||
import { useDeadHost } from "src/hooks";
|
||||
import {
|
||||
Button,
|
||||
DomainNamesField,
|
||||
Loading,
|
||||
NginxConfigField,
|
||||
SSLCertificateField,
|
||||
SSLOptionsFields,
|
||||
} from "src/components";
|
||||
import { useDeadHost, useSetDeadHost } from "src/hooks";
|
||||
import { intl } from "src/locale";
|
||||
import { showSuccess } from "src/notifications";
|
||||
|
||||
interface Props {
|
||||
id: number | "new";
|
||||
@@ -13,28 +21,31 @@ interface Props {
|
||||
}
|
||||
export function DeadHostModal({ id, onClose }: Props) {
|
||||
const { data, isLoading, error } = useDeadHost(id);
|
||||
// const { mutate: setDeadHost } = useSetDeadHost();
|
||||
const { mutate: setDeadHost } = useSetDeadHost();
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
setSubmitting(true);
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg(null);
|
||||
console.log("SUBMIT:", values);
|
||||
setSubmitting(false);
|
||||
// const { ...payload } = {
|
||||
// id: id === "new" ? undefined : id,
|
||||
// roles: [],
|
||||
// ...values,
|
||||
// };
|
||||
|
||||
// setDeadHost(payload, {
|
||||
// onError: (err: any) => setErrorMsg(err.message),
|
||||
// onSuccess: () => {
|
||||
// showSuccess(intl.formatMessage({ id: "notification.dead-host-saved" }));
|
||||
// onClose();
|
||||
// },
|
||||
// onSettled: () => setSubmitting(false),
|
||||
// });
|
||||
const { ...payload } = {
|
||||
id: id === "new" ? undefined : id,
|
||||
...values,
|
||||
};
|
||||
|
||||
setDeadHost(payload, {
|
||||
onError: (err: any) => setErrorMsg(err.message),
|
||||
onSuccess: () => {
|
||||
showSuccess(intl.formatMessage({ id: "notification.dead-host-saved" }));
|
||||
onClose();
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
setSubmitting(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -56,11 +67,12 @@ export function DeadHostModal({ id, onClose }: Props) {
|
||||
http2Support: data?.http2Support,
|
||||
hstsEnabled: data?.hstsEnabled,
|
||||
hstsSubdomains: data?.hstsSubdomains,
|
||||
meta: data?.meta || {},
|
||||
} as any
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
{() => (
|
||||
<Form>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
@@ -127,140 +139,11 @@ export function DeadHostModal({ id, onClose }: Props) {
|
||||
<SSLOptionsFields />
|
||||
</div>
|
||||
<div className="tab-pane" id="tab-advanced" role="tabpanel">
|
||||
<h4>Advanced</h4>
|
||||
<NginxConfigField />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="row">
|
||||
<div className="col-lg-6">
|
||||
<div className="mb-3">
|
||||
<Field name="name" validate={validateString(1, 50)}>
|
||||
{({ field, form }: any) => (
|
||||
<div className="form-floating mb-3">
|
||||
<input
|
||||
id="name"
|
||||
className={`form-control ${form.errors.name && form.touched.name ? "is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "user.full-name" })}
|
||||
{...field}
|
||||
/>
|
||||
<label htmlFor="name">
|
||||
{intl.formatMessage({ id: "user.full-name" })}
|
||||
</label>
|
||||
{form.errors.name ? (
|
||||
<div className="invalid-feedback">
|
||||
{form.errors.name && form.touched.name
|
||||
? form.errors.name
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
<div className="mb-3">
|
||||
<Field name="nickname" validate={validateString(1, 30)}>
|
||||
{({ field, form }: any) => (
|
||||
<div className="form-floating mb-3">
|
||||
<input
|
||||
id="nickname"
|
||||
className={`form-control ${form.errors.nickname && form.touched.nickname ? "is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "user.nickname" })}
|
||||
{...field}
|
||||
/>
|
||||
<label htmlFor="nickname">
|
||||
{intl.formatMessage({ id: "user.nickname" })}
|
||||
</label>
|
||||
{form.errors.nickname ? (
|
||||
<div className="invalid-feedback">
|
||||
{form.errors.nickname && form.touched.nickname
|
||||
? form.errors.nickname
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<Field name="email" validate={validateEmail()}>
|
||||
{({ field, form }: any) => (
|
||||
<div className="form-floating mb-3">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className={`form-control ${form.errors.email && form.touched.email ? "is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "email-address" })}
|
||||
{...field}
|
||||
/>
|
||||
<label htmlFor="email">
|
||||
{intl.formatMessage({ id: "email-address" })}
|
||||
</label>
|
||||
{form.errors.email ? (
|
||||
<div className="invalid-feedback">
|
||||
{form.errors.email && form.touched.email
|
||||
? form.errors.email
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
{currentUser && data && currentUser?.id !== data?.id ? (
|
||||
<div className="my-3">
|
||||
<h3 className="py-2">{intl.formatMessage({ id: "user.flags.title" })}</h3>
|
||||
<div className="divide-y">
|
||||
<div>
|
||||
<label className="row" htmlFor="isAdmin">
|
||||
<span className="col">
|
||||
{intl.formatMessage({ id: "role.admin" })}
|
||||
</span>
|
||||
<span className="col-auto">
|
||||
<Field name="isAdmin" type="checkbox">
|
||||
{({ field }: any) => (
|
||||
<label className="form-check form-check-single form-switch">
|
||||
<input
|
||||
{...field}
|
||||
id="isAdmin"
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="row" htmlFor="isDisabled">
|
||||
<span className="col">
|
||||
{intl.formatMessage({ id: "disabled" })}
|
||||
</span>
|
||||
<span className="col-auto">
|
||||
<Field name="isDisabled" type="checkbox">
|
||||
{({ field }: any) => (
|
||||
<label className="form-check form-check-single form-switch">
|
||||
<input
|
||||
{...field}
|
||||
id="isDisabled"
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null} */}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
|
||||
|
@@ -15,10 +15,11 @@ interface Props {
|
||||
export function DeleteConfirmModal({ title, children, onConfirm, onClose, invalidations }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onConfirm();
|
||||
@@ -30,7 +31,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
|
||||
} catch (err: any) {
|
||||
setError(intl.formatMessage({ id: err.message }));
|
||||
}
|
||||
setSubmitting(false);
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -45,7 +46,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
|
||||
{children}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button data-bs-dismiss="modal" onClick={onClose} disabled={submitting}>
|
||||
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
|
||||
{intl.formatMessage({ id: "cancel" })}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -53,8 +54,8 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
|
||||
actionType="primary"
|
||||
className="ms-auto btn-red"
|
||||
data-bs-dismiss="modal"
|
||||
isLoading={submitting}
|
||||
disabled={submitting}
|
||||
isLoading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{intl.formatMessage({ id: "action.delete" })}
|
||||
|
@@ -17,8 +17,11 @@ export function PermissionsModal({ userId, onClose }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const { data, isLoading, error } = useUser(userId);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
await setPermissions(userId, values);
|
||||
@@ -29,6 +32,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
|
||||
setErrorMsg(intl.formatMessage({ id: err.message }));
|
||||
}
|
||||
setSubmitting(false);
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const getPermissionButtons = (field: any, form: any) => {
|
||||
@@ -104,7 +108,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
{() => (
|
||||
<Form>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
|
@@ -15,8 +15,10 @@ interface Props {
|
||||
export function SetPasswordModal({ userId, onClose }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
if (isSubmitting) return;
|
||||
setError(null);
|
||||
try {
|
||||
await updateAuth(userId, values.new);
|
||||
@@ -24,6 +26,7 @@ export function SetPasswordModal({ userId, onClose }: Props) {
|
||||
} catch (err: any) {
|
||||
setError(intl.formatMessage({ id: err.message }));
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
@@ -37,7 +40,7 @@ export function SetPasswordModal({ userId, onClose }: Props) {
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
{() => (
|
||||
<Form>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{intl.formatMessage({ id: "user.set-password" })}</Modal.Title>
|
||||
|
@@ -17,9 +17,13 @@ export function UserModal({ userId, onClose }: Props) {
|
||||
const { data: currentUser, isLoading: currentIsLoading } = useUser("me");
|
||||
const { mutate: setUser } = useSetUser();
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg(null);
|
||||
|
||||
const { ...payload } = {
|
||||
id: userId === "new" ? undefined : userId,
|
||||
roles: [],
|
||||
@@ -43,7 +47,10 @@ export function UserModal({ userId, onClose }: Props) {
|
||||
showSuccess(intl.formatMessage({ id: "notification.user-saved" }));
|
||||
onClose();
|
||||
},
|
||||
onSettled: () => setSubmitting(false),
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
setSubmitting(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -68,7 +75,7 @@ export function UserModal({ userId, onClose }: Props) {
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
{() => (
|
||||
<Form>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import { intl } from "src/locale";
|
||||
|
||||
const validateString = (minLength = 0, maxLength = 0) => {
|
||||
if (minLength <= 0 && maxLength <= 0) {
|
||||
// this doesn't require translation
|
||||
@@ -6,12 +8,14 @@ const validateString = (minLength = 0, maxLength = 0) => {
|
||||
|
||||
return (value: string): string | undefined => {
|
||||
if (minLength && (typeof value === "undefined" || !value.length)) {
|
||||
return "This is required";
|
||||
return intl.formatMessage({ id: "error.required" });
|
||||
}
|
||||
if (minLength && value.length < minLength) {
|
||||
// TODO: i18n
|
||||
return `Minimum length is ${minLength} character${minLength === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (maxLength && (typeof value === "undefined" || value.length > maxLength)) {
|
||||
// TODO: i18n
|
||||
return `Maximum length is ${maxLength} character${maxLength === 1 ? "" : "s"}`;
|
||||
}
|
||||
};
|
||||
@@ -26,12 +30,14 @@ const validateNumber = (min = -1, max = -1) => {
|
||||
return (value: string): string | undefined => {
|
||||
const int: number = +value;
|
||||
if (min > -1 && !int) {
|
||||
return "This is required";
|
||||
return intl.formatMessage({ id: "error.required" });
|
||||
}
|
||||
if (min > -1 && int < min) {
|
||||
// TODO: i18n
|
||||
return `Minimum is ${min}`;
|
||||
}
|
||||
if (max > -1 && int > max) {
|
||||
// TODO: i18n
|
||||
return `Maximum is ${max}`;
|
||||
}
|
||||
};
|
||||
@@ -40,12 +46,62 @@ const validateNumber = (min = -1, max = -1) => {
|
||||
const validateEmail = () => {
|
||||
return (value: string): string | undefined => {
|
||||
if (!value.length) {
|
||||
return "This is required";
|
||||
return intl.formatMessage({ id: "error.required" });
|
||||
}
|
||||
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) {
|
||||
return "Invalid email address";
|
||||
return intl.formatMessage({ id: "error.invalid-email" });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export { validateEmail, validateNumber, validateString };
|
||||
const validateDomain = (allowWildcards = false) => {
|
||||
return (d: string): boolean => {
|
||||
const dom = d.trim().toLowerCase();
|
||||
|
||||
if (dom.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent wildcards
|
||||
if (!allowWildcards && dom.indexOf("*") !== -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent duplicate * in domain
|
||||
if ((dom.match(/\*/g) || []).length > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent some invalid characters
|
||||
if ((dom.match(/(@|,|!|&|\$|#|%|\^|\(|\))/g) || []).length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// This will match *.com type domains,
|
||||
return dom.match(/\*\.[^.]+$/m) === null;
|
||||
};
|
||||
};
|
||||
|
||||
const validateDomains = (allowWildcards = false, maxDomains?: number) => {
|
||||
const vDom = validateDomain(allowWildcards);
|
||||
|
||||
return (value: string[]): string | undefined => {
|
||||
if (!value.length) {
|
||||
return intl.formatMessage({ id: "error.required" });
|
||||
}
|
||||
|
||||
// Deny if the list of domains is hit
|
||||
if (maxDomains && value.length >= maxDomains) {
|
||||
return intl.formatMessage({ id: "error.max-domains" }, { max: maxDomains });
|
||||
}
|
||||
|
||||
// validate each domain
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (!vDom(value[i])) {
|
||||
return intl.formatMessage({ id: "error.invalid-domain" }, { domain: value[i] });
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export { validateEmail, validateNumber, validateString, validateDomains, validateDomain };
|
||||
|
@@ -129,6 +129,7 @@ const Dashboard = () => {
|
||||
- fix bad jwt not refreshing entire page
|
||||
- add help docs for host types
|
||||
- REDO SCREENSHOTS in docs folder
|
||||
- Remove letsEncryptEmail field from new certificate requests, use current user email server side
|
||||
|
||||
More for api, then implement here:
|
||||
- Properly implement refresh tokens
|
||||
|
@@ -10,10 +10,11 @@ import Empty from "./Empty";
|
||||
interface Props {
|
||||
data: DeadHost[];
|
||||
isFetching?: boolean;
|
||||
onEdit?: (id: number) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
onNew?: () => void;
|
||||
}
|
||||
export default function Table({ data, isFetching, onDelete, onNew }: Props) {
|
||||
export default function Table({ data, isFetching, onEdit, onDelete, onNew }: Props) {
|
||||
const columnHelper = createColumnHelper<DeadHost>();
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
@@ -71,7 +72,14 @@ export default function Table({ data, isFetching, onDelete, onNew }: Props) {
|
||||
{ id: info.row.original.id },
|
||||
)}
|
||||
</span>
|
||||
<a className="dropdown-item" href="#">
|
||||
<a
|
||||
className="dropdown-item"
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onEdit?.(info.row.original.id);
|
||||
}}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
{intl.formatMessage({ id: "action.edit" })}
|
||||
</a>
|
||||
@@ -100,7 +108,7 @@ export default function Table({ data, isFetching, onDelete, onNew }: Props) {
|
||||
},
|
||||
}),
|
||||
],
|
||||
[columnHelper, onDelete],
|
||||
[columnHelper, onDelete, onEdit],
|
||||
);
|
||||
|
||||
const tableInstance = useReactTable<DeadHost>({
|
||||
|
@@ -58,6 +58,7 @@ export default function TableWrapper() {
|
||||
<Table
|
||||
data={data ?? []}
|
||||
isFetching={isFetching}
|
||||
onEdit={(id: number) => setEditId(id)}
|
||||
onDelete={(id: number) => setDeleteId(id)}
|
||||
onNew={() => setEditId("new")}
|
||||
/>
|
||||
|
@@ -82,8 +82,6 @@ describe('Certificates endpoints', () => {
|
||||
domain_names: ['test.com"||echo hello-world||\\\\n test.com"'],
|
||||
meta: {
|
||||
dns_challenge: false,
|
||||
letsencrypt_agree: true,
|
||||
letsencrypt_email: 'admin@example.com',
|
||||
},
|
||||
provider: 'letsencrypt',
|
||||
},
|
||||
@@ -97,28 +95,4 @@ describe('Certificates endpoints', () => {
|
||||
expect(data.error.message).to.contain('data/domain_names/0 must match pattern');
|
||||
});
|
||||
});
|
||||
|
||||
it('Request Certificate - LE Email Escaped', () => {
|
||||
cy.task('backendApiPost', {
|
||||
token: token,
|
||||
path: '/api/nginx/certificates',
|
||||
data: {
|
||||
domain_names: ['test.com"||echo hello-world||\\\\n test.com"'],
|
||||
meta: {
|
||||
dns_challenge: false,
|
||||
letsencrypt_agree: true,
|
||||
letsencrypt_email: "admin@example.com' --version;echo hello-world",
|
||||
},
|
||||
provider: 'letsencrypt',
|
||||
},
|
||||
returnOnError: true,
|
||||
}).then((data) => {
|
||||
cy.validateSwaggerSchema('post', 400, '/nginx/certificates', data);
|
||||
expect(data).to.have.property('error');
|
||||
expect(data.error).to.have.property('message');
|
||||
expect(data.error).to.have.property('code');
|
||||
expect(data.error.code).to.equal(400);
|
||||
expect(data.error.message).to.contain('data/meta/letsencrypt_email must match pattern');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -19,8 +19,6 @@ describe('Full Certificate Provisions', () => {
|
||||
'website1.example.com'
|
||||
],
|
||||
meta: {
|
||||
letsencrypt_email: 'admin@example.com',
|
||||
letsencrypt_agree: true,
|
||||
dns_challenge: false
|
||||
},
|
||||
provider: 'letsencrypt'
|
||||
@@ -42,11 +40,9 @@ describe('Full Certificate Provisions', () => {
|
||||
'website2.example.com'
|
||||
],
|
||||
meta: {
|
||||
letsencrypt_email: "admin@example.com",
|
||||
dns_challenge: true,
|
||||
dns_provider: 'powerdns',
|
||||
dns_provider_credentials: 'dns_powerdns_api_url = http://ns1.pdns:8081\r\ndns_powerdns_api_key = npm',
|
||||
letsencrypt_agree: true,
|
||||
propagation_seconds: 5,
|
||||
},
|
||||
provider: 'letsencrypt'
|
||||
|
Reference in New Issue
Block a user