Source: controllers/UsersController.mjs

import express from "express";
import {
  UsersModel,
  USER_ROLE_ADMIN,
  USER_ROLE_TRAINER,
  USER_ROLE_MEMBER,
} from "../models/UsersModel.mjs";
import bcrypt from "bcrypt";
import { AuthenticationController } from "./AuthenticationController.mjs";
import { body } from "express-validator";
import { management } from "../utilities/formValidation.mjs";

/** HTTP handlers for users. */
export class UsersController {
  /** @type {express.Router} */
  static routes = express.Router();

  /**
   * Validation for the user management form. Field rules run for create and
   * update only. A new password is not refilled after an error; an unchanged
   * stored hash is, via the `passwordUnchanged` flag.
   * @type {express.RequestHandler[]}
   */
  static formValidation = management(
    "/users",
    [
      "firstName",
      "lastName",
      "email",
      "phone",
      "dob",
      "role",
      "passwordUnchanged",
    ],
    [
      body("firstName")
        .isString()
        .withMessage("First name must be text.")
        .bail()
        .trim()
        .isLength({ min: 1, max: 45 })
        .withMessage("First name must contain 1-45 characters."),
      body("lastName")
        .isString()
        .withMessage("Last name must be text.")
        .bail()
        .trim()
        .isLength({ min: 1, max: 45 })
        .withMessage("Last name must contain 1-45 characters."),
      body("email")
        .isString()
        .withMessage("Email must be text.")
        .bail()
        .trim()
        .isLength({ min: 1, max: 100 })
        .withMessage("Email must contain 1-100 characters.")
        .bail()
        .isEmail()
        .withMessage("Email must be valid."),
      body("password")
        .isString()
        .withMessage("Password is required.")
        .bail()
        .custom(async (value, { req }) => {
          req.body.passwordUnchanged = "0";
          // The edit form is prefilled with the stored hash; saving it unchanged keeps the password.
          if (req.body.action === "update" && /^\$2[aby]\$/.test(value)) {
            const existing = await UsersModel.getById(req.params.id).catch(
              () => null,
            );
            if (existing && existing.password === value) {
              // Lets the form refill the stored hash if other fields are invalid.
              req.body.passwordUnchanged = "1";
              return true;
            }
          }
          if (value.length < 8 || Buffer.byteLength(value, "utf8") > 36) {
            throw new Error();
          }
          return true;
        })
        .withMessage(
          "Password must be at least 8 characters and at most 36 characters.",
        ),
      body("phone")
        .isString()
        .withMessage("Phone must be text.")
        .bail()
        .trim()
        .isLength({ min: 1, max: 20 })
        .withMessage("Phone must contain 1-20 characters.")
        .bail()
        .matches(/^\+?[\d ().-]+$/)
        .withMessage("Phone must contain digits and standard phone separators.")
        .bail()
        .custom((value) => {
          const number = value.replace(/[ ().-]/g, "");
          return (
            /^(?:0[23478]\d{8}|\+61[23478]\d{8})$/.test(number) ||
            /^13\d{4}$/.test(number) ||
            /^(?:1300|1800)\d{6}$/.test(number)
          );
        })
        .withMessage("Phone must be a valid Australian phone number."),
      body("dob")
        .customSanitizer((value) => (value === "" ? null : value))
        .optional({ values: "null" })
        .isString()
        .withMessage("Date of birth must be a date.")
        .bail()
        .matches(/^\d{4}-\d{2}-\d{2}$/)
        .withMessage("Date of birth must use YYYY-MM-DD.")
        .bail()
        .isISO8601({ strict: true, strictSeparator: true })
        .withMessage("Date of birth must be a real calendar date."),
      body("role")
        .isString()
        .withMessage("Select a valid user role.")
        .bail()
        .isIn([USER_ROLE_ADMIN, USER_ROLE_TRAINER, USER_ROLE_MEMBER])
        .withMessage("Select a valid user role."),
      body("deleted")
        .optional()
        .custom(
          (value) => typeof value === "string" || Number.isSafeInteger(value),
        )
        .withMessage("Deleted must be a valid number.")
        .bail()
        .isInt({ min: 0, max: 1, allow_leading_zeroes: false })
        .withMessage("Deleted must be an integer between 0 and 1."),
      body("authenticationKey")
        .optional()
        .isString()
        .withMessage("Authentication key must be text.")
        .bail()
        .trim()
        .isLength({ max: 36 })
        .withMessage("Authentication key must contain 0-36 characters."),
    ],
    { authenticationKey: "authentication_key" },
  );

