From b0b800a0ff4a0e3b2d266f25b971dcd39ec98718 Mon Sep 17 00:00:00 2001 From: Aammya Sapra <79061216+aammya8@users.noreply.github.com> Date: Fri, 14 Jun 2024 21:40:43 -0700 Subject: [PATCH] Feature/aammya8/new account approval (#104) * Add user approval and denial functionality, as well as email user about account approval updates * backup * backup * fixed delete route * change approve/deny/delete to use email * approve/deny controllers do not get entered? but delete does * Fix Notifications UI (immediately remove corresponding card when approve/deny button clicked) * Modify routes for testing purposes * Debug statements --> user does not get found in denyUser * Email successfully sent for deny (accidentally deleted user before trying to send email earlier lol) * Fix frontend (populate account type) * Remove extra comments * added auth protection and cleaned up code * added env for emails * fix user role bug * fixed some bugs and deleted some log statements * ran lint fix --------- Co-authored-by: adhi0331 --- .github/workflows/build-and-deploy.yml | 2 + backend/package-lock.json | 19 ++++ backend/package.json | 2 + backend/src/controllers/user.ts | 94 +++++++++++++++++- backend/src/routes/user.ts | 9 ++ backend/src/util/email.ts | 59 ++++++++++++ backend/src/util/user.ts | 13 +++ frontend/src/api/user.ts | 70 +++++++++++++- .../NotificationCard/NotificationCard.tsx | 29 ++++-- .../NotificationCard/NotificationTable.tsx | 96 +++++++++++++++---- .../StudentsTable/StudentsTable.tsx | 1 - frontend/src/hooks/redirect.tsx | 3 +- frontend/src/pages/create_user_3.tsx | 3 +- frontend/src/pages/notifications.tsx | 29 +++++- 14 files changed, 399 insertions(+), 30 deletions(-) create mode 100644 backend/src/util/email.ts create mode 100644 backend/src/util/user.ts diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 587a6a53..bd8c085c 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -34,6 +34,8 @@ jobs: echo SERVICE_ACCOUNT_KEY=${{ secrets.SERVICE_ACCOUNT_KEY }} >> .env echo APP_PORT=${{ secrets.APP_PORT }} >> .env echo APP_FIREBASE_CONFIG=${{ secrets.APP_FIREBASE_CONFIG }} >> .env + echo EMAIL_ADDRESS_1=${{ secrets.EMAIL_ADDRESS_1 }} >> .env + echo PASS_1=${{ secrets.PASS_1 }} >> .env - name: Build Frontend run: cd frontend && npm ci && npm run build - name: Build Backend diff --git a/backend/package-lock.json b/backend/package-lock.json index 6225f288..bd9124f8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,11 +20,13 @@ "firebase-functions": "^4.7.0", "mongodb": "^6.3.0", "mongoose": "^8.3.1", + "nodemailer": "^6.9.13", "tsc-alias": "^1.8.8" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/nodemailer": "^6.4.14", "@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/parser": "^6.16.0", "eslint": "^8.56.0", @@ -1084,6 +1086,15 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.14", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz", + "integrity": "sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.11", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", @@ -4657,6 +4668,14 @@ "node": ">= 6.13.0" } }, + "node_modules/nodemailer": { + "version": "6.9.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz", + "integrity": "sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 50ec90f0..03947e01 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,11 +30,13 @@ "firebase-functions": "^4.7.0", "mongodb": "^6.3.0", "mongoose": "^8.3.1", + "nodemailer": "^6.9.13", "tsc-alias": "^1.8.8" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/nodemailer": "^6.4.14", "@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/parser": "^6.16.0", "eslint": "^8.56.0", diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index e4e9a660..92e619db 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -8,8 +8,10 @@ import { ServiceError } from "../errors/service"; import { ValidationError } from "../errors/validation"; import { Image } from "../models/image"; import UserModel from "../models/user"; +import { sendApprovalEmail, sendDenialEmail } from "../util/email"; import { firebaseAdminAuth } from "../util/firebase"; import { handleImageParsing } from "../util/image"; +import { deleteUserFromFirebase, deleteUserFromMongoDB } from "../util/user"; import validationErrorParser from "../util/validationErrorParser"; import { UserIdRequestBody } from "./types/types"; @@ -49,9 +51,6 @@ export const createUser = async ( name, accountType, email, - // profilePicture default "default" in User constructor - // lastChangedPassword default Date.now() in User constructor - // approvalStatus default false in User constructor }); res.status(201).json(newUser); @@ -63,6 +62,95 @@ export const createUser = async ( return; }; +export const deleteUser = async (req: Request, res: Response, nxt: NextFunction) => { + try { + const { email } = req.params; + + // Find the user by email + const user = await UserModel.findOne({ email }); + if (!user) { + throw new Error("User not found"); + } + + const userId = user._id; // _id is the uid in schema + + // delete user from Firebase and MongoDB + await deleteUserFromFirebase(userId); + await deleteUserFromMongoDB(userId); + + res.status(200).send("User deleted successfully"); + } catch (error) { + console.error("Error deleting user:", error); + nxt(error); + } +}; + +export const getNotApprovedUsers = async (req: Request, res: Response, next: NextFunction) => { + try { + // const notApprovedUsers: User[] = await UserModel.find({ approvalStatus: false }).exec(); + const notApprovedUsers = await UserModel.find({ approvalStatus: false }).exec(); + + res.status(200).json(notApprovedUsers); + } catch (error) { + console.error("Error fetching not-approved users:", error); + next(error); + } +}; + +export const approveUser = async (req: Request, res: Response, nxt: NextFunction) => { + try { + const { email } = req.body; + + const user = await UserModel.findOne({ email }); + if (!user) { + return res.status(404).send("User not found"); + } + + const userId = user._id; + + await UserModel.findByIdAndUpdate(userId, { approvalStatus: true }); + + // await sendApprovalEmail(email); + await sendApprovalEmail(email as string); + + res.status(200).send("User approved successfully"); + } catch (error) { + console.error(error); + nxt(error); + } +}; + +export const denyUser = async (req: Request, res: Response, nxt: NextFunction) => { + console.log("Inside denyUser controller"); + + try { + const { email } = req.body; + + console.log("Email from request body:", email); + + // const user = await UserModel.findOne({ email }); + const user = await UserModel.findOne({ email }); + + if (!user) { + return res.status(404).send("User not found"); + } + + console.log("User object:", user); + + const userId = user._id; + + await UserModel.findByIdAndUpdate(userId, { approvalStatus: false }); + + console.log(email as string); + await sendDenialEmail(email as string); + + res.status(200).send("User denied successfully"); + } catch (error) { + console.error(error); + nxt(error); + } +}; + export const loginUser = async ( req: Request, Record, LoginUserRequestBody>, res: Response, diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index ff231ac4..62166607 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -9,6 +9,15 @@ const router = express.Router(); router.use(express.json()); router.post("/create", UserValidator.createUser, UserController.createUser); + +router.post("/approve", [verifyAuthToken], UserController.approveUser); + +router.post("/deny", [verifyAuthToken], UserController.denyUser); + +router.delete("/delete/:email", [verifyAuthToken], UserController.deleteUser); + +router.get("/not-approved", [verifyAuthToken], UserController.getNotApprovedUsers); + router.get("/", [verifyAuthToken], UserController.loginUser); router.post("/editPhoto", [verifyAuthToken], UserValidator.editPhoto, UserController.editPhoto); router.get("/getPhoto/:id", [verifyAuthToken], UserController.getPhoto); diff --git a/backend/src/util/email.ts b/backend/src/util/email.ts new file mode 100644 index 00000000..b1b2655e --- /dev/null +++ b/backend/src/util/email.ts @@ -0,0 +1,59 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import nodemailer from "nodemailer"; + +// Create a transporter object using SMTP transport +const transporter = nodemailer.createTransport({ + service: "Gmail", + auth: { + user: process.env.EMAIL_ADDRESS_1, + pass: process.env.PASS_1, + }, +}); + +export const sendApprovalEmail = async (email: string) => { + try { + await transporter.sendMail({ + from: process.env.EMAIL_ADDRESS_1, + to: email, + subject: "Welcome to PIA! Your Account Has Been Approved", + // text: `Hello, + // Thank you for your interest in Plant It Again. + // We are emailing to let you know that your account + // creation request has been approved.` + html: `

Hello,

+

Thank you for your interest in Plant It Again.

+

We are emailing to let you know that your account creation request + has been approved.

`, + }); + console.log("Approval email sent successfully"); + } catch (error) { + console.error("Error sending approval email:", error); + } +}; + +export const sendDenialEmail = async (email: string) => { + console.log("Sending Denial Email"); + try { + await transporter.sendMail({ + from: process.env.EMAIL_ADDRESS_1, + to: email, + subject: "An Update on Your PIA Account Approval Status", + // text: `Hello, + // Thank you for your interest in Plant It Again. + // We are emailing to let you know that your account + // creation request has been denied. + // If you believe this a mistake, + // please contact us through our website` + html: `

Hello,

+

Thank you for your interest in Plant It Again.

+

We are emailing to let you know that your account creation request + has been denied.

+

If you believe this is a mistake, please contact us through our website.

`, + }); + console.log("Denial email sent successfully"); + } catch (error) { + console.error("Error sending denial email:", error); + } +}; diff --git a/backend/src/util/user.ts b/backend/src/util/user.ts new file mode 100644 index 00000000..fca54c2a --- /dev/null +++ b/backend/src/util/user.ts @@ -0,0 +1,13 @@ +import UserModel from "../models/user"; + +import { firebaseAdminAuth } from "./firebase"; + +// delete user from Firebase +export const deleteUserFromFirebase = async (userId: string): Promise => { + await firebaseAdminAuth.deleteUser(userId); +}; + +// delete user from MongoDB +export const deleteUserFromMongoDB = async (userId: string): Promise => { + await UserModel.findByIdAndDelete(userId); +}; diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 5372f016..57a27e14 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -1,4 +1,6 @@ -import { APIResult, GET, PATCH, handleAPIError } from "@/api/requests"; +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { APIResult, DELETE, GET, PATCH, POST, handleAPIError } from "@/api/requests"; export type User = { uid: string; @@ -24,6 +26,72 @@ export const verifyUser = async (firebaseToken: string): Promise } }; +export async function getNotApprovedUsers(firebaseToken: string): Promise> { + try { + const headers = createAuthHeader(firebaseToken); + console.log(headers); + const response = await GET("/user/not-approved", headers); + const json = (await response.json()) as User[]; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function approveUser(email: string, firebaseToken: string): Promise> { + try { + const headers = createAuthHeader(firebaseToken); + const response = await POST(`/user/approve`, { email }, headers); + if (response.ok) { + // return { success: true }; + return { success: true, data: undefined }; // return APIResult with empty data + } else { + const error = await response.json(); + throw new Error(error.message || "Failed to approve user"); + } + } catch (error) { + return { success: false, error: "Failed to approve user" }; + } +} + +export async function denyUser(email: string, firebaseToken: string): Promise> { + console.log("In frontend/src/api/user.ts denyUser()"); + + try { + const headers = createAuthHeader(firebaseToken); + const response = await POST(`/user/deny`, { email }, headers); + if (response.ok) { + // return { success: true }; + return { success: true, data: undefined }; // return APIResult with empty data + } else { + const error = await response.json(); + throw new Error(error.message || "Failed to deny user"); + } + } catch (error) { + return { success: false, error: "Error denying user" }; + } +} + +// delete user by email +export async function deleteUserByEmail( + email: string, + firebaseToken: string, +): Promise> { + try { + const headers = createAuthHeader(firebaseToken); + const response = await DELETE(`/user/delete/${encodeURIComponent(email)}`, undefined, headers); + if (response.ok) { + // return { success: true }; + return { success: true, data: undefined }; + } else { + const error = await response.json(); + throw new Error(error.message || "Failed to delete user"); + } + } catch (error) { + return handleAPIError(error); + } +} + type ObjectId = string; // This is a placeholder for the actual ObjectId type const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api"; export async function editPhoto( diff --git a/frontend/src/components/NotificationCard/NotificationCard.tsx b/frontend/src/components/NotificationCard/NotificationCard.tsx index a5380c14..5bdf922d 100644 --- a/frontend/src/components/NotificationCard/NotificationCard.tsx +++ b/frontend/src/components/NotificationCard/NotificationCard.tsx @@ -1,12 +1,19 @@ -import { Button } from "../Button"; - type UserInfo = { name: string; email: string; account_type: string; + onApprove: () => void; + onDeny: () => void; }; -export default function NotificationCard({ name, email, account_type }: UserInfo) { +// export default function NotificationCard({ name, email, account_type }: UserInfo) { +export default function NotificationCard({ + name, + email, + account_type, + onApprove, + onDeny, +}: UserInfo) { return ( <>
-
- +
diff --git a/frontend/src/components/NotificationCard/NotificationTable.tsx b/frontend/src/components/NotificationCard/NotificationTable.tsx index bbeb915b..1a27fdab 100644 --- a/frontend/src/components/NotificationCard/NotificationTable.tsx +++ b/frontend/src/components/NotificationCard/NotificationTable.tsx @@ -1,23 +1,87 @@ +import React, { useEffect, useState } from "react"; + import NotificationCard from "./NotificationCard"; -export default function NotificationTable() { +import { User, approveUser, deleteUserByEmail, denyUser, getNotApprovedUsers } from "@/api/user"; + +type NotificationsProps = { + firebaseToken: string; +}; + +export default function NotificationTable({ firebaseToken }: NotificationsProps) { + const [notApprovedUsers, setNotApprovedUsers] = useState([]); + + useEffect(() => { + const fetchNotApprovedUsers = async () => { + try { + const result = await getNotApprovedUsers(firebaseToken); + if (result.success) { + console.log("notApprovedUsers:", result.data); + + setNotApprovedUsers(result.data); + } else { + console.error("Failed to fetch not-approved users:", result.error); + } + } catch (error) { + console.error("Error fetching not-approved users:", error); + } + }; + + fetchNotApprovedUsers().catch((error) => { + console.error("Error fetching not-approved users:", error); + }); + }, [firebaseToken]); + + const handleApproveUser = async (email: string) => { + // Immediately update the UI (remove corresponding Notification Card) + setNotApprovedUsers((prevUsers) => prevUsers.filter((user) => user.email !== email)); + + try { + const result = await approveUser(email, firebaseToken); + if (!result.success) { + console.error("Failed to approve user:", result.error); + } + } catch (error) { + console.error("Error handling user approval:", error); + } + }; + + const handleDenyUser = async (email: string) => { + try { + // Immediately update the UI (remove corresponding Notification Card) + setNotApprovedUsers((prevUsers) => prevUsers.filter((user) => user.email !== email)); + + const denialResult = await denyUser(email, firebaseToken); + if (!denialResult.success) { + console.error("Failed to deny user:", denialResult.error); + } + + // Delete the user from Firebase and MongoDB + const deletionResult = await deleteUserByEmail(email, firebaseToken); + if (deletionResult.success) { + console.log(`User with email ${email} successfully deleted.`); + } else { + console.error(`Failed to delete user with email ${email}:`, deletionResult.error); + } + } catch (error) { + console.error("Error handling user denial:", error); + } + }; + return (
-
- -
-
-
- -
-
-
- -
+ {notApprovedUsers.map((user) => ( +
+ handleApproveUser(user.email)} + onDeny={() => handleDenyUser(user.email)} + /> +
+ ))}
); } diff --git a/frontend/src/components/StudentsTable/StudentsTable.tsx b/frontend/src/components/StudentsTable/StudentsTable.tsx index a6dae28e..8a23a1f5 100644 --- a/frontend/src/components/StudentsTable/StudentsTable.tsx +++ b/frontend/src/components/StudentsTable/StudentsTable.tsx @@ -44,7 +44,6 @@ export default function StudentsTable() { obj[student._id] = student; return obj; }, {} as StudentMap); - console.log(result.data); setAllStudents(studentsObject); setIsLoading(false); diff --git a/frontend/src/hooks/redirect.tsx b/frontend/src/hooks/redirect.tsx index 58bf0b4b..1dbb315a 100644 --- a/frontend/src/hooks/redirect.tsx +++ b/frontend/src/hooks/redirect.tsx @@ -53,7 +53,8 @@ export const useRedirection = ({ checkShouldRedirect, redirectURL }: UseRedirect */ export const useRedirectToHomeIfSignedIn = () => { useRedirection({ - checkShouldRedirect: ({ firebaseUser, piaUser }) => firebaseUser !== null && piaUser !== null, + checkShouldRedirect: ({ firebaseUser, piaUser }) => + firebaseUser !== null && piaUser !== null && piaUser.approvalStatus, redirectURL: HOME_URL, }); }; diff --git a/frontend/src/pages/create_user_3.tsx b/frontend/src/pages/create_user_3.tsx index a12d98d2..d1eb8007 100644 --- a/frontend/src/pages/create_user_3.tsx +++ b/frontend/src/pages/create_user_3.tsx @@ -17,7 +17,8 @@ export default function CreateUser() { const onBack: SubmitHandler = (data) => { console.log(data); - void router.push("/create_user_2"); + // void router.push("/create_user_2"); + void router.push("/create_user"); }; const isSuccess = createSuccess === "true"; diff --git a/frontend/src/pages/notifications.tsx b/frontend/src/pages/notifications.tsx index e121a081..cde0d050 100644 --- a/frontend/src/pages/notifications.tsx +++ b/frontend/src/pages/notifications.tsx @@ -1,9 +1,32 @@ +import { useContext, useEffect, useState } from "react"; + +import LoadingSpinner from "@/components/LoadingSpinner"; import NotificationTable from "@/components/NotificationCard/NotificationTable"; +import { UserContext } from "@/contexts/user"; import { useRedirectTo404IfNotAdmin, useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect"; export default function Notifications() { useRedirectToLoginIfNotSignedIn(); useRedirectTo404IfNotAdmin(); + + const { piaUser, firebaseUser } = useContext(UserContext); + const [firebaseToken, setFirebaseToken] = useState(""); + + useEffect(() => { + if (!piaUser || !firebaseUser) return; + + if (firebaseUser) { + firebaseUser + ?.getIdToken() + .then((token) => { + setFirebaseToken(token); + }) + .catch((error) => { + console.error(error); + }); + } + }, [piaUser, firebaseUser, firebaseToken]); + return (
@@ -11,7 +34,11 @@ export default function Notifications() {
Review information of new account creations below to approve or deny them.{" "}
- + {!piaUser || !firebaseUser ? ( + + ) : ( + + )}
);