diff --git a/backend/dist/src/app.js b/backend/dist/src/app.js index 60fbd48..3c5e960 100644 --- a/backend/dist/src/app.js +++ b/backend/dist/src/app.js @@ -13,6 +13,7 @@ const http_errors_1 = require("http-errors"); const vsr_1 = __importDefault(require("./routes/vsr")); const furnitureItem_1 = __importDefault(require("./routes/furnitureItem")); const user_1 = __importDefault(require("./routes/user")); +const confirmationEmail_1 = __importDefault(require("./routes/confirmationEmail")); const validateEnv_1 = __importDefault(require("./util/validateEnv")); const app = (0, express_1.default)(); // initializes Express to accept JSON in the request/response body @@ -29,6 +30,7 @@ app.use((0, cors_1.default)({ app.use("/api/user", user_1.default); app.use("/api/vsr", vsr_1.default); app.use("/api/furnitureItems", furnitureItem_1.default); +app.use("/api/confirmationEmail", confirmationEmail_1.default); /** * Error handler; all errors thrown by server are handled here. * Explicit typings required here because TypeScript cannot infer the argument types. diff --git a/backend/dist/src/controllers/confirmationEmail.js b/backend/dist/src/controllers/confirmationEmail.js new file mode 100644 index 0000000..f9e7da8 --- /dev/null +++ b/backend/dist/src/controllers/confirmationEmail.js @@ -0,0 +1,51 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.updateConfirmationEmail = exports.getConfirmationEmail = void 0; +const express_validator_1 = require("express-validator"); +const sanitize_html_1 = __importDefault(require("sanitize-html")); +const confirmationEmails_1 = require("../services/confirmationEmails"); +const validationErrorParser_1 = __importDefault(require("../util/validationErrorParser")); +/** + * Retrieves the VSR confirmation email from the database. + */ +const getConfirmationEmail = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + try { + const confirmationEmail = yield (0, confirmationEmails_1.retrieveConfirmaionEmail)(); + res.status(200).json({ html: confirmationEmail.html, papLogoHTML: confirmationEmails_1.PAP_LOGO_HTML }); + } + catch (error) { + next(error); + } +}); +exports.getConfirmationEmail = getConfirmationEmail; +const updateConfirmationEmail = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + const errors = (0, express_validator_1.validationResult)(req); + try { + (0, validationErrorParser_1.default)(errors); + const { html } = req.body; + const sanitizedHTML = (0, sanitize_html_1.default)(html, { + allowedTags: sanitize_html_1.default.defaults.allowedTags.concat([ + // Frontend editor uses tags for underlining. + "ins", + ]), + }); + const updatedConfirmationEmail = yield (0, confirmationEmails_1.updateConfirmationEmail)(sanitizedHTML); + res.status(200).json(updatedConfirmationEmail); + } + catch (error) { + next(error); + } +}); +exports.updateConfirmationEmail = updateConfirmationEmail; diff --git a/backend/dist/src/controllers/furnitureItem.js b/backend/dist/src/controllers/furnitureItem.js index a987b0d..051945b 100644 --- a/backend/dist/src/controllers/furnitureItem.js +++ b/backend/dist/src/controllers/furnitureItem.js @@ -12,8 +12,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getFurnitureItems = void 0; +exports.updateFurnitureItem = exports.deleteFurnitureItem = exports.createFurnitureItem = exports.getFurnitureItems = void 0; +const express_validator_1 = require("express-validator"); +const http_errors_1 = __importDefault(require("http-errors")); const furnitureItem_1 = __importDefault(require("../models/furnitureItem")); +const validationErrorParser_1 = __importDefault(require("../util/validationErrorParser")); /** * Gets all available furniture items in the database. Does not require authentication. */ @@ -32,3 +35,47 @@ const getFurnitureItems = (req, res, next) => __awaiter(void 0, void 0, void 0, } }); exports.getFurnitureItems = getFurnitureItems; +const createFurnitureItem = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + const errors = (0, express_validator_1.validationResult)(req); + try { + (0, validationErrorParser_1.default)(errors); + const furnitureItem = yield furnitureItem_1.default.create(req.body); + res.status(201).json(furnitureItem); + } + catch (error) { + next(error); + } +}); +exports.createFurnitureItem = createFurnitureItem; +const deleteFurnitureItem = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + try { + const { id } = req.params; + const deletedFurnitureItem = yield furnitureItem_1.default.findByIdAndDelete(id); + if (deletedFurnitureItem === null) { + throw (0, http_errors_1.default)(404, "FurnitureItem not found at id " + id); + } + return res.status(204).send(); + } + catch (error) { + next(error); + } +}); +exports.deleteFurnitureItem = deleteFurnitureItem; +const updateFurnitureItem = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + const errors = (0, express_validator_1.validationResult)(req); + try { + const { id } = req.params; + (0, validationErrorParser_1.default)(errors); + const updatedFurnitureItem = yield furnitureItem_1.default.findByIdAndUpdate(id, req.body, { + new: true, + }); + if (updatedFurnitureItem == null) { + throw (0, http_errors_1.default)(404, "Furniture Item not found at id " + id); + } + res.status(200).json(updatedFurnitureItem); + } + catch (error) { + next(error); + } +}); +exports.updateFurnitureItem = updateFurnitureItem; diff --git a/backend/dist/src/controllers/user.js b/backend/dist/src/controllers/user.js index 8138ae5..8ba99f7 100644 --- a/backend/dist/src/controllers/user.js +++ b/backend/dist/src/controllers/user.js @@ -1,4 +1,27 @@ "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { @@ -12,8 +35,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getWhoAmI = void 0; -const user_1 = __importDefault(require("../models/user")); +exports.deleteUser = exports.notifyResetPassword = exports.changeUserPassword = exports.createUser = exports.getUsers = exports.getWhoAmI = void 0; +const firebase_1 = require("../services/firebase"); +const user_1 = __importStar(require("../models/user")); +const express_validator_1 = require("express-validator"); +const validationErrorParser_1 = __importDefault(require("../util/validationErrorParser")); +const http_errors_1 = __importDefault(require("http-errors")); +const emails_1 = require("../services/emails"); /** * Retrieves data about the current user (their MongoDB ID, Firebase UID, and role). * Requires the user to be signed in. @@ -34,3 +62,110 @@ const getWhoAmI = (req, res, next) => __awaiter(void 0, void 0, void 0, function } }); exports.getWhoAmI = getWhoAmI; +/** + * Retrieves a list of all users in our database + */ +const getUsers = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + try { + const papUsers = yield user_1.default.find(); + const displayUsers = []; + for (const papUser of papUsers) { + const { uid, _id } = papUser; + try { + const firebaseUser = yield firebase_1.firebaseAuth.getUser(uid); + const { displayName, email } = firebaseUser; + const displayUser = { _id, uid, displayName, email }; + displayUsers.push(displayUser); + } + catch (error) { + next(error); + } + } + res.status(200).json(displayUsers); + } + catch (error) { + next(error); + } +}); +exports.getUsers = getUsers; +/** + * Creates a new user, in both the Firebase and MongoDB databases + */ +const createUser = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + const errors = (0, express_validator_1.validationResult)(req); + try { + (0, validationErrorParser_1.default)(errors); + const { name, email, password } = req.body; + // First, call the Firebase API to create a new user + const firebaseUser = yield firebase_1.firebaseAuth.createUser({ + displayName: name, + email, + password, + }); + // Now, using the UID of the new Firebase user, create a user in our MongoDB database + const user = yield user_1.default.create({ + uid: firebaseUser.uid, + // We can only create new staff accounts, not admin accounts. + role: user_1.UserRole.STAFF, + }); + res.status(201).json(user); + } + catch (error) { + next(error); + } +}); +exports.createUser = createUser; +/** + * Changes a user's password, finding the user by their UID + */ +const changeUserPassword = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + try { + const errors = (0, express_validator_1.validationResult)(req); + (0, validationErrorParser_1.default)(errors); + const { password } = req.body; + const { uid } = req.params; + const updatedUser = yield firebase_1.firebaseAuth.updateUser(uid, { + password, + }); + yield (0, emails_1.sendPasswordChangedEmailToAdmin)(updatedUser.email); + res.status(200).json(updatedUser); + } + catch (error) { + next(error); + } +}); +exports.changeUserPassword = changeUserPassword; +/** + * Sends an email to notify the user that their password has been reset. + */ +const notifyResetPassword = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + try { + const { userUid } = req; + const firebaseUser = yield firebase_1.firebaseAuth.getUser(userUid); + yield (0, emails_1.sendOwnPasswordChangedNotificationEmail)(firebaseUser.email); + yield (0, emails_1.sendPasswordChangedEmailToAdmin)(firebaseUser.email); + res.status(204).send(); + } + catch (error) { + next(error); + } +}); +exports.notifyResetPassword = notifyResetPassword; +/** + * Deletes a user from the Firebase and MongoDB databases + */ +const deleteUser = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + try { + const { uid } = req.params; + yield firebase_1.firebaseAuth.deleteUser(uid); + const deletedUser = yield user_1.default.deleteOne({ uid }); + if (deletedUser === null) { + throw (0, http_errors_1.default)(404, "User not found at uid " + uid); + } + return res.status(204).send(); + } + catch (error) { + next(error); + } +}); +exports.deleteUser = deleteUser; diff --git a/backend/dist/src/controllers/vsr.js b/backend/dist/src/controllers/vsr.js index 2a1ba94..83825f7 100644 --- a/backend/dist/src/controllers/vsr.js +++ b/backend/dist/src/controllers/vsr.js @@ -18,16 +18,16 @@ const http_errors_1 = __importDefault(require("http-errors")); const furnitureItem_1 = __importDefault(require("../models/furnitureItem")); const vsr_1 = __importDefault(require("../models/vsr")); const emails_1 = require("../services/emails"); +const vsrs_1 = require("../services/vsrs"); const validationErrorParser_1 = __importDefault(require("../util/validationErrorParser")); const exceljs_1 = __importDefault(require("exceljs")); -const mongodb_1 = require("mongodb"); /** * Gets all VSRs in the database. Requires the user to be signed in and have * staff or admin permission. */ const getAllVSRS = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { try { - const vsrs = yield vsr_1.default.find(); + const vsrs = yield (0, vsrs_1.retrieveVSRs)(req.query.search, req.query.status, req.query.incomeLevel, req.query.zipCode ? req.query.zipCode.split(",") : undefined, undefined); res.status(200).json({ vsrs }); } catch (error) { @@ -238,30 +238,14 @@ const writeSpreadsheet = (plainVsrs, res) => __awaiter(void 0, void 0, void 0, f yield workbook.xlsx.write(res); }); const bulkExportVSRS = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { - var _a, _b; try { - const filename = "vsrs.xlsx"; + const filename = `vsrs_${new Date().toISOString()}.xlsx`; // Set some headers on the response so the client knows that a file is attached res.set({ "Content-Disposition": `attachment; filename="${filename}"`, "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }); - let vsrs; - if (req.query.vsrIds && ((_a = req.query.vsrIds.length) !== null && _a !== void 0 ? _a : 0) > 0) { - // If the "vsrIds" query parameter exists and is non-empty, then find & export all VSRs - // with an _id in the vsrIds list - // Need to convert each ID string to an ObjectId object - const vsrObjectIds = (_b = req.query.vsrIds) === null || _b === void 0 ? void 0 : _b.split(",").map((_id) => new mongodb_1.ObjectId(_id)); - vsrs = (yield vsr_1.default.find({ - _id: { - $in: vsrObjectIds, - }, - })).map((doc) => doc.toObject()); - } - else { - // If the "vsrIds" query parameter is not provided or is empty, export all VSRs in the database - vsrs = (yield vsr_1.default.find()).map((doc) => doc.toObject()); - } + const vsrs = yield (0, vsrs_1.retrieveVSRs)(req.query.search, req.query.status, req.query.incomeLevel, req.query.zipCode ? req.query.zipCode.split(",") : undefined, req.query.vsrIds ? req.query.vsrIds.split(",") : undefined); yield writeSpreadsheet(vsrs, res); } catch (error) { diff --git a/backend/dist/src/models/confirmationEmail.js b/backend/dist/src/models/confirmationEmail.js new file mode 100644 index 0000000..54041cb --- /dev/null +++ b/backend/dist/src/models/confirmationEmail.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const mongoose_1 = require("mongoose"); +/** + * A model for the confirmation email sent to veterans when they fill out the + * VSR. Only one instance of this model will ever exist at once. + */ +const confirmationEmailSchema = new mongoose_1.Schema({ + // The HTML of the email + html: { type: String, required: true }, +}); +exports.default = (0, mongoose_1.model)("ConfirmationEmail", confirmationEmailSchema); diff --git a/backend/dist/src/models/vsr.js b/backend/dist/src/models/vsr.js index 963323a..c7acc90 100644 --- a/backend/dist/src/models/vsr.js +++ b/backend/dist/src/models/vsr.js @@ -51,4 +51,5 @@ const vsrSchema = new mongoose_1.Schema({ lastUpdated: { type: Date, required: true }, status: { type: String, required: true }, }); +vsrSchema.index({ name: "text" }); exports.default = (0, mongoose_1.model)("VSR", vsrSchema); diff --git a/backend/dist/src/routes/confirmationEmail.js b/backend/dist/src/routes/confirmationEmail.js new file mode 100644 index 0000000..ab73417 --- /dev/null +++ b/backend/dist/src/routes/confirmationEmail.js @@ -0,0 +1,36 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const auth_1 = require("../middleware/auth"); +const ConfirmationEmailController = __importStar(require("../controllers/confirmationEmail")); +const ConfirmationEmailValidator = __importStar(require("../validators/confirmationEmail")); +const router = express_1.default.Router(); +router.get("/", auth_1.requireSignedIn, auth_1.requireAdmin, ConfirmationEmailController.getConfirmationEmail); +router.put("/", auth_1.requireSignedIn, auth_1.requireAdmin, ConfirmationEmailValidator.updateConfirmationEmail, ConfirmationEmailController.updateConfirmationEmail); +exports.default = router; diff --git a/backend/dist/src/routes/furnitureItem.js b/backend/dist/src/routes/furnitureItem.js index 26300a8..1739522 100644 --- a/backend/dist/src/routes/furnitureItem.js +++ b/backend/dist/src/routes/furnitureItem.js @@ -27,7 +27,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", { value: true }); const express_1 = __importDefault(require("express")); +const auth_1 = require("../middleware/auth"); const FurnitureItemController = __importStar(require("../controllers/furnitureItem")); +const FurnitureItemValidator = __importStar(require("../validators/furnitureItem")); const router = express_1.default.Router(); router.get("/", FurnitureItemController.getFurnitureItems); +router.post("/", auth_1.requireSignedIn, auth_1.requireAdmin, FurnitureItemValidator.createFurnitureItem, FurnitureItemController.createFurnitureItem); +router.delete("/:id", auth_1.requireSignedIn, auth_1.requireAdmin, FurnitureItemController.deleteFurnitureItem); +router.put("/:id", auth_1.requireSignedIn, auth_1.requireAdmin, FurnitureItemValidator.updateFurnitureItem, FurnitureItemController.updateFurnitureItem); exports.default = router; diff --git a/backend/dist/src/routes/user.js b/backend/dist/src/routes/user.js index 899285e..523b544 100644 --- a/backend/dist/src/routes/user.js +++ b/backend/dist/src/routes/user.js @@ -29,6 +29,12 @@ Object.defineProperty(exports, "__esModule", { value: true }); const express_1 = __importDefault(require("express")); const auth_1 = require("../middleware/auth"); const UserController = __importStar(require("../controllers/user")); +const UserValidator = __importStar(require("../validators/user")); const router = express_1.default.Router(); router.get("/whoami", auth_1.requireSignedIn, UserController.getWhoAmI); +router.get("/", auth_1.requireSignedIn, auth_1.requireAdmin, UserController.getUsers); +router.post("/", auth_1.requireSignedIn, auth_1.requireAdmin, UserValidator.createUser, UserController.createUser); +router.patch("/:uid/password", auth_1.requireSignedIn, auth_1.requireAdmin, UserValidator.changeUserPassword, UserController.changeUserPassword); +router.post("/notifyResetPassword", auth_1.requireSignedIn, auth_1.requireStaffOrAdmin, UserController.notifyResetPassword); +router.delete("/:uid", auth_1.requireSignedIn, auth_1.requireAdmin, UserController.deleteUser); exports.default = router; diff --git a/backend/dist/src/services/confirmationEmails.js b/backend/dist/src/services/confirmationEmails.js new file mode 100644 index 0000000..de5aff2 --- /dev/null +++ b/backend/dist/src/services/confirmationEmails.js @@ -0,0 +1,45 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.updateConfirmationEmail = exports.retrieveConfirmaionEmail = exports.PAP_LOGO_HTML = exports.RECIPIENT_TEXT = void 0; +const confirmationEmail_1 = __importDefault(require("../models/confirmationEmail")); +const DEFAULT_CONFIRMATION_EMAIL_HTML = "

Dear [Recipient],

\n

\n

Thank you for submitting a Veteran Service Request (VSR) form.

\n

\n

Your VSR form has been approved. The next step is to schedule your pick-up appointment. To ensure a smooth process, please review the following information:

\n

\n

Appointment Availability & Upcoming Closure Dates

\n\n

\n

Appointment Confirmation & Details

\n\n

\n

Eligibility & Proof of Service

\n\n

\n

Transportation

\n\n

\n

Location Information

\n\n

\n

Note

\n\n

\n

Stay connected with us

\n

Facebook: @patriotsandpaws

\n

Instagram: @patriotsandpaws

\n

X: @PatriotsandPaws

\n

\n

Thank you for reaching out to us. We're here to support you.

\n

\n

Best regards,

\n

Patriots and Paws

\n

veteran@patriotsandpaws.org

\n"; +exports.RECIPIENT_TEXT = "[Recipient]"; +exports.PAP_LOGO_HTML = 'Patriots & Paws Logo'; +/** + * Retrieves the current confirmation email template from the MongoDB database, + * creating it with the default HTML if it does not exist. + */ +const retrieveConfirmaionEmail = () => __awaiter(void 0, void 0, void 0, function* () { + let confirmationEmail = yield confirmationEmail_1.default.findOne(); + if (confirmationEmail === null) { + confirmationEmail = yield confirmationEmail_1.default.create({ + html: DEFAULT_CONFIRMATION_EMAIL_HTML, + }); + } + return confirmationEmail; +}); +exports.retrieveConfirmaionEmail = retrieveConfirmaionEmail; +/** + * Saves the new HTML for the confirmation email to the database. Assumes + * that the HTML has already been validated and sanitized. + */ +const updateConfirmationEmail = (newHTML) => __awaiter(void 0, void 0, void 0, function* () { + const confirmationEmail = yield (0, exports.retrieveConfirmaionEmail)(); + const updatedConfirmationEmail = yield confirmationEmail_1.default.findByIdAndUpdate(confirmationEmail._id, { + html: newHTML, + }, { new: true }); + return updatedConfirmationEmail; +}); +exports.updateConfirmationEmail = updateConfirmationEmail; diff --git a/backend/dist/src/services/emails.js b/backend/dist/src/services/emails.js index 0893623..782259b 100644 --- a/backend/dist/src/services/emails.js +++ b/backend/dist/src/services/emails.js @@ -12,13 +12,25 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.sendVSRConfirmationEmailToVeteran = exports.sendVSRNotificationEmailToStaff = void 0; +exports.sendPasswordChangedEmailToAdmin = exports.sendOwnPasswordChangedNotificationEmail = exports.sendVSRConfirmationEmailToVeteran = exports.sendVSRNotificationEmailToStaff = void 0; require("dotenv/config"); const nodemailer_1 = __importDefault(require("nodemailer")); +const confirmationEmails_1 = require("../services/confirmationEmails"); const validateEnv_1 = __importDefault(require("../util/validateEnv")); const trimmedFrontendUrl = validateEnv_1.default.FRONTEND_ORIGIN.replace( // Trim trailing slash from frontend URL, if there is one /\/$/gi, ""); +const sendEmail = (options) => __awaiter(void 0, void 0, void 0, function* () { + const transporter = nodemailer_1.default.createTransport({ + service: "gmail", + auth: { + user: validateEnv_1.default.EMAIL_USER, + pass: validateEnv_1.default.EMAIL_APP_PASSWORD, + }, + }); + const mailOptions = Object.assign({ from: validateEnv_1.default.EMAIL_USER }, options); + yield transporter.sendMail(mailOptions); +}); /** * Sends a notification email to PAP staff when a VSR is submitted. * Throws an error if the email could not be sent. @@ -30,20 +42,11 @@ const trimmedFrontendUrl = validateEnv_1.default.FRONTEND_ORIGIN.replace( const sendVSRNotificationEmailToStaff = (name, email, id) => __awaiter(void 0, void 0, void 0, function* () { const EMAIL_SUBJECT = "New VSR Submitted"; const EMAIL_BODY = `A new VSR was submitted by ${name} from ${email}. You can view it at ${trimmedFrontendUrl}/staff/vsr/${id}`; - const transporter = nodemailer_1.default.createTransport({ - service: "gmail", - auth: { - user: validateEnv_1.default.EMAIL_USER, - pass: validateEnv_1.default.EMAIL_APP_PASSWORD, - }, - }); - const mailOptions = { - from: validateEnv_1.default.EMAIL_USER, + yield sendEmail({ to: validateEnv_1.default.EMAIL_NOTIFICATIONS_RECIPIENT, subject: EMAIL_SUBJECT, text: EMAIL_BODY, - }; - yield transporter.sendMail(mailOptions); + }); }); exports.sendVSRNotificationEmailToStaff = sendVSRNotificationEmailToStaff; /** @@ -55,82 +58,12 @@ exports.sendVSRNotificationEmailToStaff = sendVSRNotificationEmailToStaff; */ const sendVSRConfirmationEmailToVeteran = (name, email) => __awaiter(void 0, void 0, void 0, function* () { const EMAIL_SUBJECT = "VSR Submission Confirmation"; - const EMAIL_HTML = `

Dear ${name},

\ -

Thank you for submitting a veteran service request (VSR).

\ -

\ - We schedule appointments on Tuesdays, Thursday, and Saturdays from 10-3pm. You need to be\ - specific about what day and time you want to make your appointment.\ - \ -

\ - \ -

\ - PLEASE NOTE: We will not be making appointments on THURSDAY, NOVEMBER 23RD (THANKSGIVING) OR\ - SATURDAY, DECEMBER 2nd.\ -

\ - \ -

\ - Respond to this email and let us know what day and time will work and we will put you\ - down.\ -

\ - \ -

\ - You pick up your items on the date and time of your appointment. You need to come prepared on that\ - day.\ -

\ -

We will help you with as much as we can on your list.

\ -

\ - Please remember that items are donated from the community as a way of saying THANK YOU FOR YOUR\ - SERVICE, so we are like a cashless thrift store environment.\ -

\ -

You will need to provide your own transportation for all your items you are requesting.

\ -

\ - \ - Wherever you are going to rent a truck, don't wait to the last minute to rent a truck as there\ - might not be one available.\ -

\ -

We are located in the Anaheim, in Orange County.

\ -

Once we confirm your appointment we will send you the warehouse protocol and address.

\ -

\ - \ - Items in the warehouse have been donated from the General Public and are strictly for Veterans,\ - Active Military and Reservists and their immediate family (wives and school aged children) who\ - live with them. The Veteran/Active Duty/Reservist has to be at appointment and will need to show\ - proof of service, such as a VA Card, DD214 or Active Military card.\ -

\ -

They are not for family members and friends who have not served our Country.

