diff --git a/backend/src/controllers/types/userTypes.ts b/backend/src/controllers/types/userTypes.ts index 8294b4d8..3756d246 100644 --- a/backend/src/controllers/types/userTypes.ts +++ b/backend/src/controllers/types/userTypes.ts @@ -25,6 +25,10 @@ export type EditLastChangedPasswordRequestBody = UserId & { currentDate: string; }; +export type UpdateAccountTypeRequestBody = UserId & { + updateUserId: string; +}; + export type SaveImageRequest = { body: { previousImageId: string; diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index 92e619db..88389bf7 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -22,6 +22,7 @@ import { EditNameRequestBody, EditPhotoRequestBody, LoginUserRequestBody, + UpdateAccountTypeRequestBody, } from "./types/userTypes"; export const createUser = async ( @@ -162,15 +163,7 @@ export const loginUser = async ( if (!user) { throw ValidationError.USER_NOT_FOUND; } - res.status(200).json({ - uid: user._id, - role: user.accountType, - approvalStatus: user.approvalStatus, - profilePicture: user.profilePicture, - name: user.name, - email: user.email, - lastChangedPassword: user.lastChangedPassword, - }); + res.status(200).json(user); return; } catch (e) { nxt(); @@ -317,3 +310,98 @@ export const editLastChangedPassword = async ( }); } }; + +export const getAllTeamAccounts = async ( + req: Request, Record, UserIdRequestBody>, + res: Response, + nxt: NextFunction, +) => { + try { + const { uid } = req.body; + + const user = await UserModel.findById(uid); + if (!user) { + throw ValidationError.USER_NOT_FOUND; + } + + if (user.accountType !== "admin") { + throw ValidationError.UNAUTHORIZED_USER; + } + + const allTeamAccounts = await UserModel.find({ accountType: "team" }); + + return res.status(200).json(allTeamAccounts); + } catch (error) { + nxt(error); + return res.status(400).json({ + error, + }); + } +}; + +export const editAccountType = async ( + req: Request, Record, UpdateAccountTypeRequestBody>, + res: Response, + nxt: NextFunction, +) => { + try { + const { uid, updateUserId } = req.body; + + const user = await UserModel.findById(uid); + const updatedUser = await UserModel.findById(updateUserId); + if (!user || !updatedUser) { + throw ValidationError.USER_NOT_FOUND; + } + + if (user.accountType !== "admin") { + throw ValidationError.UNAUTHORIZED_USER; + } + + const updatedUserAdmin = await UserModel.findByIdAndUpdate(updateUserId, { + accountType: "admin", + }); + + return res.status(200).json(updatedUserAdmin); + } catch (error) { + nxt(error); + return res.status(400).json({ + error, + }); + } +}; + +export const editArchiveStatus = async ( + req: Request, Record, UpdateAccountTypeRequestBody>, + res: Response, + nxt: NextFunction, +) => { + try { + const { uid, updateUserId } = req.body; + + const user = await UserModel.findById(uid); + const updatedUser = await UserModel.findById(updateUserId); + if (!user || !updatedUser) { + throw ValidationError.USER_NOT_FOUND; + } + + if (updatedUser.accountType === "admin") { + throw ValidationError.UNAUTHORIZED_USER; + } + + if (user.accountType !== "admin") { + throw ValidationError.UNAUTHORIZED_USER; + } + + // Disable Firebase account to prevent login + await firebaseAdminAuth.updateUser(updateUserId, { disabled: true }); + + const updatedUserAdmin = await UserModel.findByIdAndUpdate(updateUserId, { archived: true }); + + return res.status(200).json(updatedUserAdmin); + } catch (error) { + nxt(error); + return res.status(400).json({ + error, + }); + } +}; diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index dadc1506..38d72c0b 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -14,6 +14,7 @@ const userSchema = new mongoose.Schema({ email: { type: String, required: true }, profilePicture: { type: String, required: false, default: "default" }, lastChangedPassword: { type: Date, required: false, default: Date.now() }, + archived: { type: Boolean, required: false, default: false }, }); type User = InferSchemaType; diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index 62166607..55470528 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -29,5 +29,18 @@ router.patch( UserValidator.editLastChangedPassword, UserController.editLastChangedPassword, ); +router.get("/getAllTeamAccounts", [verifyAuthToken], UserController.getAllTeamAccounts); +router.patch( + "/editAccountType", + [verifyAuthToken], + UserValidator.editAccountType, + UserController.editAccountType, +); +router.patch( + "/editArchiveStatus", + [verifyAuthToken], + UserValidator.editArchiveStatus, + UserController.editArchiveStatus, +); export default router; diff --git a/backend/src/validators/auth.ts b/backend/src/validators/auth.ts index 3efdc5e2..ac2b6797 100644 --- a/backend/src/validators/auth.ts +++ b/backend/src/validators/auth.ts @@ -34,7 +34,6 @@ const verifyAuthToken = async (req: RequestWithUserId, res: Response, next: Next let userInfo: DecodedIdToken; try { userInfo = await decodeAuthToken(token); - // req.userId = userInfo.uid; } catch (e) { return res .status(AuthError.INVALID_AUTH_TOKEN.status) diff --git a/backend/src/validators/user.ts b/backend/src/validators/user.ts index eed28cf4..ab0b1fe9 100644 --- a/backend/src/validators/user.ts +++ b/backend/src/validators/user.ts @@ -74,3 +74,19 @@ export const editLastChangedPassword: ValidationChain[] = [ .isISO8601() .withMessage("Invalid Date format"), ]; + +export const editAccountType: ValidationChain[] = [ + body("updateUserId") + .exists() + .withMessage("ID of User to be updated is required") + .notEmpty() + .withMessage("User ID cannot be empty"), +]; + +export const editArchiveStatus: ValidationChain[] = [ + body("updateUserId") + .exists() + .withMessage("ID of User to be updated is required") + .notEmpty() + .withMessage("User ID cannot be empty"), +]; diff --git a/frontend/public/icons/archive.svg b/frontend/public/icons/archive.svg new file mode 100644 index 00000000..cc3dcc51 --- /dev/null +++ b/frontend/public/icons/archive.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 57a27e14..8b87e3da 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -3,13 +3,14 @@ import { APIResult, DELETE, GET, PATCH, POST, handleAPIError } from "@/api/requests"; export type User = { - uid: string; - role: "admin" | "team"; - approvalStatus: boolean; - profilePicture: string; + _id: string; name: string; + accountType: "admin" | "team"; + approvalStatus: boolean; email: string; + profilePicture: string; lastChangedPassword: Date; + archived: boolean; }; export const createAuthHeader = (firebaseToken: string) => ({ @@ -195,3 +196,46 @@ export async function editLastChangedPassword(firebaseToken: string): Promise> { + try { + const headers = createAuthHeader(firebaseToken); + const response = await GET(`/user/getAllTeamAccounts`, headers); + + const json = (await response.json()) as User[]; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function editAccountType( + updateUserId: string, + firebaseToken: string, +): Promise> { + try { + const updateAccountData = { updateUserId }; + const headers = createAuthHeader(firebaseToken); + const response = await PATCH(`/user/editAccountType`, updateAccountData, headers); + + const json = (await response.json()) as User; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function editArchiveStatus( + updateUserId: string, + firebaseToken: string, +): Promise> { + try { + const headers = createAuthHeader(firebaseToken); + const response = await PATCH(`/user/editArchiveStatus`, { updateUserId }, headers); + + const json = (await response.json()) as User; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/frontend/src/components/Modals/ModalConfirmation.tsx b/frontend/src/components/Modals/ModalConfirmation.tsx index 43a238df..0561190b 100644 --- a/frontend/src/components/Modals/ModalConfirmation.tsx +++ b/frontend/src/components/Modals/ModalConfirmation.tsx @@ -48,7 +48,7 @@ const ModalConfirmation = forwardRef(
{icon}

{title}

- {description ?

{description}

: null} + {description ?

{description}

: null}
+ +
+ + + ); +} diff --git a/frontend/src/components/Profile/TeamAccounts/TeamAccountsList.tsx b/frontend/src/components/Profile/TeamAccounts/TeamAccountsList.tsx new file mode 100644 index 00000000..357cbb49 --- /dev/null +++ b/frontend/src/components/Profile/TeamAccounts/TeamAccountsList.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; + +import ArchiveIcon from "../../../../public/icons/archive.svg"; +import GreenCheckMarkIcon from "../../../../public/icons/green_check_mark.svg"; + +import { AccountTypes } from "./TeamAccounts"; +import { useFetchTeamAccounts } from "./hooks/useFetchTeamAccounts"; +import { useHandleAccountArchive } from "./hooks/useHandleAccountArchive"; + +import { User } from "@/api/user"; +import { Button } from "@/components/Button"; +import ModalConfirmation from "@/components/Modals/ModalConfirmation"; +import { UserData } from "@/pages/profile"; + +type TeamAccountsListProps = { + accountType: AccountTypes; + userData: UserData; +}; + +export default function TeamAccountsList({ accountType, userData }: TeamAccountsListProps) { + const { firebaseUser } = userData; + const [firebaseToken, setFirebaseToken] = useState(""); + const [allAccounts, setAllAccounts] = useState([]); + const [filteredAccounts, setFilteredAccounts] = useState([]); + + useFetchTeamAccounts({ firebaseUser, setAllAccounts, setFilteredAccounts, setFirebaseToken }); + + const { handleAccountUpdate, handleArchive } = useHandleAccountArchive({ + firebaseToken, + accountType, + allAccounts, + setAllAccounts, + setFilteredAccounts, + }); + + return ( +
    0 ? "border-[2px]" : ""} overflow-hidden rounded-lg border-pia_neutral_gray`} + > + {filteredAccounts.map((account) => { + return ( +
  • +
    +

    Name: {account.name}

    +

    Email: {account.email}

    +

    Account Type: Team

    +
    +
    + {accountType === "current" && !account.archived ? ( + <> + } + triggerElement={ +
    +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/components/Profile/TeamAccounts/hooks/useFetchTeamAccounts.ts b/frontend/src/components/Profile/TeamAccounts/hooks/useFetchTeamAccounts.ts new file mode 100644 index 00000000..b0e80007 --- /dev/null +++ b/frontend/src/components/Profile/TeamAccounts/hooks/useFetchTeamAccounts.ts @@ -0,0 +1,45 @@ +import { User as FirebaseUser } from "firebase/auth"; +import { Dispatch, SetStateAction, useEffect } from "react"; + +import { User, getAllTeamAccounts } from "@/api/user"; + +type UseFetchTeamAccounts = { + firebaseUser: FirebaseUser | null; + setFirebaseToken: Dispatch>; + setAllAccounts: Dispatch>; + setFilteredAccounts: Dispatch>; +}; + +export const useFetchTeamAccounts = ({ + firebaseUser, + setFirebaseToken, + setAllAccounts, + setFilteredAccounts, +}: UseFetchTeamAccounts) => { + useEffect(() => { + if (firebaseUser) { + firebaseUser + ?.getIdToken() + .then((token) => { + getAllTeamAccounts(token).then( + (result) => { + setFirebaseToken(token); + if (result.success) { + console.log("Fetched all team accounts", result.data); + setAllAccounts(result.data); + setFilteredAccounts(result.data); + } else { + console.error(result.error); + } + }, + (error) => { + console.error(error); + }, + ); + }) + .catch((error) => { + console.error(error); + }); + } + }, [firebaseUser]); +}; diff --git a/frontend/src/components/Profile/TeamAccounts/hooks/useHandleAccountArchive.ts b/frontend/src/components/Profile/TeamAccounts/hooks/useHandleAccountArchive.ts new file mode 100644 index 00000000..d98d38dc --- /dev/null +++ b/frontend/src/components/Profile/TeamAccounts/hooks/useHandleAccountArchive.ts @@ -0,0 +1,90 @@ +import { Dispatch, SetStateAction, useEffect } from "react"; + +import { AccountTypes } from "../TeamAccounts"; + +import { User, editAccountType, editArchiveStatus } from "@/api/user"; + +type UseHandleAccountArchiveProps = { + firebaseToken: string; + allAccounts: User[]; + setAllAccounts: Dispatch>; + setFilteredAccounts: Dispatch>; + accountType: AccountTypes; +}; + +export const useHandleAccountArchive = ({ + firebaseToken, + allAccounts, + setAllAccounts, + setFilteredAccounts, + accountType, +}: UseHandleAccountArchiveProps) => { + useEffect(() => { + const tempAccounts = allAccounts.filter((account) => { + if (account.accountType === "admin") return false; + if (accountType === "current") { + return !account.archived; + } else if (accountType === "archived") { + return account.archived; + } + return true; + }); + setFilteredAccounts(tempAccounts); + }, [accountType, allAccounts]); + + const handleArchive = (accountId: string) => { + editArchiveStatus(accountId, firebaseToken) + .then( + (result) => { + if (result.success) { + console.log("Archived user", result.data); + setAllAccounts((prev) => { + return prev.map((account) => { + if (account._id === result.data._id) { + return { ...account, archived: true }; + } + return account; + }); + }); + } else { + console.error(result.error); + } + }, + (error) => { + console.error(error); + }, + ) + .catch((error) => { + console.error(error); + }); + }; + + const handleAccountUpdate = (accountId: string) => { + editAccountType(accountId, firebaseToken) + .then( + (result) => { + if (result.success) { + console.log("Updated user account type", result.data); + setAllAccounts((prev) => { + return prev.map((account) => { + if (account._id === result.data._id) { + return { ...account, accountType: "admin" }; + } + return account; + }); + }); + } else { + console.error(result.error); + } + }, + (error) => { + console.error(error); + }, + ) + .catch((error) => { + console.error(error); + }); + }; + + return { handleAccountUpdate, handleArchive }; +}; diff --git a/frontend/src/contexts/user.tsx b/frontend/src/contexts/user.tsx index 51b91617..86df520f 100644 --- a/frontend/src/contexts/user.tsx +++ b/frontend/src/contexts/user.tsx @@ -76,7 +76,7 @@ export const UserContextProvider = ({ children }: { children: ReactNode }) => { useEffect(reloadUser, [initialLoading, firebaseUser]); const isAdmin = useMemo( - () => firebaseUser !== null && piaUser !== null && piaUser.role === "admin", + () => firebaseUser !== null && piaUser !== null && piaUser.accountType === "admin", [firebaseUser, piaUser], ); diff --git a/frontend/src/hooks/redirect.tsx b/frontend/src/hooks/redirect.tsx index 1dbb315a..bc39b51b 100644 --- a/frontend/src/hooks/redirect.tsx +++ b/frontend/src/hooks/redirect.tsx @@ -79,7 +79,7 @@ export const useRedirectToLoginIfNotSignedIn = () => { export const useRedirectTo404IfNotAdmin = () => { useRedirection({ checkShouldRedirect: ({ firebaseUser, piaUser }) => - firebaseUser === null || piaUser === null || piaUser.role !== "admin", + firebaseUser === null || piaUser === null || piaUser.accountType !== "admin", redirectURL: NOT_FOUND_URL, }); }; diff --git a/frontend/src/pages/profile.tsx b/frontend/src/pages/profile.tsx index d4c179f7..c62670e1 100644 --- a/frontend/src/pages/profile.tsx +++ b/frontend/src/pages/profile.tsx @@ -1,12 +1,9 @@ -import React, { useContext, useEffect, useState } from "react"; +import { User as FirebaseUser } from "firebase/auth"; +import React, { useContext } from "react"; -import { getPhoto } from "../api/user"; -import { BasicInfoFrame } from "../components/ProfileForm/BasicInfoFrame"; -import { ContactFrame } from "../components/ProfileForm/ContactInfoFrame"; -import { PasswordFrame } from "../components/ProfileForm/PasswordFrame"; -import { useWindowSize } from "../hooks/useWindowSize"; - -import LoadingSpinner from "@/components/LoadingSpinner"; +import { User } from "@/api/user"; +import PersonalInfo from "@/components/Profile/PersonalInfo/PersonalInfo"; +import TeamAccounts from "@/components/Profile/TeamAccounts/TeamAccounts"; import { UserContext } from "@/contexts/user"; import { useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect"; @@ -16,101 +13,21 @@ export type FrameProps = { frameFormat?: string; }; +export type UserData = { + piaUser: User | null; + firebaseUser: FirebaseUser | null; +}; + export default function Profile() { useRedirectToLoginIfNotSignedIn(); const { piaUser, firebaseUser } = useContext(UserContext); - const { isMobile } = useWindowSize(); - const [basicInfoData, setBasicInfoData] = useState({ name: "", image: "" }); - const [contactInfoData, setContactInfoData] = useState({ email: "" }); - const [passwordData, setPasswordData] = useState({ last_changed: null as Date | null }); - const [firebaseToken, setFirebaseToken] = useState(""); - const [currentImageId, setCurrentImageId] = useState("default"); - - const frameFormat = - "border-pia_neutral_gray flex w-full flex-grow-0 flex-col place-content-stretch overflow-hidden rounded-lg border-[2px] bg-white"; - - useEffect(() => { - if (!piaUser || !firebaseUser) return; - - if (firebaseUser) { - firebaseUser - ?.getIdToken() - .then((token) => { - setFirebaseToken(token); - }) - .catch((error) => { - console.error(error); - }); - } - if (piaUser.profilePicture === "default") { - setBasicInfoData((prev) => ({ ...prev, image: "default" })); - } else if (piaUser.profilePicture && firebaseToken) { - setCurrentImageId(piaUser.profilePicture); - getPhoto(piaUser.profilePicture, firebaseToken).then( - (result) => { - if (result.success) { - const newImage = result.data; - setBasicInfoData((prev) => ({ ...prev, image: newImage })); - } else { - console.error(result.error); - } - }, - (error) => { - console.error(error); - }, - ); - } - if (piaUser.name) { - setBasicInfoData((prev) => ({ ...prev, name: piaUser.name })); - } - if (piaUser.email) { - setContactInfoData((prev) => ({ ...prev, email: piaUser.email })); - } - if (piaUser.lastChangedPassword) { - setPasswordData((prev) => ({ ...prev, last_changed: piaUser.lastChangedPassword })); - } - }, [piaUser, firebaseUser, firebaseToken]); + const userData = { piaUser, firebaseUser }; return (
-

Personal Info

-
- Personal info and options to manage it. You can change or update your info at anytime. -
- {!piaUser || !firebaseUser ? ( - - ) : ( -
- - - -
- )} + +
); } diff --git a/frontend/src/styles/Button.module.css b/frontend/src/styles/Button.module.css index d8eebd61..5b53c007 100644 --- a/frontend/src/styles/Button.module.css +++ b/frontend/src/styles/Button.module.css @@ -86,8 +86,8 @@ .destructiveSecondary { background-color: rgb(var(--white)); - color: rgb(var(--black)); - border: 0.0625rem solid rgb(var(--black)); + color: rgb(var(--error-red)); + border: 0.0625rem solid rgb(var(--error-red)); } .destructiveSecondary:is(:hover, :focus-visible) { background-color: rgba(var(--error-red), 22%);