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 
			
		
		
		
	Introducing the Setup Wizard for creating the first user
- no longer setup a default - still able to do that with env vars however
This commit is contained in:
		| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|     "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", |     "$schema": "https://biomejs.dev/schemas/2.2.3/schema.json", | ||||||
|     "vcs": { |     "vcs": { | ||||||
|         "enabled": true, |         "enabled": true, | ||||||
|         "clientKind": "git", |         "clientKind": "git", | ||||||
|   | |||||||
| @@ -18,67 +18,66 @@ export default { | |||||||
| 	 * @param   {String} [issuer] | 	 * @param   {String} [issuer] | ||||||
| 	 * @returns {Promise} | 	 * @returns {Promise} | ||||||
| 	 */ | 	 */ | ||||||
| 	getTokenFromEmail: (data, issuer) => { | 	getTokenFromEmail: async (data, issuer) => { | ||||||
| 		const Token = TokenModel(); | 		const Token = TokenModel(); | ||||||
|  |  | ||||||
| 		data.scope = data.scope || "user"; | 		data.scope = data.scope || "user"; | ||||||
| 		data.expiry = data.expiry || "1d"; | 		data.expiry = data.expiry || "1d"; | ||||||
|  |  | ||||||
| 		return userModel | 		const user = await userModel | ||||||
| 			.query() | 			.query() | ||||||
| 			.where("email", data.identity.toLowerCase().trim()) | 			.where("email", data.identity.toLowerCase().trim()) | ||||||
| 			.andWhere("is_deleted", 0) | 			.andWhere("is_deleted", 0) | ||||||
| 			.andWhere("is_disabled", 0) | 			.andWhere("is_disabled", 0) | ||||||
| 			.first() | 			.first(); | ||||||
| 			.then((user) => { |  | ||||||
| 				if (user) { |  | ||||||
| 					// Get auth |  | ||||||
| 					return authModel |  | ||||||
| 						.query() |  | ||||||
| 						.where("user_id", "=", user.id) |  | ||||||
| 						.where("type", "=", "password") |  | ||||||
| 						.first() |  | ||||||
| 						.then((auth) => { |  | ||||||
| 							if (auth) { |  | ||||||
| 								return auth.verifyPassword(data.secret).then((valid) => { |  | ||||||
| 									if (valid) { |  | ||||||
| 										if (data.scope !== "user" && _.indexOf(user.roles, data.scope) === -1) { |  | ||||||
| 											// The scope requested doesn't exist as a role against the user, |  | ||||||
| 											// you shall not pass. |  | ||||||
| 											throw new errs.AuthError(`Invalid scope: ${data.scope}`); |  | ||||||
| 										} |  | ||||||
|  |  | ||||||
| 										// Create a moment of the expiry expression | 		if (!user) { | ||||||
| 										const expiry = parseDatePeriod(data.expiry); | 			throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); | ||||||
| 										if (expiry === null) { | 		} | ||||||
| 											throw new errs.AuthError(`Invalid expiry time: ${data.expiry}`); |  | ||||||
| 										} |  | ||||||
|  |  | ||||||
| 										return Token.create({ | 		const auth = await authModel | ||||||
| 											iss: issuer || "api", | 			.query() | ||||||
| 											attrs: { | 			.where("user_id", "=", user.id) | ||||||
| 												id: user.id, | 			.where("type", "=", "password") | ||||||
| 											}, | 			.first(); | ||||||
| 											scope: [data.scope], |  | ||||||
| 											expiresIn: data.expiry, | 		if (!auth) { | ||||||
| 										}).then((signed) => { | 			throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); | ||||||
| 											return { | 		} | ||||||
| 												token: signed.token, |  | ||||||
| 												expires: expiry.toISOString(), | 		const valid = await auth.verifyPassword(data.secret); | ||||||
| 											}; | 		if (!valid) { | ||||||
| 										}); | 			throw new errs.AuthError( | ||||||
| 									} | 				ERROR_MESSAGE_INVALID_AUTH, | ||||||
| 									throw new errs.AuthError( | 				ERROR_MESSAGE_INVALID_AUTH_I18N, | ||||||
| 										ERROR_MESSAGE_INVALID_AUTH, | 			); | ||||||
| 										ERROR_MESSAGE_INVALID_AUTH_I18N, | 		} | ||||||
| 									); |  | ||||||
| 								}); | 		if (data.scope !== "user" && _.indexOf(user.roles, data.scope) === -1) { | ||||||
| 							} | 			// The scope requested doesn't exist as a role against the user, | ||||||
| 							throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); | 			// you shall not pass. | ||||||
| 						}); | 			throw new errs.AuthError(`Invalid scope: ${data.scope}`); | ||||||
| 				} | 		} | ||||||
| 				throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); |  | ||||||
| 			}); | 		// Create a moment of the expiry expression | ||||||
|  | 		const expiry = parseDatePeriod(data.expiry); | ||||||
|  | 		if (expiry === null) { | ||||||
|  | 			throw new errs.AuthError(`Invalid expiry time: ${data.expiry}`); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const signed = await Token.create({ | ||||||
|  | 			iss: issuer || "api", | ||||||
|  | 			attrs: { | ||||||
|  | 				id: user.id, | ||||||
|  | 			}, | ||||||
|  | 			scope: [data.scope], | ||||||
|  | 			expiresIn: data.expiry, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		return { | ||||||
|  | 			token: signed.token, | ||||||
|  | 			expires: expiry.toISOString(), | ||||||
|  | 		}; | ||||||
| 	}, | 	}, | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| @@ -88,7 +87,7 @@ export default { | |||||||
| 	 * @param {String} [data.scope]   Only considered if existing token scope is admin | 	 * @param {String} [data.scope]   Only considered if existing token scope is admin | ||||||
| 	 * @returns {Promise} | 	 * @returns {Promise} | ||||||
| 	 */ | 	 */ | ||||||
| 	getFreshToken: (access, data) => { | 	getFreshToken: async (access, data) => { | ||||||
| 		const Token = TokenModel(); | 		const Token = TokenModel(); | ||||||
| 		const thisData = data || {}; | 		const thisData = data || {}; | ||||||
|  |  | ||||||
| @@ -115,17 +114,17 @@ export default { | |||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			return Token.create({ | 			const signed = await Token.create({ | ||||||
| 				iss: "api", | 				iss: "api", | ||||||
| 				scope: scope, | 				scope: scope, | ||||||
| 				attrs: token_attrs, | 				attrs: token_attrs, | ||||||
| 				expiresIn: thisData.expiry, | 				expiresIn: thisData.expiry, | ||||||
| 			}).then((signed) => { |  | ||||||
| 				return { |  | ||||||
| 					token: signed.token, |  | ||||||
| 					expires: expiry.toISOString(), |  | ||||||
| 				}; |  | ||||||
| 			}); | 			}); | ||||||
|  |  | ||||||
|  | 			return { | ||||||
|  | 				token: signed.token, | ||||||
|  | 				expires: expiry.toISOString(), | ||||||
|  | 			}; | ||||||
| 		} | 		} | ||||||
| 		throw new error.AssertionFailedError("Existing token contained invalid user data"); | 		throw new error.AssertionFailedError("Existing token contained invalid user data"); | ||||||
| 	}, | 	}, | ||||||
| @@ -136,7 +135,7 @@ export default { | |||||||
| 	 */ | 	 */ | ||||||
| 	getTokenFromUser: async (user) => { | 	getTokenFromUser: async (user) => { | ||||||
| 		const expire = "1d"; | 		const expire = "1d"; | ||||||
| 		const Token = new TokenModel(); | 		const Token = TokenModel(); | ||||||
| 		const expiry = parseDatePeriod(expire); | 		const expiry = parseDatePeriod(expire); | ||||||
|  |  | ||||||
| 		const signed = await Token.create({ | 		const signed = await Token.create({ | ||||||
|   | |||||||
| @@ -10,17 +10,20 @@ import internalToken from "./token.js"; | |||||||
|  |  | ||||||
| const omissions = () => { | const omissions = () => { | ||||||
| 	return ["is_deleted"]; | 	return ["is_deleted"]; | ||||||
| } | }; | ||||||
|  |  | ||||||
| const DEFAULT_AVATAR = 'https://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=200&d=mp&r=g'; | const DEFAULT_AVATAR = gravatar.url("admin@example.com", { default: "mm" }); | ||||||
|  |  | ||||||
| const internalUser = { | const internalUser = { | ||||||
| 	/** | 	/** | ||||||
|  | 	 * Create a user can happen unauthenticated only once and only when no active users exist. | ||||||
|  | 	 * Otherwise, a valid auth method is required. | ||||||
|  | 	 * | ||||||
| 	 * @param   {Access}  access | 	 * @param   {Access}  access | ||||||
| 	 * @param   {Object}  data | 	 * @param   {Object}  data | ||||||
| 	 * @returns {Promise} | 	 * @returns {Promise} | ||||||
| 	 */ | 	 */ | ||||||
| 	create: (access, data) => { | 	create: async (access, data) => { | ||||||
| 		const auth = data.auth || null; | 		const auth = data.auth || null; | ||||||
| 		delete data.auth; | 		delete data.auth; | ||||||
|  |  | ||||||
| @@ -31,61 +34,43 @@ const internalUser = { | |||||||
| 			data.is_disabled = data.is_disabled ? 1 : 0; | 			data.is_disabled = data.is_disabled ? 1 : 0; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		return access | 		await access.can("users:create", data); | ||||||
| 			.can("users:create", data) | 		data.avatar = gravatar.url(data.email, { default: "mm" }); | ||||||
| 			.then(() => { |  | ||||||
| 				data.avatar = gravatar.url(data.email, { default: "mm" }); |  | ||||||
| 				return userModel.query().insertAndFetch(data).then(utils.omitRow(omissions())); |  | ||||||
| 			}) |  | ||||||
| 			.then((user) => { |  | ||||||
| 				if (auth) { |  | ||||||
| 					return authModel |  | ||||||
| 						.query() |  | ||||||
| 						.insert({ |  | ||||||
| 							user_id: user.id, |  | ||||||
| 							type: auth.type, |  | ||||||
| 							secret: auth.secret, |  | ||||||
| 							meta: {}, |  | ||||||
| 						}) |  | ||||||
| 						.then(() => { |  | ||||||
| 							return user; |  | ||||||
| 						}); |  | ||||||
| 				} |  | ||||||
| 				return user; |  | ||||||
| 			}) |  | ||||||
| 			.then((user) => { |  | ||||||
| 				// Create permissions row as well |  | ||||||
| 				const is_admin = data.roles.indexOf("admin") !== -1; |  | ||||||
|  |  | ||||||
| 				return userPermissionModel | 		let user = await userModel.query().insertAndFetch(data).then(utils.omitRow(omissions())); | ||||||
| 					.query() | 		if (auth) { | ||||||
| 					.insert({ | 			user = await authModel.query().insert({ | ||||||
| 						user_id: user.id, | 				user_id: user.id, | ||||||
| 						visibility: is_admin ? "all" : "user", | 				type: auth.type, | ||||||
| 						proxy_hosts: "manage", | 				secret: auth.secret, | ||||||
| 						redirection_hosts: "manage", | 				meta: {}, | ||||||
| 						dead_hosts: "manage", |  | ||||||
| 						streams: "manage", |  | ||||||
| 						access_lists: "manage", |  | ||||||
| 						certificates: "manage", |  | ||||||
| 					}) |  | ||||||
| 					.then(() => { |  | ||||||
| 						return internalUser.get(access, { id: user.id, expand: ["permissions"] }); |  | ||||||
| 					}); |  | ||||||
| 			}) |  | ||||||
| 			.then((user) => { |  | ||||||
| 				// Add to audit log |  | ||||||
| 				return internalAuditLog |  | ||||||
| 					.add(access, { |  | ||||||
| 						action: "created", |  | ||||||
| 						object_type: "user", |  | ||||||
| 						object_id: user.id, |  | ||||||
| 						meta: user, |  | ||||||
| 					}) |  | ||||||
| 					.then(() => { |  | ||||||
| 						return user; |  | ||||||
| 					}); |  | ||||||
| 			}); | 			}); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Create permissions row as well | ||||||
|  | 		const isAdmin = data.roles.indexOf("admin") !== -1; | ||||||
|  |  | ||||||
|  | 		await userPermissionModel.query().insert({ | ||||||
|  | 			user_id: user.id, | ||||||
|  | 			visibility: isAdmin ? "all" : "user", | ||||||
|  | 			proxy_hosts: "manage", | ||||||
|  | 			redirection_hosts: "manage", | ||||||
|  | 			dead_hosts: "manage", | ||||||
|  | 			streams: "manage", | ||||||
|  | 			access_lists: "manage", | ||||||
|  | 			certificates: "manage", | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		user = await internalUser.get(access, { id: user.id, expand: ["permissions"] }); | ||||||
|  |  | ||||||
|  | 		await internalAuditLog.add(access, { | ||||||
|  | 			action: "created", | ||||||
|  | 			object_type: "user", | ||||||
|  | 			object_id: user.id, | ||||||
|  | 			meta: user, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		return user; | ||||||
| 	}, | 	}, | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| @@ -316,11 +301,7 @@ const internalUser = { | |||||||
| 		// Query is used for searching | 		// Query is used for searching | ||||||
| 		if (typeof search_query === "string") { | 		if (typeof search_query === "string") { | ||||||
| 			query.where(function () { | 			query.where(function () { | ||||||
| 				this.where("name", "like", `%${search_query}%`).orWhere( | 				this.where("name", "like", `%${search_query}%`).orWhere("email", "like", `%${search_query}%`); | ||||||
| 					"email", |  | ||||||
| 					"like", |  | ||||||
| 					`%${search_query}%`, |  | ||||||
| 				); |  | ||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -22,13 +22,13 @@ import errs from "./error.js"; | |||||||
| const __filename = fileURLToPath(import.meta.url); | const __filename = fileURLToPath(import.meta.url); | ||||||
| const __dirname = dirname(__filename); | const __dirname = dirname(__filename); | ||||||
|  |  | ||||||
| export default function (token_string) { | export default function (tokenString) { | ||||||
| 	const Token = TokenModel(); | 	const Token = TokenModel(); | ||||||
| 	let token_data = null; | 	let tokenData = null; | ||||||
| 	let initialised = false; | 	let initialised = false; | ||||||
| 	const object_cache = {}; | 	const objectCache = {}; | ||||||
| 	let allow_internal_access = false; | 	let allowInternalAccess = false; | ||||||
| 	let user_roles = []; | 	let userRoles = []; | ||||||
| 	let permissions = {}; | 	let permissions = {}; | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| @@ -36,65 +36,58 @@ export default function (token_string) { | |||||||
| 	 * | 	 * | ||||||
| 	 * @returns {Promise} | 	 * @returns {Promise} | ||||||
| 	 */ | 	 */ | ||||||
| 	this.init = () => { | 	this.init = async () => { | ||||||
| 		return new Promise((resolve, reject) => { | 		if (initialised) { | ||||||
| 			if (initialised) { | 			return; | ||||||
| 				resolve(); | 		} | ||||||
| 			} else if (!token_string) { |  | ||||||
| 				reject(new errs.PermissionError("Permission Denied")); | 		if (!tokenString) { | ||||||
|  | 			throw new errs.PermissionError("Permission Denied"); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		tokenData = await Token.load(tokenString); | ||||||
|  |  | ||||||
|  | 		// At this point we need to load the user from the DB and make sure they: | ||||||
|  | 		// - exist (and not soft deleted) | ||||||
|  | 		// - still have the appropriate scopes for this token | ||||||
|  | 		// This is only required when the User ID is supplied or if the token scope has `user` | ||||||
|  | 		if ( | ||||||
|  | 			tokenData.attrs.id || | ||||||
|  | 			(typeof tokenData.scope !== "undefined" && _.indexOf(tokenData.scope, "user") !== -1) | ||||||
|  | 		) { | ||||||
|  | 			// Has token user id or token user scope | ||||||
|  | 			const user = await userModel | ||||||
|  | 				.query() | ||||||
|  | 				.where("id", tokenData.attrs.id) | ||||||
|  | 				.andWhere("is_deleted", 0) | ||||||
|  | 				.andWhere("is_disabled", 0) | ||||||
|  | 				.allowGraph("[permissions]") | ||||||
|  | 				.withGraphFetched("[permissions]") | ||||||
|  | 				.first(); | ||||||
|  |  | ||||||
|  | 			if (user) { | ||||||
|  | 				// make sure user has all scopes of the token | ||||||
|  | 				// The `user` role is not added against the user row, so we have to just add it here to get past this check. | ||||||
|  | 				user.roles.push("user"); | ||||||
|  |  | ||||||
|  | 				let ok = true; | ||||||
|  | 				_.forEach(tokenData.scope, (scope_item) => { | ||||||
|  | 					if (_.indexOf(user.roles, scope_item) === -1) { | ||||||
|  | 						ok = false; | ||||||
|  | 					} | ||||||
|  | 				}); | ||||||
|  |  | ||||||
|  | 				if (!ok) { | ||||||
|  | 					throw new errs.AuthError("Invalid token scope for User"); | ||||||
|  | 				} | ||||||
|  | 				initialised = true; | ||||||
|  | 				userRoles = user.roles; | ||||||
|  | 				permissions = user.permissions; | ||||||
| 			} else { | 			} else { | ||||||
| 				resolve( | 				throw new errs.AuthError("User cannot be loaded for Token"); | ||||||
| 					Token.load(token_string).then((data) => { |  | ||||||
| 						token_data = data; |  | ||||||
|  |  | ||||||
| 						// At this point we need to load the user from the DB and make sure they: |  | ||||||
| 						// - exist (and not soft deleted) |  | ||||||
| 						// - still have the appropriate scopes for this token |  | ||||||
| 						// This is only required when the User ID is supplied or if the token scope has `user` |  | ||||||
|  |  | ||||||
| 						if ( |  | ||||||
| 							token_data.attrs.id || |  | ||||||
| 							(typeof token_data.scope !== "undefined" && |  | ||||||
| 								_.indexOf(token_data.scope, "user") !== -1) |  | ||||||
| 						) { |  | ||||||
| 							// Has token user id or token user scope |  | ||||||
| 							return userModel |  | ||||||
| 								.query() |  | ||||||
| 								.where("id", token_data.attrs.id) |  | ||||||
| 								.andWhere("is_deleted", 0) |  | ||||||
| 								.andWhere("is_disabled", 0) |  | ||||||
| 								.allowGraph("[permissions]") |  | ||||||
| 								.withGraphFetched("[permissions]") |  | ||||||
| 								.first() |  | ||||||
| 								.then((user) => { |  | ||||||
| 									if (user) { |  | ||||||
| 										// make sure user has all scopes of the token |  | ||||||
| 										// The `user` role is not added against the user row, so we have to just add it here to get past this check. |  | ||||||
| 										user.roles.push("user"); |  | ||||||
|  |  | ||||||
| 										let is_ok = true; |  | ||||||
| 										_.forEach(token_data.scope, (scope_item) => { |  | ||||||
| 											if (_.indexOf(user.roles, scope_item) === -1) { |  | ||||||
| 												is_ok = false; |  | ||||||
| 											} |  | ||||||
| 										}); |  | ||||||
|  |  | ||||||
| 										if (!is_ok) { |  | ||||||
| 											throw new errs.AuthError("Invalid token scope for User"); |  | ||||||
| 										} |  | ||||||
| 										initialised = true; |  | ||||||
| 										user_roles = user.roles; |  | ||||||
| 										permissions = user.permissions; |  | ||||||
| 									} else { |  | ||||||
| 										throw new errs.AuthError("User cannot be loaded for Token"); |  | ||||||
| 									} |  | ||||||
| 								}); |  | ||||||
| 						} |  | ||||||
| 						initialised = true; |  | ||||||
| 					}), |  | ||||||
| 				); |  | ||||||
| 			} | 			} | ||||||
| 		}); | 		} | ||||||
|  | 		initialised = true; | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| @@ -102,82 +95,64 @@ export default function (token_string) { | |||||||
| 	 * This only applies to USER token scopes, as all other tokens are not really bound | 	 * This only applies to USER token scopes, as all other tokens are not really bound | ||||||
| 	 * by object scopes | 	 * by object scopes | ||||||
| 	 * | 	 * | ||||||
| 	 * @param   {String} object_type | 	 * @param   {String} objectType | ||||||
| 	 * @returns {Promise} | 	 * @returns {Promise} | ||||||
| 	 */ | 	 */ | ||||||
| 	this.loadObjects = (object_type) => { | 	this.loadObjects = async (objectType) => { | ||||||
| 		return new Promise((resolve, reject) => { | 		let objects = null; | ||||||
| 			if (Token.hasScope("user")) { |  | ||||||
| 				if ( |  | ||||||
| 					typeof token_data.attrs.id === "undefined" || |  | ||||||
| 					!token_data.attrs.id |  | ||||||
| 				) { |  | ||||||
| 					reject(new errs.AuthError("User Token supplied without a User ID")); |  | ||||||
| 				} else { |  | ||||||
| 					const token_user_id = token_data.attrs.id ? token_data.attrs.id : 0; |  | ||||||
| 					let query; |  | ||||||
|  |  | ||||||
| 					if (typeof object_cache[object_type] === "undefined") { | 		if (Token.hasScope("user")) { | ||||||
| 						switch (object_type) { | 			if (typeof tokenData.attrs.id === "undefined" || !tokenData.attrs.id) { | ||||||
| 							// USERS - should only return yourself | 				throw new errs.AuthError("User Token supplied without a User ID"); | ||||||
| 							case "users": | 			} | ||||||
| 								resolve(token_user_id ? [token_user_id] : []); |  | ||||||
| 								break; |  | ||||||
|  |  | ||||||
| 							// Proxy Hosts | 			const tokenUserId = tokenData.attrs.id ? tokenData.attrs.id : 0; | ||||||
| 							case "proxy_hosts": | 			let query; | ||||||
| 								query = proxyHostModel |  | ||||||
| 									.query() |  | ||||||
| 									.select("id") |  | ||||||
| 									.andWhere("is_deleted", 0); |  | ||||||
|  |  | ||||||
| 								if (permissions.visibility === "user") { | 			if (typeof objectCache[objectType] !== "undefined") { | ||||||
| 									query.andWhere("owner_user_id", token_user_id); | 				objects = objectCache[objectType]; | ||||||
| 								} | 			} else { | ||||||
|  | 				switch (objectType) { | ||||||
|  | 					// USERS - should only return yourself | ||||||
|  | 					case "users": | ||||||
|  | 						objects = tokenUserId ? [tokenUserId] : []; | ||||||
|  | 						break; | ||||||
|  |  | ||||||
| 								resolve( | 					// Proxy Hosts | ||||||
| 									query.then((rows) => { | 					case "proxy_hosts": { | ||||||
| 										const result = []; | 						query = proxyHostModel.query().select("id").andWhere("is_deleted", 0); | ||||||
| 										_.forEach(rows, (rule_row) => { | 						if (permissions.visibility === "user") { | ||||||
| 											result.push(rule_row.id); | 							query.andWhere("owner_user_id", tokenUserId); | ||||||
| 										}); |  | ||||||
|  |  | ||||||
| 										// enum should not have less than 1 item |  | ||||||
| 										if (!result.length) { |  | ||||||
| 											result.push(0); |  | ||||||
| 										} |  | ||||||
|  |  | ||||||
| 										return result; |  | ||||||
| 									}), |  | ||||||
| 								); |  | ||||||
| 								break; |  | ||||||
|  |  | ||||||
| 							// DEFAULT: null |  | ||||||
| 							default: |  | ||||||
| 								resolve(null); |  | ||||||
| 								break; |  | ||||||
| 						} | 						} | ||||||
| 					} else { |  | ||||||
| 						resolve(object_cache[object_type]); | 						const rows = await query(); | ||||||
|  | 						objects = []; | ||||||
|  | 						_.forEach(rows, (ruleRow) => { | ||||||
|  | 							result.push(ruleRow.id); | ||||||
|  | 						}); | ||||||
|  |  | ||||||
|  | 						// enum should not have less than 1 item | ||||||
|  | 						if (!objects.length) { | ||||||
|  | 							objects.push(0); | ||||||
|  | 						} | ||||||
|  | 						break; | ||||||
| 					} | 					} | ||||||
| 				} | 				} | ||||||
| 			} else { | 				objectCache[objectType] = objects; | ||||||
| 				resolve(null); |  | ||||||
| 			} | 			} | ||||||
| 		}).then((objects) => { | 		} | ||||||
| 			object_cache[object_type] = objects; |  | ||||||
| 			return objects; | 		return objects; | ||||||
| 		}); |  | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| 	 * Creates a schema object on the fly with the IDs and other values required to be checked against the permissionSchema | 	 * Creates a schema object on the fly with the IDs and other values required to be checked against the permissionSchema | ||||||
| 	 * | 	 * | ||||||
| 	 * @param   {String} permission_label | 	 * @param   {String} permissionLabel | ||||||
| 	 * @returns {Object} | 	 * @returns {Object} | ||||||
| 	 */ | 	 */ | ||||||
| 	this.getObjectSchema = (permission_label) => { | 	this.getObjectSchema = async (permissionLabel) => { | ||||||
| 		const base_object_type = permission_label.split(":").shift(); | 		const baseObjectType = permissionLabel.split(":").shift(); | ||||||
|  |  | ||||||
| 		const schema = { | 		const schema = { | ||||||
| 			$id: "objects", | 			$id: "objects", | ||||||
| @@ -200,41 +175,39 @@ export default function (token_string) { | |||||||
| 			}, | 			}, | ||||||
| 		}; | 		}; | ||||||
|  |  | ||||||
| 		return this.loadObjects(base_object_type).then((object_result) => { | 		const result = await this.loadObjects(baseObjectType); | ||||||
| 			if (typeof object_result === "object" && object_result !== null) { | 		if (typeof result === "object" && result !== null) { | ||||||
| 				schema.properties[base_object_type] = { | 			schema.properties[baseObjectType] = { | ||||||
| 					type: "number", | 				type: "number", | ||||||
| 					enum: object_result, | 				enum: result, | ||||||
| 					minimum: 1, | 				minimum: 1, | ||||||
| 				}; | 			}; | ||||||
| 			} else { | 		} else { | ||||||
| 				schema.properties[base_object_type] = { | 			schema.properties[baseObjectType] = { | ||||||
| 					type: "number", | 				type: "number", | ||||||
| 					minimum: 1, | 				minimum: 1, | ||||||
| 				}; | 			}; | ||||||
| 			} | 		} | ||||||
|  |  | ||||||
| 			return schema; | 		return schema; | ||||||
| 		}); |  | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
|  | 	// here: | ||||||
|  |  | ||||||
| 	return { | 	return { | ||||||
| 		token: Token, | 		token: Token, | ||||||
|  |  | ||||||
| 		/** | 		/** | ||||||
| 		 * | 		 * | ||||||
| 		 * @param   {Boolean}  [allow_internal] | 		 * @param   {Boolean}  [allowInternal] | ||||||
| 		 * @returns {Promise} | 		 * @returns {Promise} | ||||||
| 		 */ | 		 */ | ||||||
| 		load: (allow_internal) => { | 		load: async (allowInternal) => { | ||||||
| 			return new Promise((resolve /*, reject*/) => { | 			if (tokenString) { | ||||||
| 				if (token_string) { | 				return await Token.load(tokenString); | ||||||
| 					resolve(Token.load(token_string)); | 			} | ||||||
| 				} else { | 			allowInternalAccess = allowInternal; | ||||||
| 					allow_internal_access = allow_internal; | 			return allowInternal || null; | ||||||
| 					resolve(allow_internal_access || null); |  | ||||||
| 				} |  | ||||||
| 			}); |  | ||||||
| 		}, | 		}, | ||||||
|  |  | ||||||
| 		reloadObjects: this.loadObjects, | 		reloadObjects: this.loadObjects, | ||||||
| @@ -246,7 +219,7 @@ export default function (token_string) { | |||||||
| 		 * @returns {Promise} | 		 * @returns {Promise} | ||||||
| 		 */ | 		 */ | ||||||
| 		can: async (permission, data) => { | 		can: async (permission, data) => { | ||||||
| 			if (allow_internal_access === true) { | 			if (allowInternalAccess === true) { | ||||||
| 				return true; | 				return true; | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| @@ -258,7 +231,7 @@ export default function (token_string) { | |||||||
| 					[permission]: { | 					[permission]: { | ||||||
| 						data: data, | 						data: data, | ||||||
| 						scope: Token.get("scope"), | 						scope: Token.get("scope"), | ||||||
| 						roles: user_roles, | 						roles: userRoles, | ||||||
| 						permission_visibility: permissions.visibility, | 						permission_visibility: permissions.visibility, | ||||||
| 						permission_proxy_hosts: permissions.proxy_hosts, | 						permission_proxy_hosts: permissions.proxy_hosts, | ||||||
| 						permission_redirection_hosts: permissions.redirection_hosts, | 						permission_redirection_hosts: permissions.redirection_hosts, | ||||||
| @@ -277,10 +250,9 @@ export default function (token_string) { | |||||||
| 					properties: {}, | 					properties: {}, | ||||||
| 				}; | 				}; | ||||||
|  |  | ||||||
| 				const rawData = fs.readFileSync( | 				const rawData = fs.readFileSync(`${__dirname}/access/${permission.replace(/:/gim, "-")}.json`, { | ||||||
| 					`${__dirname}/access/${permission.replace(/:/gim, "-")}.json`, | 					encoding: "utf8", | ||||||
| 					{ encoding: "utf8" }, | 				}); | ||||||
| 				); |  | ||||||
| 				permissionSchema.properties[permission] = JSON.parse(rawData); | 				permissionSchema.properties[permission] = JSON.parse(rawData); | ||||||
|  |  | ||||||
| 				const ajv = new Ajv({ | 				const ajv = new Ajv({ | ||||||
|   | |||||||
| @@ -1,15 +1,15 @@ | |||||||
| import Access from "../access.js"; | import Access from "../access.js"; | ||||||
|  |  | ||||||
| export default () => { | export default () => { | ||||||
| 	return (_, res, next) => { | 	return async (_, res, next) => { | ||||||
| 		res.locals.access = null; | 		try { | ||||||
| 		const access = new Access(res.locals.token || null); | 			res.locals.access = null; | ||||||
| 		access | 			const access = new Access(res.locals.token || null); | ||||||
| 			.load() | 			await access.load(); | ||||||
| 			.then(() => { | 			res.locals.access = access; | ||||||
| 				res.locals.access = access; | 			next(); | ||||||
| 				next(); | 		} catch (err) { | ||||||
| 			}) | 			next(err); | ||||||
| 			.catch(next); | 		} | ||||||
| 	}; | 	}; | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -14,30 +14,27 @@ const ajv = new Ajv({ | |||||||
|  * @param {Object} payload |  * @param {Object} payload | ||||||
|  * @returns {Promise} |  * @returns {Promise} | ||||||
|  */ |  */ | ||||||
| function apiValidator(schema, payload /*, description*/) { | const apiValidator = async (schema, payload /*, description*/) => { | ||||||
| 	return new Promise(function Promise_apiValidator(resolve, reject) { | 	if (!schema) { | ||||||
| 		if (schema === null) { | 		throw new errs.ValidationError("Schema is undefined"); | ||||||
| 			reject(new errs.ValidationError("Schema is undefined")); | 	} | ||||||
| 			return; |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		if (typeof payload === "undefined") { | 	// Can't use falsy check here as valid payload could be `0` or `false` | ||||||
| 			reject(new errs.ValidationError("Payload is undefined")); | 	if (typeof payload === "undefined") { | ||||||
| 			return; | 		throw new errs.ValidationError("Payload is undefined"); | ||||||
| 		} | 	} | ||||||
|  |  | ||||||
| 		const validate = ajv.compile(schema); | 	const validate = ajv.compile(schema); | ||||||
| 		const valid = validate(payload); | 	const valid = validate(payload); | ||||||
|  |  | ||||||
| 		if (valid && !validate.errors) { | 	if (valid && !validate.errors) { | ||||||
| 			resolve(payload); | 		return payload; | ||||||
| 		} else { | 	} | ||||||
| 			const message = ajv.errorsText(validate.errors); |  | ||||||
| 			const err = new errs.ValidationError(message); | 	const message = ajv.errorsText(validate.errors); | ||||||
| 			err.debug = [validate.errors, payload]; | 	const err = new errs.ValidationError(message); | ||||||
| 			reject(err); | 	err.debug = [validate.errors, payload]; | ||||||
| 		} | 	throw err; | ||||||
| 	}); | }; | ||||||
| } |  | ||||||
|  |  | ||||||
| export default apiValidator; | export default apiValidator; | ||||||
|   | |||||||
| @@ -38,7 +38,7 @@ | |||||||
| 	}, | 	}, | ||||||
| 	"devDependencies": { | 	"devDependencies": { | ||||||
| 		"@apidevtools/swagger-parser": "^10.1.0", | 		"@apidevtools/swagger-parser": "^10.1.0", | ||||||
| 		"@biomejs/biome": "2.2.0", | 		"@biomejs/biome": "^2.2.3", | ||||||
| 		"chalk": "4.1.2", | 		"chalk": "4.1.2", | ||||||
| 		"nodemon": "^2.0.2" | 		"nodemon": "^2.0.2" | ||||||
| 	}, | 	}, | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import express from "express"; | import express from "express"; | ||||||
| import errs from "../lib/error.js"; | import errs from "../lib/error.js"; | ||||||
| import pjson from "../package.json" with { type: "json" }; | import pjson from "../package.json" with { type: "json" }; | ||||||
|  | import { isSetup } from "../setup.js"; | ||||||
| import auditLogRoutes from "./audit-log.js"; | import auditLogRoutes from "./audit-log.js"; | ||||||
| import accessListsRoutes from "./nginx/access_lists.js"; | import accessListsRoutes from "./nginx/access_lists.js"; | ||||||
| import certificatesHostsRoutes from "./nginx/certificates.js"; | import certificatesHostsRoutes from "./nginx/certificates.js"; | ||||||
| @@ -24,11 +25,13 @@ const router = express.Router({ | |||||||
|  * Health Check |  * Health Check | ||||||
|  * GET /api |  * GET /api | ||||||
|  */ |  */ | ||||||
| router.get("/", (_, res /*, next*/) => { | router.get("/", async (_, res /*, next*/) => { | ||||||
| 	const version = pjson.version.split("-").shift().split("."); | 	const version = pjson.version.split("-").shift().split("."); | ||||||
|  | 	const setup = await isSetup(); | ||||||
|  |  | ||||||
| 	res.status(200).send({ | 	res.status(200).send({ | ||||||
| 		status: "OK", | 		status: "OK", | ||||||
|  | 		setup, | ||||||
| 		version: { | 		version: { | ||||||
| 			major: Number.parseInt(version.shift(), 10), | 			major: Number.parseInt(version.shift(), 10), | ||||||
| 			minor: Number.parseInt(version.shift(), 10), | 			minor: Number.parseInt(version.shift(), 10), | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ import express from "express"; | |||||||
| import internalToken from "../internal/token.js"; | import internalToken from "../internal/token.js"; | ||||||
| import jwtdecode from "../lib/express/jwt-decode.js"; | import jwtdecode from "../lib/express/jwt-decode.js"; | ||||||
| import apiValidator from "../lib/validator/api.js"; | import apiValidator from "../lib/validator/api.js"; | ||||||
|  | import { express as logger } from "../logger.js"; | ||||||
| import { getValidationSchema } from "../schema/index.js"; | import { getValidationSchema } from "../schema/index.js"; | ||||||
|  |  | ||||||
| const router = express.Router({ | const router = express.Router({ | ||||||
| @@ -23,16 +24,17 @@ router | |||||||
| 	 * We also piggy back on to this method, allowing admins to get tokens | 	 * We also piggy back on to this method, allowing admins to get tokens | ||||||
| 	 * for services like Job board and Worker. | 	 * for services like Job board and Worker. | ||||||
| 	 */ | 	 */ | ||||||
| 	.get(jwtdecode(), (req, res, next) => { | 	.get(jwtdecode(), async (req, res, next) => { | ||||||
| 		internalToken | 		try { | ||||||
| 			.getFreshToken(res.locals.access, { | 			const data = await internalToken.getFreshToken(res.locals.access, { | ||||||
| 				expiry: typeof req.query.expiry !== "undefined" ? req.query.expiry : null, | 				expiry: typeof req.query.expiry !== "undefined" ? req.query.expiry : null, | ||||||
| 				scope: typeof req.query.scope !== "undefined" ? req.query.scope : null, | 				scope: typeof req.query.scope !== "undefined" ? req.query.scope : null, | ||||||
| 			}) | 			}); | ||||||
| 			.then((data) => { | 			res.status(200).send(data); | ||||||
| 				res.status(200).send(data); | 		} catch (err) { | ||||||
| 			}) | 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||||
| 			.catch(next); | 			next(err); | ||||||
|  | 		} | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| @@ -41,12 +43,14 @@ router | |||||||
| 	 * Create a new Token | 	 * Create a new Token | ||||||
| 	 */ | 	 */ | ||||||
| 	.post(async (req, res, next) => { | 	.post(async (req, res, next) => { | ||||||
| 		apiValidator(getValidationSchema("/tokens", "post"), req.body) | 		try { | ||||||
| 			.then(internalToken.getTokenFromEmail) | 			const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body); | ||||||
| 			.then((data) => { | 			const result = await internalToken.getTokenFromEmail(data); | ||||||
| 				res.status(200).send(data); | 			res.status(200).send(result); | ||||||
| 			}) | 		} catch (err) { | ||||||
| 			.catch(next); | 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||||
|  | 			next(err); | ||||||
|  | 		} | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| export default router; | export default router; | ||||||
|   | |||||||
| @@ -1,10 +1,13 @@ | |||||||
| import express from "express"; | import express from "express"; | ||||||
| import internalUser from "../internal/user.js"; | import internalUser from "../internal/user.js"; | ||||||
|  | import Access from "../lib/access.js"; | ||||||
| import jwtdecode from "../lib/express/jwt-decode.js"; | import jwtdecode from "../lib/express/jwt-decode.js"; | ||||||
| import userIdFromMe from "../lib/express/user-id-from-me.js"; | import userIdFromMe from "../lib/express/user-id-from-me.js"; | ||||||
| import apiValidator from "../lib/validator/api.js"; | import apiValidator from "../lib/validator/api.js"; | ||||||
| import validator from "../lib/validator/index.js"; | import validator from "../lib/validator/index.js"; | ||||||
|  | import { express as logger } from "../logger.js"; | ||||||
| import { getValidationSchema } from "../schema/index.js"; | import { getValidationSchema } from "../schema/index.js"; | ||||||
|  | import { isSetup } from "../setup.js"; | ||||||
|  |  | ||||||
| const router = express.Router({ | const router = express.Router({ | ||||||
| 	caseSensitive: true, | 	caseSensitive: true, | ||||||
| @@ -27,35 +30,31 @@ router | |||||||
| 	 * | 	 * | ||||||
| 	 * Retrieve all users | 	 * Retrieve all users | ||||||
| 	 */ | 	 */ | ||||||
| 	.get((req, res, next) => { | 	.get(async (req, res, next) => { | ||||||
| 		validator( | 		try { | ||||||
| 			{ | 			const data = await validator( | ||||||
| 				additionalProperties: false, | 				{ | ||||||
| 				properties: { | 					additionalProperties: false, | ||||||
| 					expand: { | 					properties: { | ||||||
| 						$ref: "common#/properties/expand", | 						expand: { | ||||||
| 					}, | 							$ref: "common#/properties/expand", | ||||||
| 					query: { | 						}, | ||||||
| 						$ref: "common#/properties/query", | 						query: { | ||||||
|  | 							$ref: "common#/properties/query", | ||||||
|  | 						}, | ||||||
| 					}, | 					}, | ||||||
| 				}, | 				}, | ||||||
| 			}, | 				{ | ||||||
| 			{ | 					expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, | ||||||
| 				expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, | 					query: typeof req.query.query === "string" ? req.query.query : null, | ||||||
| 				query: typeof req.query.query === "string" ? req.query.query : null, | 				}, | ||||||
| 			}, | 			); | ||||||
| 		) | 			const users = await internalUser.getAll(res.locals.access, data.expand, data.query); | ||||||
| 			.then((data) => { | 			res.status(200).send(users); | ||||||
| 				return internalUser.getAll(res.locals.access, data.expand, data.query); | 		} catch (err) { | ||||||
| 			}) | 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||||
| 			.then((users) => { | 			next(err); | ||||||
| 				res.status(200).send(users); | 		} | ||||||
| 			}) |  | ||||||
| 			.catch((err) => { |  | ||||||
| 				console.log(err); |  | ||||||
| 				next(err); |  | ||||||
| 			}); |  | ||||||
| 		//.catch(next); |  | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| @@ -63,15 +62,36 @@ router | |||||||
| 	 * | 	 * | ||||||
| 	 * Create a new User | 	 * Create a new User | ||||||
| 	 */ | 	 */ | ||||||
| 	.post((req, res, next) => { | 	.post(async (req, res, next) => { | ||||||
| 		apiValidator(getValidationSchema("/users", "post"), req.body) | 		const body = req.body; | ||||||
| 			.then((payload) => { |  | ||||||
| 				return internalUser.create(res.locals.access, payload); | 		try { | ||||||
| 			}) | 			// If we are in setup mode, we don't check access for current user | ||||||
| 			.then((result) => { | 			const setup = await isSetup(); | ||||||
| 				res.status(201).send(result); | 			if (!setup) { | ||||||
| 			}) | 				logger.info("Creating a new user in setup mode"); | ||||||
| 			.catch(next); | 				const access = new Access(null); | ||||||
|  | 				await access.load(true); | ||||||
|  | 				res.locals.access = access; | ||||||
|  |  | ||||||
|  | 				// We are in setup mode, set some defaults for this first new user, such as making | ||||||
|  | 				// them an admin. | ||||||
|  | 				body.is_disabled = false; | ||||||
|  | 				if (typeof body.roles !== "object" || body.roles === null) { | ||||||
|  | 					body.roles = []; | ||||||
|  | 				} | ||||||
|  | 				if (body.roles.indexOf("admin") === -1) { | ||||||
|  | 					body.roles.push("admin"); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			const payload = await apiValidator(getValidationSchema("/users", "post"), body); | ||||||
|  | 			const user = await internalUser.create(res.locals.access, payload); | ||||||
|  | 			res.status(201).send(user); | ||||||
|  | 		} catch (err) { | ||||||
|  | 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||||
|  | 			next(err); | ||||||
|  | 		} | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -92,39 +112,37 @@ router | |||||||
| 	 * | 	 * | ||||||
| 	 * Retrieve a specific user | 	 * Retrieve a specific user | ||||||
| 	 */ | 	 */ | ||||||
| 	.get((req, res, next) => { | 	.get(async (req, res, next) => { | ||||||
| 		validator( | 		try { | ||||||
| 			{ | 			const data = await validator( | ||||||
| 				required: ["user_id"], | 				{ | ||||||
| 				additionalProperties: false, | 					required: ["user_id"], | ||||||
| 				properties: { | 					additionalProperties: false, | ||||||
| 					user_id: { | 					properties: { | ||||||
| 						$ref: "common#/properties/id", | 						user_id: { | ||||||
| 					}, | 							$ref: "common#/properties/id", | ||||||
| 					expand: { | 						}, | ||||||
| 						$ref: "common#/properties/expand", | 						expand: { | ||||||
|  | 							$ref: "common#/properties/expand", | ||||||
|  | 						}, | ||||||
| 					}, | 					}, | ||||||
| 				}, | 				}, | ||||||
| 			}, | 				{ | ||||||
| 			{ | 					user_id: req.params.user_id, | ||||||
| 				user_id: req.params.user_id, | 					expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, | ||||||
| 				expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, | 				}, | ||||||
| 			}, | 			); | ||||||
| 		) |  | ||||||
| 			.then((data) => { | 			const user = await internalUser.get(res.locals.access, { | ||||||
| 				return internalUser.get(res.locals.access, { | 				id: data.user_id, | ||||||
| 					id: data.user_id, | 				expand: data.expand, | ||||||
| 					expand: data.expand, | 				omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id), | ||||||
| 					omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id), |  | ||||||
| 				}); |  | ||||||
| 			}) |  | ||||||
| 			.then((user) => { |  | ||||||
| 				res.status(200).send(user); |  | ||||||
| 			}) |  | ||||||
| 			.catch((err) => { |  | ||||||
| 				console.log(err); |  | ||||||
| 				next(err); |  | ||||||
| 			}); | 			}); | ||||||
|  | 			res.status(200).send(user); | ||||||
|  | 		} catch (err) { | ||||||
|  | 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||||
|  | 			next(err); | ||||||
|  | 		} | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| @@ -132,16 +150,16 @@ router | |||||||
| 	 * | 	 * | ||||||
| 	 * Update and existing user | 	 * Update and existing user | ||||||
| 	 */ | 	 */ | ||||||
| 	.put((req, res, next) => { | 	.put(async (req, res, next) => { | ||||||
| 		apiValidator(getValidationSchema("/users/{userID}", "put"), req.body) | 		try { | ||||||
| 			.then((payload) => { | 			const payload = await apiValidator(getValidationSchema("/users/{userID}", "put"), req.body); | ||||||
| 				payload.id = req.params.user_id; | 			payload.id = req.params.user_id; | ||||||
| 				return internalUser.update(res.locals.access, payload); | 			const result = await internalUser.update(res.locals.access, payload); | ||||||
| 			}) | 			res.status(200).send(result); | ||||||
| 			.then((result) => { | 		} catch (err) { | ||||||
| 				res.status(200).send(result); | 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||||
| 			}) | 			next(err); | ||||||
| 			.catch(next); | 		} | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| @@ -149,13 +167,14 @@ router | |||||||
| 	 * | 	 * | ||||||
| 	 * Update and existing user | 	 * Update and existing user | ||||||
| 	 */ | 	 */ | ||||||
| 	.delete((req, res, next) => { | 	.delete(async (req, res, next) => { | ||||||
| 		internalUser | 		try { | ||||||
| 			.delete(res.locals.access, { id: req.params.user_id }) | 			const result = await internalUser.delete(res.locals.access, { id: req.params.user_id }); | ||||||
| 			.then((result) => { | 			res.status(200).send(result); | ||||||
| 				res.status(200).send(result); | 		} catch (err) { | ||||||
| 			}) | 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||||
| 			.catch(next); | 			next(err); | ||||||
|  | 		} | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -176,16 +195,16 @@ router | |||||||
| 	 * | 	 * | ||||||
| 	 * Update password for a user | 	 * Update password for a user | ||||||
| 	 */ | 	 */ | ||||||
| 	.put((req, res, next) => { | 	.put(async (req, res, next) => { | ||||||
| 		apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body) | 		try { | ||||||
| 			.then((payload) => { | 			const payload = await apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body); | ||||||
| 				payload.id = req.params.user_id; | 			payload.id = req.params.user_id; | ||||||
| 				return internalUser.setPassword(res.locals.access, payload); | 			const result = await internalUser.setPassword(res.locals.access, payload); | ||||||
| 			}) | 			res.status(200).send(result); | ||||||
| 			.then((result) => { | 		} catch (err) { | ||||||
| 				res.status(200).send(result); | 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||||
| 			}) | 			next(err); | ||||||
| 			.catch(next); | 		} | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -206,16 +225,16 @@ router | |||||||
| 	 * | 	 * | ||||||
| 	 * Set some or all permissions for a user | 	 * Set some or all permissions for a user | ||||||
| 	 */ | 	 */ | ||||||
| 	.put((req, res, next) => { | 	.put(async (req, res, next) => { | ||||||
| 		apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body) | 		try { | ||||||
| 			.then((payload) => { | 			const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body); | ||||||
| 				payload.id = req.params.user_id; | 			payload.id = req.params.user_id; | ||||||
| 				return internalUser.setPermissions(res.locals.access, payload); | 			const result = await internalUser.setPermissions(res.locals.access, payload); | ||||||
| 			}) | 			res.status(200).send(result); | ||||||
| 			.then((result) => { | 		} catch (err) { | ||||||
| 				res.status(200).send(result); | 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||||
| 			}) | 			next(err); | ||||||
| 			.catch(next); | 		} | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -235,13 +254,16 @@ router | |||||||
| 	 * | 	 * | ||||||
| 	 * Log in as a user | 	 * Log in as a user | ||||||
| 	 */ | 	 */ | ||||||
| 	.post((req, res, next) => { | 	.post(async (req, res, next) => { | ||||||
| 		internalUser | 		try { | ||||||
| 			.loginAs(res.locals.access, { id: Number.parseInt(req.params.user_id, 10) }) | 			const result = await internalUser.loginAs(res.locals.access, { | ||||||
| 			.then((result) => { | 				id: Number.parseInt(req.params.user_id, 10), | ||||||
| 				res.status(200).send(result); | 			}); | ||||||
| 			}) | 			res.status(200).send(result); | ||||||
| 			.catch(next); | 		} catch (err) { | ||||||
|  | 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||||
|  | 			next(err); | ||||||
|  | 		} | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| export default router; | export default router; | ||||||
|   | |||||||
							
								
								
									
										197
									
								
								backend/setup.js
									
									
									
									
									
								
							
							
						
						
									
										197
									
								
								backend/setup.js
									
									
									
									
									
								
							| @@ -7,65 +7,68 @@ import settingModel from "./models/setting.js"; | |||||||
| import userModel from "./models/user.js"; | import userModel from "./models/user.js"; | ||||||
| import userPermissionModel from "./models/user_permission.js"; | import userPermissionModel from "./models/user_permission.js"; | ||||||
|  |  | ||||||
|  | export const isSetup = async () => { | ||||||
|  | 	const row = await userModel.query().select("id").where("is_deleted", 0).first(); | ||||||
|  | 	return row?.id > 0; | ||||||
|  | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Creates a default admin users if one doesn't already exist in the database |  * Creates a default admin users if one doesn't already exist in the database | ||||||
|  * |  * | ||||||
|  * @returns {Promise} |  * @returns {Promise} | ||||||
|  */ |  */ | ||||||
| const setupDefaultUser = () => { | const setupDefaultUser = async () => { | ||||||
| 	return userModel | 	const initialAdminEmail = process.env.INITIAL_ADMIN_EMAIL; | ||||||
| 		.query() | 	const initialAdminPassword = process.env.INITIAL_ADMIN_PASSWORD; | ||||||
| 		.select("id") |  | ||||||
| 		.where("is_deleted", 0) |  | ||||||
| 		.first() |  | ||||||
| 		.then((row) => { |  | ||||||
| 			if (!row || !row.id) { |  | ||||||
| 				// Create a new user and set password |  | ||||||
| 				const email    = (process.env.INITIAL_ADMIN_EMAIL || 'admin@example.com').toLowerCase(); |  | ||||||
| 				const password = process.env.INITIAL_ADMIN_PASSWORD || "changeme"; |  | ||||||
|  |  | ||||||
| 				logger.info(`Creating a new user: ${email} with password: ${password}`); | 	// This will only create a new user when there are no active users in the database | ||||||
|  | 	// and the INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD environment variables are set. | ||||||
|  | 	// Otherwise, users should be shown the setup wizard in the frontend. | ||||||
|  | 	// I'm keeping this legacy behavior in case some people are automating deployments. | ||||||
|  |  | ||||||
| 				const data = { | 	if (!initialAdminEmail || !initialAdminPassword) { | ||||||
| 					is_deleted: 0, | 		return Promise.resolve(); | ||||||
| 					email: email, | 	} | ||||||
| 					name: "Administrator", |  | ||||||
| 					nickname: "Admin", |  | ||||||
| 					avatar: "", |  | ||||||
| 					roles: ["admin"], |  | ||||||
| 				}; |  | ||||||
|  |  | ||||||
| 				return userModel | 	const userIsetup = await isSetup(); | ||||||
| 					.query() | 	if (!userIsetup) { | ||||||
| 					.insertAndFetch(data) | 		// Create a new user and set password | ||||||
| 					.then((user) => { | 		logger.info(`Creating a new user: ${initialAdminEmail} with password: ${initialAdminPassword}`); | ||||||
| 						return authModel |  | ||||||
| 							.query() | 		const data = { | ||||||
| 							.insert({ | 			is_deleted: 0, | ||||||
| 								user_id: user.id, | 			email: email, | ||||||
| 								type: "password", | 			name: "Administrator", | ||||||
| 								secret: password, | 			nickname: "Admin", | ||||||
| 								meta: {}, | 			avatar: "", | ||||||
| 							}) | 			roles: ["admin"], | ||||||
| 							.then(() => { | 		}; | ||||||
| 								return userPermissionModel.query().insert({ |  | ||||||
| 									user_id: user.id, | 		const user = await userModel | ||||||
| 									visibility: "all", | 			.query() | ||||||
| 									proxy_hosts: "manage", | 			.insertAndFetch(data); | ||||||
| 									redirection_hosts: "manage", |  | ||||||
| 									dead_hosts: "manage", | 		await authModel | ||||||
| 									streams: "manage", | 			.query() | ||||||
| 									access_lists: "manage", | 			.insert({ | ||||||
| 									certificates: "manage", | 				user_id: user.id, | ||||||
| 								}); | 				type: "password", | ||||||
| 							}); | 				secret: password, | ||||||
| 					}) | 				meta: {}, | ||||||
| 					.then(() => { | 			}); | ||||||
| 						logger.info("Initial admin setup completed"); |  | ||||||
| 					}); | 		await userPermissionModel.query().insert({ | ||||||
| 			} | 			user_id: user.id, | ||||||
| 			logger.debug("Admin user setup not required"); | 			visibility: "all", | ||||||
|  | 			proxy_hosts: "manage", | ||||||
|  | 			redirection_hosts: "manage", | ||||||
|  | 			dead_hosts: "manage", | ||||||
|  | 			streams: "manage", | ||||||
|  | 			access_lists: "manage", | ||||||
|  | 			certificates: "manage", | ||||||
| 		}); | 		}); | ||||||
|  | 		logger.info("Initial admin setup completed"); | ||||||
|  | 	} | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -73,29 +76,25 @@ const setupDefaultUser = () => { | |||||||
|  * |  * | ||||||
|  * @returns {Promise} |  * @returns {Promise} | ||||||
|  */ |  */ | ||||||
| const setupDefaultSettings = () => { | const setupDefaultSettings = async () => { | ||||||
| 	return settingModel | 	const row = await settingModel | ||||||
| 		.query() | 		.query() | ||||||
| 		.select("id") | 		.select("id") | ||||||
| 		.where({ id: "default-site" }) | 		.where({ id: "default-site" }) | ||||||
| 		.first() | 		.first(); | ||||||
| 		.then((row) => { |  | ||||||
| 			if (!row || !row.id) { | 	if (!row?.id) { | ||||||
| 				settingModel | 		await settingModel | ||||||
| 					.query() | 			.query() | ||||||
| 					.insert({ | 			.insert({ | ||||||
| 						id: "default-site", | 				id: "default-site", | ||||||
| 						name: "Default Site", | 				name: "Default Site", | ||||||
| 						description: "What to show when Nginx is hit with an unknown Host", | 				description: "What to show when Nginx is hit with an unknown Host", | ||||||
| 						value: "congratulations", | 				value: "congratulations", | ||||||
| 						meta: {}, | 				meta: {}, | ||||||
| 					}) | 			}); | ||||||
| 					.then(() => { | 		logger.info("Default settings added"); | ||||||
| 						logger.info("Default settings added"); | 	} | ||||||
| 					}); |  | ||||||
| 			} |  | ||||||
| 			logger.debug("Default setting setup not required"); |  | ||||||
| 		}); |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -103,43 +102,41 @@ const setupDefaultSettings = () => { | |||||||
|  * |  * | ||||||
|  * @returns {Promise} |  * @returns {Promise} | ||||||
|  */ |  */ | ||||||
| const setupCertbotPlugins = () => { | const setupCertbotPlugins = async () => { | ||||||
| 	return certificateModel | 	const certificates = await certificateModel | ||||||
| 		.query() | 		.query() | ||||||
| 		.where("is_deleted", 0) | 		.where("is_deleted", 0) | ||||||
| 		.andWhere("provider", "letsencrypt") | 		.andWhere("provider", "letsencrypt"); | ||||||
| 		.then((certificates) => { |  | ||||||
| 			if (certificates?.length) { |  | ||||||
| 				const plugins = []; |  | ||||||
| 				const promises = []; |  | ||||||
|  |  | ||||||
| 				certificates.map((certificate) => { | 	if (certificates?.length) { | ||||||
| 					if (certificate.meta && certificate.meta.dns_challenge === true) { | 		const plugins = []; | ||||||
| 						if (plugins.indexOf(certificate.meta.dns_provider) === -1) { | 		const promises = []; | ||||||
| 							plugins.push(certificate.meta.dns_provider); |  | ||||||
| 						} |  | ||||||
|  |  | ||||||
| 						// Make sure credentials file exists | 		certificates.map((certificate) => { | ||||||
| 						const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; | 			if (certificate.meta && certificate.meta.dns_challenge === true) { | ||||||
| 						// Escape single quotes and backslashes | 				if (plugins.indexOf(certificate.meta.dns_provider) === -1) { | ||||||
| 						const escapedCredentials = certificate.meta.dns_provider_credentials | 					plugins.push(certificate.meta.dns_provider); | ||||||
| 							.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; |  | ||||||
| 				}); |  | ||||||
|  |  | ||||||
| 				return installPlugins(plugins).then(() => { | 				// Make sure credentials file exists | ||||||
| 					if (promises.length) { | 				const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; | ||||||
| 						return Promise.all(promises).then(() => { | 				// Escape single quotes and backslashes | ||||||
| 							logger.info(`Added Certbot plugins ${plugins.join(", ")}`); | 				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; | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
|  | 		await installPlugins(plugins); | ||||||
|  |  | ||||||
|  | 		if (promises.length) { | ||||||
|  | 			await Promise.all(promises); | ||||||
|  | 			logger.info(`Added Certbot plugins ${plugins.join(", ")}`); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|   | |||||||
| @@ -43,59 +43,59 @@ | |||||||
|     ajv-draft-04 "^1.0.0" |     ajv-draft-04 "^1.0.0" | ||||||
|     call-me-maybe "^1.0.2" |     call-me-maybe "^1.0.2" | ||||||
|  |  | ||||||
| "@biomejs/biome@2.2.0": | "@biomejs/biome@^2.2.3": | ||||||
|   version "2.2.0" |   version "2.2.3" | ||||||
|   resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.2.0.tgz#823ba77363651f310c47909747c879791ebd15c9" |   resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.2.3.tgz#9d17991c80e006c5ca3e21bebe84b7afd71559e3" | ||||||
|   integrity sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw== |   integrity sha512-9w0uMTvPrIdvUrxazZ42Ib7t8Y2yoGLKLdNne93RLICmaHw7mcLv4PPb5LvZLJF3141gQHiCColOh/v6VWlWmg== | ||||||
|   optionalDependencies: |   optionalDependencies: | ||||||
|     "@biomejs/cli-darwin-arm64" "2.2.0" |     "@biomejs/cli-darwin-arm64" "2.2.3" | ||||||
|     "@biomejs/cli-darwin-x64" "2.2.0" |     "@biomejs/cli-darwin-x64" "2.2.3" | ||||||
|     "@biomejs/cli-linux-arm64" "2.2.0" |     "@biomejs/cli-linux-arm64" "2.2.3" | ||||||
|     "@biomejs/cli-linux-arm64-musl" "2.2.0" |     "@biomejs/cli-linux-arm64-musl" "2.2.3" | ||||||
|     "@biomejs/cli-linux-x64" "2.2.0" |     "@biomejs/cli-linux-x64" "2.2.3" | ||||||
|     "@biomejs/cli-linux-x64-musl" "2.2.0" |     "@biomejs/cli-linux-x64-musl" "2.2.3" | ||||||
|     "@biomejs/cli-win32-arm64" "2.2.0" |     "@biomejs/cli-win32-arm64" "2.2.3" | ||||||
|     "@biomejs/cli-win32-x64" "2.2.0" |     "@biomejs/cli-win32-x64" "2.2.3" | ||||||
|  |  | ||||||
| "@biomejs/cli-darwin-arm64@2.2.0": | "@biomejs/cli-darwin-arm64@2.2.3": | ||||||
|   version "2.2.0" |   version "2.2.3" | ||||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.0.tgz#1abf9508e7d0776871710687ddad36e692dce3bc" |   resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.3.tgz#e18240343fa705dafb08ba72a7b0e88f04a8be3e" | ||||||
|   integrity sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg== |   integrity sha512-OrqQVBpadB5eqzinXN4+Q6honBz+tTlKVCsbEuEpljK8ASSItzIRZUA02mTikl3H/1nO2BMPFiJ0nkEZNy3B1w== | ||||||
|  |  | ||||||
| "@biomejs/cli-darwin-x64@2.2.0": | "@biomejs/cli-darwin-x64@2.2.3": | ||||||
|   version "2.2.0" |   version "2.2.3" | ||||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.0.tgz#3a51aa569505fedd3a32bb914d608ec27d87f26d" |   resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.3.tgz#964b51c9f649e3a725f6f43e75c4173b9ab8a3ae" | ||||||
|   integrity sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw== |   integrity sha512-OCdBpb1TmyfsTgBAM1kPMXyYKTohQ48WpiN9tkt9xvU6gKVKHY4oVwteBebiOqyfyzCNaSiuKIPjmHjUZ2ZNMg== | ||||||
|  |  | ||||||
| "@biomejs/cli-linux-arm64-musl@2.2.0": | "@biomejs/cli-linux-arm64-musl@2.2.3": | ||||||
|   version "2.2.0" |   version "2.2.3" | ||||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.0.tgz#4d720930732a825b7a8c7cfe1741aec9e7d5ae1d" |   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.3.tgz#1756c37960d5585ca865e184539b113e48719b41" | ||||||
|   integrity sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ== |   integrity sha512-q3w9jJ6JFPZPeqyvwwPeaiS/6NEszZ+pXKF+IczNo8Xj6fsii45a4gEEicKyKIytalV+s829ACZujQlXAiVLBQ== | ||||||
|  |  | ||||||
| "@biomejs/cli-linux-arm64@2.2.0": | "@biomejs/cli-linux-arm64@2.2.3": | ||||||
|   version "2.2.0" |   version "2.2.3" | ||||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.0.tgz#d0a5c153ff9243b15600781947d70d6038226feb" |   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.3.tgz#036c6334d5b09b51233ce5120b18f4c89a15a74c" | ||||||
|   integrity sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw== |   integrity sha512-g/Uta2DqYpECxG+vUmTAmUKlVhnGEcY7DXWgKP8ruLRa8Si1QHsWknPY3B/wCo0KgYiFIOAZ9hjsHfNb9L85+g== | ||||||
|  |  | ||||||
| "@biomejs/cli-linux-x64-musl@2.2.0": | "@biomejs/cli-linux-x64-musl@2.2.3": | ||||||
|   version "2.2.0" |   version "2.2.3" | ||||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.0.tgz#946095b0a444f395b2df9244153e1cd6b07404c0" |   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.3.tgz#e6cce01910b9f56c1645c5518595d0b1eb38c245" | ||||||
|   integrity sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg== |   integrity sha512-y76Dn4vkP1sMRGPFlNc+OTETBhGPJ90jY3il6jAfur8XWrYBQV3swZ1Jo0R2g+JpOeeoA0cOwM7mJG6svDz79w== | ||||||
|  |  | ||||||
| "@biomejs/cli-linux-x64@2.2.0": | "@biomejs/cli-linux-x64@2.2.3": | ||||||
|   version "2.2.0" |   version "2.2.3" | ||||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.0.tgz#ae01e0a70c7cd9f842c77dfb4ebd425734667a34" |   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.3.tgz#f328e7cfde92fad6c7ad215df1f51b146b4ed007" | ||||||
|   integrity sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw== |   integrity sha512-LEtyYL1fJsvw35CxrbQ0gZoxOG3oZsAjzfRdvRBRHxOpQ91Q5doRVjvWW/wepgSdgk5hlaNzfeqpyGmfSD0Eyw== | ||||||
|  |  | ||||||
| "@biomejs/cli-win32-arm64@2.2.0": | "@biomejs/cli-win32-arm64@2.2.3": | ||||||
|   version "2.2.0" |   version "2.2.3" | ||||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.0.tgz#09a3988b9d4bab8b8b3a41b4de9560bf70943964" |   resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.3.tgz#b8d64ca6dc1c50b8f3d42475afd31b7b44460935" | ||||||
|   integrity sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA== |   integrity sha512-Ms9zFYzjcJK7LV+AOMYnjN3pV3xL8Prxf9aWdDVL74onLn5kcvZ1ZMQswE5XHtnd/r/0bnUd928Rpbs14BzVmA== | ||||||
|  |  | ||||||
| "@biomejs/cli-win32-x64@2.2.0": | "@biomejs/cli-win32-x64@2.2.3": | ||||||
|   version "2.2.0" |   version "2.2.3" | ||||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.0.tgz#5d2523b421d847b13fac146cf745436ea8a72b95" |   resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.3.tgz#ecafffddf0c0675c825735c7cc917cbc8c538433" | ||||||
|   integrity sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww== |   integrity sha512-gvCpewE7mBwBIpqk1YrUqNR4mCiyJm6UI3YWQQXkedSSEwzRdodRpaKhbdbHw1/hmTWOVXQ+Eih5Qctf4TCVOQ== | ||||||
|  |  | ||||||
| "@gar/promisify@^1.0.1": | "@gar/promisify@^1.0.1": | ||||||
|   version "1.1.3" |   version "1.1.3" | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ services: | |||||||
|   fullstack: |   fullstack: | ||||||
|     image: "${IMAGE}:${BRANCH_LOWER}-ci-${BUILD_NUMBER}" |     image: "${IMAGE}:${BRANCH_LOWER}-ci-${BUILD_NUMBER}" | ||||||
|     environment: |     environment: | ||||||
|  |       TZ: "${TZ:-Australia/Brisbane}" | ||||||
|       DEBUG: 'true' |       DEBUG: 'true' | ||||||
|       FORCE_COLOR: 1 |       FORCE_COLOR: 1 | ||||||
|       # Required for DNS Certificate provisioning in CI |       # Required for DNS Certificate provisioning in CI | ||||||
|   | |||||||
| @@ -18,6 +18,7 @@ services: | |||||||
|           - website2.example.com |           - website2.example.com | ||||||
|           - website3.example.com |           - website3.example.com | ||||||
|     environment: |     environment: | ||||||
|  |       TZ: "${TZ:-Australia/Brisbane}" | ||||||
|       PUID: 1000 |       PUID: 1000 | ||||||
|       PGID: 1000 |       PGID: 1000 | ||||||
|       FORCE_COLOR: 1 |       FORCE_COLOR: 1 | ||||||
| @@ -49,6 +50,7 @@ services: | |||||||
|       - ../backend:/app |       - ../backend:/app | ||||||
|       - ../frontend:/app/frontend |       - ../frontend:/app/frontend | ||||||
|       - ../global:/app/global |       - ../global:/app/global | ||||||
|  |       - '/etc/localtime:/etc/localtime:ro' | ||||||
|     healthcheck: |     healthcheck: | ||||||
|       test: ["CMD", "/usr/bin/check-health"] |       test: ["CMD", "/usr/bin/check-health"] | ||||||
|       interval: 10s |       interval: 10s | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ | |||||||
|     "preview": "vitepress preview" |     "preview": "vitepress preview" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "vitepress": "^1.4.0" |     "vitepress": "^1.6.4" | ||||||
|   }, |   }, | ||||||
|   "dependencies": {} |   "dependencies": {} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -228,3 +228,13 @@ To enable the geoip2 module, you can create the custom configuration file `/data | |||||||
| load_module /usr/lib/nginx/modules/ngx_http_geoip2_module.so; | load_module /usr/lib/nginx/modules/ngx_http_geoip2_module.so; | ||||||
| load_module /usr/lib/nginx/modules/ngx_stream_geoip2_module.so; | load_module /usr/lib/nginx/modules/ngx_stream_geoip2_module.so; | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  | ## Auto Initial User Creation | ||||||
|  |  | ||||||
|  | Setting these environment variables will create the default user on startup, skipping the UI first user setup screen: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  |     environment: | ||||||
|  |       INITIAL_ADMIN_EMAIL: my@example.com | ||||||
|  |       INITIAL_ADMIN_PASSWORD: mypassword1 | ||||||
|  | ``` | ||||||
|   | |||||||
| @@ -23,4 +23,10 @@ Your best bet is to ask the [Reddit community for support](https://www.reddit.co | |||||||
|  |  | ||||||
| ## When adding username and password access control to a proxy host, I can no longer login into the app. | ## When adding username and password access control to a proxy host, I can no longer login into the app. | ||||||
|  |  | ||||||
| Having an Access Control List (ACL) with username and password requires the browser to always send this username and password in the `Authorization` header on each request. If your proxied app also requires authentication (like Nginx Proxy Manager itself), most likely the app will also use the `Authorization` header to transmit this information, as this is the standardized header meant for this kind of information. However having multiples of the same headers is not allowed in the [internet standard](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) and almost all apps do not support multiple values in the `Authorization` header. Hence one of the two logins will be broken. This can only be fixed by either removing one of the logins or by changing the app to use other non-standard headers for authorization. | Having an Access Control List (ACL) with username and password requires the browser to always send this username | ||||||
|  | and password in the `Authorization` header on each request. If your proxied app also requires authentication (like | ||||||
|  | Nginx Proxy Manager itself), most likely the app will also use the `Authorization` header to transmit this information, | ||||||
|  | as this is the standardized header meant for this kind of information. However having multiples of the same headers | ||||||
|  | is not allowed in the [internet standard](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) and almost all apps | ||||||
|  | do not support multiple values in the `Authorization` header. Hence one of the two logins will be broken. This can | ||||||
|  | only be fixed by either removing one of the logins or by changing the app to use other non-standard headers for authorization. | ||||||
|   | |||||||
| @@ -35,7 +35,7 @@ so that the barrier for entry here is low. | |||||||
|  |  | ||||||
| ## Features | ## Features | ||||||
|  |  | ||||||
| - Beautiful and Secure Admin Interface based on [Tabler](https://tabler.github.io/) | - Beautiful and Secure Admin Interface based on [Tabler](https://tabler.io/) | ||||||
| - Easily create forwarding domains, redirections, streams and 404 hosts without knowing anything about Nginx | - Easily create forwarding domains, redirections, streams and 404 hosts without knowing anything about Nginx | ||||||
| - Free SSL using Let's Encrypt or provide your own custom SSL certificates | - Free SSL using Let's Encrypt or provide your own custom SSL certificates | ||||||
| - Access Lists and basic HTTP Authentication for your hosts | - Access Lists and basic HTTP Authentication for your hosts | ||||||
| @@ -66,6 +66,8 @@ services: | |||||||
|   app: |   app: | ||||||
|     image: 'jc21/nginx-proxy-manager:latest' |     image: 'jc21/nginx-proxy-manager:latest' | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|  |     environment: | ||||||
|  |       TZ: "Australia/Brisbane" | ||||||
|     ports: |     ports: | ||||||
|       - '80:80' |       - '80:80' | ||||||
|       - '81:81' |       - '81:81' | ||||||
| @@ -89,17 +91,10 @@ docker compose up -d | |||||||
| 4. Log in to the Admin UI | 4. Log in to the Admin UI | ||||||
|  |  | ||||||
| When your docker container is running, connect to it on port `81` for the admin interface. | When your docker container is running, connect to it on port `81` for the admin interface. | ||||||
| Sometimes this can take a little bit because of the entropy of keys. |  | ||||||
|  |  | ||||||
| [http://127.0.0.1:81](http://127.0.0.1:81) | [http://127.0.0.1:81](http://127.0.0.1:81) | ||||||
|  |  | ||||||
| Default Admin User: | This startup can take a minute depending on your hardware. | ||||||
| ``` |  | ||||||
| Email:    admin@example.com |  | ||||||
| Password: changeme |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| Immediately after logging in with this default user you will be asked to modify your details and change your password. |  | ||||||
|  |  | ||||||
|  |  | ||||||
| ## Contributing | ## Contributing | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ services: | |||||||
|   app: |   app: | ||||||
|     image: 'jc21/nginx-proxy-manager:latest' |     image: 'jc21/nginx-proxy-manager:latest' | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|  |  | ||||||
|     ports: |     ports: | ||||||
|       # These ports are in format <host-port>:<container-port> |       # These ports are in format <host-port>:<container-port> | ||||||
|       - '80:80' # Public HTTP Port |       - '80:80' # Public HTTP Port | ||||||
| @@ -21,7 +22,9 @@ services: | |||||||
|       # Add any other Stream port you want to expose |       # Add any other Stream port you want to expose | ||||||
|       # - '21:21' # FTP |       # - '21:21' # FTP | ||||||
|  |  | ||||||
|     #environment: |     environment: | ||||||
|  |       TZ: "Australia/Brisbane" | ||||||
|  |  | ||||||
|       # Uncomment this if you want to change the location of |       # Uncomment this if you want to change the location of | ||||||
|       # the SQLite DB file within the container |       # the SQLite DB file within the container | ||||||
|       # DB_SQLITE_FILE: "/data/database.sqlite" |       # DB_SQLITE_FILE: "/data/database.sqlite" | ||||||
| @@ -65,6 +68,7 @@ services: | |||||||
|       # Add any other Stream port you want to expose |       # Add any other Stream port you want to expose | ||||||
|       # - '21:21' # FTP |       # - '21:21' # FTP | ||||||
|     environment: |     environment: | ||||||
|  |       TZ: "Australia/Brisbane" | ||||||
|       # Mysql/Maria connection parameters: |       # Mysql/Maria connection parameters: | ||||||
|       DB_MYSQL_HOST: "db" |       DB_MYSQL_HOST: "db" | ||||||
|       DB_MYSQL_PORT: 3306 |       DB_MYSQL_PORT: 3306 | ||||||
| @@ -115,6 +119,7 @@ services: | |||||||
|       # Add any other Stream port you want to expose |       # Add any other Stream port you want to expose | ||||||
|       # - '21:21' # FTP |       # - '21:21' # FTP | ||||||
|     environment: |     environment: | ||||||
|  |       TZ: "Australia/Brisbane" | ||||||
|       # Postgres parameters: |       # Postgres parameters: | ||||||
|       DB_POSTGRES_HOST: 'db' |       DB_POSTGRES_HOST: 'db' | ||||||
|       DB_POSTGRES_PORT: '5432' |       DB_POSTGRES_PORT: '5432' | ||||||
| @@ -173,21 +178,3 @@ After the app is running for the first time, the following will happen: | |||||||
| 3. A default admin user will be created | 3. A default admin user will be created | ||||||
|  |  | ||||||
| This process can take a couple of minutes depending on your machine. | This process can take a couple of minutes depending on your machine. | ||||||
|  |  | ||||||
| ## Default Administrator User |  | ||||||
|  |  | ||||||
| ``` |  | ||||||
| Email:    admin@example.com |  | ||||||
| Password: changeme |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| Immediately after logging in with this default user you will be asked to modify your details and change your password. You can change defaults with: |  | ||||||
|  |  | ||||||
|  |  | ||||||
| ``` |  | ||||||
|     environment: |  | ||||||
|       INITIAL_ADMIN_EMAIL: my@example.com |  | ||||||
|       INITIAL_ADMIN_PASSWORD: mypassword1 |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|     "$schema": "https://biomejs.dev/schemas/2.2.2/schema.json", |     "$schema": "https://biomejs.dev/schemas/2.2.3/schema.json", | ||||||
|     "vcs": { |     "vcs": { | ||||||
|         "enabled": true, |         "enabled": true, | ||||||
|         "clientKind": "git", |         "clientKind": "git", | ||||||
|   | |||||||
| @@ -13,8 +13,9 @@ import { | |||||||
| import { useAuthState } from "src/context"; | import { useAuthState } from "src/context"; | ||||||
| import { useHealth } from "src/hooks"; | import { useHealth } from "src/hooks"; | ||||||
|  |  | ||||||
| const Dashboard = lazy(() => import("src/pages/Dashboard")); | const Setup = lazy(() => import("src/pages/Setup")); | ||||||
| const Login = lazy(() => import("src/pages/Login")); | const Login = lazy(() => import("src/pages/Login")); | ||||||
|  | const Dashboard = lazy(() => import("src/pages/Dashboard")); | ||||||
| const Settings = lazy(() => import("src/pages/Settings")); | const Settings = lazy(() => import("src/pages/Settings")); | ||||||
| const Certificates = lazy(() => import("src/pages/Certificates")); | const Certificates = lazy(() => import("src/pages/Certificates")); | ||||||
| const Access = lazy(() => import("src/pages/Access")); | const Access = lazy(() => import("src/pages/Access")); | ||||||
| @@ -37,6 +38,10 @@ function Router() { | |||||||
| 		return <Unhealthy />; | 		return <Unhealthy />; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	if (!health.data?.setup) { | ||||||
|  | 		return <Setup />; | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	if (!authenticated) { | 	if (!authenticated) { | ||||||
| 		return ( | 		return ( | ||||||
| 			<Suspense fallback={<LoadingPage />}> | 			<Suspense fallback={<LoadingPage />}> | ||||||
|   | |||||||
| @@ -88,15 +88,19 @@ interface PostArgs { | |||||||
| 	url: string; | 	url: string; | ||||||
| 	params?: queryString.StringifiableRecord; | 	params?: queryString.StringifiableRecord; | ||||||
| 	data?: any; | 	data?: any; | ||||||
|  | 	noAuth?: boolean; | ||||||
| } | } | ||||||
|  |  | ||||||
| export async function post({ url, params, data }: PostArgs, abortController?: AbortController) { | export async function post({ url, params, data, noAuth }: PostArgs, abortController?: AbortController) { | ||||||
| 	const apiUrl = buildUrl({ url, params }); | 	const apiUrl = buildUrl({ url, params }); | ||||||
| 	const method = "POST"; | 	const method = "POST"; | ||||||
|  |  | ||||||
| 	let headers = { | 	let headers: Record<string, string> = {}; | ||||||
| 		...buildAuthHeader(), | 	if (!noAuth) { | ||||||
| 	}; | 		headers = { | ||||||
|  | 			...buildAuthHeader(), | ||||||
|  | 		}; | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	let body: string | FormData | undefined; | 	let body: string | FormData | undefined; | ||||||
| 	// Check if the data is an instance of FormData | 	// Check if the data is an instance of FormData | ||||||
|   | |||||||
| @@ -1,12 +1,27 @@ | |||||||
| import * as api from "./base"; | import * as api from "./base"; | ||||||
| import type { User } from "./models"; | import type { User } from "./models"; | ||||||
|  |  | ||||||
| export async function createUser(item: User, abortController?: AbortController): Promise<User> { | export interface AuthOptions { | ||||||
|  | 	type: string; | ||||||
|  | 	secret: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface NewUser { | ||||||
|  | 	name: string; | ||||||
|  | 	nickname: string; | ||||||
|  | 	email: string; | ||||||
|  | 	isDisabled?: boolean; | ||||||
|  | 	auth?: AuthOptions; | ||||||
|  | 	roles?: string[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export async function createUser(item: NewUser, noAuth?: boolean, abortController?: AbortController): Promise<User> { | ||||||
| 	return await api.post( | 	return await api.post( | ||||||
| 		{ | 		{ | ||||||
| 			url: "/users", | 			url: "/users", | ||||||
| 			// todo: only use whitelist of fields for this data | 			// todo: only use whitelist of fields for this data | ||||||
| 			data: item, | 			data: item, | ||||||
|  | 			noAuth, | ||||||
| 		}, | 		}, | ||||||
| 		abortController, | 		abortController, | ||||||
| 	); | 	); | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ import type { AppVersion } from "./models"; | |||||||
| export interface HealthResponse { | export interface HealthResponse { | ||||||
| 	status: string; | 	status: string; | ||||||
| 	version: AppVersion; | 	version: AppVersion; | ||||||
|  | 	setup: boolean; | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface TokenResponse { | export interface TokenResponse { | ||||||
|   | |||||||
| @@ -72,6 +72,8 @@ | |||||||
|   "role.standard-user": "Standard User", |   "role.standard-user": "Standard User", | ||||||
|   "save": "Save", |   "save": "Save", | ||||||
|   "settings.title": "Settings", |   "settings.title": "Settings", | ||||||
|  |   "setup.preamble": "Get started by creating your admin account.", | ||||||
|  |   "setup.title": "Welcome!", | ||||||
|   "sign-in": "Sign in", |   "sign-in": "Sign in", | ||||||
|   "streams.actions-title": "Stream #{id}", |   "streams.actions-title": "Stream #{id}", | ||||||
|   "streams.add": "Add Stream", |   "streams.add": "Add Stream", | ||||||
|   | |||||||
| @@ -218,6 +218,12 @@ | |||||||
| 	"settings.title": { | 	"settings.title": { | ||||||
| 		"defaultMessage": "Settings" | 		"defaultMessage": "Settings" | ||||||
| 	}, | 	}, | ||||||
|  | 	"setup.preamble": { | ||||||
|  | 		"defaultMessage": "Get started by creating your admin account." | ||||||
|  | 	}, | ||||||
|  | 	"setup.title": { | ||||||
|  | 		"defaultMessage": "Welcome!" | ||||||
|  | 	}, | ||||||
| 	"sign-in": { | 	"sign-in": { | ||||||
| 		"defaultMessage": "Sign in" | 		"defaultMessage": "Sign in" | ||||||
| 	}, | 	}, | ||||||
|   | |||||||
| @@ -122,18 +122,15 @@ const Dashboard = () => { | |||||||
| 			<pre> | 			<pre> | ||||||
| 				<code>{`Todo: | 				<code>{`Todo: | ||||||
|  |  | ||||||
|  | - Users: permissions modal and trigger after adding user | ||||||
| - modal dialgs for everything | - modal dialgs for everything | ||||||
| - Tables | - Tables | ||||||
| - check mobile | - check mobile | ||||||
| - fix bad jwt not refreshing entire page | - fix bad jwt not refreshing entire page | ||||||
| - add help docs for host types | - add help docs for host types | ||||||
| - show user as disabled on user table |  | ||||||
|  |  | ||||||
| More for api, then implement here: | More for api, then implement here: | ||||||
| - Properly implement refresh tokens | - Properly implement refresh tokens | ||||||
| - don't create default user, instead use the is_setup from v3 |  | ||||||
|   - also remove the initial user/pass env vars |  | ||||||
|   - update docs for this |  | ||||||
| - Add error message_18n for all backend errors | - Add error message_18n for all backend errors | ||||||
| - minor: certificates expand with hosts needs to omit 'is_deleted' | - minor: certificates expand with hosts needs to omit 'is_deleted' | ||||||
| `}</code> | `}</code> | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								frontend/src/pages/Setup/index.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								frontend/src/pages/Setup/index.module.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | .logo { | ||||||
|  | 	width: 200px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .helperBtns { | ||||||
|  | 	position: absolute; | ||||||
|  | 	top: 10px; | ||||||
|  | 	right: 10px; | ||||||
|  | 	z-index: 1000; | ||||||
|  | } | ||||||
							
								
								
									
										191
									
								
								frontend/src/pages/Setup/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								frontend/src/pages/Setup/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,191 @@ | |||||||
|  | import { useQueryClient } from "@tanstack/react-query"; | ||||||
|  | import cn from "classnames"; | ||||||
|  | import { Field, Form, Formik } from "formik"; | ||||||
|  | import { useState } from "react"; | ||||||
|  | import { Alert } from "react-bootstrap"; | ||||||
|  | import { createUser } from "src/api/backend"; | ||||||
|  | import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components"; | ||||||
|  | import { useAuthState } from "src/context"; | ||||||
|  | import { intl } from "src/locale"; | ||||||
|  | import { validateEmail, validateString } from "src/modules/Validations"; | ||||||
|  | import styles from "./index.module.css"; | ||||||
|  |  | ||||||
|  | interface Payload { | ||||||
|  | 	name: string; | ||||||
|  | 	email: string; | ||||||
|  | 	password: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default function Setup() { | ||||||
|  | 	const queryClient = useQueryClient(); | ||||||
|  | 	const { login } = useAuthState(); | ||||||
|  | 	const [errorMsg, setErrorMsg] = useState<string | null>(null); | ||||||
|  |  | ||||||
|  | 	const onSubmit = async (values: Payload, { setSubmitting }: any) => { | ||||||
|  | 		setErrorMsg(null); | ||||||
|  |  | ||||||
|  | 		// Set a nickname, which is the first word of the name | ||||||
|  | 		const nickname = values.name.split(" ")[0]; | ||||||
|  |  | ||||||
|  | 		const { password, ...payload } = { | ||||||
|  | 			...values, | ||||||
|  | 			...{ | ||||||
|  | 				nickname, | ||||||
|  | 				auth: { | ||||||
|  | 					type: "password", | ||||||
|  | 					secret: values.password, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		try { | ||||||
|  | 			const user = await createUser(payload, true); | ||||||
|  | 			if (user && typeof user.id !== "undefined" && user.id) { | ||||||
|  | 				try { | ||||||
|  | 					await login(user.email, password); | ||||||
|  | 					// Trigger a Health change | ||||||
|  | 					await queryClient.refetchQueries({ queryKey: ["health"] }); | ||||||
|  | 					// window.location.reload(); | ||||||
|  | 				} catch (err: any) { | ||||||
|  | 					setErrorMsg(err.message); | ||||||
|  | 				} | ||||||
|  | 			} else { | ||||||
|  | 				setErrorMsg("cannot_create_user"); | ||||||
|  | 			} | ||||||
|  | 		} catch (err: any) { | ||||||
|  | 			setErrorMsg(err.message); | ||||||
|  | 		} | ||||||
|  | 		setSubmitting(false); | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	return ( | ||||||
|  | 		<Page className="page page-center"> | ||||||
|  | 			<div className={cn("d-none", "d-md-flex", styles.helperBtns)}> | ||||||
|  | 				<LocalePicker /> | ||||||
|  | 				<ThemeSwitcher /> | ||||||
|  | 			</div> | ||||||
|  | 			<div className="container container-tight py-4"> | ||||||
|  | 				<div className="text-center mb-4"> | ||||||
|  | 					<img | ||||||
|  | 						className={styles.logo} | ||||||
|  | 						src="/images/logo-text-horizontal-grey.png" | ||||||
|  | 						alt="Nginx Proxy Manager" | ||||||
|  | 					/> | ||||||
|  | 				</div> | ||||||
|  | 				<div className="card card-md"> | ||||||
|  | 					<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible> | ||||||
|  | 						{errorMsg} | ||||||
|  | 					</Alert> | ||||||
|  | 					<Formik | ||||||
|  | 						initialValues={ | ||||||
|  | 							{ | ||||||
|  | 								name: "", | ||||||
|  | 								email: "", | ||||||
|  | 								password: "", | ||||||
|  | 							} as any | ||||||
|  | 						} | ||||||
|  | 						onSubmit={onSubmit} | ||||||
|  | 					> | ||||||
|  | 						{({ isSubmitting }) => ( | ||||||
|  | 							<Form> | ||||||
|  | 								<div className="card-body text-center py-4 p-sm-5"> | ||||||
|  | 									<h1 className="mt-5">{intl.formatMessage({ id: "setup.title" })}</h1> | ||||||
|  | 									<p className="text-secondary">{intl.formatMessage({ id: "setup.preamble" })}</p> | ||||||
|  | 								</div> | ||||||
|  | 								<hr /> | ||||||
|  | 								<div className="card-body"> | ||||||
|  | 									<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 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> | ||||||
|  | 									<div className="mb-3"> | ||||||
|  | 										<Field name="password" validate={validateString(8, 100)}> | ||||||
|  | 											{({ field, form }: any) => ( | ||||||
|  | 												<div className="form-floating mb-3"> | ||||||
|  | 													<input | ||||||
|  | 														id="password" | ||||||
|  | 														type="password" | ||||||
|  | 														className={`form-control ${form.errors.password && form.touched.password ? "is-invalid" : ""}`} | ||||||
|  | 														placeholder={intl.formatMessage({ id: "user.new-password" })} | ||||||
|  | 														{...field} | ||||||
|  | 													/> | ||||||
|  | 													<label htmlFor="password"> | ||||||
|  | 														{intl.formatMessage({ id: "user.new-password" })} | ||||||
|  | 													</label> | ||||||
|  | 													{form.errors.password ? ( | ||||||
|  | 														<div className="invalid-feedback"> | ||||||
|  | 															{form.errors.password && form.touched.password | ||||||
|  | 																? form.errors.password | ||||||
|  | 																: null} | ||||||
|  | 														</div> | ||||||
|  | 													) : null} | ||||||
|  | 												</div> | ||||||
|  | 											)} | ||||||
|  | 										</Field> | ||||||
|  | 									</div> | ||||||
|  | 								</div> | ||||||
|  | 								<div className="text-center my-3 mx-3"> | ||||||
|  | 									<Button | ||||||
|  | 										type="submit" | ||||||
|  | 										actionType="primary" | ||||||
|  | 										data-bs-dismiss="modal" | ||||||
|  | 										isLoading={isSubmitting} | ||||||
|  | 										disabled={isSubmitting} | ||||||
|  | 										className="w-100" | ||||||
|  | 									> | ||||||
|  | 										{intl.formatMessage({ id: "save" })} | ||||||
|  | 									</Button> | ||||||
|  | 								</div> | ||||||
|  | 							</Form> | ||||||
|  | 						)} | ||||||
|  | 					</Formik> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  | 		</Page> | ||||||
|  | 	); | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user