\ -

Thank you for contacting us.

\ -

Volunteer

\ - \ -

veteran@patriotsandpaws.org

\ - Patriots & Paws Logo\ -

\ - Facebook\ - https://www.facebook.com/pages/Patriots-and-Paws/283613748323930\ -

\ -

Twitter @patriotsandpaws

\ -

Instagram patriotsandpaws

\ - `; - const transporter = nodemailer_1.default.createTransport({ - service: "gmail", - auth: { - user: validateEnv_1.default.EMAIL_USER, - pass: validateEnv_1.default.EMAIL_APP_PASSWORD, - }, - }); - const mailOptions = { - from: validateEnv_1.default.EMAIL_USER, + const confirmationEmail = yield (0, confirmationEmails_1.retrieveConfirmaionEmail)(); + const emailHTML = confirmationEmail.html.replace(confirmationEmails_1.RECIPIENT_TEXT, name) + confirmationEmails_1.PAP_LOGO_HTML; + yield sendEmail({ to: email, subject: EMAIL_SUBJECT, - html: EMAIL_HTML, + html: emailHTML, attachments: [ { filename: "pap_logo.png", @@ -138,7 +71,26 @@ const sendVSRConfirmationEmailToVeteran = (name, email) => __awaiter(void 0, voi cid: "pap_logo.png", }, ], - }; - yield transporter.sendMail(mailOptions); + }); }); exports.sendVSRConfirmationEmailToVeteran = sendVSRConfirmationEmailToVeteran; +const sendOwnPasswordChangedNotificationEmail = (email) => __awaiter(void 0, void 0, void 0, function* () { + const EMAIL_SUBJECT = "PAP Application Password Change Confirmation"; + const EMAIL_BODY = `Your password for the ${email} account has been changed.`; + yield sendEmail({ + to: email, + subject: EMAIL_SUBJECT, + text: EMAIL_BODY, + }); +}); +exports.sendOwnPasswordChangedNotificationEmail = sendOwnPasswordChangedNotificationEmail; +const sendPasswordChangedEmailToAdmin = (email) => __awaiter(void 0, void 0, void 0, function* () { + const EMAIL_SUBJECT = "PAP Application Password Change Notification"; + const EMAIL_BODY = `The password for the ${email} account has been changed.`; + yield sendEmail({ + to: validateEnv_1.default.EMAIL_NOTIFICATIONS_RECIPIENT, + subject: EMAIL_SUBJECT, + text: EMAIL_BODY, + }); +}); +exports.sendPasswordChangedEmailToAdmin = sendPasswordChangedEmailToAdmin; diff --git a/backend/dist/src/services/vsrs.js b/backend/dist/src/services/vsrs.js new file mode 100644 index 0000000..6d78cbe --- /dev/null +++ b/backend/dist/src/services/vsrs.js @@ -0,0 +1,68 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.retrieveVSRs = void 0; +const vsr_1 = __importDefault(require("../models/vsr")); +const retrieveVSRs = (search, status, incomeLevel, zipCodes, vsrIds) => __awaiter(void 0, void 0, void 0, function* () { + let vsrs = yield vsr_1.default.aggregate([ + ...(search + ? [ + { + $match: { name: { $regex: new RegExp(search, "i") } }, + }, + ] + : []), + { + $addFields: { + statusOrder: { + $switch: { + branches: [ + { case: { $eq: ["$status", "Received"] }, then: 1 }, + { case: { $eq: ["$status", "Approved"] }, then: 2 }, + { case: { $eq: ["$status", "Appointment Scheduled"] }, then: 3 }, + { case: { $eq: ["$status", "Complete"] }, then: 4 }, + { case: { $eq: ["$status", "No-show / Incomplete"] }, then: 5 }, + { case: { $eq: ["$status", "Archived"] }, then: 6 }, + ], + default: 99, + }, + }, + }, + }, + { $sort: { statusOrder: 1, dateReceived: -1 } }, + ]); + if (vsrIds && vsrIds.length > 0) { + const vsrIdsSet = new Set(vsrIds); + vsrs = vsrs.filter((vsr) => vsrIdsSet.has(vsr._id.toString())); + } + if (status) { + vsrs = vsrs.filter((vsr) => vsr.status === status); + } + if (incomeLevel) { + const incomeMap = { + "50000": "$50,001 and over", + "25000": "$25,001 - $50,000", + "12500": "$12,501 - $25,000", + "0": "$12,500 and under", + }; + vsrs = vsrs.filter((vsr) => { + return vsr.incomeLevel === incomeMap[incomeLevel]; + }); + } + if (zipCodes && zipCodes.length > 0) { + vsrs = vsrs.filter((vsr) => zipCodes.includes(vsr.zipCode.toString())); + } + return vsrs; +}); +exports.retrieveVSRs = retrieveVSRs; diff --git a/backend/dist/src/validators/confirmationEmail.js b/backend/dist/src/validators/confirmationEmail.js new file mode 100644 index 0000000..885ea93 --- /dev/null +++ b/backend/dist/src/validators/confirmationEmail.js @@ -0,0 +1,32 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.updateConfirmationEmail = void 0; +const express_validator_1 = require("express-validator"); +const html_validator_1 = __importDefault(require("html-validator")); +const makeHTMLValidator = () => (0, express_validator_1.body)("html") + .exists({ checkFalsy: true }) + .withMessage("HTML is required") + .isString() + .withMessage("HTML must be a string") + .custom((html) => __awaiter(void 0, void 0, void 0, function* () { + try { + (0, html_validator_1.default)({ data: html }); + return true; + } + catch (error) { + return false; + } +})); +exports.updateConfirmationEmail = [makeHTMLValidator()]; diff --git a/backend/dist/src/validators/furnitureItem.js b/backend/dist/src/validators/furnitureItem.js new file mode 100644 index 0000000..c7e4f9b --- /dev/null +++ b/backend/dist/src/validators/furnitureItem.js @@ -0,0 +1,36 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.updateFurnitureItem = exports.createFurnitureItem = void 0; +const express_validator_1 = require("express-validator"); +const makeCategoryValidator = () => (0, express_validator_1.body)("category") + .exists({ checkFalsy: true }) + .withMessage("Category is required") + .isString() + .withMessage("Category must be a string"); +const makeNameValidator = () => (0, express_validator_1.body)("name") + .exists({ checkFalsy: true }) + .withMessage("Name is required") + .isString() + .withMessage("Name must be a string"); +const makeAllowMultipleValidator = () => (0, express_validator_1.body)("allowMultiple") + .exists({ checkFalsy: false }) + .withMessage("Must specify if multiple of these items can be requested") + .isBoolean() + .withMessage("allowMultiple must be a boolean"); +const makeCategoryIndexValidator = () => (0, express_validator_1.body)("categoryIndex") + .exists({ checkFalsy: true }) + .withMessage("Category index is required") + .isInt({ min: 0 }) + .withMessage("Category index must be positive and an integer"); +exports.createFurnitureItem = [ + makeCategoryValidator(), + makeNameValidator(), + makeAllowMultipleValidator(), + makeCategoryIndexValidator(), +]; +exports.updateFurnitureItem = [ + makeCategoryValidator(), + makeNameValidator(), + makeAllowMultipleValidator(), + makeCategoryIndexValidator(), +]; diff --git a/backend/dist/src/validators/user.js b/backend/dist/src/validators/user.js new file mode 100644 index 0000000..8440816 --- /dev/null +++ b/backend/dist/src/validators/user.js @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.changeUserPassword = exports.createUser = void 0; +const express_validator_1 = require("express-validator"); +/** + * Validators for creating and updating users + */ +const makeNameValidator = () => (0, express_validator_1.body)("name") + .exists({ checkFalsy: true }) + .withMessage("Name is required") + .isString() + .withMessage("Name must be a string"); +const makeEmailValidator = () => (0, express_validator_1.body)("email") + .exists({ checkFalsy: true }) + .withMessage("Email is required") + .isString() + .withMessage("Email must be a string"); +const makePasswordValidator = () => (0, express_validator_1.body)("password") + .exists({ checkFalsy: true }) + .withMessage("Password is required") + .isString() + .withMessage("Password must be a string"); +exports.createUser = [makeNameValidator(), makeEmailValidator(), makePasswordValidator()]; +exports.changeUserPassword = [makePasswordValidator()]; diff --git a/backend/package-lock.json b/backend/package-lock.json index 2549bc4..4c41e3e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "express": "^4.18.2", "express-validator": "^7.0.1", "firebase": "^10.7.2", + "html-validator": "^6.0.1", "http-errors": "^2.0.0", "jest": "^29.7.0", "module-alias": "^2.2.3", @@ -22,6 +23,7 @@ "mongodb-memory-server": "^9.1.1", "mongoose": "^7.4.0", "nodemailer": "^6.9.8", + "sanitize-html": "^2.13.0", "supertest": "^6.3.3", "ts-jest": "^29.1.1", "tsc-alias": "^1.8.8" @@ -29,9 +31,11 @@ "devDependencies": { "@types/cors": "^2.8.13", "@types/express": "^4.17.17", + "@types/html-validator": "^5.0.6", "@types/http-errors": "^2.0.1", "@types/jest": "^29.5.10", "@types/nodemailer": "^6.4.14", + "@types/sanitize-html": "^2.11.0", "@types/supertest": "^2.0.16", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", @@ -2219,6 +2223,26 @@ "node": ">=6" } }, + "node_modules/@html-validate/stylish": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@html-validate/stylish/-/stylish-2.0.1.tgz", + "integrity": "sha512-iRLjgQnNq66rcsTukun6KwMhPEoUV2R3atPbTSapnEvD1aETjD+pfS+1yYrmaPeJFgXHzfsSYjAuyUVq7EID/Q==", + "dependencies": { + "kleur": "^4.0.0", + "text-table": "^0.2.0" + }, + "engines": { + "node": ">= 12.0" + } + }, + "node_modules/@html-validate/stylish/node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "dev": true, @@ -2732,6 +2756,21 @@ "version": "1.1.0", "license": "BSD-3-Clause" }, + "node_modules/@sidvind/better-ajv-errors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@sidvind/better-ajv-errors/-/better-ajv-errors-1.1.1.tgz", + "integrity": "sha512-CXnmMcV4QoyWuFA0zlDk0AWMHftaMFAIFWz68AH4EXOO2iUEq0gsonJEhY3OjM08xHYobqqDeCAPPEsL5E+8QA==", + "dependencies": { + "@babel/code-frame": "^7.16.0", + "chalk": "^4.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "peerDependencies": { + "ajv": "4.11.8 - 8" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "license": "MIT" @@ -3656,6 +3695,15 @@ "@types/node": "*" } }, + "node_modules/@types/html-validator": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/html-validator/-/html-validator-5.0.6.tgz", + "integrity": "sha512-X6bxQBMURCHqF7uS0waQIALDkdbaLYdnoZ9RSANz1ov8/+0JyThkx296AbsAHRL63Xl4MJul79Ncu9QgR51wfQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.4", "dev": true, @@ -3736,6 +3784,13 @@ "@types/node": "*" } }, + "node_modules/@types/prettier": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", + "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", + "optional": true, + "peer": true + }, "node_modules/@types/qs": { "version": "6.9.10", "dev": true, @@ -3774,6 +3829,15 @@ "node": ">= 0.12" } }, + "node_modules/@types/sanitize-html": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.11.0.tgz", + "integrity": "sha512-7oxPGNQHXLHE48r/r/qjn7q0hlrs3kL7oZnGj0Wf/h9tj/6ibFyRkNbsDxaBBZ4XUZ0Dx5LGCyDJ04ytSofacQ==", + "dev": true, + "dependencies": { + "htmlparser2": "^8.0.0" + } + }, "node_modules/@types/semver": { "version": "7.5.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", @@ -4075,7 +4139,6 @@ }, "node_modules/acorn": { "version": "8.11.2", - "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4086,7 +4149,6 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -4094,7 +4156,6 @@ }, "node_modules/acorn-walk": { "version": "8.3.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -4115,7 +4176,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4464,6 +4524,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, "node_modules/b4a": { "version": "1.6.4", "license": "ISC" @@ -5306,6 +5375,57 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.3.1", "license": "BSD-2-Clause", @@ -5434,6 +5554,17 @@ "dev": true, "optional": true }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/envalid": { "version": "7.3.1", "license": "MIT", @@ -5574,7 +5705,6 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5887,7 +6017,6 @@ }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", - "dev": true, "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -5898,7 +6027,6 @@ }, "node_modules/espree": { "version": "9.6.1", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", @@ -6129,7 +6257,6 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -6475,6 +6602,19 @@ "version": "1.0.0", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/fstream": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", @@ -6872,156 +7012,1521 @@ "version": "2.0.2", "license": "MIT" }, - "node_modules/http-errors": { - "version": "2.0.0", - "license": "MIT", + "node_modules/html-validator": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/html-validator/-/html-validator-6.0.1.tgz", + "integrity": "sha512-b21ESfN6FStukGoqGgtkxhs3DSp9ziw8zH9LRv+0YniC6/YhbfgWlq6lr/QLJmfzN2fKev6pVs4TKtl7F/Q/4Q==", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "axios": "0.27.2", + "html-validate": "7.0.0", + "valid-url": "1.0.9" }, "engines": { - "node": ">= 0.8" + "node": ">=14.19.2" } }, - "node_modules/http-parser-js": { - "version": "0.5.8", - "license": "MIT" - }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, + "node_modules/html-validator/node_modules/@jest/console": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-28.1.3.tgz", + "integrity": "sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==", "optional": true, + "peer": true, "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3", + "slash": "^3.0.0" }, "engines": { - "node": ">= 6" + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, + "node_modules/html-validator/node_modules/@jest/core": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-28.1.3.tgz", + "integrity": "sha512-CIKBrlaKOzA7YG19BEqCw3SLIsEwjZkeJzf5bdooVnW4bH5cktqe3JX+G2YV1aK5vP8N9na1IGWFzYaTp6k6NA==", "optional": true, + "peer": true, "dependencies": { - "debug": "4" + "@jest/console": "^28.1.3", + "@jest/reporters": "^28.1.3", + "@jest/test-result": "^28.1.3", + "@jest/transform": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^28.1.3", + "jest-config": "^28.1.3", + "jest-haste-map": "^28.1.3", + "jest-message-util": "^28.1.3", + "jest-regex-util": "^28.0.2", + "jest-resolve": "^28.1.3", + "jest-resolve-dependencies": "^28.1.3", + "jest-runner": "^28.1.3", + "jest-runtime": "^28.1.3", + "jest-snapshot": "^28.1.3", + "jest-util": "^28.1.3", + "jest-validate": "^28.1.3", + "jest-watcher": "^28.1.3", + "micromatch": "^4.0.4", + "pretty-format": "^28.1.3", + "rimraf": "^3.0.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">= 6.0.0" + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/https-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", - "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "node_modules/html-validator/node_modules/@jest/environment": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-28.1.3.tgz", + "integrity": "sha512-1bf40cMFTEkKyEf585R9Iz1WayDjHoHqvts0XFYEqyKM3cFWDpeMoqKKTAF9LSYQModPUlh8FKptoM2YcMWAXA==", + "optional": true, + "peer": true, "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" + "@jest/fake-timers": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "jest-mock": "^28.1.3" }, "engines": { - "node": ">= 14" + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "license": "Apache-2.0", + "node_modules/html-validator/node_modules/@jest/expect": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-28.1.3.tgz", + "integrity": "sha512-lzc8CpUbSoE4dqT0U+g1qODQjBRHPpCPXissXD4mS9+sWQdmmpeJ9zSH1rS1HEkrsMN0fb7nKrJ9giAR1d3wBw==", + "optional": true, + "peer": true, + "dependencies": { + "expect": "^28.1.3", + "jest-snapshot": "^28.1.3" + }, "engines": { - "node": ">=10.17.0" + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/husky": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", - "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", - "dev": true, - "bin": { - "husky": "lib/bin.js" + "node_modules/html-validator/node_modules/@jest/expect-utils": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-28.1.3.tgz", + "integrity": "sha512-wvbi9LUrHJLn3NlDW6wF2hvIMtd4JUl2QNVrjq+IBSHirgfrR3o9RnVtxzdEGO2n9JyIWwHnLfby5KzqBGg2YA==", + "optional": true, + "peer": true, + "dependencies": { + "jest-get-type": "^28.0.2" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "license": "MIT", + "node_modules/html-validator/node_modules/@jest/fake-timers": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-28.1.3.tgz", + "integrity": "sha512-D/wOkL2POHv52h+ok5Oj/1gOG9HSywdoPtFsRCUmlCILXNn5eIWmcnd3DIiWlJnpGvQtmajqBP95Ei0EimxfLw==", + "optional": true, + "peer": true, "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "@jest/types": "^28.1.3", + "@sinonjs/fake-timers": "^9.1.2", + "@types/node": "*", + "jest-message-util": "^28.1.3", + "jest-mock": "^28.1.3", + "jest-util": "^28.1.3" }, "engines": { - "node": ">=0.10.0" + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/idb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/ignore": { - "version": "5.3.0", - "license": "MIT", + "node_modules/html-validator/node_modules/@jest/globals": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-28.1.3.tgz", + "integrity": "sha512-XFU4P4phyryCXu1pbcqMO0GSQcYe1IsalYCDzRNyhetyeyxMcIxa11qPNDpVNLeretItNqEmYYQn1UYz/5x1NA==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/environment": "^28.1.3", + "@jest/expect": "^28.1.3", + "@jest/types": "^28.1.3" + }, "engines": { - "node": ">= 4" + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "dev": true, - "license": "ISC" - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, + "node_modules/html-validator/node_modules/@jest/reporters": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-28.1.3.tgz", + "integrity": "sha512-JuAy7wkxQZVNU/V6g9xKzCGC5LVXx9FDcABKsSXp5MiKPEE2144a/vXTEDoyzjUpZKfVwp08Wqg5A4WfTMAzjg==", + "optional": true, + "peer": true, "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^28.1.3", + "@jest/test-result": "^28.1.3", + "@jest/transform": "^28.1.3", + "@jest/types": "^28.1.3", + "@jridgewell/trace-mapping": "^0.3.13", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3", + "jest-worker": "^28.1.3", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^9.0.1" }, "engines": { - "node": ">=6" + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/import-local": { - "version": "3.1.0", - "license": "MIT", + "node_modules/html-validator/node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/html-validator/node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "optional": true, + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/html-validator/node_modules/@jest/reporters/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/html-validator/node_modules/@jest/schemas": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", + "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "optional": true, + "peer": true, + "dependencies": { + "@sinclair/typebox": "^0.24.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/@jest/source-map": { + "version": "28.1.2", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-28.1.2.tgz", + "integrity": "sha512-cV8Lx3BeStJb8ipPHnqVw/IM2VCMWO3crWZzYodSIkxXnRcXJipCdx1JCK0K5MsJJouZQTH73mzf4vgxRaH9ww==", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.13", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/@jest/test-result": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-28.1.3.tgz", + "integrity": "sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/console": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/@jest/test-sequencer": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-28.1.3.tgz", + "integrity": "sha512-NIMPEqqa59MWnDi1kvXXpYbqsfQmSJsIbnd85mdVGkiDfQ9WQQTXOLsvISUfonmnBT+w85WEgneCigEEdHDFxw==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/test-result": "^28.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^28.1.3", + "slash": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/@jest/transform": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-28.1.3.tgz", + "integrity": "sha512-u5dT5di+oFI6hfcLOHGTAfmUxFRrjK+vnaP0kkVow9Md/M7V/MxqQMOz/VV25UZO8pzeA9PjfTpOu6BDuwSPQA==", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^28.1.3", + "@jridgewell/trace-mapping": "^0.3.13", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^28.1.3", + "jest-regex-util": "^28.0.2", + "jest-util": "^28.1.3", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/@jest/types": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-28.1.3.tgz", + "integrity": "sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/schemas": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/html-validator/node_modules/@sinclair/typebox": { + "version": "0.24.51", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", + "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", + "optional": true, + "peer": true + }, + "node_modules/html-validator/node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "optional": true, + "peer": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/html-validator/node_modules/@sinonjs/fake-timers": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz", + "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==", + "optional": true, + "peer": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/html-validator/node_modules/ajv": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.14.0.tgz", + "integrity": "sha512-oYs1UUtO97ZO2lJ4bwnWeQW8/zvOIQLGKcvPTsWmvc2SYgBb+upuNS5NxoLaMU4h8Ju3Nbj6Cq8mD2LQoqVKFA==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.4.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/html-validator/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/html-validator/node_modules/babel-jest": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-28.1.3.tgz", + "integrity": "sha512-epUaPOEWMk3cWX0M/sPvCHHCe9fMFAa/9hXEgKP8nFfNl/jlGkE9ucq9NqkZGXLDduCJYS0UvSlPUwC0S+rH6Q==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/transform": "^28.1.3", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^28.1.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/html-validator/node_modules/babel-plugin-jest-hoist": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-28.1.3.tgz", + "integrity": "sha512-Ys3tUKAmfnkRUpPdpa98eYrAR0nV+sSFUZZEGuQ2EbFd1y4SOLtD5QDNHAq+bb9a+bbXvYQC4b+ID/THIMcU6Q==", + "optional": true, + "peer": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/babel-preset-jest": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-28.1.3.tgz", + "integrity": "sha512-L+fupJvlWAHbQfn74coNX3zf60LXMJsezNvvx8eIh7iOR1luJ1poxYgQk1F8PYtNq/6QODDHCqsSnTFSWC491A==", + "optional": true, + "peer": true, + "dependencies": { + "babel-plugin-jest-hoist": "^28.1.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/html-validator/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/html-validator/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/html-validator/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "optional": true, + "peer": true + }, + "node_modules/html-validator/node_modules/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "optional": true, + "peer": true + }, + "node_modules/html-validator/node_modules/diff-sequences": { + "version": "28.1.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-28.1.1.tgz", + "integrity": "sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==", + "optional": true, + "peer": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/emittery": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", + "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/html-validator/node_modules/expect": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz", + "integrity": "sha512-eEh0xn8HlsuOBxFgIss+2mX85VAS4Qy3OSkjV7rlBWljtA4oWH37glVGyOZSZvErDT/yBywZdPGwCXuTvSG85g==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/expect-utils": "^28.1.3", + "jest-get-type": "^28.0.2", + "jest-matcher-utils": "^28.1.3", + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/html-validator/node_modules/html-validate": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-validate/-/html-validate-7.0.0.tgz", + "integrity": "sha512-Sq+ZQQSnkabgjplF2qNjbA4RdTkOP/99V2Q7gP2rr4BLUEEjlpfJQx3DeHtpzfBJmKFA/NzT7wb2qrzGCpcilA==", + "dependencies": { + "@babel/code-frame": "^7.10.0", + "@html-validate/stylish": "^2.0.0", + "@sidvind/better-ajv-errors": "^1.1.1", + "acorn-walk": "^8.0.0", + "ajv": "^8.0.0", + "deepmerge": "^4.2.0", + "espree": "^9.0.0", + "glob": "^8.0.0", + "ignore": "^5.0.0", + "kleur": "^4.1.0", + "minimist": "^1.2.0", + "prompts": "^2.0.0", + "semver": "^7.0.0" + }, + "bin": { + "html-validate": "bin/html-validate.js" + }, + "engines": { + "node": ">= 14.0" + }, + "peerDependencies": { + "jest": "^25.1 || ^26 || ^27.1 || ^28", + "jest-diff": "^25.1 || ^26 || ^27.1 || ^28", + "jest-snapshot": "^25.1 || ^26 || ^27.1 || ^28" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + }, + "jest-diff": { + "optional": true + }, + "jest-snapshot": { + "optional": true + } + } + }, + "node_modules/html-validator/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/html-validator/node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/html-validator/node_modules/jest": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest/-/jest-28.1.3.tgz", + "integrity": "sha512-N4GT5on8UkZgH0O5LUavMRV1EDEhNTL0KEfRmDIeZHSV7p2XgLoY9t9VDUgL6o+yfdgYHVxuz81G8oB9VG5uyA==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/core": "^28.1.3", + "@jest/types": "^28.1.3", + "import-local": "^3.0.2", + "jest-cli": "^28.1.3" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/html-validator/node_modules/jest-changed-files": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-28.1.3.tgz", + "integrity": "sha512-esaOfUWJXk2nfZt9SPyC8gA1kNfdKLkQWyzsMlqq8msYSlNKfmZxfRgZn4Cd4MGVUF+7v6dBs0d5TOAKa7iIiA==", + "optional": true, + "peer": true, + "dependencies": { + "execa": "^5.0.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-circus": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-28.1.3.tgz", + "integrity": "sha512-cZ+eS5zc79MBwt+IhQhiEp0OeBddpc1n8MBo1nMB8A7oPMKEO+Sre+wHaLJexQUj9Ya/8NOBY0RESUgYjB6fow==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/environment": "^28.1.3", + "@jest/expect": "^28.1.3", + "@jest/test-result": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^28.1.3", + "jest-matcher-utils": "^28.1.3", + "jest-message-util": "^28.1.3", + "jest-runtime": "^28.1.3", + "jest-snapshot": "^28.1.3", + "jest-util": "^28.1.3", + "p-limit": "^3.1.0", + "pretty-format": "^28.1.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-cli": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-28.1.3.tgz", + "integrity": "sha512-roY3kvrv57Azn1yPgdTebPAXvdR2xfezaKKYzVxZ6It/5NCxzJym6tUI5P1zkdWhfUYkxEI9uZWcQdaFLo8mJQ==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/core": "^28.1.3", + "@jest/test-result": "^28.1.3", + "@jest/types": "^28.1.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "import-local": "^3.0.2", + "jest-config": "^28.1.3", + "jest-util": "^28.1.3", + "jest-validate": "^28.1.3", + "prompts": "^2.0.1", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/html-validator/node_modules/jest-config": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-28.1.3.tgz", + "integrity": "sha512-MG3INjByJ0J4AsNBm7T3hsuxKQqFIiRo/AUqb1q9LRKI5UU6Aar9JHbr9Ivn1TVwfUD9KirRoM/T6u8XlcQPHQ==", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^28.1.3", + "@jest/types": "^28.1.3", + "babel-jest": "^28.1.3", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^28.1.3", + "jest-environment-node": "^28.1.3", + "jest-get-type": "^28.0.2", + "jest-regex-util": "^28.0.2", + "jest-resolve": "^28.1.3", + "jest-runner": "^28.1.3", + "jest-util": "^28.1.3", + "jest-validate": "^28.1.3", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^28.1.3", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/html-validator/node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/html-validator/node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "optional": true, + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/html-validator/node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/html-validator/node_modules/jest-diff": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-28.1.3.tgz", + "integrity": "sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==", + "optional": true, + "peer": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^28.1.1", + "jest-get-type": "^28.0.2", + "pretty-format": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-docblock": { + "version": "28.1.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-28.1.1.tgz", + "integrity": "sha512-3wayBVNiOYx0cwAbl9rwm5kKFP8yHH3d/fkEaL02NPTkDojPtheGB7HZSFY4wzX+DxyrvhXz0KSCVksmCknCuA==", + "optional": true, + "peer": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-each": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-28.1.3.tgz", + "integrity": "sha512-arT1z4sg2yABU5uogObVPvSlSMQlDA48owx07BDPAiasW0yYpYHYOo4HHLz9q0BVzDVU4hILFjzJw0So9aCL/g==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^28.1.3", + "chalk": "^4.0.0", + "jest-get-type": "^28.0.2", + "jest-util": "^28.1.3", + "pretty-format": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-environment-node": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-28.1.3.tgz", + "integrity": "sha512-ugP6XOhEpjAEhGYvp5Xj989ns5cB1K6ZdjBYuS30umT4CQEETaxSiPcZ/E1kFktX4GkrcM4qu07IIlDYX1gp+A==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/environment": "^28.1.3", + "@jest/fake-timers": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "jest-mock": "^28.1.3", + "jest-util": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-get-type": { + "version": "28.0.2", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-28.0.2.tgz", + "integrity": "sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA==", + "optional": true, + "peer": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-haste-map": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-28.1.3.tgz", + "integrity": "sha512-3S+RQWDXccXDKSWnkHa/dPwt+2qwA8CJzR61w3FoYCvoo3Pn8tvGcysmMF0Bj0EX5RYvAI2EIvC57OmotfdtKA==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^28.1.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^28.0.2", + "jest-util": "^28.1.3", + "jest-worker": "^28.1.3", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/html-validator/node_modules/jest-leak-detector": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-28.1.3.tgz", + "integrity": "sha512-WFVJhnQsiKtDEo5lG2mM0v40QWnBM+zMdHHyJs8AWZ7J0QZJS59MsyKeJHWhpBZBH32S48FOVvGyOFT1h0DlqA==", + "optional": true, + "peer": true, + "dependencies": { + "jest-get-type": "^28.0.2", + "pretty-format": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-matcher-utils": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-28.1.3.tgz", + "integrity": "sha512-kQeJ7qHemKfbzKoGjHHrRKH6atgxMk8Enkk2iPQ3XwO6oE/KYD8lMYOziCkeSB9G4adPM4nR1DE8Tf5JeWH6Bw==", + "optional": true, + "peer": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^28.1.3", + "jest-get-type": "^28.0.2", + "pretty-format": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-message-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz", + "integrity": "sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^28.1.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^28.1.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-mock": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-28.1.3.tgz", + "integrity": "sha512-o3J2jr6dMMWYVH4Lh/NKmDXdosrsJgi4AviS8oXLujcjpCMBb1FMsblDnOXKZKfSiHLxYub1eS0IHuRXsio9eA==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-regex-util": { + "version": "28.0.2", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-28.0.2.tgz", + "integrity": "sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==", + "optional": true, + "peer": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-resolve": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-28.1.3.tgz", + "integrity": "sha512-Z1W3tTjE6QaNI90qo/BJpfnvpxtaFTFw5CDgwpyE/Kz8U/06N1Hjf4ia9quUhCh39qIGWF1ZuxFiBiJQwSEYKQ==", + "optional": true, + "peer": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^28.1.3", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^28.1.3", + "jest-validate": "^28.1.3", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-resolve-dependencies": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-28.1.3.tgz", + "integrity": "sha512-qa0QO2Q0XzQoNPouMbCc7Bvtsem8eQgVPNkwn9LnS+R2n8DaVDPL/U1gngC0LTl1RYXJU0uJa2BMC2DbTfFrHA==", + "optional": true, + "peer": true, + "dependencies": { + "jest-regex-util": "^28.0.2", + "jest-snapshot": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-runner": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-28.1.3.tgz", + "integrity": "sha512-GkMw4D/0USd62OVO0oEgjn23TM+YJa2U2Wu5zz9xsQB1MxWKDOlrnykPxnMsN0tnJllfLPinHTka61u0QhaxBA==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/console": "^28.1.3", + "@jest/environment": "^28.1.3", + "@jest/test-result": "^28.1.3", + "@jest/transform": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.10.2", + "graceful-fs": "^4.2.9", + "jest-docblock": "^28.1.1", + "jest-environment-node": "^28.1.3", + "jest-haste-map": "^28.1.3", + "jest-leak-detector": "^28.1.3", + "jest-message-util": "^28.1.3", + "jest-resolve": "^28.1.3", + "jest-runtime": "^28.1.3", + "jest-util": "^28.1.3", + "jest-watcher": "^28.1.3", + "jest-worker": "^28.1.3", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-runtime": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-28.1.3.tgz", + "integrity": "sha512-NU+881ScBQQLc1JHG5eJGU7Ui3kLKrmwCPPtYsJtBykixrM2OhVQlpMmFWJjMyDfdkGgBMNjXCGB/ebzsgNGQw==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/environment": "^28.1.3", + "@jest/fake-timers": "^28.1.3", + "@jest/globals": "^28.1.3", + "@jest/source-map": "^28.1.2", + "@jest/test-result": "^28.1.3", + "@jest/transform": "^28.1.3", + "@jest/types": "^28.1.3", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^28.1.3", + "jest-message-util": "^28.1.3", + "jest-mock": "^28.1.3", + "jest-regex-util": "^28.0.2", + "jest-resolve": "^28.1.3", + "jest-snapshot": "^28.1.3", + "jest-util": "^28.1.3", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/html-validator/node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "optional": true, + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/html-validator/node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/html-validator/node_modules/jest-snapshot": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-28.1.3.tgz", + "integrity": "sha512-4lzMgtiNlc3DU/8lZfmqxN3AYD6GGLbl+72rdBpXvcV+whX7mDrREzkPdp2RnmfIiWBg1YbuFSkXduF2JcafJg==", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^28.1.3", + "@jest/transform": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/babel__traverse": "^7.0.6", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^28.1.3", + "graceful-fs": "^4.2.9", + "jest-diff": "^28.1.3", + "jest-get-type": "^28.0.2", + "jest-haste-map": "^28.1.3", + "jest-matcher-utils": "^28.1.3", + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3", + "natural-compare": "^1.4.0", + "pretty-format": "^28.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-28.1.3.tgz", + "integrity": "sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-validate": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-28.1.3.tgz", + "integrity": "sha512-SZbOGBWEsaTxBGCOpsRWlXlvNkvTkY0XxRfh7zYmvd8uL5Qzyg0CHAXiXKROflh801quA6+/DsT4ODDthOC/OA==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^28.1.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^28.0.2", + "leven": "^3.1.0", + "pretty-format": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-watcher": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-28.1.3.tgz", + "integrity": "sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/test-result": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.10.2", + "jest-util": "^28.1.3", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/jest-worker": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz", + "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==", + "optional": true, + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/html-validator/node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/html-validator/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-validator/node_modules/pretty-format": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", + "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "optional": true, + "peer": true, + "dependencies": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/html-validator/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "optional": true, + "peer": true + }, + "node_modules/html-validator/node_modules/resolve.exports": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.1.tgz", + "integrity": "sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-validator/node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/html-validator/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "optional": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true, + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.0", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.1.0", + "license": "MIT", "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" @@ -7285,6 +8790,14 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-regex": { "version": "1.1.4", "dev": true, @@ -8041,8 +9554,7 @@ "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -8804,6 +10316,23 @@ "url": "https://github.com/sponsors/raouldeheer" } }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "license": "MIT" @@ -9208,6 +10737,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, "node_modules/parseurl": { "version": "1.3.3", "license": "MIT", @@ -9342,6 +10876,33 @@ "node": ">=12" } }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -9655,6 +11216,14 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "license": "MIT", @@ -9747,7 +11316,7 @@ }, "node_modules/rimraf": { "version": "3.0.2", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -9832,6 +11401,19 @@ "version": "2.1.2", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.13.0.tgz", + "integrity": "sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, "node_modules/saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", @@ -10027,6 +11609,14 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.13", "license": "MIT", @@ -10286,6 +11876,20 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "optional": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "license": "MIT", @@ -10358,6 +11962,23 @@ "node": ">= 6" } }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "optional": true, + "peer": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "license": "ISC", @@ -10377,7 +11998,6 @@ }, "node_modules/text-table": { "version": "0.2.0", - "dev": true, "license": "MIT" }, "node_modules/tmp": { @@ -10824,7 +12444,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -10879,6 +12498,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/valid-url": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", + "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==" + }, "node_modules/validator": { "version": "13.11.0", "license": "MIT", diff --git a/backend/package.json b/backend/package.json index 7cb109b..20deac6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "express": "^4.18.2", "express-validator": "^7.0.1", "firebase": "^10.7.2", + "html-validator": "^6.0.1", "http-errors": "^2.0.0", "jest": "^29.7.0", "module-alias": "^2.2.3", @@ -27,6 +28,7 @@ "mongodb-memory-server": "^9.1.1", "mongoose": "^7.4.0", "nodemailer": "^6.9.8", + "sanitize-html": "^2.13.0", "supertest": "^6.3.3", "ts-jest": "^29.1.1", "tsc-alias": "^1.8.8" @@ -34,9 +36,11 @@ "devDependencies": { "@types/cors": "^2.8.13", "@types/express": "^4.17.17", + "@types/html-validator": "^5.0.6", "@types/http-errors": "^2.0.1", "@types/jest": "^29.5.10", "@types/nodemailer": "^6.4.14", + "@types/sanitize-html": "^2.11.0", "@types/supertest": "^2.0.16", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index d18a108..e837dbd 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,7 @@ import { isHttpError } from "http-errors"; import vsrRoutes from "src/routes/vsr"; import furnitureItemRoutes from "src/routes/furnitureItem"; import userRoutes from "src/routes/user"; +import confirmationEmailRoutes from "src/routes/confirmationEmail"; import env from "src/util/validateEnv"; const app = express(); @@ -31,6 +32,7 @@ app.use( app.use("/api/user", userRoutes); app.use("/api/vsr", vsrRoutes); app.use("/api/furnitureItems", furnitureItemRoutes); +app.use("/api/confirmationEmail", confirmationEmailRoutes); /** * Error handler; all errors thrown by server are handled here. diff --git a/backend/src/controllers/confirmationEmail.ts b/backend/src/controllers/confirmationEmail.ts new file mode 100644 index 0000000..bdd6836 --- /dev/null +++ b/backend/src/controllers/confirmationEmail.ts @@ -0,0 +1,40 @@ +import { RequestHandler } from "express"; +import { validationResult } from "express-validator"; +import sanitizeHtml from "sanitize-html"; +import { PAPRequest } from "src/middleware/auth"; +import { + PAP_LOGO_HTML, + retrieveConfirmaionEmail, + updateConfirmationEmail as updateConfirmationEmailService, +} from "src/services/confirmationEmails"; +import validationErrorParser from "src/util/validationErrorParser"; + +/** + * Retrieves the VSR confirmation email from the database. + */ +export const getConfirmationEmail: RequestHandler = async (req: PAPRequest, res, next) => { + try { + const confirmationEmail = await retrieveConfirmaionEmail(); + res.status(200).json({ html: confirmationEmail.html, papLogoHTML: PAP_LOGO_HTML }); + } catch (error) { + next(error); + } +}; + +export const updateConfirmationEmail: RequestHandler = async (req: PAPRequest, res, next) => { + const errors = validationResult(req); + try { + validationErrorParser(errors); + const { html } = req.body; + const sanitizedHTML = sanitizeHtml(html, { + allowedTags: sanitizeHtml.defaults.allowedTags.concat([ + // Frontend editor uses tags for underlining. + "ins", + ]), + }); + const updatedConfirmationEmail = await updateConfirmationEmailService(sanitizedHTML); + res.status(200).json(updatedConfirmationEmail); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/furnitureItem.ts b/backend/src/controllers/furnitureItem.ts index 947e330..a5f1049 100644 --- a/backend/src/controllers/furnitureItem.ts +++ b/backend/src/controllers/furnitureItem.ts @@ -1,5 +1,8 @@ import { RequestHandler } from "express"; +import { validationResult } from "express-validator"; +import createHttpError from "http-errors"; import FurnitureItemModel from "src/models/furnitureItem"; +import validationErrorParser from "src/util/validationErrorParser"; /** * Gets all available furniture items in the database. Does not require authentication. @@ -19,3 +22,48 @@ export const getFurnitureItems: RequestHandler = async (req, res, next) => { next(error); } }; + +export const createFurnitureItem: RequestHandler = async (req, res, next) => { + const errors = validationResult(req); + try { + validationErrorParser(errors); + const furnitureItem = await FurnitureItemModel.create(req.body); + res.status(201).json(furnitureItem); + } catch (error) { + next(error); + } +}; + +export const deleteFurnitureItem: RequestHandler = async (req, res, next) => { + try { + const { id } = req.params; + const deletedFurnitureItem = await FurnitureItemModel.findByIdAndDelete(id); + if (deletedFurnitureItem === null) { + throw createHttpError(404, "FurnitureItem not found at id " + id); + } + return res.status(204).send(); + } catch (error) { + next(error); + } +}; + +export const updateFurnitureItem: RequestHandler = async (req, res, next) => { + const errors = validationResult(req); + try { + const { id } = req.params; + + validationErrorParser(errors); + + const updatedFurnitureItem = await FurnitureItemModel.findByIdAndUpdate(id, req.body, { + new: true, + }); + + if (updatedFurnitureItem == null) { + throw createHttpError(404, "Furniture Item not found at id " + id); + } + + res.status(200).json(updatedFurnitureItem); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index 1ff74a2..c463fa7 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -1,6 +1,14 @@ import { RequestHandler } from "express"; import { PAPRequest } from "src/middleware/auth"; -import UserModel from "src/models/user"; +import { firebaseAuth } from "src/services/firebase"; +import UserModel, { DisplayUser, UserRole } from "src/models/user"; +import { validationResult } from "express-validator"; +import validationErrorParser from "src/util/validationErrorParser"; +import createHttpError from "http-errors"; +import { + sendOwnPasswordChangedNotificationEmail, + sendPasswordChangedEmailToAdmin, +} from "src/services/emails"; /** * Retrieves data about the current user (their MongoDB ID, Firebase UID, and role). @@ -20,3 +28,120 @@ export const getWhoAmI: RequestHandler = async (req: PAPRequest, res, next) => { next(error); } }; + +/** + * Retrieves a list of all users in our database + */ +export const getUsers: RequestHandler = async (req: PAPRequest, res, next) => { + try { + const papUsers = await UserModel.find(); + const displayUsers: DisplayUser[] = []; + for (const papUser of papUsers) { + const { uid, _id } = papUser; + + try { + const firebaseUser = await firebaseAuth.getUser(uid); + const { displayName, email } = firebaseUser!; + + const displayUser = { _id, uid, displayName, email }; + displayUsers.push(displayUser); + } catch (error) { + next(error); + } + } + + res.status(200).json(displayUsers); + } catch (error) { + next(error); + } +}; + +/** + * Creates a new user, in both the Firebase and MongoDB databases + */ +export const createUser: RequestHandler = async (req: PAPRequest, res, next) => { + const errors = validationResult(req); + + try { + validationErrorParser(errors); + + const { name, email, password } = req.body; + + // First, call the Firebase API to create a new user + const firebaseUser = await firebaseAuth.createUser({ + displayName: name, + email, + password, + }); + + // Now, using the UID of the new Firebase user, create a user in our MongoDB database + const user = await UserModel.create({ + uid: firebaseUser.uid, + // We can only create new staff accounts, not admin accounts. + role: UserRole.STAFF, + }); + + res.status(201).json(user); + } catch (error) { + next(error); + } +}; + +/** + * Changes a user's password, finding the user by their UID + */ +export const changeUserPassword: RequestHandler = async (req, res, next) => { + try { + const errors = validationResult(req); + + validationErrorParser(errors); + + const { password } = req.body; + const { uid } = req.params; + + const updatedUser = await firebaseAuth.updateUser(uid, { + password, + }); + + await sendPasswordChangedEmailToAdmin(updatedUser.email!); + + res.status(200).json(updatedUser); + } catch (error) { + next(error); + } +}; + +/** + * Sends an email to notify the user that their password has been reset. + */ +export const notifyResetPassword: RequestHandler = async (req: PAPRequest, res, next) => { + try { + const { userUid } = req; + const firebaseUser = await firebaseAuth.getUser(userUid!); + await sendOwnPasswordChangedNotificationEmail(firebaseUser.email!); + await sendPasswordChangedEmailToAdmin(firebaseUser.email!); + + res.status(204).send(); + } catch (error) { + next(error); + } +}; + +/** + * Deletes a user from the Firebase and MongoDB databases + */ +export const deleteUser: RequestHandler = async (req, res, next) => { + try { + const { uid } = req.params; + + await firebaseAuth.deleteUser(uid); + + const deletedUser = await UserModel.deleteOne({ uid }); + if (deletedUser === null) { + throw createHttpError(404, "User not found at uid " + uid); + } + return res.status(204).send(); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/vsr.ts b/backend/src/controllers/vsr.ts index 6e73bb9..36e6ab3 100644 --- a/backend/src/controllers/vsr.ts +++ b/backend/src/controllers/vsr.ts @@ -7,6 +7,7 @@ import { sendVSRConfirmationEmailToVeteran, sendVSRNotificationEmailToStaff, } from "src/services/emails"; +import { retrieveVSRs } from "src/services/vsrs"; import validationErrorParser from "src/util/validationErrorParser"; import ExcelJS from "exceljs"; import { ObjectId } from "mongodb"; @@ -19,7 +20,13 @@ type FurnitureItemEntry = FurnitureItem & { _id: ObjectId }; */ export const getAllVSRS: RequestHandler = async (req, res, next) => { try { - const vsrs = await VSRModel.find(); + const vsrs = await retrieveVSRs( + req.query.search as string | undefined, + req.query.status as string | undefined, + req.query.incomeLevel as string | undefined, + req.query.zipCode ? (req.query.zipCode as string).split(",") : undefined, + undefined, + ); res.status(200).json({ vsrs }); } catch (error) { @@ -289,32 +296,20 @@ const writeSpreadsheet = async (plainVsrs: VSR[], res: Response) => { export const bulkExportVSRS: RequestHandler = async (req, res, next) => { try { - const filename = "vsrs.xlsx"; + const filename = `vsrs_${new Date().toISOString()}.xlsx`; // Set some headers on the response so the client knows that a file is attached res.set({ "Content-Disposition": `attachment; filename="${filename}"`, "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }); - let vsrs: VSR[]; - - if (req.query.vsrIds && ((req.query.vsrIds.length ?? 0) as number) > 0) { - // If the "vsrIds" query parameter exists and is non-empty, then find & export all VSRs - // with an _id in the vsrIds list - - // Need to convert each ID string to an ObjectId object - const vsrObjectIds = (req.query.vsrIds as string)?.split(",").map((_id) => new ObjectId(_id)); - vsrs = ( - await VSRModel.find({ - _id: { - $in: vsrObjectIds, - }, - }) - ).map((doc) => doc.toObject()); - } else { - // If the "vsrIds" query parameter is not provided or is empty, export all VSRs in the database - vsrs = (await VSRModel.find()).map((doc) => doc.toObject()); - } + const vsrs = await retrieveVSRs( + req.query.search as string | undefined, + req.query.status as string | undefined, + req.query.incomeLevel as string | undefined, + req.query.zipCode ? (req.query.zipCode as string).split(",") : undefined, + req.query.vsrIds ? (req.query.vsrIds as string).split(",") : undefined, + ); await writeSpreadsheet(vsrs, res); } catch (error) { diff --git a/backend/src/models/confirmationEmail.ts b/backend/src/models/confirmationEmail.ts new file mode 100644 index 0000000..dfa7914 --- /dev/null +++ b/backend/src/models/confirmationEmail.ts @@ -0,0 +1,14 @@ +import { InferSchemaType, Schema, model } from "mongoose"; + +/** + * A model for the confirmation email sent to veterans when they fill out the + * VSR. Only one instance of this model will ever exist at once. + */ +const confirmationEmailSchema = new Schema({ + // The HTML of the email + html: { type: String, required: true }, +}); + +export type ConfirmationEmail = InferSchemaType; + +export default model("ConfirmationEmail", confirmationEmailSchema); diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index 566df40..3ef15d2 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -1,3 +1,4 @@ +import { ObjectId } from "mongodb"; import { InferSchemaType, Schema, model } from "mongoose"; /** @@ -28,6 +29,13 @@ export enum UserRole { ADMIN = "admin", } +export interface DisplayUser { + _id: ObjectId; + uid: string; + email: string | undefined; + displayName: string | undefined; +} + type User = InferSchemaType; export default model("User", userSchema); diff --git a/backend/src/models/vsr.ts b/backend/src/models/vsr.ts index cd46f87..a1c5717 100644 --- a/backend/src/models/vsr.ts +++ b/backend/src/models/vsr.ts @@ -58,4 +58,6 @@ const vsrSchema = new Schema({ export type FurnitureInput = InferSchemaType; export type VSR = InferSchemaType; +vsrSchema.index({ name: "text" }); + export default model("VSR", vsrSchema); diff --git a/backend/src/routes/confirmationEmail.ts b/backend/src/routes/confirmationEmail.ts new file mode 100644 index 0000000..915abf9 --- /dev/null +++ b/backend/src/routes/confirmationEmail.ts @@ -0,0 +1,17 @@ +import express from "express"; +import { requireAdmin, requireSignedIn } from "src/middleware/auth"; +import * as ConfirmationEmailController from "src/controllers/confirmationEmail"; +import * as ConfirmationEmailValidator from "src/validators/confirmationEmail"; + +const router = express.Router(); + +router.get("/", requireSignedIn, requireAdmin, ConfirmationEmailController.getConfirmationEmail); +router.put( + "/", + requireSignedIn, + requireAdmin, + ConfirmationEmailValidator.updateConfirmationEmail, + ConfirmationEmailController.updateConfirmationEmail, +); + +export default router; diff --git a/backend/src/routes/furnitureItem.ts b/backend/src/routes/furnitureItem.ts index 3af27bf..e71c74e 100644 --- a/backend/src/routes/furnitureItem.ts +++ b/backend/src/routes/furnitureItem.ts @@ -1,8 +1,25 @@ import express from "express"; +import { requireSignedIn, requireAdmin } from "src/middleware/auth"; import * as FurnitureItemController from "src/controllers/furnitureItem"; +import * as FurnitureItemValidator from "src/validators/furnitureItem"; const router = express.Router(); router.get("/", FurnitureItemController.getFurnitureItems); +router.post( + "/", + requireSignedIn, + requireAdmin, + FurnitureItemValidator.createFurnitureItem, + FurnitureItemController.createFurnitureItem, +); +router.delete("/:id", requireSignedIn, requireAdmin, FurnitureItemController.deleteFurnitureItem); +router.put( + "/:id", + requireSignedIn, + requireAdmin, + FurnitureItemValidator.updateFurnitureItem, + FurnitureItemController.updateFurnitureItem, +); export default router; diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index efd4171..2e1a3bb 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -1,10 +1,33 @@ import express from "express"; -import { requireSignedIn } from "src/middleware/auth"; +import { requireAdmin, requireSignedIn, requireStaffOrAdmin } from "src/middleware/auth"; import * as UserController from "src/controllers/user"; +import * as UserValidator from "src/validators/user"; const router = express.Router(); router.get("/whoami", requireSignedIn, UserController.getWhoAmI); +router.get("/", requireSignedIn, requireAdmin, UserController.getUsers); +router.post( + "/", + requireSignedIn, + requireAdmin, + UserValidator.createUser, + UserController.createUser, +); +router.patch( + "/:uid/password", + requireSignedIn, + requireAdmin, + UserValidator.changeUserPassword, + UserController.changeUserPassword, +); +router.post( + "/notifyResetPassword", + requireSignedIn, + requireStaffOrAdmin, + UserController.notifyResetPassword, +); +router.delete("/:uid", requireSignedIn, requireAdmin, UserController.deleteUser); export default router; diff --git a/backend/src/services/confirmationEmails.ts b/backend/src/services/confirmationEmails.ts new file mode 100644 index 0000000..8502391 --- /dev/null +++ b/backend/src/services/confirmationEmails.ts @@ -0,0 +1,39 @@ +import ConfirmationEmailModel from "src/models/confirmationEmail"; + +const DEFAULT_CONFIRMATION_EMAIL_HTML = + "

Dear [Recipient],

\n

\n

Thank you for submitting a Veteran Service Request (VSR) form.

\n

\n

Your VSR form has been approved. The next step is to schedule your pick-up appointment. To ensure a smooth process, please review the following information:

\n

\n

Appointment Availability & Upcoming Closure Dates

\n
    \n
  • Appointments are available on Tuesdays, Thursdays, and Saturdays from 10:00 AM to 3:00 PM.
  • \n
  • Please specify your preferred appointment day and time when responding to this email.
  • \n
  • Please note that we will not be scheduling appointments on Thursday, November 23rd (Thanksgiving) and Saturday, December 2nd.
  • \n
\n

\n

Appointment Confirmation & Details

\n
    \n
  • Once you respond with your preferred date and time, we'll promptly reserve your spot and provide you with the appointment protocol, address, and instructions.
  • \n
  • On the scheduled day, please ensure you come prepared to pick up your items.
  • \n
\n

\n

Eligibility & Proof of Service

\n
    \n
  • Our services are reserved for Veterans, Active Military, Reservists, and their immediate family members (spouses and school-aged children residing with them).
  • \n
  • Proof of service, such as a VA Card, DD214, or Active Military card, will be required at the appointment.
  • \n
\n

\n

Transportation

\n
    \n
  • Remember to arrange your own transportation to take the items you request.
  • \n
  • We advise against waiting until the last minute to rent a truck, as availability may be limited.
  • \n
\n

\n

Location Information

\n
    \n
  • We are located in Anaheim, Orange County.
  • \n
\n

\n

Note

\n
    \n
  • Please remember that items are donated from the community as a way of saying THANK YOU FOR YOUR SERVICE, so we are like a cashless thrift store environment.
  • \n
  • Our services are not extended to family members and friends who have not served our country.
  • \n
\n

\n

Stay connected with us

\n

Facebook: @patriotsandpaws

\n

Instagram: @patriotsandpaws

\n

X: @PatriotsandPaws

\n

\n

Thank you for reaching out to us. We're here to support you.

\n

\n

Best regards,

\n

Patriots and Paws

\n

veteran@patriotsandpaws.org

\n"; + +export const RECIPIENT_TEXT = "[Recipient]"; + +export const PAP_LOGO_HTML = + 'Patriots & Paws Logo'; + +/** + * Retrieves the current confirmation email template from the MongoDB database, + * creating it with the default HTML if it does not exist. + */ +export const retrieveConfirmaionEmail = async () => { + let confirmationEmail = await ConfirmationEmailModel.findOne(); + if (confirmationEmail === null) { + confirmationEmail = await ConfirmationEmailModel.create({ + html: DEFAULT_CONFIRMATION_EMAIL_HTML, + }); + } + return confirmationEmail; +}; + +/** + * Saves the new HTML for the confirmation email to the database. Assumes + * that the HTML has already been validated and sanitized. + */ +export const updateConfirmationEmail = async (newHTML: string) => { + const confirmationEmail = await retrieveConfirmaionEmail(); + const updatedConfirmationEmail = await ConfirmationEmailModel.findByIdAndUpdate( + confirmationEmail._id, + { + html: newHTML, + }, + { new: true }, + ); + return updatedConfirmationEmail; +}; diff --git a/backend/src/services/emails.ts b/backend/src/services/emails.ts index f229faa..fe431c1 100644 --- a/backend/src/services/emails.ts +++ b/backend/src/services/emails.ts @@ -1,5 +1,11 @@ import "dotenv/config"; import nodemailer from "nodemailer"; +import Mail from "nodemailer/lib/mailer"; +import { + PAP_LOGO_HTML, + RECIPIENT_TEXT, + retrieveConfirmaionEmail, +} from "src/services/confirmationEmails"; import env from "src/util/validateEnv"; const trimmedFrontendUrl = env.FRONTEND_ORIGIN.replace( @@ -8,6 +14,23 @@ const trimmedFrontendUrl = env.FRONTEND_ORIGIN.replace( "", ); +const sendEmail = async (options: Mail.Options) => { + const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: env.EMAIL_USER, + pass: env.EMAIL_APP_PASSWORD, + }, + }); + + const mailOptions = { + from: env.EMAIL_USER, + ...options, + }; + + await transporter.sendMail(mailOptions); +}; + /** * Sends a notification email to PAP staff when a VSR is submitted. * Throws an error if the email could not be sent. @@ -20,22 +43,11 @@ const sendVSRNotificationEmailToStaff = async (name: string, email: string, id: const EMAIL_SUBJECT = "New VSR Submitted"; const EMAIL_BODY = `A new VSR was submitted by ${name} from ${email}. You can view it at ${trimmedFrontendUrl}/staff/vsr/${id}`; - const transporter = nodemailer.createTransport({ - service: "gmail", - auth: { - user: env.EMAIL_USER, - pass: env.EMAIL_APP_PASSWORD, - }, - }); - - const mailOptions = { - from: env.EMAIL_USER, + await sendEmail({ to: env.EMAIL_NOTIFICATIONS_RECIPIENT, subject: EMAIL_SUBJECT, text: EMAIL_BODY, - }; - - await transporter.sendMail(mailOptions); + }); }; /** @@ -47,84 +59,13 @@ const sendVSRNotificationEmailToStaff = async (name: string, email: string, id: */ const sendVSRConfirmationEmailToVeteran = async (name: string, email: string) => { const EMAIL_SUBJECT = "VSR Submission Confirmation"; - const EMAIL_HTML = `

