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": { | ||||
|         "enabled": true, | ||||
|         "clientKind": "git", | ||||
|   | ||||
| @@ -18,30 +18,41 @@ export default { | ||||
| 	 * @param   {String} [issuer] | ||||
| 	 * @returns {Promise} | ||||
| 	 */ | ||||
| 	getTokenFromEmail: (data, issuer) => { | ||||
| 	getTokenFromEmail: async (data, issuer) => { | ||||
| 		const Token = TokenModel(); | ||||
|  | ||||
| 		data.scope = data.scope || "user"; | ||||
| 		data.expiry = data.expiry || "1d"; | ||||
|  | ||||
| 		return userModel | ||||
| 		const user = await userModel | ||||
| 			.query() | ||||
| 			.where("email", data.identity.toLowerCase().trim()) | ||||
| 			.andWhere("is_deleted", 0) | ||||
| 			.andWhere("is_disabled", 0) | ||||
| 			.first() | ||||
| 			.then((user) => { | ||||
| 				if (user) { | ||||
| 					// Get auth | ||||
| 					return authModel | ||||
| 			.first(); | ||||
|  | ||||
| 		if (!user) { | ||||
| 			throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); | ||||
| 		} | ||||
|  | ||||
| 		const auth = await authModel | ||||
| 			.query() | ||||
| 			.where("user_id", "=", user.id) | ||||
| 			.where("type", "=", "password") | ||||
| 						.first() | ||||
| 						.then((auth) => { | ||||
| 							if (auth) { | ||||
| 								return auth.verifyPassword(data.secret).then((valid) => { | ||||
| 									if (valid) { | ||||
| 			.first(); | ||||
|  | ||||
| 		if (!auth) { | ||||
| 			throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); | ||||
| 		} | ||||
|  | ||||
| 		const valid = await auth.verifyPassword(data.secret); | ||||
| 		if (!valid) { | ||||
| 			throw new errs.AuthError( | ||||
| 				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, | ||||
| 			// you shall not pass. | ||||
| @@ -54,31 +65,19 @@ export default { | ||||
| 			throw new errs.AuthError(`Invalid expiry time: ${data.expiry}`); | ||||
| 		} | ||||
|  | ||||
| 										return Token.create({ | ||||
| 		const signed = await Token.create({ | ||||
| 			iss: issuer || "api", | ||||
| 			attrs: { | ||||
| 				id: user.id, | ||||
| 			}, | ||||
| 			scope: [data.scope], | ||||
| 			expiresIn: data.expiry, | ||||
| 										}).then((signed) => { | ||||
| 		}); | ||||
|  | ||||
| 		return { | ||||
| 			token: signed.token, | ||||
| 			expires: expiry.toISOString(), | ||||
| 		}; | ||||
| 										}); | ||||
| 									} | ||||
| 									throw new errs.AuthError( | ||||
| 										ERROR_MESSAGE_INVALID_AUTH, | ||||
| 										ERROR_MESSAGE_INVALID_AUTH_I18N, | ||||
| 									); | ||||
| 								}); | ||||
| 							} | ||||
| 							throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); | ||||
| 						}); | ||||
| 				} | ||||
| 				throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); | ||||
| 			}); | ||||
| 	}, | ||||
|  | ||||
| 	/** | ||||
| @@ -88,7 +87,7 @@ export default { | ||||
| 	 * @param {String} [data.scope]   Only considered if existing token scope is admin | ||||
| 	 * @returns {Promise} | ||||
| 	 */ | ||||
| 	getFreshToken: (access, data) => { | ||||
| 	getFreshToken: async (access, data) => { | ||||
| 		const Token = TokenModel(); | ||||
| 		const thisData = data || {}; | ||||
|  | ||||
| @@ -115,17 +114,17 @@ export default { | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return Token.create({ | ||||
| 			const signed = await Token.create({ | ||||
| 				iss: "api", | ||||
| 				scope: scope, | ||||
| 				attrs: token_attrs, | ||||
| 				expiresIn: thisData.expiry, | ||||
| 			}).then((signed) => { | ||||
| 			}); | ||||
|  | ||||
| 			return { | ||||
| 				token: signed.token, | ||||
| 				expires: expiry.toISOString(), | ||||
| 			}; | ||||
| 			}); | ||||
| 		} | ||||
| 		throw new error.AssertionFailedError("Existing token contained invalid user data"); | ||||
| 	}, | ||||
| @@ -136,7 +135,7 @@ export default { | ||||
| 	 */ | ||||
| 	getTokenFromUser: async (user) => { | ||||
| 		const expire = "1d"; | ||||
| 		const Token = new TokenModel(); | ||||
| 		const Token = TokenModel(); | ||||
| 		const expiry = parseDatePeriod(expire); | ||||
|  | ||||
| 		const signed = await Token.create({ | ||||
|   | ||||
| @@ -10,17 +10,20 @@ import internalToken from "./token.js"; | ||||
|  | ||||
| const omissions = () => { | ||||
| 	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 = { | ||||
| 	/** | ||||
| 	 * 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   {Object}  data | ||||
| 	 * @returns {Promise} | ||||
| 	 */ | ||||
| 	create: (access, data) => { | ||||
| 	create: async (access, data) => { | ||||
| 		const auth = data.auth || null; | ||||
| 		delete data.auth; | ||||
|  | ||||
| @@ -31,61 +34,43 @@ const internalUser = { | ||||
| 			data.is_disabled = data.is_disabled ? 1 : 0; | ||||
| 		} | ||||
|  | ||||
| 		return access | ||||
| 			.can("users:create", data) | ||||
| 			.then(() => { | ||||
| 		await access.can("users:create", data); | ||||
| 		data.avatar = gravatar.url(data.email, { default: "mm" }); | ||||
| 				return userModel.query().insertAndFetch(data).then(utils.omitRow(omissions())); | ||||
| 			}) | ||||
| 			.then((user) => { | ||||
|  | ||||
| 		let user = await userModel.query().insertAndFetch(data).then(utils.omitRow(omissions())); | ||||
| 		if (auth) { | ||||
| 					return authModel | ||||
| 						.query() | ||||
| 						.insert({ | ||||
| 			user = await 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 | ||||
| 					.query() | ||||
| 					.insert({ | ||||
| 		// Create permissions row as well | ||||
| 		const isAdmin = data.roles.indexOf("admin") !== -1; | ||||
|  | ||||
| 		await userPermissionModel.query().insert({ | ||||
| 			user_id: user.id, | ||||
| 						visibility: is_admin ? "all" : "user", | ||||
| 			visibility: isAdmin ? "all" : "user", | ||||
| 			proxy_hosts: "manage", | ||||
| 			redirection_hosts: "manage", | ||||
| 			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, { | ||||
|  | ||||
| 		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, | ||||
| 					}) | ||||
| 					.then(() => { | ||||
| 		}); | ||||
|  | ||||
| 		return user; | ||||
| 					}); | ||||
| 			}); | ||||
| 	}, | ||||
|  | ||||
| 	/** | ||||
| @@ -316,11 +301,7 @@ const internalUser = { | ||||
| 		// Query is used for searching | ||||
| 		if (typeof search_query === "string") { | ||||
| 			query.where(function () { | ||||
| 				this.where("name", "like", `%${search_query}%`).orWhere( | ||||
| 					"email", | ||||
| 					"like", | ||||
| 					`%${search_query}%`, | ||||
| 				); | ||||
| 				this.where("name", "like", `%${search_query}%`).orWhere("email", "like", `%${search_query}%`); | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
|   | ||||
| @@ -22,13 +22,13 @@ import errs from "./error.js"; | ||||
| const __filename = fileURLToPath(import.meta.url); | ||||
| const __dirname = dirname(__filename); | ||||
|  | ||||
| export default function (token_string) { | ||||
| export default function (tokenString) { | ||||
| 	const Token = TokenModel(); | ||||
| 	let token_data = null; | ||||
| 	let tokenData = null; | ||||
| 	let initialised = false; | ||||
| 	const object_cache = {}; | ||||
| 	let allow_internal_access = false; | ||||
| 	let user_roles = []; | ||||
| 	const objectCache = {}; | ||||
| 	let allowInternalAccess = false; | ||||
| 	let userRoles = []; | ||||
| 	let permissions = {}; | ||||
|  | ||||
| 	/** | ||||
| @@ -36,65 +36,58 @@ export default function (token_string) { | ||||
| 	 * | ||||
| 	 * @returns {Promise} | ||||
| 	 */ | ||||
| 	this.init = () => { | ||||
| 		return new Promise((resolve, reject) => { | ||||
| 	this.init = async () => { | ||||
| 		if (initialised) { | ||||
| 				resolve(); | ||||
| 			} else if (!token_string) { | ||||
| 				reject(new errs.PermissionError("Permission Denied")); | ||||
| 			} else { | ||||
| 				resolve( | ||||
| 					Token.load(token_string).then((data) => { | ||||
| 						token_data = data; | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		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 ( | ||||
| 							token_data.attrs.id || | ||||
| 							(typeof token_data.scope !== "undefined" && | ||||
| 								_.indexOf(token_data.scope, "user") !== -1) | ||||
| 			tokenData.attrs.id || | ||||
| 			(typeof tokenData.scope !== "undefined" && _.indexOf(tokenData.scope, "user") !== -1) | ||||
| 		) { | ||||
| 			// Has token user id or token user scope | ||||
| 							return userModel | ||||
| 			const user = await userModel | ||||
| 				.query() | ||||
| 								.where("id", token_data.attrs.id) | ||||
| 				.where("id", tokenData.attrs.id) | ||||
| 				.andWhere("is_deleted", 0) | ||||
| 				.andWhere("is_disabled", 0) | ||||
| 				.allowGraph("[permissions]") | ||||
| 				.withGraphFetched("[permissions]") | ||||
| 								.first() | ||||
| 								.then((user) => { | ||||
| 				.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 is_ok = true; | ||||
| 										_.forEach(token_data.scope, (scope_item) => { | ||||
| 				let ok = true; | ||||
| 				_.forEach(tokenData.scope, (scope_item) => { | ||||
| 					if (_.indexOf(user.roles, scope_item) === -1) { | ||||
| 												is_ok = false; | ||||
| 						ok = false; | ||||
| 					} | ||||
| 				}); | ||||
|  | ||||
| 										if (!is_ok) { | ||||
| 				if (!ok) { | ||||
| 					throw new errs.AuthError("Invalid token scope for User"); | ||||
| 				} | ||||
| 				initialised = true; | ||||
| 										user_roles = user.roles; | ||||
| 				userRoles = user.roles; | ||||
| 				permissions = user.permissions; | ||||
| 			} else { | ||||
| 				throw new errs.AuthError("User cannot be loaded for Token"); | ||||
| 			} | ||||
| 								}); | ||||
| 		} | ||||
| 		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 | ||||
| 	 * by object scopes | ||||
| 	 * | ||||
| 	 * @param   {String} object_type | ||||
| 	 * @param   {String} objectType | ||||
| 	 * @returns {Promise} | ||||
| 	 */ | ||||
| 	this.loadObjects = (object_type) => { | ||||
| 		return new Promise((resolve, reject) => { | ||||
| 	this.loadObjects = async (objectType) => { | ||||
| 		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; | ||||
| 			if (typeof tokenData.attrs.id === "undefined" || !tokenData.attrs.id) { | ||||
| 				throw new errs.AuthError("User Token supplied without a User ID"); | ||||
| 			} | ||||
|  | ||||
| 			const tokenUserId = tokenData.attrs.id ? tokenData.attrs.id : 0; | ||||
| 			let query; | ||||
|  | ||||
| 					if (typeof object_cache[object_type] === "undefined") { | ||||
| 						switch (object_type) { | ||||
| 			if (typeof objectCache[objectType] !== "undefined") { | ||||
| 				objects = objectCache[objectType]; | ||||
| 			} else { | ||||
| 				switch (objectType) { | ||||
| 					// USERS - should only return yourself | ||||
| 					case "users": | ||||
| 								resolve(token_user_id ? [token_user_id] : []); | ||||
| 						objects = tokenUserId ? [tokenUserId] : []; | ||||
| 						break; | ||||
|  | ||||
| 					// Proxy Hosts | ||||
| 							case "proxy_hosts": | ||||
| 								query = proxyHostModel | ||||
| 									.query() | ||||
| 									.select("id") | ||||
| 									.andWhere("is_deleted", 0); | ||||
|  | ||||
| 					case "proxy_hosts": { | ||||
| 						query = proxyHostModel.query().select("id").andWhere("is_deleted", 0); | ||||
| 						if (permissions.visibility === "user") { | ||||
| 									query.andWhere("owner_user_id", token_user_id); | ||||
| 							query.andWhere("owner_user_id", tokenUserId); | ||||
| 						} | ||||
|  | ||||
| 								resolve( | ||||
| 									query.then((rows) => { | ||||
| 										const result = []; | ||||
| 										_.forEach(rows, (rule_row) => { | ||||
| 											result.push(rule_row.id); | ||||
| 						const rows = await query(); | ||||
| 						objects = []; | ||||
| 						_.forEach(rows, (ruleRow) => { | ||||
| 							result.push(ruleRow.id); | ||||
| 						}); | ||||
|  | ||||
| 						// enum should not have less than 1 item | ||||
| 										if (!result.length) { | ||||
| 											result.push(0); | ||||
| 						if (!objects.length) { | ||||
| 							objects.push(0); | ||||
| 						} | ||||
|  | ||||
| 										return result; | ||||
| 									}), | ||||
| 								); | ||||
| 								break; | ||||
|  | ||||
| 							// DEFAULT: null | ||||
| 							default: | ||||
| 								resolve(null); | ||||
| 						break; | ||||
| 					} | ||||
| 					} else { | ||||
| 						resolve(object_cache[object_type]); | ||||
| 				} | ||||
| 				objectCache[objectType] = objects; | ||||
| 			} | ||||
| 		} | ||||
| 			} else { | ||||
| 				resolve(null); | ||||
| 			} | ||||
| 		}).then((objects) => { | ||||
| 			object_cache[object_type] = objects; | ||||
|  | ||||
| 		return objects; | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	/** | ||||
| 	 * 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} | ||||
| 	 */ | ||||
| 	this.getObjectSchema = (permission_label) => { | ||||
| 		const base_object_type = permission_label.split(":").shift(); | ||||
| 	this.getObjectSchema = async (permissionLabel) => { | ||||
| 		const baseObjectType = permissionLabel.split(":").shift(); | ||||
|  | ||||
| 		const schema = { | ||||
| 			$id: "objects", | ||||
| @@ -200,41 +175,39 @@ export default function (token_string) { | ||||
| 			}, | ||||
| 		}; | ||||
|  | ||||
| 		return this.loadObjects(base_object_type).then((object_result) => { | ||||
| 			if (typeof object_result === "object" && object_result !== null) { | ||||
| 				schema.properties[base_object_type] = { | ||||
| 		const result = await this.loadObjects(baseObjectType); | ||||
| 		if (typeof result === "object" && result !== null) { | ||||
| 			schema.properties[baseObjectType] = { | ||||
| 				type: "number", | ||||
| 					enum: object_result, | ||||
| 				enum: result, | ||||
| 				minimum: 1, | ||||
| 			}; | ||||
| 		} else { | ||||
| 				schema.properties[base_object_type] = { | ||||
| 			schema.properties[baseObjectType] = { | ||||
| 				type: "number", | ||||
| 				minimum: 1, | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		return schema; | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	// here: | ||||
|  | ||||
| 	return { | ||||
| 		token: Token, | ||||
|  | ||||
| 		/** | ||||
| 		 * | ||||
| 		 * @param   {Boolean}  [allow_internal] | ||||
| 		 * @param   {Boolean}  [allowInternal] | ||||
| 		 * @returns {Promise} | ||||
| 		 */ | ||||
| 		load: (allow_internal) => { | ||||
| 			return new Promise((resolve /*, reject*/) => { | ||||
| 				if (token_string) { | ||||
| 					resolve(Token.load(token_string)); | ||||
| 				} else { | ||||
| 					allow_internal_access = allow_internal; | ||||
| 					resolve(allow_internal_access || null); | ||||
| 		load: async (allowInternal) => { | ||||
| 			if (tokenString) { | ||||
| 				return await Token.load(tokenString); | ||||
| 			} | ||||
| 			}); | ||||
| 			allowInternalAccess = allowInternal; | ||||
| 			return allowInternal || null; | ||||
| 		}, | ||||
|  | ||||
| 		reloadObjects: this.loadObjects, | ||||
| @@ -246,7 +219,7 @@ export default function (token_string) { | ||||
| 		 * @returns {Promise} | ||||
| 		 */ | ||||
| 		can: async (permission, data) => { | ||||
| 			if (allow_internal_access === true) { | ||||
| 			if (allowInternalAccess === true) { | ||||
| 				return true; | ||||
| 			} | ||||
|  | ||||
| @@ -258,7 +231,7 @@ export default function (token_string) { | ||||
| 					[permission]: { | ||||
| 						data: data, | ||||
| 						scope: Token.get("scope"), | ||||
| 						roles: user_roles, | ||||
| 						roles: userRoles, | ||||
| 						permission_visibility: permissions.visibility, | ||||
| 						permission_proxy_hosts: permissions.proxy_hosts, | ||||
| 						permission_redirection_hosts: permissions.redirection_hosts, | ||||
| @@ -277,10 +250,9 @@ export default function (token_string) { | ||||
| 					properties: {}, | ||||
| 				}; | ||||
|  | ||||
| 				const rawData = fs.readFileSync( | ||||
| 					`${__dirname}/access/${permission.replace(/:/gim, "-")}.json`, | ||||
| 					{ encoding: "utf8" }, | ||||
| 				); | ||||
| 				const rawData = fs.readFileSync(`${__dirname}/access/${permission.replace(/:/gim, "-")}.json`, { | ||||
| 					encoding: "utf8", | ||||
| 				}); | ||||
| 				permissionSchema.properties[permission] = JSON.parse(rawData); | ||||
|  | ||||
| 				const ajv = new Ajv({ | ||||
|   | ||||
| @@ -1,15 +1,15 @@ | ||||
| import Access from "../access.js"; | ||||
|  | ||||
| export default () => { | ||||
| 	return (_, res, next) => { | ||||
| 	return async (_, res, next) => { | ||||
| 		try { | ||||
| 			res.locals.access = null; | ||||
| 			const access = new Access(res.locals.token || null); | ||||
| 		access | ||||
| 			.load() | ||||
| 			.then(() => { | ||||
| 			await access.load(); | ||||
| 			res.locals.access = access; | ||||
| 			next(); | ||||
| 			}) | ||||
| 			.catch(next); | ||||
| 		} catch (err) { | ||||
| 			next(err); | ||||
| 		} | ||||
| 	}; | ||||
| }; | ||||
|   | ||||
| @@ -14,30 +14,27 @@ const ajv = new Ajv({ | ||||
|  * @param {Object} payload | ||||
|  * @returns {Promise} | ||||
|  */ | ||||
| function apiValidator(schema, payload /*, description*/) { | ||||
| 	return new Promise(function Promise_apiValidator(resolve, reject) { | ||||
| 		if (schema === null) { | ||||
| 			reject(new errs.ValidationError("Schema is undefined")); | ||||
| 			return; | ||||
| const apiValidator = async (schema, payload /*, description*/) => { | ||||
| 	if (!schema) { | ||||
| 		throw new errs.ValidationError("Schema is undefined"); | ||||
| 	} | ||||
|  | ||||
| 	// Can't use falsy check here as valid payload could be `0` or `false` | ||||
| 	if (typeof payload === "undefined") { | ||||
| 			reject(new errs.ValidationError("Payload is undefined")); | ||||
| 			return; | ||||
| 		throw new errs.ValidationError("Payload is undefined"); | ||||
| 	} | ||||
|  | ||||
| 	const validate = ajv.compile(schema); | ||||
| 	const valid = validate(payload); | ||||
|  | ||||
| 	if (valid && !validate.errors) { | ||||
| 			resolve(payload); | ||||
| 		} else { | ||||
| 		return payload; | ||||
| 	} | ||||
|  | ||||
| 	const message = ajv.errorsText(validate.errors); | ||||
| 	const err = new errs.ValidationError(message); | ||||
| 	err.debug = [validate.errors, payload]; | ||||
| 			reject(err); | ||||
| 		} | ||||
| 	}); | ||||
| } | ||||
| 	throw err; | ||||
| }; | ||||
|  | ||||
| export default apiValidator; | ||||
|   | ||||
| @@ -38,7 +38,7 @@ | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
| 		"@apidevtools/swagger-parser": "^10.1.0", | ||||
| 		"@biomejs/biome": "2.2.0", | ||||
| 		"@biomejs/biome": "^2.2.3", | ||||
| 		"chalk": "4.1.2", | ||||
| 		"nodemon": "^2.0.2" | ||||
| 	}, | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import express from "express"; | ||||
| import errs from "../lib/error.js"; | ||||
| import pjson from "../package.json" with { type: "json" }; | ||||
| import { isSetup } from "../setup.js"; | ||||
| import auditLogRoutes from "./audit-log.js"; | ||||
| import accessListsRoutes from "./nginx/access_lists.js"; | ||||
| import certificatesHostsRoutes from "./nginx/certificates.js"; | ||||
| @@ -24,11 +25,13 @@ const router = express.Router({ | ||||
|  * Health Check | ||||
|  * GET /api | ||||
|  */ | ||||
| router.get("/", (_, res /*, next*/) => { | ||||
| router.get("/", async (_, res /*, next*/) => { | ||||
| 	const version = pjson.version.split("-").shift().split("."); | ||||
| 	const setup = await isSetup(); | ||||
|  | ||||
| 	res.status(200).send({ | ||||
| 		status: "OK", | ||||
| 		setup, | ||||
| 		version: { | ||||
| 			major: 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 jwtdecode from "../lib/express/jwt-decode.js"; | ||||
| import apiValidator from "../lib/validator/api.js"; | ||||
| import { express as logger } from "../logger.js"; | ||||
| import { getValidationSchema } from "../schema/index.js"; | ||||
|  | ||||
| const router = express.Router({ | ||||
| @@ -23,16 +24,17 @@ router | ||||
| 	 * We also piggy back on to this method, allowing admins to get tokens | ||||
| 	 * for services like Job board and Worker. | ||||
| 	 */ | ||||
| 	.get(jwtdecode(), (req, res, next) => { | ||||
| 		internalToken | ||||
| 			.getFreshToken(res.locals.access, { | ||||
| 	.get(jwtdecode(), async (req, res, next) => { | ||||
| 		try { | ||||
| 			const data = await internalToken.getFreshToken(res.locals.access, { | ||||
| 				expiry: typeof req.query.expiry !== "undefined" ? req.query.expiry : null, | ||||
| 				scope: typeof req.query.scope !== "undefined" ? req.query.scope : null, | ||||
| 			}) | ||||
| 			.then((data) => { | ||||
| 			}); | ||||
| 			res.status(200).send(data); | ||||
| 			}) | ||||
| 			.catch(next); | ||||
| 		} catch (err) { | ||||
| 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||
| 			next(err); | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	/** | ||||
| @@ -41,12 +43,14 @@ router | ||||
| 	 * Create a new Token | ||||
| 	 */ | ||||
| 	.post(async (req, res, next) => { | ||||
| 		apiValidator(getValidationSchema("/tokens", "post"), req.body) | ||||
| 			.then(internalToken.getTokenFromEmail) | ||||
| 			.then((data) => { | ||||
| 				res.status(200).send(data); | ||||
| 			}) | ||||
| 			.catch(next); | ||||
| 		try { | ||||
| 			const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body); | ||||
| 			const result = await internalToken.getTokenFromEmail(data); | ||||
| 			res.status(200).send(result); | ||||
| 		} catch (err) { | ||||
| 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||
| 			next(err); | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| export default router; | ||||
|   | ||||
| @@ -1,10 +1,13 @@ | ||||
| import express from "express"; | ||||
| import internalUser from "../internal/user.js"; | ||||
| import Access from "../lib/access.js"; | ||||
| import jwtdecode from "../lib/express/jwt-decode.js"; | ||||
| import userIdFromMe from "../lib/express/user-id-from-me.js"; | ||||
| import apiValidator from "../lib/validator/api.js"; | ||||
| import validator from "../lib/validator/index.js"; | ||||
| import { express as logger } from "../logger.js"; | ||||
| import { getValidationSchema } from "../schema/index.js"; | ||||
| import { isSetup } from "../setup.js"; | ||||
|  | ||||
| const router = express.Router({ | ||||
| 	caseSensitive: true, | ||||
| @@ -27,8 +30,9 @@ router | ||||
| 	 * | ||||
| 	 * Retrieve all users | ||||
| 	 */ | ||||
| 	.get((req, res, next) => { | ||||
| 		validator( | ||||
| 	.get(async (req, res, next) => { | ||||
| 		try { | ||||
| 			const data = await validator( | ||||
| 				{ | ||||
| 					additionalProperties: false, | ||||
| 					properties: { | ||||
| @@ -44,18 +48,13 @@ router | ||||
| 					expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, | ||||
| 					query: typeof req.query.query === "string" ? req.query.query : null, | ||||
| 				}, | ||||
| 		) | ||||
| 			.then((data) => { | ||||
| 				return internalUser.getAll(res.locals.access, data.expand, data.query); | ||||
| 			}) | ||||
| 			.then((users) => { | ||||
| 			); | ||||
| 			const users = await internalUser.getAll(res.locals.access, data.expand, data.query); | ||||
| 			res.status(200).send(users); | ||||
| 			}) | ||||
| 			.catch((err) => { | ||||
| 				console.log(err); | ||||
| 		} catch (err) { | ||||
| 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||
| 			next(err); | ||||
| 			}); | ||||
| 		//.catch(next); | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	/** | ||||
| @@ -63,15 +62,36 @@ router | ||||
| 	 * | ||||
| 	 * Create a new User | ||||
| 	 */ | ||||
| 	.post((req, res, next) => { | ||||
| 		apiValidator(getValidationSchema("/users", "post"), req.body) | ||||
| 			.then((payload) => { | ||||
| 				return internalUser.create(res.locals.access, payload); | ||||
| 			}) | ||||
| 			.then((result) => { | ||||
| 				res.status(201).send(result); | ||||
| 			}) | ||||
| 			.catch(next); | ||||
| 	.post(async (req, res, next) => { | ||||
| 		const body = req.body; | ||||
|  | ||||
| 		try { | ||||
| 			// If we are in setup mode, we don't check access for current user | ||||
| 			const setup = await isSetup(); | ||||
| 			if (!setup) { | ||||
| 				logger.info("Creating a new user in setup mode"); | ||||
| 				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,8 +112,9 @@ router | ||||
| 	 * | ||||
| 	 * Retrieve a specific user | ||||
| 	 */ | ||||
| 	.get((req, res, next) => { | ||||
| 		validator( | ||||
| 	.get(async (req, res, next) => { | ||||
| 		try { | ||||
| 			const data = await validator( | ||||
| 				{ | ||||
| 					required: ["user_id"], | ||||
| 					additionalProperties: false, | ||||
| @@ -110,21 +131,18 @@ router | ||||
| 					user_id: req.params.user_id, | ||||
| 					expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, | ||||
| 				}, | ||||
| 		) | ||||
| 			.then((data) => { | ||||
| 				return internalUser.get(res.locals.access, { | ||||
| 			); | ||||
|  | ||||
| 			const user = await internalUser.get(res.locals.access, { | ||||
| 				id: data.user_id, | ||||
| 				expand: data.expand, | ||||
| 				omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id), | ||||
| 			}); | ||||
| 			}) | ||||
| 			.then((user) => { | ||||
| 			res.status(200).send(user); | ||||
| 			}) | ||||
| 			.catch((err) => { | ||||
| 				console.log(err); | ||||
| 		} catch (err) { | ||||
| 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||
| 			next(err); | ||||
| 			}); | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	/** | ||||
| @@ -132,16 +150,16 @@ router | ||||
| 	 * | ||||
| 	 * Update and existing user | ||||
| 	 */ | ||||
| 	.put((req, res, next) => { | ||||
| 		apiValidator(getValidationSchema("/users/{userID}", "put"), req.body) | ||||
| 			.then((payload) => { | ||||
| 	.put(async (req, res, next) => { | ||||
| 		try { | ||||
| 			const payload = await apiValidator(getValidationSchema("/users/{userID}", "put"), req.body); | ||||
| 			payload.id = req.params.user_id; | ||||
| 				return internalUser.update(res.locals.access, payload); | ||||
| 			}) | ||||
| 			.then((result) => { | ||||
| 			const result = await internalUser.update(res.locals.access, payload); | ||||
| 			res.status(200).send(result); | ||||
| 			}) | ||||
| 			.catch(next); | ||||
| 		} catch (err) { | ||||
| 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||
| 			next(err); | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	/** | ||||
| @@ -149,13 +167,14 @@ router | ||||
| 	 * | ||||
| 	 * Update and existing user | ||||
| 	 */ | ||||
| 	.delete((req, res, next) => { | ||||
| 		internalUser | ||||
| 			.delete(res.locals.access, { id: req.params.user_id }) | ||||
| 			.then((result) => { | ||||
| 	.delete(async (req, res, next) => { | ||||
| 		try { | ||||
| 			const result = await internalUser.delete(res.locals.access, { id: req.params.user_id }); | ||||
| 			res.status(200).send(result); | ||||
| 			}) | ||||
| 			.catch(next); | ||||
| 		} catch (err) { | ||||
| 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||
| 			next(err); | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| /** | ||||
| @@ -176,16 +195,16 @@ router | ||||
| 	 * | ||||
| 	 * Update password for a user | ||||
| 	 */ | ||||
| 	.put((req, res, next) => { | ||||
| 		apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body) | ||||
| 			.then((payload) => { | ||||
| 	.put(async (req, res, next) => { | ||||
| 		try { | ||||
| 			const payload = await apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body); | ||||
| 			payload.id = req.params.user_id; | ||||
| 				return internalUser.setPassword(res.locals.access, payload); | ||||
| 			}) | ||||
| 			.then((result) => { | ||||
| 			const result = await internalUser.setPassword(res.locals.access, payload); | ||||
| 			res.status(200).send(result); | ||||
| 			}) | ||||
| 			.catch(next); | ||||
| 		} catch (err) { | ||||
| 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||
| 			next(err); | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| /** | ||||
| @@ -206,16 +225,16 @@ router | ||||
| 	 * | ||||
| 	 * Set some or all permissions for a user | ||||
| 	 */ | ||||
| 	.put((req, res, next) => { | ||||
| 		apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body) | ||||
| 			.then((payload) => { | ||||
| 	.put(async (req, res, next) => { | ||||
| 		try { | ||||
| 			const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body); | ||||
| 			payload.id = req.params.user_id; | ||||
| 				return internalUser.setPermissions(res.locals.access, payload); | ||||
| 			}) | ||||
| 			.then((result) => { | ||||
| 			const result = await internalUser.setPermissions(res.locals.access, payload); | ||||
| 			res.status(200).send(result); | ||||
| 			}) | ||||
| 			.catch(next); | ||||
| 		} catch (err) { | ||||
| 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||
| 			next(err); | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| /** | ||||
| @@ -235,13 +254,16 @@ router | ||||
| 	 * | ||||
| 	 * Log in as a user | ||||
| 	 */ | ||||
| 	.post((req, res, next) => { | ||||
| 		internalUser | ||||
| 			.loginAs(res.locals.access, { id: Number.parseInt(req.params.user_id, 10) }) | ||||
| 			.then((result) => { | ||||
| 	.post(async (req, res, next) => { | ||||
| 		try { | ||||
| 			const result = await internalUser.loginAs(res.locals.access, { | ||||
| 				id: Number.parseInt(req.params.user_id, 10), | ||||
| 			}); | ||||
| 			res.status(200).send(result); | ||||
| 			}) | ||||
| 			.catch(next); | ||||
| 		} catch (err) { | ||||
| 			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); | ||||
| 			next(err); | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| export default router; | ||||
|   | ||||
| @@ -7,24 +7,33 @@ import settingModel from "./models/setting.js"; | ||||
| import userModel from "./models/user.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 | ||||
|  * | ||||
|  * @returns {Promise} | ||||
|  */ | ||||
| const setupDefaultUser = () => { | ||||
| 	return userModel | ||||
| 		.query() | ||||
| 		.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"; | ||||
| const setupDefaultUser = async () => { | ||||
| 	const initialAdminEmail = process.env.INITIAL_ADMIN_EMAIL; | ||||
| 	const initialAdminPassword = process.env.INITIAL_ADMIN_PASSWORD; | ||||
|  | ||||
| 				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. | ||||
|  | ||||
| 	if (!initialAdminEmail || !initialAdminPassword) { | ||||
| 		return Promise.resolve(); | ||||
| 	} | ||||
|  | ||||
| 	const userIsetup = await isSetup(); | ||||
| 	if (!userIsetup) { | ||||
| 		// Create a new user and set password | ||||
| 		logger.info(`Creating a new user: ${initialAdminEmail} with password: ${initialAdminPassword}`); | ||||
|  | ||||
| 		const data = { | ||||
| 			is_deleted: 0, | ||||
| @@ -35,20 +44,20 @@ const setupDefaultUser = () => { | ||||
| 			roles: ["admin"], | ||||
| 		}; | ||||
|  | ||||
| 				return userModel | ||||
| 		const user = await userModel | ||||
| 			.query() | ||||
| 					.insertAndFetch(data) | ||||
| 					.then((user) => { | ||||
| 						return authModel | ||||
| 			.insertAndFetch(data); | ||||
|  | ||||
| 		await authModel | ||||
| 			.query() | ||||
| 			.insert({ | ||||
| 				user_id: user.id, | ||||
| 				type: "password", | ||||
| 				secret: password, | ||||
| 				meta: {}, | ||||
| 							}) | ||||
| 							.then(() => { | ||||
| 								return userPermissionModel.query().insert({ | ||||
| 			}); | ||||
|  | ||||
| 		await userPermissionModel.query().insert({ | ||||
| 			user_id: user.id, | ||||
| 			visibility: "all", | ||||
| 			proxy_hosts: "manage", | ||||
| @@ -58,14 +67,8 @@ const setupDefaultUser = () => { | ||||
| 			access_lists: "manage", | ||||
| 			certificates: "manage", | ||||
| 		}); | ||||
| 							}); | ||||
| 					}) | ||||
| 					.then(() => { | ||||
| 		logger.info("Initial admin setup completed"); | ||||
| 					}); | ||||
| 	} | ||||
| 			logger.debug("Admin user setup not required"); | ||||
| 		}); | ||||
| }; | ||||
|  | ||||
| /** | ||||
| @@ -73,15 +76,15 @@ const setupDefaultUser = () => { | ||||
|  * | ||||
|  * @returns {Promise} | ||||
|  */ | ||||
| const setupDefaultSettings = () => { | ||||
| 	return settingModel | ||||
| const setupDefaultSettings = async () => { | ||||
| 	const row = await settingModel | ||||
| 		.query() | ||||
| 		.select("id") | ||||
| 		.where({ id: "default-site" }) | ||||
| 		.first() | ||||
| 		.then((row) => { | ||||
| 			if (!row || !row.id) { | ||||
| 				settingModel | ||||
| 		.first(); | ||||
|  | ||||
| 	if (!row?.id) { | ||||
| 		await settingModel | ||||
| 			.query() | ||||
| 			.insert({ | ||||
| 				id: "default-site", | ||||
| @@ -89,13 +92,9 @@ const setupDefaultSettings = () => { | ||||
| 				description: "What to show when Nginx is hit with an unknown Host", | ||||
| 				value: "congratulations", | ||||
| 				meta: {}, | ||||
| 					}) | ||||
| 					.then(() => { | ||||
| 			}); | ||||
| 		logger.info("Default settings added"); | ||||
| 					}); | ||||
| 	} | ||||
| 			logger.debug("Default setting setup not required"); | ||||
| 		}); | ||||
| }; | ||||
|  | ||||
| /** | ||||
| @@ -103,12 +102,12 @@ const setupDefaultSettings = () => { | ||||
|  * | ||||
|  * @returns {Promise} | ||||
|  */ | ||||
| const setupCertbotPlugins = () => { | ||||
| 	return certificateModel | ||||
| const setupCertbotPlugins = async () => { | ||||
| 	const certificates = await certificateModel | ||||
| 		.query() | ||||
| 		.where("is_deleted", 0) | ||||
| 		.andWhere("provider", "letsencrypt") | ||||
| 		.then((certificates) => { | ||||
| 		.andWhere("provider", "letsencrypt"); | ||||
|  | ||||
| 	if (certificates?.length) { | ||||
| 		const plugins = []; | ||||
| 		const promises = []; | ||||
| @@ -131,15 +130,13 @@ const setupCertbotPlugins = () => { | ||||
| 			return true; | ||||
| 		}); | ||||
|  | ||||
| 				return installPlugins(plugins).then(() => { | ||||
| 		await installPlugins(plugins); | ||||
|  | ||||
| 		if (promises.length) { | ||||
| 						return Promise.all(promises).then(() => { | ||||
| 			await Promise.all(promises); | ||||
| 			logger.info(`Added Certbot plugins ${plugins.join(", ")}`); | ||||
| 						}); | ||||
| 		} | ||||
| 				}); | ||||
| 	} | ||||
| 		}); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -43,59 +43,59 @@ | ||||
|     ajv-draft-04 "^1.0.0" | ||||
|     call-me-maybe "^1.0.2" | ||||
|  | ||||
| "@biomejs/biome@2.2.0": | ||||
|   version "2.2.0" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.2.0.tgz#823ba77363651f310c47909747c879791ebd15c9" | ||||
|   integrity sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw== | ||||
| "@biomejs/biome@^2.2.3": | ||||
|   version "2.2.3" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.2.3.tgz#9d17991c80e006c5ca3e21bebe84b7afd71559e3" | ||||
|   integrity sha512-9w0uMTvPrIdvUrxazZ42Ib7t8Y2yoGLKLdNne93RLICmaHw7mcLv4PPb5LvZLJF3141gQHiCColOh/v6VWlWmg== | ||||
|   optionalDependencies: | ||||
|     "@biomejs/cli-darwin-arm64" "2.2.0" | ||||
|     "@biomejs/cli-darwin-x64" "2.2.0" | ||||
|     "@biomejs/cli-linux-arm64" "2.2.0" | ||||
|     "@biomejs/cli-linux-arm64-musl" "2.2.0" | ||||
|     "@biomejs/cli-linux-x64" "2.2.0" | ||||
|     "@biomejs/cli-linux-x64-musl" "2.2.0" | ||||
|     "@biomejs/cli-win32-arm64" "2.2.0" | ||||
|     "@biomejs/cli-win32-x64" "2.2.0" | ||||
|     "@biomejs/cli-darwin-arm64" "2.2.3" | ||||
|     "@biomejs/cli-darwin-x64" "2.2.3" | ||||
|     "@biomejs/cli-linux-arm64" "2.2.3" | ||||
|     "@biomejs/cli-linux-arm64-musl" "2.2.3" | ||||
|     "@biomejs/cli-linux-x64" "2.2.3" | ||||
|     "@biomejs/cli-linux-x64-musl" "2.2.3" | ||||
|     "@biomejs/cli-win32-arm64" "2.2.3" | ||||
|     "@biomejs/cli-win32-x64" "2.2.3" | ||||
|  | ||||
| "@biomejs/cli-darwin-arm64@2.2.0": | ||||
|   version "2.2.0" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.0.tgz#1abf9508e7d0776871710687ddad36e692dce3bc" | ||||
|   integrity sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg== | ||||
| "@biomejs/cli-darwin-arm64@2.2.3": | ||||
|   version "2.2.3" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.3.tgz#e18240343fa705dafb08ba72a7b0e88f04a8be3e" | ||||
|   integrity sha512-OrqQVBpadB5eqzinXN4+Q6honBz+tTlKVCsbEuEpljK8ASSItzIRZUA02mTikl3H/1nO2BMPFiJ0nkEZNy3B1w== | ||||
|  | ||||
| "@biomejs/cli-darwin-x64@2.2.0": | ||||
|   version "2.2.0" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.0.tgz#3a51aa569505fedd3a32bb914d608ec27d87f26d" | ||||
|   integrity sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw== | ||||
| "@biomejs/cli-darwin-x64@2.2.3": | ||||
|   version "2.2.3" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.3.tgz#964b51c9f649e3a725f6f43e75c4173b9ab8a3ae" | ||||
|   integrity sha512-OCdBpb1TmyfsTgBAM1kPMXyYKTohQ48WpiN9tkt9xvU6gKVKHY4oVwteBebiOqyfyzCNaSiuKIPjmHjUZ2ZNMg== | ||||
|  | ||||
| "@biomejs/cli-linux-arm64-musl@2.2.0": | ||||
|   version "2.2.0" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.0.tgz#4d720930732a825b7a8c7cfe1741aec9e7d5ae1d" | ||||
|   integrity sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ== | ||||
| "@biomejs/cli-linux-arm64-musl@2.2.3": | ||||
|   version "2.2.3" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.3.tgz#1756c37960d5585ca865e184539b113e48719b41" | ||||
|   integrity sha512-q3w9jJ6JFPZPeqyvwwPeaiS/6NEszZ+pXKF+IczNo8Xj6fsii45a4gEEicKyKIytalV+s829ACZujQlXAiVLBQ== | ||||
|  | ||||
| "@biomejs/cli-linux-arm64@2.2.0": | ||||
|   version "2.2.0" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.0.tgz#d0a5c153ff9243b15600781947d70d6038226feb" | ||||
|   integrity sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw== | ||||
| "@biomejs/cli-linux-arm64@2.2.3": | ||||
|   version "2.2.3" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.3.tgz#036c6334d5b09b51233ce5120b18f4c89a15a74c" | ||||
|   integrity sha512-g/Uta2DqYpECxG+vUmTAmUKlVhnGEcY7DXWgKP8ruLRa8Si1QHsWknPY3B/wCo0KgYiFIOAZ9hjsHfNb9L85+g== | ||||
|  | ||||
| "@biomejs/cli-linux-x64-musl@2.2.0": | ||||
|   version "2.2.0" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.0.tgz#946095b0a444f395b2df9244153e1cd6b07404c0" | ||||
|   integrity sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg== | ||||
| "@biomejs/cli-linux-x64-musl@2.2.3": | ||||
|   version "2.2.3" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.3.tgz#e6cce01910b9f56c1645c5518595d0b1eb38c245" | ||||
|   integrity sha512-y76Dn4vkP1sMRGPFlNc+OTETBhGPJ90jY3il6jAfur8XWrYBQV3swZ1Jo0R2g+JpOeeoA0cOwM7mJG6svDz79w== | ||||
|  | ||||
| "@biomejs/cli-linux-x64@2.2.0": | ||||
|   version "2.2.0" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.0.tgz#ae01e0a70c7cd9f842c77dfb4ebd425734667a34" | ||||
|   integrity sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw== | ||||
| "@biomejs/cli-linux-x64@2.2.3": | ||||
|   version "2.2.3" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.3.tgz#f328e7cfde92fad6c7ad215df1f51b146b4ed007" | ||||
|   integrity sha512-LEtyYL1fJsvw35CxrbQ0gZoxOG3oZsAjzfRdvRBRHxOpQ91Q5doRVjvWW/wepgSdgk5hlaNzfeqpyGmfSD0Eyw== | ||||
|  | ||||
| "@biomejs/cli-win32-arm64@2.2.0": | ||||
|   version "2.2.0" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.0.tgz#09a3988b9d4bab8b8b3a41b4de9560bf70943964" | ||||
|   integrity sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA== | ||||
| "@biomejs/cli-win32-arm64@2.2.3": | ||||
|   version "2.2.3" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.3.tgz#b8d64ca6dc1c50b8f3d42475afd31b7b44460935" | ||||
|   integrity sha512-Ms9zFYzjcJK7LV+AOMYnjN3pV3xL8Prxf9aWdDVL74onLn5kcvZ1ZMQswE5XHtnd/r/0bnUd928Rpbs14BzVmA== | ||||
|  | ||||
| "@biomejs/cli-win32-x64@2.2.0": | ||||
|   version "2.2.0" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.0.tgz#5d2523b421d847b13fac146cf745436ea8a72b95" | ||||
|   integrity sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww== | ||||
| "@biomejs/cli-win32-x64@2.2.3": | ||||
|   version "2.2.3" | ||||
|   resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.3.tgz#ecafffddf0c0675c825735c7cc917cbc8c538433" | ||||
|   integrity sha512-gvCpewE7mBwBIpqk1YrUqNR4mCiyJm6UI3YWQQXkedSSEwzRdodRpaKhbdbHw1/hmTWOVXQ+Eih5Qctf4TCVOQ== | ||||
|  | ||||
| "@gar/promisify@^1.0.1": | ||||
|   version "1.1.3" | ||||
|   | ||||
| @@ -7,6 +7,7 @@ services: | ||||
|   fullstack: | ||||
|     image: "${IMAGE}:${BRANCH_LOWER}-ci-${BUILD_NUMBER}" | ||||
|     environment: | ||||
|       TZ: "${TZ:-Australia/Brisbane}" | ||||
|       DEBUG: 'true' | ||||
|       FORCE_COLOR: 1 | ||||
|       # Required for DNS Certificate provisioning in CI | ||||
|   | ||||
| @@ -18,6 +18,7 @@ services: | ||||
|           - website2.example.com | ||||
|           - website3.example.com | ||||
|     environment: | ||||
|       TZ: "${TZ:-Australia/Brisbane}" | ||||
|       PUID: 1000 | ||||
|       PGID: 1000 | ||||
|       FORCE_COLOR: 1 | ||||
| @@ -49,6 +50,7 @@ services: | ||||
|       - ../backend:/app | ||||
|       - ../frontend:/app/frontend | ||||
|       - ../global:/app/global | ||||
|       - '/etc/localtime:/etc/localtime:ro' | ||||
|     healthcheck: | ||||
|       test: ["CMD", "/usr/bin/check-health"] | ||||
|       interval: 10s | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
|     "preview": "vitepress preview" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "vitepress": "^1.4.0" | ||||
|     "vitepress": "^1.6.4" | ||||
|   }, | ||||
|   "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_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. | ||||
|  | ||||
| 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 | ||||
|  | ||||
| - 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 | ||||
| - Free SSL using Let's Encrypt or provide your own custom SSL certificates | ||||
| - Access Lists and basic HTTP Authentication for your hosts | ||||
| @@ -66,6 +66,8 @@ services: | ||||
|   app: | ||||
|     image: 'jc21/nginx-proxy-manager:latest' | ||||
|     restart: unless-stopped | ||||
|     environment: | ||||
|       TZ: "Australia/Brisbane" | ||||
|     ports: | ||||
|       - '80:80' | ||||
|       - '81:81' | ||||
| @@ -89,17 +91,10 @@ docker compose up -d | ||||
| 4. Log in to the Admin UI | ||||
|  | ||||
| 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) | ||||
|  | ||||
| Default Admin 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. | ||||
| This startup can take a minute depending on your hardware. | ||||
|  | ||||
|  | ||||
| ## Contributing | ||||
|   | ||||
| @@ -13,6 +13,7 @@ services: | ||||
|   app: | ||||
|     image: 'jc21/nginx-proxy-manager:latest' | ||||
|     restart: unless-stopped | ||||
|  | ||||
|     ports: | ||||
|       # These ports are in format <host-port>:<container-port> | ||||
|       - '80:80' # Public HTTP Port | ||||
| @@ -21,7 +22,9 @@ services: | ||||
|       # Add any other Stream port you want to expose | ||||
|       # - '21:21' # FTP | ||||
|  | ||||
|     #environment: | ||||
|     environment: | ||||
|       TZ: "Australia/Brisbane" | ||||
|  | ||||
|       # Uncomment this if you want to change the location of | ||||
|       # the SQLite DB file within the container | ||||
|       # DB_SQLITE_FILE: "/data/database.sqlite" | ||||
| @@ -65,6 +68,7 @@ services: | ||||
|       # Add any other Stream port you want to expose | ||||
|       # - '21:21' # FTP | ||||
|     environment: | ||||
|       TZ: "Australia/Brisbane" | ||||
|       # Mysql/Maria connection parameters: | ||||
|       DB_MYSQL_HOST: "db" | ||||
|       DB_MYSQL_PORT: 3306 | ||||
| @@ -115,6 +119,7 @@ services: | ||||
|       # Add any other Stream port you want to expose | ||||
|       # - '21:21' # FTP | ||||
|     environment: | ||||
|       TZ: "Australia/Brisbane" | ||||
|       # Postgres parameters: | ||||
|       DB_POSTGRES_HOST: 'db' | ||||
|       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 | ||||
|  | ||||
| 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": { | ||||
|         "enabled": true, | ||||
|         "clientKind": "git", | ||||
|   | ||||
| @@ -13,8 +13,9 @@ import { | ||||
| import { useAuthState } from "src/context"; | ||||
| 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 Dashboard = lazy(() => import("src/pages/Dashboard")); | ||||
| const Settings = lazy(() => import("src/pages/Settings")); | ||||
| const Certificates = lazy(() => import("src/pages/Certificates")); | ||||
| const Access = lazy(() => import("src/pages/Access")); | ||||
| @@ -37,6 +38,10 @@ function Router() { | ||||
| 		return <Unhealthy />; | ||||
| 	} | ||||
|  | ||||
| 	if (!health.data?.setup) { | ||||
| 		return <Setup />; | ||||
| 	} | ||||
|  | ||||
| 	if (!authenticated) { | ||||
| 		return ( | ||||
| 			<Suspense fallback={<LoadingPage />}> | ||||
|   | ||||
| @@ -88,15 +88,19 @@ interface PostArgs { | ||||
| 	url: string; | ||||
| 	params?: queryString.StringifiableRecord; | ||||
| 	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 method = "POST"; | ||||
|  | ||||
| 	let headers = { | ||||
| 	let headers: Record<string, string> = {}; | ||||
| 	if (!noAuth) { | ||||
| 		headers = { | ||||
| 			...buildAuthHeader(), | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	let body: string | FormData | undefined; | ||||
| 	// Check if the data is an instance of FormData | ||||
|   | ||||
| @@ -1,12 +1,27 @@ | ||||
| import * as api from "./base"; | ||||
| 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( | ||||
| 		{ | ||||
| 			url: "/users", | ||||
| 			// todo: only use whitelist of fields for this data | ||||
| 			data: item, | ||||
| 			noAuth, | ||||
| 		}, | ||||
| 		abortController, | ||||
| 	); | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import type { AppVersion } from "./models"; | ||||
| export interface HealthResponse { | ||||
| 	status: string; | ||||
| 	version: AppVersion; | ||||
| 	setup: boolean; | ||||
| } | ||||
|  | ||||
| export interface TokenResponse { | ||||
|   | ||||
| @@ -72,6 +72,8 @@ | ||||
|   "role.standard-user": "Standard User", | ||||
|   "save": "Save", | ||||
|   "settings.title": "Settings", | ||||
|   "setup.preamble": "Get started by creating your admin account.", | ||||
|   "setup.title": "Welcome!", | ||||
|   "sign-in": "Sign in", | ||||
|   "streams.actions-title": "Stream #{id}", | ||||
|   "streams.add": "Add Stream", | ||||
|   | ||||
| @@ -218,6 +218,12 @@ | ||||
| 	"settings.title": { | ||||
| 		"defaultMessage": "Settings" | ||||
| 	}, | ||||
| 	"setup.preamble": { | ||||
| 		"defaultMessage": "Get started by creating your admin account." | ||||
| 	}, | ||||
| 	"setup.title": { | ||||
| 		"defaultMessage": "Welcome!" | ||||
| 	}, | ||||
| 	"sign-in": { | ||||
| 		"defaultMessage": "Sign in" | ||||
| 	}, | ||||
|   | ||||
| @@ -122,18 +122,15 @@ const Dashboard = () => { | ||||
| 			<pre> | ||||
| 				<code>{`Todo: | ||||
|  | ||||
| - Users: permissions modal and trigger after adding user | ||||
| - modal dialgs for everything | ||||
| - Tables | ||||
| - check mobile | ||||
| - fix bad jwt not refreshing entire page | ||||
| - add help docs for host types | ||||
| - show user as disabled on user table | ||||
|  | ||||
| More for api, then implement here: | ||||
| - 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 | ||||
| - minor: certificates expand with hosts needs to omit 'is_deleted' | ||||
| `}</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