You've already forked nginx-proxy-manager
							
							
				mirror of
				https://github.com/NginxProxyManager/nginx-proxy-manager.git
				synced 2025-10-30 18:05:34 +03: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,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; | ||||
|   | ||||
| @@ -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,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; | ||||
| 		}); | ||||
|   | ||||
| @@ -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} | ||||
| 								/> | ||||
| 								<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> | ||||
|   | ||||
| @@ -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")} | ||||
| 				/> | ||||
|   | ||||
| @@ -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: 'admin@example.com', | ||||
| 					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: "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