Dear ${name},

\ -

Thank you for submitting a veteran service request (VSR).

\ -

\ - We schedule appointments on Tuesdays, Thursday, and Saturdays from 10-3pm. You need to be\ - specific about what day and time you want to make your appointment.\ - \ -

\ - \ -

\ - PLEASE NOTE: We will not be making appointments on THURSDAY, NOVEMBER 23RD (THANKSGIVING) OR\ - SATURDAY, DECEMBER 2nd.\ -

\ - \ -

\ - Respond to this email and let us know what day and time will work and we will put you\ - down.\ -

\ - \ -

\ - You pick up your items on the date and time of your appointment. You need to come prepared on that\ - day.\ -

\ -

We will help you with as much as we can on your list.

\ -

\ - Please remember that items are donated from the community as a way of saying THANK YOU FOR YOUR\ - SERVICE, so we are like a cashless thrift store environment.\ -

\ -

You will need to provide your own transportation for all your items you are requesting.

\ -

\ - \ - Wherever you are going to rent a truck, don't wait to the last minute to rent a truck as there\ - might not be one available.\ -

\ -

We are located in the Anaheim, in Orange County.

\ -

Once we confirm your appointment we will send you the warehouse protocol and address.

\ -

\ - \ - Items in the warehouse have been donated from the General Public and are strictly for Veterans,\ - Active Military and Reservists and their immediate family (wives and school aged children) who\ - live with them. The Veteran/Active Duty/Reservist has to be at appointment and will need to show\ - proof of service, such as a VA Card, DD214 or Active Military card.\ -