  static {
    this.routes.get(
      "/",
      AuthenticationController.restrict("admin"),
      this.viewUserManagement,
    );

    this.routes.get(
      "/:id",
      AuthenticationController.restrict("admin"),
      this.viewUserManagement,
    );

    this.routes.post(
      "/",
      AuthenticationController.restrict("admin"),
      this.formValidation,
      this.handleUserManagement,
    );

    this.routes.post(
      "/:id",
      AuthenticationController.restrict("admin"),
      this.formValidation,
      this.handleUserManagement,
    );
  }

  /**
   * Renders the user management page with a filtered, sorted and paginated user
   * list. Loads the user in the URL into the edit form, even when that user is
   * not on the current page.
   * @type {express.RequestHandler}
   */
  static viewUserManagement(req, res) {
    const selectedUserId = req.params.id;
    const query = req.query ?? {};
    const selectedSearchTerm = String(query.search_term ?? "").trim();
    const selectedRole = [
      USER_ROLE_ADMIN,
      USER_ROLE_TRAINER,
      USER_ROLE_MEMBER,
    ].includes(query.role)
      ? query.role
      : "";
    const selectedSortBy = Object.keys(UsersModel.SORTABLE_COLUMNS).includes(
      query.sort_by,
    )
      ? query.sort_by
      : "last_name";
    const selectedSortDir = query.sort_dir === "desc" ? "desc" : "asc";
    const pageSize = 7;
    const selectedPage = Math.max(1, Number(query.page) || 1);
    const usersPromise = UsersModel.list({
      searchTerm: selectedSearchTerm,
      role: selectedRole,
      sortBy: selectedSortBy,
      sortDir: selectedSortDir,
      page: selectedPage,
      pageSize,
    });

    usersPromise
      .then(async ({ users, total }) => {
        const selectedUser =
          users.find((e) => String(e.id) === String(selectedUserId)) ??
          (selectedUserId
            ? await UsersModel.getById(selectedUserId).catch(() => null)
            : null) ??
          new UsersModel(null, "", "", "", "", "", "", "", 0, 0);

        res.render("user_management.ejs", {
          users,
          selectedUser,
          selectedSearchTerm,
          selectedRole,
          selectedSortBy,
          selectedSortDir,
          selectedPage,
          totalPages: Math.max(1, Math.ceil(total / pageSize)),
          authenticatedUser: req.authenticatedUser,
          role: "admin",
          userDeleted: query.user_deleted === "1",
          userCreated: query.user_created === "1",
        });
      })
      .catch((error) => {
        console.log(error);
        res.status(500).render("status.ejs", {
          status: "Database Error",
          message: "Users could not be loaded.",
        });
      });
  }

  /**
   * Creates, updates or deletes a user from the validated management form.
   * Hashes the password unless it is already a bcrypt hash or the action is delete.
   * @type {express.RequestHandler}
   */
  static handleUserManagement(req, res) {
    const selectedUserId = req.params.id;
    const formData = req.body;
    const action = formData.action;

    const user = new UsersModel(
      selectedUserId,
      formData["firstName"],
      formData["lastName"],
      formData["role"],
      formData["email"],
      formData["password"],
      formData["phone"],
      formData["dob"],
      formData["deleted"],
      formData["authenticationKey"] ?? formData["authentication_key"] ?? 0,
    );

    // hash the password if it is not hashed
    if (action !== "delete" && !/^\$2[aby]\$/.test(user.password)) {
      user.password = bcrypt.hashSync(user.password, 10);
    }

    if (action === "create") {
      UsersModel.create(user)
        .then(() => {
          res.redirect("/users");
        })
        .catch((error) => {
          res.render("status.ejs", {
            status: "Database Error",
            message: "The user could not be created.",
          });
          console.error(error);
        });
    } else if (action === "update") {
      UsersModel.update(user)
        .then((result) => {
          if (result.affectedRows > 0) {
            res.redirect("/users");
          } else {
            res.render("status.ejs", {
              status: "User Update Failed",
              message: "The user could not be found.",
            });
          }
        })
        .catch((error) => {
          res.render("status.ejs", {
            status: "Database Error",
            message: "The user could not be updated.",
          });
          console.error(error);
        });
    } else if (action === "delete") {
      UsersModel.delete(user.id)
        .then((result) => {
          if (result.affectedRows > 0) {
            res.redirect("/users");
          } else {
            res.render("status.ejs", {
              status: "User Deletion Failed",
              message: "The user could not be found.",
            });
          }
        })
        .catch((error) => {
          res.render("status.ejs", {
            status: "Database Error",
            message: "The user could not be deleted.",
          });
          console.error(error);
        });
    } else {
      res.render("status.ejs", {
        status: "Invalid Action",
        message: "The form doesn't support this action.",
      });
    }
  }
}