Bladeren bron

Introducing the Setup Wizard for creating the first user

- no longer setup a default
- still able to do that with env vars however
Jamie Curnow 3 maanden geleden
bovenliggende
commit
0b2fa826e0

+ 1 - 1
backend/biome.json

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

+ 59 - 60
backend/internal/token.js

@@ -18,67 +18,66 @@ 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
-						.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
-										const expiry = parseDatePeriod(data.expiry);
-										if (expiry === null) {
-											throw new errs.AuthError(`Invalid expiry time: ${data.expiry}`);
-										}
-
-										return 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);
-			});
+			.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();
+
+		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.
+			throw new errs.AuthError(`Invalid scope: ${data.scope}`);
+		}
+
+		// 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
 	 * @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(),
-				};
 			});
+
+			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({

+ 42 - 61
backend/internal/user.js

@@ -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("[email protected]", { 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(() => {
-				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;
+		await access.can("users:create", data);
+		data.avatar = gravatar.url(data.email, { default: "mm" });
 
-				return userPermissionModel
-					.query()
-					.insert({
-						user_id: user.id,
-						visibility: is_admin ? "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, {
-						action: "created",
-						object_type: "user",
-						object_id: user.id,
-						meta: user,
-					})
-					.then(() => {
-						return user;
-					});
+		let user = await userModel.query().insertAndFetch(data).then(utils.omitRow(omissions()));
+		if (auth) {
+			user = await authModel.query().insert({
+				user_id: user.id,
+				type: auth.type,
+				secret: auth.secret,
+				meta: {},
 			});
+		}
+
+		// 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
 		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}%`);
 			});
 		}
 

+ 128 - 156
backend/lib/access.js

@@ -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) => {
-			if (initialised) {
-				resolve();
-			} else if (!token_string) {
-				reject(new errs.PermissionError("Permission Denied"));
+	this.init = async () => {
+		if (initialised) {
+			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 (
+			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 {
-				resolve(
-					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;
-					}),
-				);
+				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) => {
-			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") {
-						switch (object_type) {
-							// USERS - should only return yourself
-							case "users":
-								resolve(token_user_id ? [token_user_id] : []);
-								break;
-
-							// Proxy Hosts
-							case "proxy_hosts":
-								query = proxyHostModel
-									.query()
-									.select("id")
-									.andWhere("is_deleted", 0);
-
-								if (permissions.visibility === "user") {
-									query.andWhere("owner_user_id", token_user_id);
-								}
-
-								resolve(
-									query.then((rows) => {
-										const result = [];
-										_.forEach(rows, (rule_row) => {
-											result.push(rule_row.id);
-										});
-
-										// enum should not have less than 1 item
-										if (!result.length) {
-											result.push(0);
-										}
-
-										return result;
-									}),
-								);
-								break;
-
-							// DEFAULT: null
-							default:
-								resolve(null);
-								break;
+	this.loadObjects = async (objectType) => {
+		let objects = null;
+
+		if (Token.hasScope("user")) {
+			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 objectCache[objectType] !== "undefined") {
+				objects = objectCache[objectType];
+			} else {
+				switch (objectType) {
+					// USERS - should only return yourself
+					case "users":
+						objects = tokenUserId ? [tokenUserId] : [];
+						break;
+
+					// Proxy Hosts
+					case "proxy_hosts": {
+						query = proxyHostModel.query().select("id").andWhere("is_deleted", 0);
+						if (permissions.visibility === "user") {
+							query.andWhere("owner_user_id", tokenUserId);
+						}
+
+						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);
 						}
-					} else {
-						resolve(object_cache[object_type]);
+						break;
 					}
 				}
-			} else {
-				resolve(null);
+				objectCache[objectType] = objects;
 			}
-		}).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
 	 *
-	 * @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] = {
-					type: "number",
-					enum: object_result,
-					minimum: 1,
-				};
-			} else {
-				schema.properties[base_object_type] = {
-					type: "number",
-					minimum: 1,
-				};
-			}
+		const result = await this.loadObjects(baseObjectType);
+		if (typeof result === "object" && result !== null) {
+			schema.properties[baseObjectType] = {
+				type: "number",
+				enum: result,
+				minimum: 1,
+			};
+		} else {
+			schema.properties[baseObjectType] = {
+				type: "number",
+				minimum: 1,
+			};
+		}
 
-			return schema;
-		});
+		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({

+ 10 - 10
backend/lib/express/jwt-decode.js

@@ -1,15 +1,15 @@
 import Access from "../access.js";
 
 export default () => {
-	return (_, res, next) => {
-		res.locals.access = null;
-		const access = new Access(res.locals.token || null);
-		access
-			.load()
-			.then(() => {
-				res.locals.access = access;
-				next();
-			})
-			.catch(next);
+	return async (_, res, next) => {
+		try {
+			res.locals.access = null;
+			const access = new Access(res.locals.token || null);
+			await access.load();
+			res.locals.access = access;
+			next();
+		} catch (err) {
+			next(err);
+		}
 	};
 };

+ 19 - 22
backend/lib/validator/api.js

@@ -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");
+	}
 
-		if (typeof payload === "undefined") {
-			reject(new errs.ValidationError("Payload is undefined"));
-			return;
-		}
+	// Can't use falsy check here as valid payload could be `0` or `false`
+	if (typeof payload === "undefined") {
+		throw new errs.ValidationError("Payload is undefined");
+	}
 
-		const validate = ajv.compile(schema);
-		const valid = validate(payload);
+	const validate = ajv.compile(schema);
+	const valid = validate(payload);
 
-		if (valid && !validate.errors) {
-			resolve(payload);
-		} else {
-			const message = ajv.errorsText(validate.errors);
-			const err = new errs.ValidationError(message);
-			err.debug = [validate.errors, payload];
-			reject(err);
-		}
-	});
-}
+	if (valid && !validate.errors) {
+		return payload;
+	}
+
+	const message = ajv.errorsText(validate.errors);
+	const err = new errs.ValidationError(message);
+	err.debug = [validate.errors, payload];
+	throw err;
+};
 
 export default apiValidator;

+ 1 - 1
backend/package.json

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

+ 4 - 1
backend/routes/main.js

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

+ 18 - 14
backend/routes/tokens.js

@@ -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);
+			});
+			res.status(200).send(data);
+		} 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;

+ 132 - 110
backend/routes/users.js

@@ -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,35 +30,31 @@ router
 	 *
 	 * Retrieve all users
 	 */
-	.get((req, res, next) => {
-		validator(
-			{
-				additionalProperties: false,
-				properties: {
-					expand: {
-						$ref: "common#/properties/expand",
-					},
-					query: {
-						$ref: "common#/properties/query",
+	.get(async (req, res, next) => {
+		try {
+			const data = await validator(
+				{
+					additionalProperties: false,
+					properties: {
+						expand: {
+							$ref: "common#/properties/expand",
+						},
+						query: {
+							$ref: "common#/properties/query",
+						},
 					},
 				},
-			},
-			{
-				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) => {
-				res.status(200).send(users);
-			})
-			.catch((err) => {
-				console.log(err);
-				next(err);
-			});
-		//.catch(next);
+				{
+					expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
+					query: typeof req.query.query === "string" ? req.query.query : null,
+				},
+			);
+			const users = await internalUser.getAll(res.locals.access, data.expand, data.query);
+			res.status(200).send(users);
+		} catch (err) {
+			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`);
+			next(err);
+		}
 	})
 
 	/**
@@ -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,39 +112,37 @@ router
 	 *
 	 * Retrieve a specific user
 	 */
-	.get((req, res, next) => {
-		validator(
-			{
-				required: ["user_id"],
-				additionalProperties: false,
-				properties: {
-					user_id: {
-						$ref: "common#/properties/id",
-					},
-					expand: {
-						$ref: "common#/properties/expand",
+	.get(async (req, res, next) => {
+		try {
+			const data = await validator(
+				{
+					required: ["user_id"],
+					additionalProperties: false,
+					properties: {
+						user_id: {
+							$ref: "common#/properties/id",
+						},
+						expand: {
+							$ref: "common#/properties/expand",
+						},
 					},
 				},
-			},
-			{
-				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, {
-					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);
-				next(err);
+				{
+					user_id: req.params.user_id,
+					expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
+				},
+			);
+
+			const user = await internalUser.get(res.locals.access, {
+				id: data.user_id,
+				expand: data.expand,
+				omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id),
 			});
+			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
 	 */
-	.put((req, res, next) => {
-		apiValidator(getValidationSchema("/users/{userID}", "put"), req.body)
-			.then((payload) => {
-				payload.id = req.params.user_id;
-				return internalUser.update(res.locals.access, payload);
-			})
-			.then((result) => {
-				res.status(200).send(result);
-			})
-			.catch(next);
+	.put(async (req, res, next) => {
+		try {
+			const payload = await apiValidator(getValidationSchema("/users/{userID}", "put"), req.body);
+			payload.id = req.params.user_id;
+			const result = await internalUser.update(res.locals.access, payload);
+			res.status(200).send(result);
+		} 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) => {
-				res.status(200).send(result);
-			})
-			.catch(next);
+	.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 (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) => {
-				payload.id = req.params.user_id;
-				return internalUser.setPassword(res.locals.access, payload);
-			})
-			.then((result) => {
-				res.status(200).send(result);
-			})
-			.catch(next);
+	.put(async (req, res, next) => {
+		try {
+			const payload = await apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body);
+			payload.id = req.params.user_id;
+			const result = await internalUser.setPassword(res.locals.access, payload);
+			res.status(200).send(result);
+		} 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) => {
-				payload.id = req.params.user_id;
-				return internalUser.setPermissions(res.locals.access, payload);
-			})
-			.then((result) => {
-				res.status(200).send(result);
-			})
-			.catch(next);
+	.put(async (req, res, next) => {
+		try {
+			const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body);
+			payload.id = req.params.user_id;
+			const result = await internalUser.setPermissions(res.locals.access, payload);
+			res.status(200).send(result);
+		} 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) => {
-				res.status(200).send(result);
-			})
-			.catch(next);
+	.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 (err) {
+			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`);
+			next(err);
+		}
 	});
 
 export default router;

+ 103 - 106
backend/setup.js

@@ -7,65 +7,68 @@ 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 || '[email protected]').toLowerCase();
-				const password = process.env.INITIAL_ADMIN_PASSWORD || "changeme";
-
-				logger.info(`Creating a new user: ${email} with password: ${password}`);
-
-				const data = {
-					is_deleted: 0,
-					email: email,
-					name: "Administrator",
-					nickname: "Admin",
-					avatar: "",
-					roles: ["admin"],
-				};
-
-				return userModel
-					.query()
-					.insertAndFetch(data)
-					.then((user) => {
-						return authModel
-							.query()
-							.insert({
-								user_id: user.id,
-								type: "password",
-								secret: password,
-								meta: {},
-							})
-							.then(() => {
-								return userPermissionModel.query().insert({
-									user_id: user.id,
-									visibility: "all",
-									proxy_hosts: "manage",
-									redirection_hosts: "manage",
-									dead_hosts: "manage",
-									streams: "manage",
-									access_lists: "manage",
-									certificates: "manage",
-								});
-							});
-					})
-					.then(() => {
-						logger.info("Initial admin setup completed");
-					});
-			}
-			logger.debug("Admin user setup not required");
+const setupDefaultUser = async () => {
+	const initialAdminEmail = process.env.INITIAL_ADMIN_EMAIL;
+	const initialAdminPassword = process.env.INITIAL_ADMIN_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,
+			email: email,
+			name: "Administrator",
+			nickname: "Admin",
+			avatar: "",
+			roles: ["admin"],
+		};
+
+		const user = await userModel
+			.query()
+			.insertAndFetch(data);
+
+		await authModel
+			.query()
+			.insert({
+				user_id: user.id,
+				type: "password",
+				secret: password,
+				meta: {},
+			});
+
+		await userPermissionModel.query().insert({
+			user_id: user.id,
+			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}
  */
-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
-					.query()
-					.insert({
-						id: "default-site",
-						name: "Default Site",
-						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");
-		});
+		.first();
+
+	if (!row?.id) {
+		await settingModel
+			.query()
+			.insert({
+				id: "default-site",
+				name: "Default Site",
+				description: "What to show when Nginx is hit with an unknown Host",
+				value: "congratulations",
+				meta: {},
+			});
+		logger.info("Default settings added");
+	}
 };
 
 /**
@@ -103,43 +102,41 @@ 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) => {
-			if (certificates?.length) {
-				const plugins = [];
-				const promises = [];
-
-				certificates.map((certificate) => {
-					if (certificate.meta && certificate.meta.dns_challenge === true) {
-						if (plugins.indexOf(certificate.meta.dns_provider) === -1) {
-							plugins.push(certificate.meta.dns_provider);
-						}
-
-						// Make sure credentials file exists
-						const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
-						// Escape single quotes and backslashes
-						const escapedCredentials = certificate.meta.dns_provider_credentials
-							.replaceAll("'", "\\'")
-							.replaceAll("\\", "\\\\");
-						const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`;
-						promises.push(utils.exec(credentials_cmd));
-					}
-					return true;
-				});
-
-				return installPlugins(plugins).then(() => {
-					if (promises.length) {
-						return Promise.all(promises).then(() => {
-							logger.info(`Added Certbot plugins ${plugins.join(", ")}`);
-						});
-					}
-				});
+		.andWhere("provider", "letsencrypt");
+
+	if (certificates?.length) {
+		const plugins = [];
+		const promises = [];
+
+		certificates.map((certificate) => {
+			if (certificate.meta && certificate.meta.dns_challenge === true) {
+				if (plugins.indexOf(certificate.meta.dns_provider) === -1) {
+					plugins.push(certificate.meta.dns_provider);
+				}
+
+				// Make sure credentials file exists
+				const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
+				// Escape single quotes and backslashes
+				const escapedCredentials = certificate.meta.dns_provider_credentials
+					.replaceAll("'", "\\'")
+					.replaceAll("\\", "\\\\");
+				const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`;
+				promises.push(utils.exec(credentials_cmd));
 			}
+			return true;
 		});
+
+		await installPlugins(plugins);
+
+		if (promises.length) {
+			await Promise.all(promises);
+			logger.info(`Added Certbot plugins ${plugins.join(", ")}`);
+		}
+	}
 };
 
 /**

+ 52 - 52
backend/yarn.lock

@@ -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/[email protected].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/[email protected].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/[email protected].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/[email protected].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/[email protected].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/[email protected].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/[email protected].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/[email protected].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-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/[email protected].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/[email protected].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/[email protected].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/[email protected].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/[email protected].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/[email protected].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/[email protected].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/[email protected].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"

+ 1 - 0
docker/docker-compose.ci.yml

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

+ 2 - 0
docker/docker-compose.dev.yml

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

+ 1 - 1
docs/package.json

@@ -5,7 +5,7 @@
     "preview": "vitepress preview"
   },
   "devDependencies": {
-    "vitepress": "^1.4.0"
+    "vitepress": "^1.6.4"
   },
   "dependencies": {}
 }

+ 10 - 0
docs/src/advanced-config/index.md

@@ -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: [email protected]
+      INITIAL_ADMIN_PASSWORD: mypassword1
+```

+ 7 - 1
docs/src/faq/index.md

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

+ 4 - 9
docs/src/guide/index.md

@@ -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:    [email protected]
-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

+ 6 - 19
docs/src/setup/index.md

@@ -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:    [email protected]
-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: [email protected]
-      INITIAL_ADMIN_PASSWORD: mypassword1
-```
-
-

+ 1 - 1
frontend/biome.json

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

+ 6 - 1
frontend/src/Router.tsx

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

+ 8 - 4
frontend/src/api/backend/base.ts

@@ -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 = {
-		...buildAuthHeader(),
-	};
+	let headers: Record<string, string> = {};
+	if (!noAuth) {
+		headers = {
+			...buildAuthHeader(),
+		};
+	}
 
 	let body: string | FormData | undefined;
 	// Check if the data is an instance of FormData

+ 16 - 1
frontend/src/api/backend/createUser.ts

@@ -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,
 	);

+ 1 - 0
frontend/src/api/backend/responseTypes.ts

@@ -3,6 +3,7 @@ import type { AppVersion } from "./models";
 export interface HealthResponse {
 	status: string;
 	version: AppVersion;
+	setup: boolean;
 }
 
 export interface TokenResponse {

+ 2 - 0
frontend/src/locale/lang/en.json

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

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

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

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

@@ -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 - 0
frontend/src/pages/Setup/index.module.css

@@ -0,0 +1,10 @@
+.logo {
+	width: 200px;
+}
+
+.helperBtns {
+	position: absolute;
+	top: 10px;
+	right: 10px;
+	z-index: 1000;
+}

+ 191 - 0
frontend/src/pages/Setup/index.tsx

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