\ -

They are not for family members and friends who have not served our Country.

\ -

Thank you for contacting us.

\ -

Volunteer

\ - \ -

veteran@patriotsandpaws.org

\ - Patriots & Paws Logo\ -

\ - Facebook\ - https://www.facebook.com/pages/Patriots-and-Paws/283613748323930\ -

\ -

Twitter @patriotsandpaws

\ -

Instagram patriotsandpaws

\ - `; - - const transporter = nodemailer.createTransport({ - service: "gmail", - auth: { - user: env.EMAIL_USER, - pass: env.EMAIL_APP_PASSWORD, - }, - }); + const confirmationEmail = await retrieveConfirmaionEmail(); + const emailHTML = confirmationEmail.html.replace(RECIPIENT_TEXT, name) + PAP_LOGO_HTML; - const mailOptions = { - from: env.EMAIL_USER, + await sendEmail({ to: email, subject: EMAIL_SUBJECT, - html: EMAIL_HTML, + html: emailHTML, attachments: [ { filename: "pap_logo.png", @@ -132,9 +73,34 @@ const sendVSRConfirmationEmailToVeteran = async (name: string, email: string) => cid: "pap_logo.png", }, ], - }; + }); +}; - await transporter.sendMail(mailOptions); +const sendOwnPasswordChangedNotificationEmail = async (email: string) => { + const EMAIL_SUBJECT = "PAP Application Password Change Confirmation"; + const EMAIL_BODY = `Your password for the ${email} account has been changed.`; + + await sendEmail({ + to: email, + subject: EMAIL_SUBJECT, + text: EMAIL_BODY, + }); +}; + +const sendPasswordChangedEmailToAdmin = async (email: string) => { + const EMAIL_SUBJECT = "PAP Application Password Change Notification"; + const EMAIL_BODY = `The password for the ${email} account has been changed.`; + + await sendEmail({ + to: env.EMAIL_NOTIFICATIONS_RECIPIENT, + subject: EMAIL_SUBJECT, + text: EMAIL_BODY, + }); }; -export { sendVSRNotificationEmailToStaff, sendVSRConfirmationEmailToVeteran }; +export { + sendVSRNotificationEmailToStaff, + sendVSRConfirmationEmailToVeteran, + sendOwnPasswordChangedNotificationEmail, + sendPasswordChangedEmailToAdmin, +}; diff --git a/backend/src/services/vsrs.ts b/backend/src/services/vsrs.ts new file mode 100644 index 0000000..bd35e04 --- /dev/null +++ b/backend/src/services/vsrs.ts @@ -0,0 +1,65 @@ +import VSRModel from "src/models/vsr"; + +export const retrieveVSRs = async ( + search: string | undefined, + status: string | undefined, + incomeLevel: string | undefined, + zipCodes: string[] | undefined, + vsrIds: string[] | undefined, +) => { + let vsrs = await VSRModel.aggregate([ + ...(search + ? [ + { + $match: { name: { $regex: new RegExp(search, "i") } }, + }, + ] + : []), + { + $addFields: { + statusOrder: { + $switch: { + branches: [ + { case: { $eq: ["$status", "Received"] }, then: 1 }, + { case: { $eq: ["$status", "Approved"] }, then: 2 }, + { case: { $eq: ["$status", "Appointment Scheduled"] }, then: 3 }, + { case: { $eq: ["$status", "Complete"] }, then: 4 }, + { case: { $eq: ["$status", "No-show / Incomplete"] }, then: 5 }, + { case: { $eq: ["$status", "Archived"] }, then: 6 }, + ], + default: 99, + }, + }, + }, + }, + { $sort: { statusOrder: 1, dateReceived: -1 } }, + ]); + + if (vsrIds && vsrIds.length > 0) { + const vsrIdsSet = new Set(vsrIds); + vsrs = vsrs.filter((vsr) => vsrIdsSet.has(vsr._id.toString())); + } + + if (status) { + vsrs = vsrs.filter((vsr) => vsr.status === status); + } + + if (incomeLevel) { + const incomeMap: { [key: string]: string } = { + "50000": "$50,001 and over", + "25000": "$25,001 - $50,000", + "12500": "$12,501 - $25,000", + "0": "$12,500 and under", + }; + + vsrs = vsrs.filter((vsr) => { + return vsr.incomeLevel === incomeMap[incomeLevel]; + }); + } + + if (zipCodes && zipCodes.length > 0) { + vsrs = vsrs.filter((vsr) => zipCodes.includes(vsr.zipCode.toString())); + } + + return vsrs; +}; diff --git a/backend/src/validators/confirmationEmail.ts b/backend/src/validators/confirmationEmail.ts new file mode 100644 index 0000000..906973c --- /dev/null +++ b/backend/src/validators/confirmationEmail.ts @@ -0,0 +1,19 @@ +import { body } from "express-validator"; +import validator from "html-validator"; + +const makeHTMLValidator = () => + body("html") + .exists({ checkFalsy: true }) + .withMessage("HTML is required") + .isString() + .withMessage("HTML must be a string") + .custom(async (html: string) => { + try { + validator({ data: html }); + return true; + } catch (error) { + return false; + } + }); + +export const updateConfirmationEmail = [makeHTMLValidator()]; diff --git a/backend/src/validators/furnitureItem.ts b/backend/src/validators/furnitureItem.ts new file mode 100644 index 0000000..167d460 --- /dev/null +++ b/backend/src/validators/furnitureItem.ts @@ -0,0 +1,43 @@ +import { body } from "express-validator"; + +const makeCategoryValidator = () => + body("category") + .exists({ checkFalsy: true }) + .withMessage("Category is required") + .isString() + .withMessage("Category must be a string"); + +const makeNameValidator = () => + body("name") + .exists({ checkFalsy: true }) + .withMessage("Name is required") + .isString() + .withMessage("Name must be a string"); + +const makeAllowMultipleValidator = () => + body("allowMultiple") + .exists({ checkFalsy: false }) + .withMessage("Must specify if multiple of these items can be requested") + .isBoolean() + .withMessage("allowMultiple must be a boolean"); + +const makeCategoryIndexValidator = () => + body("categoryIndex") + .exists({ checkFalsy: true }) + .withMessage("Category index is required") + .isInt({ min: 0 }) + .withMessage("Category index must be positive and an integer"); + +export const createFurnitureItem = [ + makeCategoryValidator(), + makeNameValidator(), + makeAllowMultipleValidator(), + makeCategoryIndexValidator(), +]; + +export const updateFurnitureItem = [ + makeCategoryValidator(), + makeNameValidator(), + makeAllowMultipleValidator(), + makeCategoryIndexValidator(), +]; diff --git a/backend/src/validators/user.ts b/backend/src/validators/user.ts new file mode 100644 index 0000000..be81a4c --- /dev/null +++ b/backend/src/validators/user.ts @@ -0,0 +1,30 @@ +import { body } from "express-validator"; + +/** + * Validators for creating and updating users + */ + +const makeNameValidator = () => + body("name") + .exists({ checkFalsy: true }) + .withMessage("Name is required") + .isString() + .withMessage("Name must be a string"); + +const makeEmailValidator = () => + body("email") + .exists({ checkFalsy: true }) + .withMessage("Email is required") + .isString() + .withMessage("Email must be a string"); + +const makePasswordValidator = () => + body("password") + .exists({ checkFalsy: true }) + .withMessage("Password is required") + .isString() + .withMessage("Password must be a string"); + +export const createUser = [makeNameValidator(), makeEmailValidator(), makePasswordValidator()]; + +export const changeUserPassword = [makePasswordValidator()]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fae4eeb..5b2ead5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,15 +18,19 @@ "@react-pdf/renderer": "^3.4.4", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^14.1.2", + "draft-js": "^0.11.7", + "draftjs-to-html": "^0.9.1", "email-validator": "^2.0.4", "envalid": "^8.0.0", "firebase": "^10.11.0", + "html-to-draftjs": "^1.5.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "moment": "^2.30.1", "next": "^14.2.3", "react": "^18", "react-dom": "^18", + "react-draft-wysiwyg": "^1.15.0", "react-hook-form": "^7.49.3", "react-pdf": "^8.0.2", "styled-components": "^6.1.8", @@ -34,10 +38,14 @@ }, "devDependencies": { "@next/eslint-plugin-next": "^14.0.3", + "@types/draft-js": "^0.11.18", + "@types/draftjs-to-html": "^0.8.4", + "@types/html-to-draftjs": "^1.4.3", "@types/jest": "^29.5.10", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/react-draft-wysiwyg": "^1.13.8", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.54.0", @@ -2662,6 +2670,66 @@ "glob": "10.3.10" } }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", + "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", + "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", + "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", + "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@next/swc-linux-x64-gnu": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", @@ -2692,6 +2760,51 @@ "node": ">= 10" } }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", + "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", + "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", + "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3145,6 +3258,34 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/draft-js": { + "version": "0.11.18", + "resolved": "https://registry.npmjs.org/@types/draft-js/-/draft-js-0.11.18.tgz", + "integrity": "sha512-lP6yJ+EKv5tcG1dflWgDKeezdwBa8wJ7KkiNrrHqXuXhl/VGes1SKjEfKHDZqOz19KQbrAhFvNhDPWwnQXYZGQ==", + "dev": true, + "dependencies": { + "@types/react": "*", + "immutable": "~3.7.4" + } + }, + "node_modules/@types/draft-js/node_modules/immutable": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", + "integrity": "sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@types/draftjs-to-html": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@types/draftjs-to-html/-/draftjs-to-html-0.8.4.tgz", + "integrity": "sha512-5FZcjFoJL57N/IttLCTCNI0krX+181oCl5hf76u3TqPkqBAphHrJAO9ReYesx9138kcObaYmpnWC2Yrqxoqd2Q==", + "dev": true, + "dependencies": { + "@types/draft-js": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3153,6 +3294,15 @@ "@types/node": "*" } }, + "node_modules/@types/html-to-draftjs": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@types/html-to-draftjs/-/html-to-draftjs-1.4.3.tgz", + "integrity": "sha512-8TAgtzFA/sfwjGNXJ7Zod5tgnqsfJFQZYB8Nmu9GIoF7AVSwRm2quF1Ve09dHgHxSVsTSSy2diZSVdeSvTe7vg==", + "dev": true, + "dependencies": { + "@types/draft-js": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3267,6 +3417,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-draft-wysiwyg": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/@types/react-draft-wysiwyg/-/react-draft-wysiwyg-1.13.8.tgz", + "integrity": "sha512-qo0vIHjqAYq3Hz93Td3ecNodgVjKsvrWkBJ6mZXJSUmQzrQ5Fu7NS9oV5PXnVbnuqPoJLALbCSiz8UCksLribg==", + "dev": true, + "dependencies": { + "@types/draft-js": "*", + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -3846,6 +4006,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -4285,6 +4450,11 @@ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==" }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4428,6 +4598,16 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, + "node_modules/core-js": { + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.1.tgz", + "integrity": "sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -4955,6 +5135,42 @@ "node": ">=12" } }, + "node_modules/draft-js": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/draft-js/-/draft-js-0.11.7.tgz", + "integrity": "sha512-ne7yFfN4sEL82QPQEn80xnADR8/Q6ALVworbC5UOSzOvjffmYfFsr3xSZtxbIirti14R7Y33EZC5rivpLgIbsg==", + "dependencies": { + "fbjs": "^2.0.0", + "immutable": "~3.7.4", + "object-assign": "^4.1.1" + }, + "peerDependencies": { + "react": ">=0.14.0", + "react-dom": ">=0.14.0" + } + }, + "node_modules/draft-js/node_modules/immutable": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", + "integrity": "sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/draftjs-to-html": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/draftjs-to-html/-/draftjs-to-html-0.9.1.tgz", + "integrity": "sha512-fFstE6+IayaVFBEvaFt/wN8vdj8FsTRzij7dy7LI9QIwf5LgfHFi9zSpvCg+feJ2tbYVqHxUkjcibwpsTpgFVQ==" + }, + "node_modules/draftjs-utils": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/draftjs-utils/-/draftjs-utils-0.10.2.tgz", + "integrity": "sha512-EstHqr3R3JVcilJrBaO/A+01GvwwKmC7e4TCjC7S94ZeMh4IVmf60OuQXtHHpwItK8C2JCi3iljgN5KHkJboUg==", + "peerDependencies": { + "draft-js": "^0.11.x", + "immutable": "3.x.x || 4.x.x" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5936,6 +6152,26 @@ "bser": "2.1.1" } }, + "node_modules/fbjs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-2.0.0.tgz", + "integrity": "sha512-8XA8ny9ifxrAWlyhAbexXcs3rRMtxWcs3M0lctLfB49jRDHiaxj+Mo0XxbwE7nKZYzgCFoq64FS+WFd4IycPPQ==", + "dependencies": { + "core-js": "^3.6.4", + "cross-fetch": "^3.0.4", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.18" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -6535,6 +6771,15 @@ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" }, + "node_modules/html-to-draftjs": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/html-to-draftjs/-/html-to-draftjs-1.5.0.tgz", + "integrity": "sha512-kggLXBNciKDwKf+KYsuE+V5gw4dZ7nHyGMX9m0wy7urzWjKGWyNFetmArRLvRV0VrxKN70WylFsJvMTJx02OBQ==", + "peerDependencies": { + "draft-js": "^0.10.x || ^0.11.x", + "immutable": "3.x.x || 4.x.x" + } + }, "node_modules/http-parser-js": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", @@ -6618,6 +6863,12 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", + "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==", + "peer": true + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -8433,6 +8684,14 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/linkify-it": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz", + "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -9441,6 +9700,14 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dependencies": { + "asap": "~2.0.3" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -9583,6 +9850,24 @@ "loose-envify": "^1.1.0" } }, + "node_modules/react-draft-wysiwyg": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/react-draft-wysiwyg/-/react-draft-wysiwyg-1.15.0.tgz", + "integrity": "sha512-p1cYZcWc6/ALFBVksbFoCM3b29fGQDlZLIMrXng0TU/UElxIOF2/AWWo4L5auIYVhmqKTZ0NkNjnXOzGGuxyeA==", + "dependencies": { + "classnames": "^2.2.6", + "draftjs-utils": "^0.10.2", + "html-to-draftjs": "^1.5.0", + "linkify-it": "^2.2.0", + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "draft-js": "^0.10.x || ^0.11.x", + "immutable": "3.x.x || 4.x.x", + "react": "0.13.x || 0.14.x || ^15.0.0-0 || 15.x.x || ^16.0.0-0 || ^16.x.x || ^17.x.x || ^18.x.x", + "react-dom": "0.13.x || 0.14.x || ^15.0.0-0 || 15.x.x || ^16.0.0-0 || ^16.x.x || ^17.x.x || ^18.x.x" + } + }, "node_modules/react-hook-form": { "version": "7.51.5", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.5.tgz", @@ -10022,6 +10307,11 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", @@ -10925,6 +11215,33 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "0.7.38", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.38.tgz", + "integrity": "sha512-fYmIy7fKTSFAhG3fuPlubeGaMoAd6r0rSnfEsO5nEY55i26KSLt9EH7PLQiiqPUhNqYIJvSkTy1oArIcXAbPbA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "engines": { + "node": "*" + } + }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -11513,111 +11830,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-2.0.1.tgz", "integrity": "sha512-tT/oChyDXelLo2A+UVnlW9GU7CsvFMaEnd9kVFsaiCQonFAXd3xrHhkLYu+suwwosrAEQ746xBU+HvYtm1Zs2Q==" - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", - "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", - "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", - "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", - "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", - "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", - "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", - "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/frontend/package.json b/frontend/package.json index 0ef07be..bade5c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,15 +24,19 @@ "@react-pdf/renderer": "^3.4.4", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^14.1.2", + "draft-js": "^0.11.7", + "draftjs-to-html": "^0.9.1", "email-validator": "^2.0.4", "envalid": "^8.0.0", "firebase": "^10.11.0", + "html-to-draftjs": "^1.5.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "moment": "^2.30.1", "next": "^14.2.3", "react": "^18", "react-dom": "^18", + "react-draft-wysiwyg": "^1.15.0", "react-hook-form": "^7.49.3", "react-pdf": "^8.0.2", "styled-components": "^6.1.8", @@ -40,10 +44,14 @@ }, "devDependencies": { "@next/eslint-plugin-next": "^14.0.3", + "@types/draft-js": "^0.11.18", + "@types/draftjs-to-html": "^0.8.4", + "@types/html-to-draftjs": "^1.4.3", "@types/jest": "^29.5.10", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/react-draft-wysiwyg": "^1.13.8", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.54.0", diff --git a/frontend/public/furniture_items.svg b/frontend/public/furniture_items.svg new file mode 100644 index 0000000..10e5ae3 --- /dev/null +++ b/frontend/public/furniture_items.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/ic_add.svg b/frontend/public/ic_add.svg new file mode 100644 index 0000000..b369b4b --- /dev/null +++ b/frontend/public/ic_add.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/ic_close_clear.svg b/frontend/public/ic_close_clear.svg new file mode 100644 index 0000000..e37e297 --- /dev/null +++ b/frontend/public/ic_close_clear.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/ic_close_red.svg b/frontend/public/ic_close_red.svg index af8b99b..2e19648 100644 --- a/frontend/public/ic_close_red.svg +++ b/frontend/public/ic_close_red.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/public/ic_edit_light.svg b/frontend/public/ic_edit_light.svg new file mode 100644 index 0000000..5b1de93 --- /dev/null +++ b/frontend/public/ic_edit_light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/ic_email.svg b/frontend/public/ic_email.svg new file mode 100644 index 0000000..54fddfb --- /dev/null +++ b/frontend/public/ic_email.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/ic_home.svg b/frontend/public/ic_home.svg new file mode 100644 index 0000000..31dc7dc --- /dev/null +++ b/frontend/public/ic_home.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/ic_lock.svg b/frontend/public/ic_lock.svg new file mode 100644 index 0000000..df77a0c --- /dev/null +++ b/frontend/public/ic_lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/ic_profile.svg b/frontend/public/ic_profile.svg new file mode 100644 index 0000000..031c845 --- /dev/null +++ b/frontend/public/ic_profile.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/ic_settings.svg b/frontend/public/ic_settings.svg new file mode 100644 index 0000000..fed6907 --- /dev/null +++ b/frontend/public/ic_settings.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/ic_simplehelp.svg b/frontend/public/ic_simplehelp.svg new file mode 100644 index 0000000..640777b --- /dev/null +++ b/frontend/public/ic_simplehelp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/logout.svg b/frontend/public/logout.svg new file mode 100644 index 0000000..1a89bc7 --- /dev/null +++ b/frontend/public/logout.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/mdi_trash_light.svg b/frontend/public/mdi_trash_light.svg new file mode 100644 index 0000000..0f38b56 --- /dev/null +++ b/frontend/public/mdi_trash_light.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/pap_logo.png b/frontend/public/pap_logo.png new file mode 100644 index 0000000..4a2c8db Binary files /dev/null and b/frontend/public/pap_logo.png differ diff --git a/frontend/src/api/ConfirmationEmails.ts b/frontend/src/api/ConfirmationEmails.ts new file mode 100644 index 0000000..aeb58a5 --- /dev/null +++ b/frontend/src/api/ConfirmationEmails.ts @@ -0,0 +1,36 @@ +import { createAuthHeader } from "@/api/Users"; +import { APIResult, get, handleAPIError, put } from "@/api/requests"; + +export interface ConfirmationEmail { + html: string; + papLogoHTML: string; +} + +export const getConfirmationEmail = async ( + firebaseToken: string, +): Promise> => { + try { + const response = await get("/api/confirmationEmail", createAuthHeader(firebaseToken)); + const json = (await response.json()) as ConfirmationEmail; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +}; + +export const updateConfirmationEmail = async ( + firebaseToken: string, + newHTML: string, +): Promise> => { + try { + const response = await put( + `/api/confirmationEmail`, + { html: newHTML }, + createAuthHeader(firebaseToken), + ); + const json = (await response.json()) as ConfirmationEmail; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +}; diff --git a/frontend/src/api/FurnitureItems.ts b/frontend/src/api/FurnitureItems.ts index e20f904..7ad4096 100644 --- a/frontend/src/api/FurnitureItems.ts +++ b/frontend/src/api/FurnitureItems.ts @@ -1,4 +1,5 @@ -import { APIResult, handleAPIError, get } from "@/api/requests"; +import { APIResult, handleAPIError, get, post, put, httpDelete } from "@/api/requests"; +import { createAuthHeader } from "@/api/Users"; export interface FurnitureItem { _id: string; @@ -8,6 +9,13 @@ export interface FurnitureItem { categoryIndex: number; } +export interface CreateFurnitureItem { + category: string; + name: string; + allowMultiple: boolean; + categoryIndex: number; +} + export async function getFurnitureItems(): Promise> { try { const response = await get(`/api/furnitureItems`); @@ -17,3 +25,50 @@ export async function getFurnitureItems(): Promise> { return handleAPIError(error); } } + +export async function addFurnitureItem( + furnitureItem: CreateFurnitureItem, + firebaseToken: string, +): Promise> { + try { + const response = await post( + `/api/furnitureItems`, + furnitureItem, + createAuthHeader(firebaseToken), + ); + const json = (await response.json()) as FurnitureItem; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function updateFurnitureItem( + id: string, + furnitureItem: FurnitureItem, + firebaseToken: string, +): Promise> { + try { + const response = await put( + `/api/furnitureItems/${id}`, + furnitureItem, + createAuthHeader(firebaseToken), + ); + const json = (await response.json()) as FurnitureItem; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function deleteFurnitureItem( + id: string, + firebaseToken: string, +): Promise> { + try { + await httpDelete(`/api/furnitureItems/${id}`, createAuthHeader(firebaseToken)); + return { success: true, data: null }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/frontend/src/api/Users.ts b/frontend/src/api/Users.ts index 64e76b7..ff6fac3 100644 --- a/frontend/src/api/Users.ts +++ b/frontend/src/api/Users.ts @@ -1,4 +1,5 @@ -import { APIResult, get, handleAPIError } from "@/api/requests"; +import { APIResult, get, handleAPIError, httpDelete, patch, post } from "@/api/requests"; +import { User as FirebaseUser } from "firebase/auth"; export interface User { _id: string; @@ -6,6 +7,19 @@ export interface User { role: string; } +export interface DisplayUser { + _id: string; + uid: string; + email: string | undefined; + displayName: string | undefined; +} + +export interface CreateUserRequest { + name: string; + email: string; + password: string; +} + export const createAuthHeader = (firebaseToken: string) => ({ Authorization: `Bearer ${firebaseToken}`, }); @@ -19,3 +33,62 @@ export const getWhoAmI = async (firebaseToken: string): Promise> return handleAPIError(error); } }; + +export const getAllUsers = async (firebaseToken: string): Promise> => { + try { + const response = await get("/api/user", createAuthHeader(firebaseToken)); + const json = (await response.json()) as DisplayUser[]; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +}; + +export const changeUserPassword = async ( + uid: string, + password: string, + firebaseToken: string, +): Promise> => { + try { + const response = await patch( + `/api/user/${uid}/password`, + { password }, + createAuthHeader(firebaseToken), + ); + const json = (await response.json()) as FirebaseUser; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +}; + +export const notifyResetPassword = async (firebaseToken: string): Promise> => { + try { + await post("/api/user/notifyResetPassword", {}, createAuthHeader(firebaseToken)); + return { success: true, data: null }; + } catch (error) { + return handleAPIError(error); + } +}; + +export const deleteUser = async (uid: string, firebaseToken: string): Promise> => { + try { + await httpDelete(`/api/user/${uid}`, createAuthHeader(firebaseToken)); + return { success: true, data: null }; + } catch (error) { + return handleAPIError(error); + } +}; + +export const createUser = async ( + firebaseToken: string, + request: CreateUserRequest, +): Promise> => { + try { + const response = await post("/api/user", request, createAuthHeader(firebaseToken)); + const json = (await response.json()) as User; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +}; diff --git a/frontend/src/api/VSRs.ts b/frontend/src/api/VSRs.ts index 725dd7f..b2c9cdc 100644 --- a/frontend/src/api/VSRs.ts +++ b/frontend/src/api/VSRs.ts @@ -156,9 +156,39 @@ export async function createVSR(vsr: CreateVSRRequest): Promise> } } -export async function getAllVSRs(firebaseToken: string): Promise> { +const incomeMap: { [key: string]: string } = { + "$50,001 and over": "50000", + "$25,001 - $50,000": "25000", + "$12,501 - $25,000": "12500", + "$12,500 and under": "0", +}; + +export async function getAllVSRs( + firebaseToken: string, + search?: string, + zipCodes?: string[], + income?: string, + status?: string, +): Promise> { + const searchParams = new URLSearchParams(); + if (search) { + searchParams.set("search", search); + } + if (zipCodes) { + searchParams.set("zipCode", zipCodes.join(", ")); + } + if (income) { + searchParams.set("incomeLevel", incomeMap[income]); + } + if (status) { + searchParams.set("status", status); + } + + const searchParamsString = searchParams.toString(); + const urlString = `/api/vsr${searchParamsString ? "?" + searchParamsString : ""}`; + try { - const response = await get("/api/vsr", createAuthHeader(firebaseToken)); + const response = await get(urlString, createAuthHeader(firebaseToken)); const json = (await response.json()) as VSRListJson; return { success: true, data: json.vsrs.map(parseVSR) }; } catch (error) { @@ -220,17 +250,41 @@ export async function updateVSR( export async function bulkExportVSRS( firebaseToken: string, vsrIds: string[], + search?: string, + zipCodes?: string[], + income?: string, + status?: string, ): Promise> { + const searchParams = new URLSearchParams(); + if (search) { + searchParams.set("search", search); + } + if (zipCodes) { + searchParams.set("zipCode", zipCodes.join(", ")); + } + if (income) { + searchParams.set("incomeLevel", incomeMap[income]); + } + if (status) { + searchParams.set("status", status); + } + + if (vsrIds && vsrIds.length > 0) { + searchParams.set("vsrIds", vsrIds.join(",")); + } + + const searchParamsString = searchParams.toString(); + const urlString = `/api/vsr/bulk_export${searchParamsString ? "?" + searchParamsString : ""}`; + try { - const query = vsrIds.length === 0 ? "" : `?vsrIds=${vsrIds.join(",")}`; - const response = await get(`/api/vsr/bulk_export${query}`, createAuthHeader(firebaseToken)); + const response = await get(urlString, createAuthHeader(firebaseToken)); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.setAttribute("download", "vsrs.xlsx"); + link.setAttribute("download", `vsrs_${new Date().toISOString()}.xlsx`); document.body.appendChild(link); link.click(); diff --git a/frontend/src/app/handlePasswordReset/page.tsx b/frontend/src/app/handlePasswordReset/page.tsx index 74d4d48..ec2c07f 100644 --- a/frontend/src/app/handlePasswordReset/page.tsx +++ b/frontend/src/app/handlePasswordReset/page.tsx @@ -19,6 +19,7 @@ import { FirebaseError } from "firebase/app"; import { NotificationBanner } from "@/components/shared/NotificationBanner"; import { LoadingScreen } from "@/components/shared/LoadingScreen"; import styles from "@/app/handlePasswordReset/page.module.css"; +import { notifyResetPassword } from "@/api/Users"; enum ResetPasswordPageError { NO_INTERNET, @@ -123,7 +124,12 @@ const PasswordReset: React.FC = () => { } try { - await signInWithEmailAndPassword(auth, email, data.newPassword); + const { user } = await signInWithEmailAndPassword(auth, email, data.newPassword); + const firebaseToken = await user.getIdToken(); + const result = await notifyResetPassword(firebaseToken); + if (!result.success) { + console.error(`Notifying user of password reset failed with error ${result.error}`); + } } catch (error) { console.error("Firebase login failed with error: ", error); diff --git a/frontend/src/app/staff/emailTemplate/page.module.css b/frontend/src/app/staff/emailTemplate/page.module.css new file mode 100644 index 0000000..ef1b4e3 --- /dev/null +++ b/frontend/src/app/staff/emailTemplate/page.module.css @@ -0,0 +1,103 @@ +.main { + padding: 128px 64px; + background-color: #eceff3; + display: flex; + flex-direction: column; + text-align: left; + gap: 32px; +} + +.topRow { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 40px; +} + +.title { + font-size: 40px; + width: 100%; + color: var(--Accent-Blue-1, #102d5f); + font-family: Lora; + font-weight: 700; +} + +.buttonsContainer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: end; + gap: 40px; + width: 100%; +} + +.description { + color: #000; + font-family: "Open Sans"; + font-size: 20px; + font-weight: 400; +} + +.formContainer { + display: flex; + flex-direction: column; + background-color: white; + border-radius: 20px; + padding: 64px; + margin-top: 32px; + align-items: flex-start; + width: 100%; + height: 100%; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .main { + padding: 64px 48px; + } + + .title { + font-size: 36px; + } + + .buttonsContainer { + gap: 16px; + } + + .button { + width: 100%; + } + + .description { + font-size: 18px; + } + + .formContainer { + margin-top: 0; + padding: 32px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .main { + padding: 36px 24px; + } + + .title { + font-size: 28px; + } + + .buttonsContainer { + flex-direction: column; + } + + .description { + font-size: 14px; + } + + .formContainer { + padding: 32px 16px; + } +} diff --git a/frontend/src/app/staff/emailTemplate/page.tsx b/frontend/src/app/staff/emailTemplate/page.tsx new file mode 100644 index 0000000..a4c7428 --- /dev/null +++ b/frontend/src/app/staff/emailTemplate/page.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { getConfirmationEmail, updateConfirmationEmail } from "@/api/ConfirmationEmails"; +import HeaderBar from "@/components/shared/HeaderBar"; +import { UserContext } from "@/contexts/userContext"; +import { + useRedirectToHomeIfNotAdmin, + useRedirectToLoginIfNotSignedIn, +} from "@/hooks/useRedirection"; +import { ComponentType, useContext, useEffect, useState } from "react"; +import { Button } from "@/components/shared/Button"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import { ContentState, EditorState, convertToRaw } from "draft-js"; +import { EditorProps } from "react-draft-wysiwyg"; +import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css"; +import { LoadingScreen } from "@/components/shared/LoadingScreen"; +import { NotificationBanner } from "@/components/shared/NotificationBanner"; +import draftToHtml from "draftjs-to-html"; +import { useDirtyForm } from "@/hooks/useDirtyForm"; +import { ConfirmDiscardEditsModal } from "@/components/shared/ConfirmDiscardEditsModal"; +import dynamic from "next/dynamic"; +import styles from "@/app/staff/emailTemplate/page.module.css"; + +const Editor = dynamic( + () => + import("react-draft-wysiwyg").then( + (mod) => mod.Editor as unknown as ComponentType, + ), + { + ssr: false, + }, +); +const htmlToDraft = + typeof window === "undefined" ? null : import("html-to-draftjs").then((mod) => mod.default); + +export default function EditEmailTemplate() { + const { firebaseUser } = useContext(UserContext); + + const [initialHTML, setInitialHTML] = useState(""); + const [papLogoHTML, setPAPLogoHTML] = useState(""); + const [editorState, setEditorState] = useState(EditorState.createEmpty()); + const [loadingGet, setLoadingGet] = useState(false); + const [errorGet, setErrorGet] = useState(false); + + const [loadingSave, setLoadingSave] = useState(false); + const [successSave, setSuccessSave] = useState(false); + const [errorSave, setErrorSave] = useState(false); + const [isDirty, setIsDirty] = useState(false); + const [discardEditsModalOpen, setDiscardEditsModalOpen] = useState(false); + + useDirtyForm({ isDirty }); + + const { isTablet } = useScreenSizes(); + + useRedirectToLoginIfNotSignedIn(); + useRedirectToHomeIfNotAdmin(); + + const htmlToEditorState = async (html: string) => { + if (!htmlToDraft) { + return EditorState.createEmpty(); + } + const contentBlock = (await htmlToDraft)(html); + const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks); + return EditorState.createWithContent(contentState); + }; + + const fetchConfirmationEmail = async () => { + if (!firebaseUser) { + return; + } + setLoadingGet(true); + const firebaseToken = await firebaseUser.getIdToken(); + const result = await getConfirmationEmail(firebaseToken); + if (result.success) { + setInitialHTML(result.data.html); + htmlToEditorState(result.data.html).then(setEditorState); + setPAPLogoHTML(result.data.papLogoHTML.replace("cid:", "/")); + } else { + console.error(`Error retrieving confirmation email: ${result.error}`); + setErrorGet(true); + } + setLoadingGet(false); + }; + + useEffect(() => { + fetchConfirmationEmail(); + }, [firebaseUser]); + + const saveChanges = async () => { + setLoadingSave(true); + setErrorSave(false); + setSuccessSave(false); + const newHTML = draftToHtml(convertToRaw(editorState.getCurrentContent())); + const firebaseToken = await firebaseUser?.getIdToken(); + const result = await updateConfirmationEmail(firebaseToken!, newHTML); + if (result.success) { + setInitialHTML(result.data.html); + htmlToEditorState(result.data.html).then(setEditorState); + setSuccessSave(true); + } else { + console.error(`Error saving email changes: ${result.error}`); + setErrorSave(true); + } + setLoadingSave(false); + setIsDirty(false); + }; + + const discardChanges = async () => { + htmlToEditorState(initialHTML).then(setEditorState); + setIsDirty(false); + }; + + const renderButtons = () => ( +
+
+ ); + + return ( + <> + +
+
+

Edit Email Template

+ {isTablet ? null : renderButtons()} +
+

+ To edit the VSR confirmation email template, make your desired changes in the text field + below, and then click "Save Changes". +

+ {loadingGet ? ( + + ) : ( +
+ setIsDirty(true)} + /> +
+
+ )} + {isTablet ? renderButtons() : null} + + setDiscardEditsModalOpen(false)} + onDiscardChanges={discardChanges} + /> + + {/* Success/error notifications */} + setErrorGet(false)} + /> + setSuccessSave(false)} + /> + setErrorSave(false)} + /> +
+ + ); +} diff --git a/frontend/src/app/staff/furnitureItems/page.module.css b/frontend/src/app/staff/furnitureItems/page.module.css new file mode 100644 index 0000000..568b23e --- /dev/null +++ b/frontend/src/app/staff/furnitureItems/page.module.css @@ -0,0 +1,106 @@ +.title { + font-size: 40px; +} + +.description { + color: #000; + /* Desktop/Body 1 */ + font-family: "Open Sans"; + font-size: 20px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.main { + padding: 128px 64px; + background-color: #eceff3; + display: flex; + flex-direction: column; + color: var(--Accent-Blue-1, #102d5f); + text-align: left; + gap: 32px; + /* Desktop/H1 */ + font-family: Lora; + font-size: 20px; + font-style: normal; + font-weight: 700; + line-height: normal; +} + +.formContainer { + display: flex; + flex-direction: column; + background-color: white; + border-radius: 20px; + padding: 64px; + margin-top: 32px; + align-items: flex-start; + width: 100%; + height: 100%; +} + +.sectionTitle { + color: var(--Accent-Blue-1, #102d5f); + font-family: Lora; + font-size: 24px; + font-style: normal; + font-weight: 700; + line-height: normal; + padding-bottom: 32px; +} + +.furnishings { + display: flex; + width: 100%; + flex-direction: column; + gap: 64px; + align-self: flex-start; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .main { + padding: 64px 48px; + } + + .title { + font-size: 36px; + } + + .description { + font-size: 18px; + } + + .formContainer { + margin-top: 0; + padding: 32px; + } + + .sectionTitle { + font-size: 28px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .main { + padding: 36px 24px; + } + + .title { + font-size: 28px; + } + + .description { + font-size: 14px; + } + + .formContainer { + padding: 32px 16px; + } + + .sectionTitle { + font-size: 20px; + } +} diff --git a/frontend/src/app/staff/furnitureItems/page.tsx b/frontend/src/app/staff/furnitureItems/page.tsx new file mode 100644 index 0000000..081038c --- /dev/null +++ b/frontend/src/app/staff/furnitureItems/page.tsx @@ -0,0 +1,87 @@ +"use client"; +import HeaderBar from "@/components/shared/HeaderBar"; +import { EditTemplate } from "@/components/FurnitureRequest/EditTemplate"; +import { useMemo } from "react"; +import { FurnitureItem, getFurnitureItems } from "@/api/FurnitureItems"; +import React, { useEffect, useState } from "react"; +import { + useRedirectToHomeIfNotAdmin, + useRedirectToLoginIfNotSignedIn, +} from "@/hooks/useRedirection"; +import styles from "@/app/staff/furnitureItems/page.module.css"; + +export default function FurnitureItemTemplate() { + const [furnitureItems, setFurnitureItems] = useState(); + const [editingCategory, setEditingCategory] = useState(); + + useRedirectToLoginIfNotSignedIn(); + useRedirectToHomeIfNotAdmin(); + + useEffect(() => { + fetchFurnitureItems(); + }, []); + + const fetchFurnitureItems = () => { + getFurnitureItems().then((result) => { + if (result.success) { + setFurnitureItems(result.data); + } else { + setFurnitureItems([]); + } + }); + }; + + const furnitureCategoriesToItems = useMemo( + () => + furnitureItems?.reduce( + (prevMap: Record, curItem) => ({ + ...prevMap, + [curItem.category]: [...(prevMap[curItem.category] ?? []), curItem], + }), + {}, + ), + [furnitureItems], + ); + + const handleBeginEditing = (category: string) => { + setEditingCategory(category); + }; + + const handleFinishEditing = () => { + setEditingCategory(undefined); + fetchFurnitureItems(); + }; + + return ( + <> + +
+

Furnishing Request Form Template

+

+ Add, edit, or remove furniture items for veterans to select on the VSR. Remember to save + your edits. +

+ +
+

Furnishings

+
+ {furnitureCategoriesToItems + ? Object.entries(furnitureCategoriesToItems!).map(([category, items]) => ( + handleBeginEditing(category)} + onFinishEditing={handleFinishEditing} + /> + )) + : null} +
+
+
+ + ); +} diff --git a/frontend/src/app/staff/profile/page.module.css b/frontend/src/app/staff/profile/page.module.css new file mode 100644 index 0000000..87bf361 --- /dev/null +++ b/frontend/src/app/staff/profile/page.module.css @@ -0,0 +1,66 @@ +.page { + background-color: var(--color-background); + min-height: 100vh; +} + +.main { + padding: 64px; + display: flex; + flex-direction: column; + color: var(--Accent-Blue-1, #102d5f); + text-align: left; + + padding: 105px 120px; + gap: 40px; + font-family: var(--font-title); + font-weight: 700; +} + +.row { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.column { + display: flex; + flex-direction: column; +} + +.title { + font-size: 40px; +} + +.subtitle { + font-size: 24px; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .main { + padding: 64px 48px; + } + + .title { + font-size: 36px; + } + + .subtitle { + font-size: 28px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .main { + padding: 36px 24px; + } + + .title { + font-size: 28px; + } + + .subtitle { + font-size: 20px; + } +} diff --git a/frontend/src/app/staff/profile/page.tsx b/frontend/src/app/staff/profile/page.tsx new file mode 100644 index 0000000..442d812 --- /dev/null +++ b/frontend/src/app/staff/profile/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import HeaderBar from "@/components/shared/HeaderBar"; +import React, { useContext, useEffect, useState } from "react"; +import { Button } from "@/components/shared/Button"; +import { UserProfile } from "@/components/Profile/UserProfile"; +import { OwnProfile } from "@/components/Profile/OwnProfile"; +import { DisplayUser, getAllUsers } from "@/api/Users"; +import { UserContext } from "@/contexts/userContext"; +import { useRedirectToLoginIfNotSignedIn } from "@/hooks/useRedirection"; +import styles from "@/app/staff/profile/page.module.css"; +import { ADMIN_ROLE } from "@/constants/roles"; +import { LoadingScreen } from "@/components/shared/LoadingScreen"; +import { NotificationBanner } from "@/components/shared/NotificationBanner"; +import { CreateUserModal } from "@/components/Profile/CreateUserModal"; + +enum AllUsersError { + NO_INTERNET, + INTERNAL, + NONE, +} + +export default function Profile() { + const { + firebaseUser, + papUser, + successNotificationOpen, + setSuccessNotificationOpen, + errorNotificationOpen, + setErrorNotificationOpen, + } = useContext(UserContext); + const [users, setUsers] = useState(); + const [loadingUsers, setLoadingUsers] = useState(false); + const [usersError, setUsersError] = useState(AllUsersError.NONE); + const [createUserModalOpen, setCreateUserModalOpen] = useState(false); + + const fetchUsers = async () => { + if (!firebaseUser || !papUser || papUser.role !== ADMIN_ROLE) { + return; + } + + setLoadingUsers(true); + setUsersError(AllUsersError.NONE); + const firebaseToken = await firebaseUser.getIdToken(); + const result = await getAllUsers(firebaseToken); + if (result.success) { + setUsers(result.data); + } else { + if (result.error === "Failed to fetch") { + setUsersError(AllUsersError.NO_INTERNET); + } else { + console.error(`Cannot retrieve users: error ${result.error}`); + setUsersError(AllUsersError.INTERNAL); + } + } + setLoadingUsers(false); + }; + + useEffect(() => { + fetchUsers(); + }, [firebaseUser, papUser]); + + useEffect(() => { + setSuccessNotificationOpen(null); + setErrorNotificationOpen(null); + }, []); + + useRedirectToLoginIfNotSignedIn(); + + const renderErrorNotification = () => { + switch (usersError) { + case AllUsersError.NO_INTERNET: + return ( + setUsersError(AllUsersError.NONE)} + /> + ); + case AllUsersError.INTERNAL: + return ( + setUsersError(AllUsersError.NONE)} + /> + ); + } + }; + + return ( +
+ + {loadingUsers ? ( + + ) : ( +
+

Account Settings

+

User Profile

+ + {/* ACCOUNT INFO */} + + {papUser?.role === ADMIN_ROLE ? ( + <> +
+

Manage Users

+
+ {users?.map((user, index) => + user.uid != firebaseUser?.uid ? ( + + ) : null, + )} + + ) : null} +
+ )} + {renderErrorNotification()} + + setCreateUserModalOpen(false)} + afterCreateUser={fetchUsers} + /> + + setSuccessNotificationOpen(null)} + /> + setErrorNotificationOpen(null)} + /> +
+ ); +} diff --git a/frontend/src/app/staff/vsr/page.module.css b/frontend/src/app/staff/vsr/page.module.css index bf37fb8..efc7ba5 100644 --- a/frontend/src/app/staff/vsr/page.module.css +++ b/frontend/src/app/staff/vsr/page.module.css @@ -72,6 +72,37 @@ width: 100%; } +.appliedFiltersContainer { + display: flex; + flex-direction: row; + gap: 16px; + align-items: center; + flex-wrap: wrap; +} + +.filterChips { + display: flex; + flex-direction: row; + gap: 16px; + align-items: center; + flex-wrap: wrap; +} + +.appliedText { + color: #222; + font-family: "Open Sans"; + font-size: 20px; + font-weight: 400; + text-wrap: nowrap; +} + +.noVsrs { + color: var(--Accent-Blue-1, #102d5f); + font-family: Lora; + font-size: 40px; + font-weight: 700; +} + /* shrink the margins at a screen size larger than tablet to avoid overflow */ @media screen and (max-width: 1150px) { .column { @@ -79,6 +110,26 @@ } } +/* tablet version */ +@media screen and (max-width: 850px) { + .appliedFiltersContainer { + flex-direction: column; + align-items: start; + } + + .filterChips { + flex-wrap: wrap; + } + + .appliedText { + font-size: 14px; + } + + .noVsrs { + font-size: 36px; + } +} + @media screen and (max-width: 600px) { .row_right { gap: 4px; @@ -98,6 +149,10 @@ .statusLabel { font-size: 12px; } + + .noVsrs { + font-size: 28px; + } } @media screen and (max-width: 475px) { diff --git a/frontend/src/app/staff/vsr/page.tsx b/frontend/src/app/staff/vsr/page.tsx index 47aae55..bbef0c2 100644 --- a/frontend/src/app/staff/vsr/page.tsx +++ b/frontend/src/app/staff/vsr/page.tsx @@ -1,7 +1,8 @@ "use client"; import VSRTable from "@/components/VSRTable/VSRTable"; -import SearchKeyword from "@/components/VSRTable/SearchKeyword"; +import FilterModal from "@/components/VSRTable/FilterModal"; +import { SearchKeyword } from "@/components/VSRTable/SearchKeyword"; import PageTitle from "@/components/VSRTable/PageTitle"; import HeaderBar from "@/components/shared/HeaderBar"; import Image from "next/image"; @@ -10,14 +11,16 @@ import { StatusDropdown } from "@/components/shared/StatusDropdown"; import { useMediaQuery } from "@mui/material"; import { useRedirectToLoginIfNotSignedIn } from "@/hooks/useRedirection"; import { UserContext } from "@/contexts/userContext"; -import { DeleteVSRsModal } from "@/components/shared/DeleteVSRsModal"; -import { VSR, getAllVSRs, bulkExportVSRS } from "@/api/VSRs"; +import { ConfirmDeleteModal } from "@/components/shared/ConfirmDeleteModal"; +import { VSR, getAllVSRs, bulkExportVSRS, deleteVSR } from "@/api/VSRs"; import { VSRErrorModal } from "@/components/VSRForm/VSRErrorModal"; import { useScreenSizes } from "@/hooks/useScreenSizes"; import { LoadingScreen } from "@/components/shared/LoadingScreen"; import { Button } from "@/components/shared/Button"; import { NotificationBanner } from "@/components/shared/NotificationBanner"; +import FilterChip from "@/components/VSRTable/FilterChip"; import styles from "@/app/staff/vsr/page.module.css"; +import { ADMIN_ROLE } from "@/constants/roles"; enum VSRTableError { CANNOT_FETCH_VSRS_NO_INTERNET, @@ -48,6 +51,17 @@ export default function VSRTableView() { const [selectedVsrIds, setSelectedVsrIds] = useState([]); const [deleteVsrModalOpen, setDeleteVsrModalOpen] = useState(false); + const [loadingDelete, setLoadingDelete] = useState(false); + const [deleteSuccessNotificationOpen, setDeleteSuccessNotificationOpen] = useState(false); + const [deleteErrorNotificationOpen, setDeleteErrorNotificationOpen] = useState(false); + + const [filterModalAnchorElement, setFilterModalAnchorElement] = useState( + null, + ); + const [filteredZipCodes, setFilteredZipCodes] = useState(); + const [filteredIncome, setFilteredIncome] = useState(); + const [search, setSearch] = useState(); + const [status, setStatus] = useState(); useRedirectToLoginIfNotSignedIn(); @@ -63,7 +77,7 @@ export default function VSRTableView() { setLoadingVsrs(true); firebaseUser?.getIdToken().then((firebaseToken) => { - getAllVSRs(firebaseToken).then((result) => { + getAllVSRs(firebaseToken, search, filteredZipCodes, filteredIncome, status).then((result) => { if (result.success) { setVsrs(result.data); } else { @@ -82,7 +96,46 @@ export default function VSRTableView() { // Fetch the VSRs from the backend once the Firebase user loads. useEffect(() => { fetchVSRs(); - }, [firebaseUser]); + }, [firebaseUser, search, filteredZipCodes, filteredIncome, status]); + + const onDelete = async () => { + if (loadingDelete || !firebaseUser) { + return; + } + + setDeleteSuccessNotificationOpen(false); + setDeleteErrorNotificationOpen(false); + setLoadingDelete(true); + + try { + const firebaseToken = await firebaseUser.getIdToken(); + if (!firebaseToken) { + setLoadingDelete(false); + return; + } + + await Promise.all( + selectedVsrIds.map((vsrId) => + deleteVSR(vsrId, firebaseToken).then((res) => { + if (res.success) { + return Promise.resolve(); + } else { + return Promise.reject(res.error); + } + }), + ), + ); + setDeleteSuccessNotificationOpen(true); + setSelectedVsrIds([]); + fetchVSRs(); + } catch (error) { + console.error(`Error deleting VSR(s): ${error}`); + setDeleteErrorNotificationOpen(true); + } finally { + setLoadingDelete(false); + setDeleteVsrModalOpen(false); + } + }; /** * Renders an error modal corresponding to the page's error state, or renders @@ -208,7 +261,14 @@ export default function VSRTableView() { setExportError(VSRExportError.NONE); setLoadingExport(true); firebaseUser?.getIdToken().then((firebaseToken) => { - bulkExportVSRS(firebaseToken, selectedVsrIds).then((result) => { + bulkExportVSRS( + firebaseToken, + selectedVsrIds, + search, + filteredZipCodes, + filteredIncome, + status, + ).then((result) => { if (result.success) { setExportSuccess(true); } else { @@ -224,24 +284,32 @@ export default function VSRTableView() { }); }; + const renderSearchBar = () => ; + return (
- +
- {searchOnOwnRow ? null : } + {searchOnOwnRow ? null : renderSearchBar()}

Status:

- + { + setStatus(value === "All Statuses" ? undefined : value); + }} + includeAllStatuses + />
- {papUser?.role === "admin" && atLeastOneRowSelected ? ( + {papUser?.role === ADMIN_ROLE && atLeastOneRowSelected ? (
- {searchOnOwnRow ? : null} + {searchOnOwnRow ? renderSearchBar() : null} + + {(filteredZipCodes && filteredZipCodes.length > 0) || filteredIncome ? ( +
+

Applied Filters:

+ + {filteredZipCodes?.map((zipCode) => ( + { + setFilteredZipCodes(filteredZipCodes?.filter((z) => z !== zipCode)); + }} + /> + ))} + {filteredIncome ? ( + { + setFilteredIncome(undefined); + }} + /> + ) : null} + +
+ ) : null} +
{loadingVsrs ? ( - ) : ( + ) : vsrs?.length !== 0 ? ( + ) : ( +
+

No VSRs found.

+
)}
@@ -302,14 +398,53 @@ export default function VSRTableView() { /> {renderErrorModal()} {renderExportErrorModal()} - setDeleteVsrModalOpen(false)} - afterDelete={() => { - setSelectedVsrIds([]); - fetchVSRs(); + title="Delete VSR(s)" + content={ + <> + {"Deleted VSR’s "} + cannot + {" be recovered. Are you sure you’d like to delete the selected VSR forms ("} + {1} + {")?"} + + } + cancelText="Cancel" + confirmText="Delete VSR(s)" + onConfirm={onDelete} + buttonLoading={loadingDelete} + /> + + setDeleteSuccessNotificationOpen(false)} + /> + setDeleteErrorNotificationOpen(false)} + /> + { + setFilterModalAnchorElement(null); + }} + initialZipCodes={filteredZipCodes ?? []} + initialIncomeLevel={filteredIncome ?? ""} + onInputEntered={(zipCodes: string[] | undefined, incomeLevel: string | undefined) => { + setFilteredZipCodes(zipCodes); + setFilteredIncome(incomeLevel); + }} + onResetFilters={() => { + setFilteredZipCodes(undefined); + setFilteredIncome(undefined); }} - vsrIds={selectedVsrIds} />
); diff --git a/frontend/src/app/vsr/page.tsx b/frontend/src/app/vsr/page.tsx index 10def4f..e1034a3 100644 --- a/frontend/src/app/vsr/page.tsx +++ b/frontend/src/app/vsr/page.tsx @@ -35,6 +35,7 @@ import { ICreateVSRFormInput, IVSRFormInput } from "@/components/VSRForm/VSRForm import { vsrInputFieldValidators } from "@/components/VSRForm/VSRFormValidators"; import { ListDetail, SingleDetail } from "@/components/VSRIndividual"; import styles from "@/app/vsr/page.module.css"; +import { useDirtyForm } from "@/hooks/useDirtyForm"; enum VSRFormError { CANNOT_RETRIEVE_FURNITURE_NO_INTERNET, @@ -56,11 +57,13 @@ const VeteranServiceRequest: React.FC = () => { register, handleSubmit, control, - formState: { errors, isValid }, + formState: { errors, isValid, dirtyFields }, watch, reset, } = formProps; + useDirtyForm({ isDirty: Object.keys(dirtyFields).length > 0 }); + /** * Internal state for fields that are complicated and cannot be controlled with a * named form field alone (e.g. there is a multiple choice and a text field for "Other") @@ -419,7 +422,7 @@ const VeteranServiceRequest: React.FC = () => { return (
- +

Veteran Service Request Form

@@ -691,7 +694,7 @@ const VeteranServiceRequest: React.FC = () => { return (

- +
@@ -1082,7 +1085,7 @@ const VeteranServiceRequest: React.FC = () => { return (
- +
@@ -1098,6 +1101,7 @@ const VeteranServiceRequest: React.FC = () => {
{(items ?? []).map((furnitureItem) => ( { return (
- +

Veteran Service Request Form

diff --git a/frontend/src/components/FurnitureRequest/EditTemplate/index.tsx b/frontend/src/components/FurnitureRequest/EditTemplate/index.tsx new file mode 100644 index 0000000..4dd6158 --- /dev/null +++ b/frontend/src/components/FurnitureRequest/EditTemplate/index.tsx @@ -0,0 +1,357 @@ +import styles from "@/components/FurnitureRequest/EditTemplate/styles.module.css"; +import { useContext, useState } from "react"; +import { + FurnitureItem, + addFurnitureItem, + updateFurnitureItem, + deleteFurnitureItem, + CreateFurnitureItem, +} from "@/api/FurnitureItems"; +import { FurnitureItemSelection } from "@/components/VSRForm/FurnitureItemSelection"; +import { FieldDetail } from "@/components/VSRIndividual/FieldDetails/FieldDetail"; +import TextField from "@/components/shared/input/TextField"; +import { UserContext } from "@/contexts/userContext"; +3; +import { Checkbox } from "@mui/material"; +import { ConfirmDeleteModal } from "@/components/shared/ConfirmDeleteModal"; +import { Button } from "@/components/shared/Button"; +import { NotificationBanner } from "@/components/shared/NotificationBanner"; +import { ConfirmDiscardEditsModal } from "@/components/shared/ConfirmDiscardEditsModal"; +import { useDirtyForm } from "@/hooks/useDirtyForm"; + +enum FurnitureItemAction { + NONE, + CREATE, + EDIT, + DELETE, +} + +export interface EditTemplateProps { + furnitureItems: FurnitureItem[]; + categoryTitle: string; + categoryName: string; + isEditing: boolean; + isDisabled: boolean; + onBeginEditing: () => void; + onFinishEditing: () => void; +} + +export const EditTemplate = ({ + furnitureItems, + categoryTitle, + categoryName, + isEditing, + isDisabled, + onBeginEditing, + onFinishEditing, +}: EditTemplateProps) => { + const { firebaseUser } = useContext(UserContext); + const [isAddingNewItem, setIsAddingNewItem] = useState(false); + const [editingItemId, setEditingItemId] = useState(null); + const [itemName, setItemName] = useState(""); + const [allowMultiple, setAllowMultiple] = useState(false); + const [discardEditsConfirmationModalOpen, setDiscardEditsConfirmationModalOpen] = useState(false); + const [confirmDeleteModal, setConfirmDeleteModal] = useState(false); + const [loading, setLoading] = useState(false); + const [successNotificationOpen, setSuccessNotificationOpen] = useState(false); + const [errorNotificationOpen, setErrorNotificationOpen] = useState(false); + const [lastAction, setLastAction] = useState(FurnitureItemAction.NONE); + + const getFurnitureItemById = (itemId: string) => { + for (const furnitureItem of furnitureItems) { + if (furnitureItem._id === itemId) { + return furnitureItem; + } + } + return null; + }; + + const handleStartEditItem = (itemId: string) => { + const furnitureItem = getFurnitureItemById(itemId); + setEditingItemId(itemId); + setItemName(furnitureItem?.name ?? ""); + setAllowMultiple(furnitureItem?.allowMultiple ?? false); + }; + + const onDelete = async () => { + setLoading(true); + setLastAction(FurnitureItemAction.DELETE); + setSuccessNotificationOpen(false); + setErrorNotificationOpen(false); + + const firebaseToken = await firebaseUser?.getIdToken(); + if (!firebaseToken || editingItemId === null) { + setErrorNotificationOpen(true); + setLoading(false); + return; + } + const response = await deleteFurnitureItem(editingItemId, firebaseToken); + if (response.success) { + setSuccessNotificationOpen(true); + onFinishEditing(); + } else { + console.error(`Cannot delete Furniture Item. Error: ${response.error}`); + setErrorNotificationOpen(true); + } + setConfirmDeleteModal(false); + setLoading(false); + resetState(); + }; + + const handleAddNewItem = () => { + setIsAddingNewItem(true); + }; + + const handleSaveChanges = async () => { + setLoading(true); + setSuccessNotificationOpen(false); + setErrorNotificationOpen(false); + + if (isAddingNewItem) { + setLastAction(FurnitureItemAction.CREATE); + const createFurnitureItem: CreateFurnitureItem = { + category: categoryName, + name: itemName, + allowMultiple: allowMultiple, + categoryIndex: furnitureItems.length + 1, + }; + const firebaseToken = await firebaseUser?.getIdToken(); + if (!firebaseToken) { + setErrorNotificationOpen(true); + setLoading(false); + return; + } + const response = await addFurnitureItem(createFurnitureItem, firebaseToken); + if (response.success) { + setSuccessNotificationOpen(true); + onFinishEditing(); + } else { + console.error(`Cannot create Furniture Item. Error: ${response.error}`); + setErrorNotificationOpen(true); + } + } else if (editingItemId !== null) { + setLastAction(FurnitureItemAction.EDIT); + const furnitureItem = getFurnitureItemById(editingItemId); + if (furnitureItem === null) { + setErrorNotificationOpen(true); + setLoading(false); + return; + } + const editFurnitureItem: FurnitureItem = { + _id: furnitureItem._id, + category: categoryName, + name: itemName, + allowMultiple: allowMultiple, + categoryIndex: furnitureItem.categoryIndex, + }; + const firebaseToken = await firebaseUser?.getIdToken(); + if (!firebaseToken) { + setLoading(false); + return; + } + const response = await updateFurnitureItem( + furnitureItem._id, + editFurnitureItem, + firebaseToken, + ); + if (response.success) { + setSuccessNotificationOpen(true); + onFinishEditing(); + } else { + console.error(`Cannot edit Furniture Item. Error: ${response.error}`); + setErrorNotificationOpen(true); + } + } else { + setErrorNotificationOpen(true); + } + + setLoading(false); + resetState(); + }; + + const resetState = () => { + setIsAddingNewItem(false); + setEditingItemId(null); + setItemName(""); + setAllowMultiple(false); + setConfirmDeleteModal(false); + }; + + const canSelectAnotherItem = isEditing && !isAddingNewItem && !editingItemId; + + const hasUnsavedChanges = + (isAddingNewItem && (itemName !== "" || allowMultiple)) || + (editingItemId !== null && + (itemName !== getFurnitureItemById(editingItemId)?.name || + allowMultiple !== getFurnitureItemById(editingItemId)?.allowMultiple)); + useDirtyForm({ isDirty: hasUnsavedChanges }); + + return ( + <> +

+ {isEditing ? ( +

Select an item to edit or add a new item

+ ) : null} + +
+
+ {furnitureItems.map((furnitureItem) => ( + { + if (canSelectAnotherItem) { + handleStartEditItem(furnitureItem._id); + } + }} + /> + ))} +
+ + {isDisabled ? null : isEditing ? ( + <> + {isAddingNewItem || editingItemId !== null ? ( + <> +
+ setItemName(e.target.value)} + /> + +
+ setAllowMultiple(checked)} + sx={{ + color: "#102D5F", + "&.Mui-checked": { + color: "#102D5F", + }, + }} + /> +

Multiple Quantities

+
+ + ) : null} + +
+ {!isAddingNewItem && !editingItemId ? ( +
+ + ) : ( +
+
+ )} +
+ +
+ + setConfirmDeleteModal(false)} + title={"Delete Furniture Item"} + content={"Are you sure you want to delete the selected Furniture Item?"} + cancelText={"Continue Editing"} + confirmText={"Delete Item"} + buttonLoading={false} + onConfirm={onDelete} + /> + + setDiscardEditsConfirmationModalOpen(false)} + onDiscardChanges={() => { + onFinishEditing(); + resetState(); + }} + /> + + {/* Success/error notifications */} + setSuccessNotificationOpen(false)} + /> + setErrorNotificationOpen(false)} + /> + + ); +}; diff --git a/frontend/src/components/FurnitureRequest/EditTemplate/styles.module.css b/frontend/src/components/FurnitureRequest/EditTemplate/styles.module.css new file mode 100644 index 0000000..8ff4169 --- /dev/null +++ b/frontend/src/components/FurnitureRequest/EditTemplate/styles.module.css @@ -0,0 +1,80 @@ +.column { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 16px; +} + +.column:not(:last-child) { + padding-bottom: 32px; +} + +.chipContainer { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 16px; +} + +.roundButton { + border-radius: 64px; +} + +.boxShadow { + padding: 32px; + border-radius: 8px; + background: #fff; + box-shadow: 0px 0px 14.6px 0px rgba(0, 0, 0, 0.1); +} + +.selectTag { + color: var(--Primary-Background-Dark, #232220); + font-family: "Open Sans"; + font-size: 18px; + font-weight: 400; + margin-bottom: 16px; +} + +.buttonsRow { + display: flex; + flex-direction: row; + gap: 16px; + flex-wrap: wrap; +} + +.editButton { + border-radius: 64px; +} + +.textField { + margin-top: 32px; +} + +.spacer { + width: 100%; +} + +.checkboxRow { + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 16px; +} + +.multipleQuantityText { + color: var(--Primary-Background-Dark, #232220); + font-family: "Open Sans"; + font-size: 16px; + font-weight: 400; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .selectTag { + font-size: 14px; + } + + .multipleQuantityText { + font-size: 14px; + } +} diff --git a/frontend/src/components/Profile/ChangePasswordModal/index.tsx b/frontend/src/components/Profile/ChangePasswordModal/index.tsx new file mode 100644 index 0000000..4ecef24 --- /dev/null +++ b/frontend/src/components/Profile/ChangePasswordModal/index.tsx @@ -0,0 +1,153 @@ +import Image from "next/image"; +import { useContext, useState } from "react"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { BaseModal } from "@/components/shared/BaseModal"; +import { changeUserPassword } from "@/api/Users"; +import { UserContext } from "@/contexts/userContext"; +import TextField from "@/components/shared/input/TextField"; +import { IconButton } from "@mui/material"; +import { Button } from "@/components/shared/Button"; +import styles from "@/components/Profile/ChangePasswordModal/styles.module.css"; + +interface IChangePasswordFormInput { + password: string; + confirmPassword: string; +} + +interface ChangePasswordModalProps { + isOpen: boolean; + onClose: () => unknown; + uid: string; + afterChangePassword: () => unknown; +} + +export const ChangePasswordModal = ({ + isOpen, + onClose, + uid, + afterChangePassword, +}: ChangePasswordModalProps) => { + const { firebaseUser, setSuccessNotificationOpen, setErrorNotificationOpen } = + useContext(UserContext); + const [passwordVisible, setPasswordVisible] = useState(false); + const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const { + handleSubmit, + register, + reset, + formState: { errors, isValid }, + watch, + } = useForm(); + + const onSubmit: SubmitHandler = async (data) => { + setLoading(true); + setSuccessNotificationOpen(null); + setErrorNotificationOpen(null); + + const firebaseToken = await firebaseUser?.getIdToken(); + const result = await changeUserPassword(uid, data.password, firebaseToken!); + if (result.success) { + setSuccessNotificationOpen("changePassword"); + } else { + console.error(`Changing password failed with error: ${result.error}`); + setErrorNotificationOpen("changePassword"); + } + setLoading(false); + reset(); + afterChangePassword(); + onClose(); + }; + + return ( + <> + +

Change this user’s login credentials

+ + + password.length >= 6 || "New password must be at least 6 characters", + }, + })} + required={false} + error={!!errors.password} + helperText={errors.password?.message} + type={passwordVisible ? "text" : "password"} + InputProps={{ + endAdornment: ( + setPasswordVisible((prevVisible) => !prevVisible)} + className={styles.visibilityButton} + > + {passwordVisible + + ), + }} + /> + + + confirmPassword === watch().password || "Passwords do not match", + }, + })} + required={false} + error={!!errors.confirmPassword} + helperText={errors.confirmPassword?.message} + type={confirmPasswordVisible ? "text" : "password"} + InputProps={{ + endAdornment: ( + setConfirmPasswordVisible((prevVisible) => !prevVisible)} + className={styles.visibilityButton} + > + {confirmPasswordVisible + + ), + }} + /> + +
+ } + bottomRow={null} + /> + + ); +}; diff --git a/frontend/src/components/Profile/ChangePasswordModal/styles.module.css b/frontend/src/components/Profile/ChangePasswordModal/styles.module.css new file mode 100644 index 0000000..cf94649 --- /dev/null +++ b/frontend/src/components/Profile/ChangePasswordModal/styles.module.css @@ -0,0 +1,57 @@ +.root { + display: flex; + flex-direction: column; + gap: 64px; +} + +.subtitle { + color: #000; + font-family: "Open Sans"; + font-size: 20px; + font-weight: 400; +} + +.form { + display: flex; + flex-direction: column; + gap: 32px; +} + +.submitButton { + padding: 12px 32px; + text-align: center; + font-family: Lora; + font-size: 24px; + font-weight: 700; + width: 100%; +} + +.disabledButton { + background: grey !important; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .subtitle { + font-size: 18px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .root { + gap: 32px; + } + + .form { + gap: 16px; + } + + .subtitle { + font-size: 14px; + } + + .submitButton { + font-size: 18px; + } +} diff --git a/frontend/src/components/Profile/CreateUserModal/index.tsx b/frontend/src/components/Profile/CreateUserModal/index.tsx new file mode 100644 index 0000000..bc976e0 --- /dev/null +++ b/frontend/src/components/Profile/CreateUserModal/index.tsx @@ -0,0 +1,203 @@ +import emailValidator from "email-validator"; +import Image from "next/image"; +import { useContext, useState } from "react"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { BaseModal } from "@/components/shared/BaseModal"; +import { CreateUserRequest, createUser } from "@/api/Users"; +import { UserContext } from "@/contexts/userContext"; +import { NotificationBanner } from "@/components/shared/NotificationBanner"; +import TextField from "@/components/shared/input/TextField"; +import { IconButton } from "@mui/material"; +import { Button } from "@/components/shared/Button"; +import styles from "@/components/Profile/CreateUserModal/styles.module.css"; + +interface ICreateUserFormInput { + name: string; + email: string; + password: string; + confirmPassword: string; +} + +interface CreateUserModalProps { + isOpen: boolean; + onClose: () => unknown; + afterCreateUser: () => unknown; +} + +export const CreateUserModal = ({ isOpen, onClose, afterCreateUser }: CreateUserModalProps) => { + const { firebaseUser } = useContext(UserContext); + const [passwordVisible, setPasswordVisible] = useState(false); + const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false); + const [loading, setLoading] = useState(false); + const [successNotificationOpen, setSuccessNotificationOpen] = useState(false); + const [errorNotificationOpen, setErrorNotificationOpen] = useState(false); + + const { + handleSubmit, + register, + reset, + formState: { errors, isValid }, + watch, + } = useForm(); + + const onSubmit: SubmitHandler = async (data) => { + setLoading(true); + setSuccessNotificationOpen(false); + setErrorNotificationOpen(false); + + const createUserRequest: CreateUserRequest = { + name: data.name, + email: data.email, + password: data.password, + }; + + const firebaseToken = await firebaseUser?.getIdToken(); + const result = await createUser(firebaseToken!, createUserRequest); + if (result.success) { + setSuccessNotificationOpen(true); + } else { + console.error(`Creating user failed with error: ${result.error}`); + setErrorNotificationOpen(true); + } + setLoading(false); + reset(); + afterCreateUser(); + onClose(); + }; + + return ( + <> + +

+ Invite a new staff member by entering their information below! +

+
+ + + + emailValidator.validate(emailAddress) || + "This field must be a valid email address", + }, + })} + required={false} + error={!!errors.email} + helperText={errors.email?.message} + /> + + + password.length >= 6 || "Password must be at least 6 characters", + }, + })} + required={false} + error={!!errors.password} + helperText={errors.password?.message} + type={passwordVisible ? "text" : "password"} + InputProps={{ + endAdornment: ( + setPasswordVisible((prevVisible) => !prevVisible)} + className={styles.visibilityButton} + > + {passwordVisible + + ), + }} + /> + + + confirmPassword === watch().password || "Passwords do not match", + }, + })} + required={false} + error={!!errors.confirmPassword} + helperText={errors.confirmPassword?.message} + type={confirmPasswordVisible ? "text" : "password"} + InputProps={{ + endAdornment: ( + setConfirmPasswordVisible((prevVisible) => !prevVisible)} + className={styles.visibilityButton} + > + {confirmPasswordVisible + + ), + }} + /> + +
+ } + bottomRow={null} + /> + + setSuccessNotificationOpen(false)} + /> + setErrorNotificationOpen(false)} + /> + + ); +}; diff --git a/frontend/src/components/Profile/CreateUserModal/styles.module.css b/frontend/src/components/Profile/CreateUserModal/styles.module.css new file mode 100644 index 0000000..cf94649 --- /dev/null +++ b/frontend/src/components/Profile/CreateUserModal/styles.module.css @@ -0,0 +1,57 @@ +.root { + display: flex; + flex-direction: column; + gap: 64px; +} + +.subtitle { + color: #000; + font-family: "Open Sans"; + font-size: 20px; + font-weight: 400; +} + +.form { + display: flex; + flex-direction: column; + gap: 32px; +} + +.submitButton { + padding: 12px 32px; + text-align: center; + font-family: Lora; + font-size: 24px; + font-weight: 700; + width: 100%; +} + +.disabledButton { + background: grey !important; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .subtitle { + font-size: 18px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .root { + gap: 32px; + } + + .form { + gap: 16px; + } + + .subtitle { + font-size: 14px; + } + + .submitButton { + font-size: 18px; + } +} diff --git a/frontend/src/components/Profile/OwnProfile/index.tsx b/frontend/src/components/Profile/OwnProfile/index.tsx new file mode 100644 index 0000000..fd41332 --- /dev/null +++ b/frontend/src/components/Profile/OwnProfile/index.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import styles from "@/components/Profile/OwnProfile/styles.module.css"; +import { Avatar } from "@mui/material"; + +export interface AdminProps { + name: string; + email: string; +} +export function OwnProfile({ name, email }: AdminProps) { + return ( +
+ {name?.slice(0, 1)} +
+

Name

+

{name}

+
+
+

Email Account

+

{email}

+
+
+ ); +} diff --git a/frontend/src/components/Profile/OwnProfile/styles.module.css b/frontend/src/components/Profile/OwnProfile/styles.module.css new file mode 100644 index 0000000..17b3bb2 --- /dev/null +++ b/frontend/src/components/Profile/OwnProfile/styles.module.css @@ -0,0 +1,70 @@ +.root { + display: flex; + flex-direction: row; + border-radius: 6px; + background: var(--Functional-Background, #f9f9f9); + + padding: 32px; + align-items: flex-start; + align-self: stretch; + margin-bottom: 64px; +} + +.info_column { + display: flex; + flex-direction: column; + gap: 16px; + margin-left: 64px; +} + +.info_column_right { + display: flex; + flex-direction: column; + gap: 16px; + margin-left: 96px; +} + +.info_type { + color: var(--Dark-Gray, #484848); + font-family: var(--font-open-sans); + font-size: 16px; + font-weight: 400; +} + +.info { + color: var(--Primary-Background-Dark, #232220); + font-family: var(--font-open-sans); + font-size: 20px; + font-weight: 400; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .info_column_right { + margin-left: 48px; + } + + .info_type { + font-size: 14px; + } + + .info { + font-size: 14px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .root { + flex-direction: column; + gap: 32px; + } + + .info_column { + margin-left: 0; + } + + .info_column_right { + margin-left: 0; + } +} diff --git a/frontend/src/components/Profile/UserProfile/index.tsx b/frontend/src/components/Profile/UserProfile/index.tsx new file mode 100644 index 0000000..c0fb5a7 --- /dev/null +++ b/frontend/src/components/Profile/UserProfile/index.tsx @@ -0,0 +1,113 @@ +import React, { useContext, useState } from "react"; +import styles from "@/components/Profile/UserProfile/styles.module.css"; +import Image from "next/image"; +import { Avatar, ClickAwayListener, Popper } from "@mui/material"; +import { ConfirmDeleteModal } from "@/components/shared/ConfirmDeleteModal"; +import { UserContext } from "@/contexts/userContext"; +import { deleteUser } from "@/api/Users"; +import { ChangePasswordModal } from "@/components/Profile/ChangePasswordModal"; + +export interface UserProps { + uid: string; + name: string; + email: string; + afterChangeUser: () => unknown; +} +export function UserProfile({ uid, name, email, afterChangeUser }: UserProps) { + const { firebaseUser, setSuccessNotificationOpen, setErrorNotificationOpen } = + useContext(UserContext); + const [actionsPopupAnchorEl, setActionsPopupAnchorEl] = useState(null); + const [changePasswordModalOpen, setChangePasswordModalOpen] = useState(false); + const [deleteUserModalOpen, setDeleteUserModalOpen] = useState(false); + const [loadingDelete, setLoadingDelete] = useState(false); + + const handleConfirmDeleteUser = async () => { + setLoadingDelete(true); + setSuccessNotificationOpen(null); + setErrorNotificationOpen(null); + + const firebaseToken = await firebaseUser?.getIdToken(); + const result = await deleteUser(uid, firebaseToken!); + if (result.success) { + setSuccessNotificationOpen("deleteUser"); + } else { + console.error(`Error deleting user: ${result.error}`); + setErrorNotificationOpen("deleteUser"); + } + setLoadingDelete(false); + setDeleteUserModalOpen(false); + afterChangeUser(); + }; + + return ( + <> +
+ {name?.slice(0, 1)} +
+

{name}

+

{email}

+
+
+ Gear { + setActionsPopupAnchorEl(e.target as HTMLElement); + }} + /> +
+ {actionsPopupAnchorEl === null ? null : ( + setActionsPopupAnchorEl(null)}> + +
+
{ + setChangePasswordModalOpen(true); + setActionsPopupAnchorEl(null); + }} + > + Lock +

Change Password

+
+
{ + setDeleteUserModalOpen(true); + setActionsPopupAnchorEl(null); + }} + > + Delete +

Remove User

+
+
+
+
+ )} + + setChangePasswordModalOpen(false)} + afterChangePassword={afterChangeUser} + /> + setDeleteUserModalOpen(false)} + title="Delete User" + content="Deleting a user denies them all access to this web application. Are you sure you’d like to remove this user?" + cancelText="Cancel" + confirmText="Yes, Remove User" + buttonLoading={loadingDelete} + onConfirm={handleConfirmDeleteUser} + /> + + ); +} diff --git a/frontend/src/components/Profile/UserProfile/styles.module.css b/frontend/src/components/Profile/UserProfile/styles.module.css new file mode 100644 index 0000000..24319a2 --- /dev/null +++ b/frontend/src/components/Profile/UserProfile/styles.module.css @@ -0,0 +1,92 @@ +.root { + display: flex; + flex-direction: row; + border-radius: 6px; + background: var(--Functional-Background, #f9f9f9); + + padding: 26px 34px; + align-items: center; + gap: 40px; + position: relative; +} + +.name { + color: var(--Accent-Blue-1, #102d5f); + font-family: Lora; + font-size: 24px; + font-weight: 700; +} + +.email { + color: var(--Neutral-Gray6, #484848); + font-family: "Open Sans"; + font-size: 20px; + font-weight: 400; +} + +.row { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.settingsIcon { + cursor: pointer; + position: absolute; + right: 26px; + top: 26px; +} + +.menuRoot { + display: flex; + flex-direction: column; + background-color: white; + margin-right: 3px; + min-width: 250px; + border-radius: 8px; + box-shadow: 0px 0px 24px 0px rgba(0, 0, 0, 0.2); +} + +.menuRow { + display: flex; + flex-direction: row; + align-items: center; + cursor: pointer; + padding: 12px; + gap: 12px; +} + +.menuText { + color: var(--Primary-Background-Dark, #232220); + font-family: "Open Sans"; + font-size: 14px; + font-weight: 400; +} + +.redText { + color: #be2d46; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .email { + font-size: 14px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .root { + flex-direction: column; + align-items: start; + gap: 32px; + } + + .row { + flex-direction: column; + align-items: start; + gap: 16px; + } +} diff --git a/frontend/src/components/VSRForm/FurnitureItemSelection/index.tsx b/frontend/src/components/VSRForm/FurnitureItemSelection/index.tsx index 42656bf..87a9731 100644 --- a/frontend/src/components/VSRForm/FurnitureItemSelection/index.tsx +++ b/frontend/src/components/VSRForm/FurnitureItemSelection/index.tsx @@ -5,8 +5,12 @@ import Image from "next/image"; export interface FurnitureItemSelectionProps { furnitureItem: FurnitureItem; - selection: FurnitureInput; - onChangeSelection: (newSelection: FurnitureInput) => unknown; + selection?: FurnitureInput; + onChangeSelection?: (newSelection: FurnitureInput) => unknown; + isActive: boolean; + isDisabled?: boolean; + isSelected?: boolean; + onChipClicked?: () => unknown; } /** @@ -17,30 +21,48 @@ export const FurnitureItemSelection = ({ furnitureItem, selection, onChangeSelection, + isActive, + isDisabled, + isSelected, + onChipClicked, }: FurnitureItemSelectionProps) => { const handleChipClicked = () => { - if (selection.quantity === 0) { - incrementCount(); - } else if (!furnitureItem.allowMultiple) { - onChangeSelection({ ...selection, quantity: 0 }); + if (isActive) { + if (selection!.quantity === 0) { + incrementCount(); + } else if (!furnitureItem.allowMultiple) { + onChangeSelection!({ ...selection!, quantity: 0 }); + } + } else { + onChipClicked?.(); } }; const incrementCount = () => { - onChangeSelection({ ...selection, quantity: selection.quantity + 1 }); + if (isActive) { + onChangeSelection!({ ...selection!, quantity: selection!.quantity + 1 }); + } else { + onChipClicked?.(); + } }; const decrementCount = () => { - if (selection.quantity > 0) { - onChangeSelection({ ...selection, quantity: selection.quantity - 1 }); + if (isActive) { + if (selection!.quantity > 0) { + onChangeSelection!({ ...selection!, quantity: selection!.quantity - 1 }); + } + } else { + onChipClicked?.(); } }; + isSelected = isSelected || (selection && selection.quantity > 0); + return (
0 ? styles.chipSelected : styles.chipUnselected - }`} + isSelected ? styles.chipSelected : styles.chipUnselected + } ${isDisabled ? (isSelected ? styles.selectedDisabled : styles.unselectedDisabled) : ""}`} onClick={handleChipClicked} >
@@ -56,16 +78,14 @@ export const FurnitureItemSelection = ({ type="button" > 0 ? styles.decSelected : styles.dec - }`} + className={`${styles.dec} ${isSelected ? styles.decSelected : styles.dec}`} src="/icon_minus.svg" width={22} height={22} alt="dropdown" /> - {selection.quantity} + {selection?.quantity ?? 0}
- } + onDiscardChanges={discardChanges} /> {/* Modals & notifications for saving changes to VSR */} - setSaveEditsConfirmationModalOpen(false)} - title="Save Changes" - content="Would you like to save your changes?" - bottomRow={ -
-
- } - /> { subText="An error occurred, please check your internet connection or try again later" onDismissClicked={() => setDownloadErrorNotificationOpen(false)} /> + setSuccessNotificationOpen(false)} + /> + setErrorNotificationOpen(false)} + /> ); }; diff --git a/frontend/src/components/VSRIndividual/VSRIndividualPage/styles.module.css b/frontend/src/components/VSRIndividual/VSRIndividualPage/styles.module.css index f3a123c..1c72f1f 100644 --- a/frontend/src/components/VSRIndividual/VSRIndividualPage/styles.module.css +++ b/frontend/src/components/VSRIndividual/VSRIndividualPage/styles.module.css @@ -44,12 +44,6 @@ flex-direction: row; } -.modalButton { - font-size: 24px; - text-align: center; - width: 100%; -} - .blueOutlinedButton { background-color: transparent; border: 1px solid var(--color-tse-accent-blue-1); @@ -66,12 +60,6 @@ background-color: #be2d46; } -.modalBottomRow { - display: flex; - flex-direction: row; - gap: 32px; -} - #edit:hover { background-color: #c7def1; } @@ -183,10 +171,6 @@ .page { padding: 0 48px; } - - .modalButton { - font-size: 18px; - } } /* mobile version */ @@ -202,8 +186,4 @@ .actions { gap: 16px; } - - .modalButton { - font-size: 14px; - } } diff --git a/frontend/src/components/VSRTable/FilterChip/index.tsx b/frontend/src/components/VSRTable/FilterChip/index.tsx new file mode 100644 index 0000000..ea3571c --- /dev/null +++ b/frontend/src/components/VSRTable/FilterChip/index.tsx @@ -0,0 +1,24 @@ +import styles from "@/components/VSRTable/FilterChip/styles.module.css"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import Image from "next/image"; + +interface FilterChipProps { + label: string; + onDelete: () => void; +} + +const FilterChip = ({ label, onDelete }: FilterChipProps) => { + const { isTablet } = useScreenSizes(); + const iconSize = isTablet ? 18 : 24; + + return ( +
+

{label}

+ +
+ ); +}; + +export default FilterChip; diff --git a/frontend/src/components/VSRTable/FilterChip/styles.module.css b/frontend/src/components/VSRTable/FilterChip/styles.module.css new file mode 100644 index 0000000..24df3d6 --- /dev/null +++ b/frontend/src/components/VSRTable/FilterChip/styles.module.css @@ -0,0 +1,51 @@ +.filterChip { + display: flex; + height: 40px; + justify-content: center; + align-items: center; + gap: 8px; + border-radius: 64px; + border: 1px solid var(--Secondary-1, #102d5f); + background: var(--Secondary-1, #102d5f); + align-self: stretch; + padding: 8px 16px; +} + +.filterText { + color: #fff; + text-align: center; + font-family: "Open Sans"; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.deleteButton { + display: flex; + cursor: pointer; + border: none !important; + background: transparent; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .filterChip { + height: 36px; + } + + .filterText { + font-size: 14px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .filterChip { + height: 30px; + } + + .filterText { + font-size: 12px; + } +} diff --git a/frontend/src/components/VSRTable/FilterModal/index.tsx b/frontend/src/components/VSRTable/FilterModal/index.tsx new file mode 100644 index 0000000..f35b082 --- /dev/null +++ b/frontend/src/components/VSRTable/FilterModal/index.tsx @@ -0,0 +1,148 @@ +import styles from "@/components/VSRTable/FilterModal/styles.module.css"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/shared/Button"; +import TextField from "@/components/shared/input/TextField"; +import MultipleChoice from "@/components/shared/input/MultipleChoice"; +import { incomeOptions } from "@/constants/fieldOptions"; +import FilterChip from "@/components/VSRTable/FilterChip"; +import { ClickAwayListener, Popper } from "@mui/material"; + +interface FilterModalProps { + anchorElement: HTMLElement | null; + initialZipCodes: string[]; + initialIncomeLevel: string; + onClose: () => unknown; + onInputEntered: (zipCodes: string[] | undefined, incomeLevel: string | undefined) => void; + onResetFilters: () => void; +} + +const FilterModal = ({ + anchorElement, + initialZipCodes, + initialIncomeLevel, + onClose, + onInputEntered, + onResetFilters, +}: FilterModalProps) => { + const [zipCodes, setZipCodes] = useState(initialZipCodes); + const [currentZipCode, setCurrentZipCode] = useState(""); + const [zipCodeError, setZipCodeError] = useState(false); + + const [income, setIncome] = useState(initialIncomeLevel); + + useEffect(() => { + setZipCodes(initialZipCodes); + setIncome(initialIncomeLevel); + }, [initialZipCodes, initialIncomeLevel]); + + const applyButtonEnabled = zipCodes.length > 0 || currentZipCode !== "" || income !== ""; + + const handleZipCodeChange = (e: React.ChangeEvent) => { + setCurrentZipCode(e.target.value); + setZipCodeError(false); + }; + + const handleApplyFilter = () => { + if (currentZipCode.trim() !== "" && currentZipCode.length !== 5) { + setZipCodeError(true); + return; + } + // Pass the entered zip codes to the parent component when the user applies the filter + onInputEntered( + [...zipCodes, ...(currentZipCode.trim() === "" ? [] : [currentZipCode.trim()])], + income, + ); + setCurrentZipCode(""); + setZipCodeError(false); + onClose(); // Close the modal + }; + + const handleReset = () => { + onResetFilters(); + setCurrentZipCode(""); + setZipCodeError(false); + onClose(); // Close the modal + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + if (currentZipCode.trim()) { + if (currentZipCode.length === 5) { + setZipCodeError(false); + setZipCodes((prevZipCodes) => [...prevZipCodes, currentZipCode.trim()]); + setCurrentZipCode(""); // Clear the text field after adding the zipcode + } else { + setZipCodeError(true); + } + } + event.preventDefault(); // Prevent the default action of the Enter key + } + }; + + const renderPopper = () => ( + +
+ + setIncome(Array.isArray(newValue) ? newValue[0] : newValue) + } + required={false} + /> + + {zipCodes && zipCodes.length > 0 ? ( +
+ {zipCodes?.map((zipCode) => ( + { + setZipCodes(zipCodes?.filter((z) => z !== zipCode)); + }} + /> + ))} +
+ ) : null} + +
+
+
+
+ ); + + return anchorElement === null ? ( + renderPopper() + ) : ( + {renderPopper()} + ); +}; + +export default FilterModal; diff --git a/frontend/src/components/VSRTable/FilterModal/styles.module.css b/frontend/src/components/VSRTable/FilterModal/styles.module.css new file mode 100644 index 0000000..9185642 --- /dev/null +++ b/frontend/src/components/VSRTable/FilterModal/styles.module.css @@ -0,0 +1,66 @@ +.root { + display: flex; + flex-direction: column; + background-color: white; + border-radius: 8px; + box-shadow: 0px 0px 24px 0px rgba(0, 0, 0, 0.2); + padding: 16px; + gap: 32px; + max-width: 410px; + margin-top: 12px; +} + +.closeButton { + float: "right"; + border: "none"; + background: "none"; + cursor: "pointer"; +} + +.buttonContainer { + display: flex; + flex-direction: row; + gap: 16px; +} + +.button { + width: 100%; + padding: 12px 24px; + text-align: center; + font-family: "Lora"; + font-size: 24px; + font-weight: 700; +} + +.filterChips { + display: flex; + flex-direction: row; + gap: 16px; + align-items: center; + flex-wrap: wrap; +} + +.disabled { + background-color: grey !important; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .root { + max-width: calc(100vw - 96px); + margin-left: 48px; + margin-top: 8px; + } + + .buttonContainer { + gap: 32px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .root { + max-width: calc(100vw - 48px); + margin-left: 24px; + } +} diff --git a/frontend/src/components/VSRTable/SearchKeyword/index.tsx b/frontend/src/components/VSRTable/SearchKeyword/index.tsx index cf92950..7833cf6 100644 --- a/frontend/src/components/VSRTable/SearchKeyword/index.tsx +++ b/frontend/src/components/VSRTable/SearchKeyword/index.tsx @@ -1,16 +1,26 @@ import styles from "@/components/VSRTable/SearchKeyword/styles.module.css"; import Image from "next/image"; -import * as React from "react"; +import React from "react"; +import { debounce } from "@mui/material"; /** * A component for the Search input above the VSR table. */ -export default function SearchKeyword() { + +interface SearchProps { + onUpdate: (search: string) => void; +} + +export const SearchKeyword = ({ onUpdate }: SearchProps) => { return (
{/* image */} Search - + onUpdate(e.target.value), 250)} + />
); -} +}; diff --git a/frontend/src/components/shared/BaseModal/index.tsx b/frontend/src/components/shared/BaseModal/index.tsx index 395307e..149b354 100644 --- a/frontend/src/components/shared/BaseModal/index.tsx +++ b/frontend/src/components/shared/BaseModal/index.tsx @@ -24,7 +24,7 @@ export const BaseModal = ({ isOpen, onClose, title, content, bottomRow }: BaseMo

{title}

{content}

-
{bottomRow}
+ {bottomRow ?
{bottomRow}
: null}
diff --git a/frontend/src/components/shared/ConfirmDeleteModal/index.tsx b/frontend/src/components/shared/ConfirmDeleteModal/index.tsx new file mode 100644 index 0000000..c7040a7 --- /dev/null +++ b/frontend/src/components/shared/ConfirmDeleteModal/index.tsx @@ -0,0 +1,58 @@ +import styles from "@/components/shared/ConfirmDeleteModal/styles.module.css"; +import { ReactElement } from "react"; +import { BaseModal } from "@/components/shared/BaseModal"; +import { Button } from "@/components/shared/Button"; + +interface ConfirmDeleteModalProps { + isOpen: boolean; + onClose: () => unknown; + title: string; + content: string | ReactElement; + cancelText: string; + confirmText: string; + buttonLoading: boolean; + onConfirm: () => unknown; +} + +/** + * A modal that asks the user to confirm whether they want to delete one or more + * VSRs, and then deletes them if they confirm that they want to. + */ +export const ConfirmDeleteModal = ({ + isOpen, + onClose, + title, + content, + cancelText, + confirmText, + buttonLoading, + onConfirm, +}: ConfirmDeleteModalProps) => { + return ( + +
+ } + /> + ); +}; diff --git a/frontend/src/components/shared/DeleteVSRsModal/styles.module.css b/frontend/src/components/shared/ConfirmDeleteModal/styles.module.css similarity index 100% rename from frontend/src/components/shared/DeleteVSRsModal/styles.module.css rename to frontend/src/components/shared/ConfirmDeleteModal/styles.module.css diff --git a/frontend/src/components/shared/ConfirmDiscardEditsModal/index.tsx b/frontend/src/components/shared/ConfirmDiscardEditsModal/index.tsx new file mode 100644 index 0000000..3930e04 --- /dev/null +++ b/frontend/src/components/shared/ConfirmDiscardEditsModal/index.tsx @@ -0,0 +1,47 @@ +import { BaseModal } from "@/components/shared/BaseModal"; +import { Button } from "@/components/shared/Button"; +import styles from "@/components/shared/ConfirmDiscardEditsModal/styles.module.css"; + +interface ConfirmDiscardEditsModalProps { + isOpen: boolean; + onClose: () => unknown; + onDiscardChanges: () => unknown; +} + +export const ConfirmDiscardEditsModal = ({ + isOpen, + onClose, + onDiscardChanges, +}: ConfirmDiscardEditsModalProps) => { + return ( + +
+ } + /> + ); +}; diff --git a/frontend/src/components/shared/ConfirmDiscardEditsModal/styles.module.css b/frontend/src/components/shared/ConfirmDiscardEditsModal/styles.module.css new file mode 100644 index 0000000..f1827d7 --- /dev/null +++ b/frontend/src/components/shared/ConfirmDiscardEditsModal/styles.module.css @@ -0,0 +1,25 @@ +.modalBottomRow { + display: flex; + flex-direction: row; + gap: 32px; +} + +.modalButton { + font-size: 24px; + text-align: center; + width: 100%; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .modalButton { + font-size: 18px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .modalButton { + font-size: 14px; + } +} diff --git a/frontend/src/components/shared/DeleteVSRsModal/index.tsx b/frontend/src/components/shared/DeleteVSRsModal/index.tsx deleted file mode 100644 index 0c8d335..0000000 --- a/frontend/src/components/shared/DeleteVSRsModal/index.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { deleteVSR } from "@/api/VSRs"; -import { UserContext } from "@/contexts/userContext"; -import { useContext, useState } from "react"; -import { NotificationBanner } from "@/components/shared/NotificationBanner"; -import { BaseModal } from "@/components/shared/BaseModal"; -import { Button } from "@/components/shared/Button"; -import styles from "@/components/shared/DeleteVSRsModal/styles.module.css"; - -interface DeleteVSRsModalProps { - isOpen: boolean; - onClose: () => unknown; - afterDelete: () => unknown; - vsrIds: string[]; -} - -/** - * A modal that asks the user to confirm whether they want to delete one or more - * VSRs, and then deletes them if they confirm that they want to. - */ -export const DeleteVSRsModal = ({ isOpen, onClose, afterDelete, vsrIds }: DeleteVSRsModalProps) => { - const { firebaseUser } = useContext(UserContext); - - const [loadingDelete, setLoadingDelete] = useState(false); - const [successNotificationOpen, setSuccessNotificationOpen] = useState(false); - const [errorNotificationOpen, setErrorNotificationOpen] = useState(false); - - const onDelete = async () => { - if (loadingDelete || !firebaseUser) { - return; - } - - setSuccessNotificationOpen(false); - setErrorNotificationOpen(false); - setLoadingDelete(true); - - try { - const firebaseToken = await firebaseUser.getIdToken(); - if (!firebaseToken) { - setLoadingDelete(false); - return; - } - - await Promise.all( - vsrIds.map((vsrId) => - deleteVSR(vsrId, firebaseToken).then((res) => { - if (res.success) { - return Promise.resolve(); - } else { - return Promise.reject(res.error); - } - }), - ), - ); - - setSuccessNotificationOpen(true); - afterDelete(); - } catch (error) { - console.error(`Error deleting VSR(s): ${error}`); - setErrorNotificationOpen(true); - } finally { - setLoadingDelete(false); - onClose(); - } - }; - - return ( - <> - - {"Deleted VSR’s "} - cannot - {" be recovered. Are you sure you’d like to delete the selected VSR forms ("} - {vsrIds.length} - {")?"} - - } - bottomRow={ -
-
- } - /> - setSuccessNotificationOpen(false)} - /> - setErrorNotificationOpen(false)} - /> - - ); -}; diff --git a/frontend/src/components/shared/HeaderBar/index.tsx b/frontend/src/components/shared/HeaderBar/index.tsx index 376d579..62040a2 100644 --- a/frontend/src/components/shared/HeaderBar/index.tsx +++ b/frontend/src/components/shared/HeaderBar/index.tsx @@ -1,27 +1,37 @@ import Image from "next/image"; -import React, { useState } from "react"; +import Link from "next/link"; +import React, { useContext, useState } from "react"; import { useScreenSizes } from "@/hooks/useScreenSizes"; import { signOut } from "firebase/auth"; import { initFirebase } from "@/firebase/firebase"; import { Button } from "@/components/shared/Button"; import { NotificationBanner } from "@/components/shared/NotificationBanner"; +import { UserContext } from "@/contexts/userContext"; +import { Avatar, ClickAwayListener, Popper } from "@mui/material"; import styles from "@/components/shared/HeaderBar/styles.module.css"; +import { ADMIN_ROLE } from "@/constants/roles"; interface HeaderBarProps { - showLogoutButton: boolean; + veteranVersion: boolean; } /** * A component that displays a header bar at the top of the screen and, optionally, * a logout button for staff and admins. */ -const HeaderBar = ({ showLogoutButton }: HeaderBarProps) => { +const HeaderBar = ({ veteranVersion }: HeaderBarProps) => { + const { firebaseUser, papUser } = useContext(UserContext); const { isTablet } = useScreenSizes(); const { auth } = initFirebase(); const [loading, setLoading] = useState(false); const [errorNotificationOpen, setErrorNotificationOpen] = useState(false); + const [popupAnchor, setPopupAnchor] = useState(null); const logout = () => { + if (loading) { + return; + } + setErrorNotificationOpen(false); setLoading(true); signOut(auth) @@ -32,18 +42,101 @@ const HeaderBar = ({ showLogoutButton }: HeaderBarProps) => { .finally(() => setLoading(false)); }; + const menuOptions = [ + { + iconSrc: "/ic_home.svg", + iconAlt: "Home", + text: "Dashboard", + linkPath: "/staff/vsr", + }, + { + iconSrc: "/ic_profile.svg", + iconAlt: "Profile", + text: "Profile", + linkPath: "/staff/profile", + }, + ...(papUser?.role === ADMIN_ROLE + ? [ + { + iconSrc: "/furniture_items.svg", + iconAlt: "Furniture Items", + text: "Edit Furniture Items", + linkPath: "/staff/furnitureItems", + }, + { + iconSrc: "/ic_email.svg", + iconAlt: "Email", + text: "Edit Email Template", + linkPath: "/staff/emailTemplate", + }, + ] + : []), + { + iconSrc: "/logout.svg", + iconAlt: "Logout", + text: "Log Out", + onClick: logout, + }, + ]; + + const renderMenuRow = (option: (typeof menuOptions)[number]) => ( +
{ + option.onClick?.(); + setPopupAnchor(null); + }} + key={option.text} + > + {option.iconAlt} +

{option.text}

+
+ ); + + const renderMenuOption = (option: (typeof menuOptions)[number]) => + option.linkPath ? ( + + {renderMenuRow(option)} + + ) : ( + renderMenuRow(option) + ); + return ( -
- logo - {showLogoutButton ? ( -
+ + {popupAnchor === null ? null : ( + setPopupAnchor(null)}> + +
{menuOptions.map(renderMenuOption)}
+
+
+ )} + { subText="An error occurred while signing out, please check your internet connection or try again later" onDismissClicked={() => setErrorNotificationOpen(false)} /> -
+ ); }; diff --git a/frontend/src/components/shared/HeaderBar/styles.module.css b/frontend/src/components/shared/HeaderBar/styles.module.css index 65f7189..b823c12 100644 --- a/frontend/src/components/shared/HeaderBar/styles.module.css +++ b/frontend/src/components/shared/HeaderBar/styles.module.css @@ -7,15 +7,54 @@ top: 0; left: 0; width: 100%; - padding: 27px 24px 26px 63px; - padding-right: 24px; + padding: 18px 120px; background-color: #fff; border-bottom: 1px solid rgba(0, 0, 0, 0.25); } +.avatar { + width: 43px; + height: 43px; + font-size: 24px; + cursor: pointer; +} + +.menuRoot { + display: flex; + flex-direction: column; + background-color: white; + margin-top: 8px; + min-width: 250px; + border-radius: 8px; + box-shadow: 0px 0px 24px 0px rgba(0, 0, 0, 0.2); +} + +.menuRow { + display: flex; + flex-direction: row; + align-items: center; + cursor: pointer; + padding: 12px; + gap: 12px; +} + +.menuText { + color: var(--Primary-Background-Dark, #232220); + font-family: "Open Sans"; + font-size: 14px; + font-weight: 400; +} + /* tablet version */ @media screen and (max-width: 850px) { .headerBar { - padding: 52px 12px 20px 24px; + padding: 18px 48px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .headerBar { + padding: 52px 24px 24px; } } diff --git a/frontend/src/components/shared/NotificationBanner/index.tsx b/frontend/src/components/shared/NotificationBanner/index.tsx index 28ec7f9..c2b2332 100644 --- a/frontend/src/components/shared/NotificationBanner/index.tsx +++ b/frontend/src/components/shared/NotificationBanner/index.tsx @@ -1,7 +1,7 @@ import { useScreenSizes } from "@/hooks/useScreenSizes"; import { Portal } from "@mui/material"; import Image from "next/image"; -import { CSSProperties } from "react"; +import { CSSProperties, useEffect } from "react"; import styles from "@/components/shared/NotificationBanner/styles.module.css"; interface NotificationBannerProps { @@ -36,6 +36,14 @@ export const NotificationBanner = ({ const variantColor = variant === "undone" ? "#102D5F" : variant === "error" ? "#BE2D46" : "#3bb966"; + useEffect(() => { + if (isOpen) { + // Automatically dismiss banner after 5 seconds + const timeout = setTimeout(onDismissClicked, 5000); + return () => clearTimeout(timeout); + } + }, [isOpen]); + return isOpen ? (
diff --git a/frontend/src/components/shared/NotificationBanner/styles.module.css b/frontend/src/components/shared/NotificationBanner/styles.module.css index ecf4849..4009891 100644 --- a/frontend/src/components/shared/NotificationBanner/styles.module.css +++ b/frontend/src/components/shared/NotificationBanner/styles.module.css @@ -1,5 +1,5 @@ .root { - position: fixed; + position: absolute; top: 133px; left: 50%; transform: translateX(-50%); @@ -11,6 +11,7 @@ padding: 12px 24px; gap: 16px; background-color: white; + z-index: 2; } .icon { diff --git a/frontend/src/components/shared/StatusDropdown/index.tsx b/frontend/src/components/shared/StatusDropdown/index.tsx index e91acd2..321eefc 100644 --- a/frontend/src/components/shared/StatusDropdown/index.tsx +++ b/frontend/src/components/shared/StatusDropdown/index.tsx @@ -41,6 +41,14 @@ export const STATUS_OPTIONS: StatusOption[] = [ }, ]; +/** + * A special status-like option for all statuses + */ +export const ALL_STATUSES_OPTION: StatusOption = { + value: "All Statuses", + color: "transparent", +}; + /** * An input component that displays a dropdown menu with all available status * options and enables the user to select a status. @@ -48,9 +56,10 @@ export const STATUS_OPTIONS: StatusOption[] = [ export interface StatusDropdownProps { value: string; onChanged?: (value: string) => void; + includeAllStatuses: boolean; } -export function StatusDropdown({ value, onChanged }: StatusDropdownProps) { +export function StatusDropdown({ value, onChanged, includeAllStatuses }: StatusDropdownProps) { const [selectedValue, setSelectedValue] = useState(value); const [isOpen, setIsOpen] = useState(false); @@ -132,11 +141,13 @@ export function StatusDropdown({ value, onChanged }: StatusDropdownProps) { }} IconComponent={DropdownIcon} > - {...STATUS_OPTIONS.map((status) => ( - - - - ))} + {...(includeAllStatuses ? [ALL_STATUSES_OPTION] : []) + .concat(STATUS_OPTIONS) + .map((status) => ( + + + + ))}
diff --git a/frontend/src/constants/roles.ts b/frontend/src/constants/roles.ts new file mode 100644 index 0000000..2312176 --- /dev/null +++ b/frontend/src/constants/roles.ts @@ -0,0 +1,2 @@ +export const STAFF_ROLE = "staff"; +export const ADMIN_ROLE = "admin"; diff --git a/frontend/src/contexts/userContext.tsx b/frontend/src/contexts/userContext.tsx index ae7161d..2516dc0 100644 --- a/frontend/src/contexts/userContext.tsx +++ b/frontend/src/contexts/userContext.tsx @@ -5,11 +5,17 @@ import { User as FirebaseUser, onAuthStateChanged } from "firebase/auth"; import { User, getWhoAmI } from "@/api/Users"; import { initFirebase } from "@/firebase/firebase"; +type Notification = "deleteUser" | "changePassword" | null; + interface IUserContext { firebaseUser: FirebaseUser | null; papUser: User | null; loadingUser: boolean; reloadUser: () => unknown; + successNotificationOpen: Notification; + setSuccessNotificationOpen: (notification: Notification) => unknown; + errorNotificationOpen: Notification; + setErrorNotificationOpen: (notification: Notification) => unknown; } /** @@ -21,6 +27,10 @@ export const UserContext = createContext({ papUser: null, loadingUser: true, reloadUser: () => {}, + successNotificationOpen: null, + setSuccessNotificationOpen: () => {}, + errorNotificationOpen: null, + setErrorNotificationOpen: () => {}, }); /** @@ -32,6 +42,8 @@ export const UserContextProvider = ({ children }: { children: ReactNode }) => { const [initialLoading, setInitialLoading] = useState(true); const [papUser, setPapUser] = useState(null); const [loadingUser, setLoadingUser] = useState(true); + const [successNotificationOpen, setSuccessNotificationOpen] = useState(null); + const [errorNotificationOpen, setErrorNotificationOpen] = useState(null); const { auth } = initFirebase(); @@ -68,7 +80,18 @@ export const UserContextProvider = ({ children }: { children: ReactNode }) => { useEffect(reloadUser, [initialLoading, firebaseUser]); return ( - + {children} ); diff --git a/frontend/src/hooks/useDirtyForm.ts b/frontend/src/hooks/useDirtyForm.ts new file mode 100644 index 0000000..3bd07ec --- /dev/null +++ b/frontend/src/hooks/useDirtyForm.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; + +interface UseDirtyFormProps { + isDirty: boolean; +} + +/** + * A hook that conditionally marks the page as dirty so the browser will display + * a confirmation dialog when the user tries to leave or reload it. + */ +export const useDirtyForm = ({ isDirty }: UseDirtyFormProps) => { + /** + * Returning true from this event handler tells the browser to display a confirmation + * dialog telling the user they have unsaved changes. + */ + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (!isDirty) { + return false; + } + + e.preventDefault(); + e.returnValue = true; + return true; + }; + + useEffect(() => { + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [isDirty]); +}; diff --git a/frontend/src/hooks/useRedirection.ts b/frontend/src/hooks/useRedirection.ts index 38f864e..3c4cc72 100644 --- a/frontend/src/hooks/useRedirection.ts +++ b/frontend/src/hooks/useRedirection.ts @@ -1,11 +1,12 @@ import { User } from "@/api/Users"; +import { ADMIN_ROLE } from "@/constants/roles"; import { UserContext } from "@/contexts/userContext"; import { User as FirebaseUser } from "firebase/auth"; import { useRouter } from "next/navigation"; import { useContext, useEffect } from "react"; export const LOGIN_URL = "/login"; -export const HOME_URL = "/staff/vsr"; +export const HOME_URL = "/staff/profile"; /** * An interface for the user's current authentication credentials @@ -64,3 +65,13 @@ export const useRedirectToLoginIfNotSignedIn = () => { redirectURL: LOGIN_URL, }); }; + +/** + * A hook that redirects the user to the staff home page if they are signed in, but not an admin + */ +export const useRedirectToHomeIfNotAdmin = () => { + useRedirection({ + checkShouldRedirect: ({ papUser }) => papUser !== null && papUser.role !== ADMIN_ROLE, + redirectURL: HOME_URL, + }); +}; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 1c31bfb..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "PAP-Inventory-Processing", - "lockfileVersion": 3, - "requires": true, - "packages": {} -}