Эх сурвалжийг харах

404 hosts add update complete, fix certbot renewals

and remove the need for email and agreement on cert requests
Jamie Curnow 2 сар өмнө
parent
commit
18537b9288
32 өөрчлөгдсөн 449 нэмэгдсэн , 446 устгасан
  1. 46 49
      backend/internal/certificate.js
  2. 24 11
      backend/internal/dead-host.js
  3. 39 40
      backend/lib/certbot.js
  4. 2 0
      backend/routes/nginx/certificates.js
  5. 0 6
      backend/schema/components/certificate-object.json
  6. 0 2
      backend/schema/paths/nginx/certificates/certID/get.json
  7. 0 2
      backend/schema/paths/nginx/certificates/certID/renew/post.json
  8. 0 2
      backend/schema/paths/nginx/certificates/get.json
  9. 0 2
      backend/schema/paths/nginx/certificates/post.json
  10. 7 5
      backend/setup.js
  11. 5 0
      frontend/src/App.css
  12. 3 11
      frontend/src/components/Form/DNSProviderFields.module.css
  13. 22 19
      frontend/src/components/Form/DNSProviderFields.tsx
  14. 14 54
      frontend/src/components/Form/DomainNamesField.tsx
  15. 40 0
      frontend/src/components/Form/NginxConfigField.tsx
  16. 17 10
      frontend/src/components/Form/SSLCertificateField.tsx
  17. 24 24
      frontend/src/components/Form/SSLOptionsFields.tsx
  18. 1 0
      frontend/src/components/Form/index.ts
  19. 17 3
      frontend/src/locale/lang/en.json
  20. 48 6
      frontend/src/locale/src/en.json
  21. 7 1
      frontend/src/modals/ChangePasswordModal.tsx
  22. 33 150
      frontend/src/modals/DeadHostModal.tsx
  23. 7 6
      frontend/src/modals/DeleteConfirmModal.tsx
  24. 5 1
      frontend/src/modals/PermissionsModal.tsx
  25. 4 1
      frontend/src/modals/SetPasswordModal.tsx
  26. 9 2
      frontend/src/modals/UserModal.tsx
  27. 61 5
      frontend/src/modules/Validations.tsx
  28. 1 0
      frontend/src/pages/Dashboard/index.tsx
  29. 11 3
      frontend/src/pages/Nginx/DeadHosts/Table.tsx
  30. 1 0
      frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx
  31. 1 27
      test/cypress/e2e/api/Certificates.cy.js
  32. 0 4
      test/cypress/e2e/api/FullCertProvision.cy.js

+ 46 - 49
backend/internal/certificate.js

@@ -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,13 +209,12 @@ const internalCertificate = {
 		data.meta = _.assign({}, data.meta || {}, certificate.meta);
 
 		// Add to audit log
-		await internalAuditLog
-			.add(access, {
-				action: "created",
-				object_type: "certificate",
-				object_id: certificate.id,
-				meta: data,
-			});
+		await internalAuditLog.add(access, {
+			action: "created",
+			object_type: "certificate",
+			object_id: certificate.id,
+			meta: data,
+		});
 
 		return certificate;
 	},
@@ -248,13 +252,12 @@ const internalCertificate = {
 		}
 
 		// Add to audit log
-		await internalAuditLog
-			.add(access, {
-				action: "updated",
-				object_type: "certificate",
-				object_id: row.id,
-				meta: _.omit(data, ["expires_on"]), // this prevents json circular reference because expires_on might be raw
-			});
+		await internalAuditLog.add(access, {
+			action: "updated",
+			object_type: "certificate",
+			object_id: row.id,
+			meta: _.omit(data, ["expires_on"]), // this prevents json circular reference because expires_on might be raw
+		});
 
 		return savedRow;
 	},
@@ -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,12 +370,9 @@ const internalCertificate = {
 			throw new error.ItemNotFoundError(data.id);
 		}
 
-		await certificateModel
-			.query()
-			.where("id", row.id)
-			.patch({
-				is_deleted: 1,
-			});
+		await certificateModel.query().where("id", row.id).patch({
+			is_deleted: 1,
+		});
 
 		// Add to audit log
 		row.meta = internalCertificate.cleanMeta(row.meta);
@@ -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,11 +857,9 @@ const internalCertificate = {
 				`${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`,
 			);
 
-			const updatedCertificate = await certificateModel
-				.query()
-				.patchAndFetchById(certificate.id, {
-					expires_on: moment(certInfo.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
-				});
+			const updatedCertificate = await certificateModel.query().patchAndFetchById(certificate.id, {
+				expires_on: moment(certInfo.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
+			});
 
 			// Add to audit log
 			await internalAuditLog.add(access, {
@@ -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;

+ 24 - 11
backend/internal/dead-host.js

@@ -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",

+ 39 - 40
backend/lib/certbot.js

@@ -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 };

+ 2 - 0
backend/routes/nginx/certificates.js

@@ -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}`);

+ 0 - 6
backend/schema/components/certificate-object.json

@@ -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

+ 0 - 2
backend/schema/paths/nginx/certificates/certID/get.json

@@ -36,8 +36,6 @@
 								"domain_names": ["test.example.com"],
 								"expires_on": "2025-01-07T04:34:18.000Z",
 								"meta": {
-									"letsencrypt_email": "[email protected]",
-									"letsencrypt_agree": true,
 									"dns_challenge": false
 								}
 							}

+ 0 - 2
backend/schema/paths/nginx/certificates/certID/renew/post.json

@@ -37,8 +37,6 @@
 								"nice_name": "My Test Cert",
 								"domain_names": ["test.jc21.supernerd.pro"],
 								"meta": {
-									"letsencrypt_email": "[email protected]",
-									"letsencrypt_agree": true,
 									"dns_challenge": false
 								}
 							}

+ 0 - 2
backend/schema/paths/nginx/certificates/get.json

@@ -36,8 +36,6 @@
 									"domain_names": ["test.example.com"],
 									"expires_on": "2025-01-07T04:34:18.000Z",
 									"meta": {
-										"letsencrypt_email": "[email protected]",
-										"letsencrypt_agree": true,
 										"dns_challenge": false
 									}
 								}

+ 0 - 2
backend/schema/paths/nginx/certificates/post.json

@@ -52,8 +52,6 @@
 								"nice_name": "test.example.com",
 								"domain_names": ["test.example.com"],
 								"meta": {
-									"letsencrypt_email": "[email protected]",
-									"letsencrypt_agree": true,
 									"dns_challenge": false,
 									"letsencrypt_certificate": {
 										"cn": "test.example.com",

+ 7 - 5
backend/setup.js

@@ -121,11 +121,13 @@ const setupCertbotPlugins = async () => {
 				// Make sure credentials file exists
 				const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
 				// Escape single quotes and backslashes
-				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));
+				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;
 		});

+ 5 - 0
frontend/src/App.css

@@ -65,3 +65,8 @@
 		}
 	}
 }
+
+.textareaMono {
+	font-family: 'Courier New', Courier, monospace !important;
+	resize: vertical;
+}

+ 3 - 11
frontend/src/components/Form/DNSProviderFields.module.css

@@ -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;
-}

+ 22 - 19
frontend/src/components/Form/DNSProviderFields.tsx

@@ -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}
 								/>
-								<small className="text-muted">
-									This plugin requires a configuration file containing an API token or other
-									credentials to your provider
-								</small>
-								<small className="text-danger">
-									This data will be stored as plaintext in the database and in a file!
-								</small>
+								<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>

+ 14 - 54
frontend/src/components/Form/DomainNamesField.tsx

@@ -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 - 0
frontend/src/components/Form/NginxConfigField.tsx

@@ -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>
+	);
+}

+ 17 - 10
frontend/src/components/Form/SSLCertificateField.tsx

@@ -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={{

+ 24 - 24
frontend/src/components/Form/SSLOptionsFields.tsx

@@ -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 - 0
frontend/src/components/Form/index.ts

@@ -1,4 +1,5 @@
 export * from "./DNSProviderFields";
 export * from "./DomainNamesField";
+export * from "./NginxConfigField";
 export * from "./SSLCertificateField";
 export * from "./SSLOptionsFields";

+ 17 - 3
frontend/src/locale/lang/en.json

@@ -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"
 }

+ 48 - 6
frontend/src/locale/src/en.json

@@ -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"
 	}
 }

+ 7 - 1
frontend/src/modals/ChangePasswordModal.tsx

@@ -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>

+ 33 - 150
frontend/src/modals/DeadHostModal.tsx

@@ -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}>

+ 7 - 6
frontend/src/modals/DeleteConfirmModal.tsx

@@ -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" })}

+ 5 - 1
frontend/src/modals/PermissionsModal.tsx

@@ -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>

+ 4 - 1
frontend/src/modals/SetPasswordModal.tsx

@@ -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>

+ 9 - 2
frontend/src/modals/UserModal.tsx

@@ -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>

+ 61 - 5
frontend/src/modules/Validations.tsx

@@ -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" });
+		}
+	};
+};
+
+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 };
+export { validateEmail, validateNumber, validateString, validateDomains, validateDomain };

+ 1 - 0
frontend/src/pages/Dashboard/index.tsx

@@ -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

+ 11 - 3
frontend/src/pages/Nginx/DeadHosts/Table.tsx

@@ -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>({

+ 1 - 0
frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx

@@ -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")}
 				/>

+ 1 - 27
test/cypress/e2e/api/Certificates.cy.js

@@ -81,9 +81,7 @@ describe('Certificates endpoints', () => {
 			data:  {
 				domain_names: ['test.com"||echo hello-world||\\\\n test.com"'],
 				meta:         {
-					dns_challenge:     false,
-					letsencrypt_agree: true,
-					letsencrypt_email: '[email protected]',
+					dns_challenge: false,
 				},
 				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: "[email protected]' --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');
-		});
-	});
 });

+ 0 - 4
test/cypress/e2e/api/FullCertProvision.cy.js

@@ -19,8 +19,6 @@ describe('Full Certificate Provisions', () => {
 					'website1.example.com'
 				],
 				meta: {
-					letsencrypt_email: '[email protected]',
-					letsencrypt_agree: true,
 					dns_challenge: false
 				},
 				provider: 'letsencrypt'
@@ -42,11 +40,9 @@ describe('Full Certificate Provisions', () => {
 					'website2.example.com'
 				],
 				meta: {
-					letsencrypt_email: "[email protected]",
 					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'